11import { describe , expect , test } from "bun:test" ;
22import { spawn , spawnSync , type ChildProcess } from "node:child_process" ;
3- import { copyFileSync , mkdirSync , mkdtempSync , realpathSync , rmSync , writeFileSync } from "node:fs" ;
3+ import { chmodSync , copyFileSync , mkdirSync , mkdtempSync , realpathSync , rmSync , writeFileSync } from "node:fs" ;
44import { createServer } from "node:net" ;
55import { tmpdir } from "node:os" ;
66import { join , resolve } from "node:path" ;
@@ -128,6 +128,33 @@ function sameProcess(actual: WindowsProcessIdentity | null, expected: WindowsPro
128128 && sameWindowsPath ( actual . executablePath , expected . executablePath ) ;
129129}
130130
131+ function captureWindowsProcessIdentity ( pid : number ) : WindowsProcessIdentity {
132+ let lastError : unknown ;
133+ for ( let attempt = 0 ; attempt < 20 ; attempt += 1 ) {
134+ try {
135+ const identity = windowsProcessIdentity ( pid ) ;
136+ if ( identity ) return identity ;
137+ } catch ( error ) {
138+ lastError = error ;
139+ }
140+ Bun . sleepSync ( 100 ) ;
141+ }
142+ throw new Error ( `could not capture process identity for PID ${ pid } : ${ String ( lastError ?? "process not found" ) } ` ) ;
143+ }
144+
145+ function inspectWindowsProcessIdentity ( pid : number ) : WindowsProcessIdentity | null {
146+ let lastError : unknown ;
147+ for ( let attempt = 0 ; attempt < 5 ; attempt += 1 ) {
148+ try {
149+ return windowsProcessIdentity ( pid ) ;
150+ } catch ( error ) {
151+ lastError = error ;
152+ Bun . sleepSync ( 100 ) ;
153+ }
154+ }
155+ throw lastError ;
156+ }
157+
131158function removeTree ( path : string ) : void {
132159 // Windows can retain the copied executable's image handle briefly after
133160 // taskkill returns. Retry only transient fixture-cleanup errors, with a cap.
@@ -153,15 +180,19 @@ async function effectiveRuntime(override: string): Promise<string> {
153180 const opencodexHome = join ( root , "opencodex" ) ;
154181 const codexHome = join ( root , "codex" ) ;
155182 const grokHome = join ( root , "grok" ) ;
156- mkdirSync ( opencodexHome , { recursive : true } ) ;
157- mkdirSync ( codexHome , { recursive : true } ) ;
158- mkdirSync ( grokHome , { recursive : true } ) ;
159-
160- const port = await freePort ( ) ;
183+ let port : number | null = null ;
161184 let launcher : ChildProcess | null = null ;
185+ let launcherPid : number | null = null ;
162186 let ownedLauncher : WindowsProcessIdentity | null = null ;
163187 let ownedProxy : WindowsProcessIdentity | null = null ;
188+ let runtimePath : string | undefined ;
189+ let hasPrimaryError = false ;
190+ let primaryError : unknown ;
164191 try {
192+ mkdirSync ( opencodexHome , { recursive : true } ) ;
193+ mkdirSync ( codexHome , { recursive : true } ) ;
194+ mkdirSync ( grokHome , { recursive : true } ) ;
195+ port = await freePort ( ) ;
165196 launcher = spawn ( "node" , [ BIN_OCX , "start" , "--port" , String ( port ) ] , {
166197 stdio : "ignore" ,
167198 windowsHide : true ,
@@ -176,8 +207,8 @@ async function effectiveRuntime(override: string): Promise<string> {
176207 } ,
177208 } ) ;
178209 if ( ! launcher . pid ) throw new Error ( "Node launcher has no process id" ) ;
179- ownedLauncher = windowsProcessIdentity ( launcher . pid ) ;
180- if ( ! ownedLauncher ) throw new Error ( "could not capture Node launcher process identity" ) ;
210+ launcherPid = launcher . pid ;
211+ ownedLauncher = captureWindowsProcessIdentity ( launcherPid ) ;
181212
182213 const health = await waitForHealth ( port , 25_000 , launcher ) ;
183214 if ( ! health ) throw new Error ( "proxy did not become healthy" ) ;
@@ -186,34 +217,150 @@ async function effectiveRuntime(override: string): Promise<string> {
186217 throw new Error ( "health PID is not the spawned Node launcher's direct Bun child" ) ;
187218 }
188219 ownedProxy = identity ;
189- return identity . executablePath ;
190- } finally {
191- // Both identities were captured from processes this test spawned. Re-query
192- // before each kill so a reused PID can never become a cleanup target.
193- const cleanupErrors : string [ ] = [ ] ;
194- for ( const [ label , identity ] of [
195- [ "launcher" , ownedLauncher ] ,
196- [ "proxy" , ownedProxy ] ,
197- ] as const ) {
198- if ( ! identity || ! sameProcess ( windowsProcessIdentity ( identity . pid ) , identity ) ) continue ;
199- try {
200- killProxy ( identity . pid ) ;
201- } catch ( error ) {
202- cleanupErrors . push ( `${ label } cleanup failed: ${ String ( error ) } ` ) ;
220+ runtimePath = identity . executablePath ;
221+ } catch ( error ) {
222+ hasPrimaryError = true ;
223+ primaryError = error ;
224+ }
225+
226+ const cleanupErrors : string [ ] = [ ] ;
227+ let launcherTreeStopped = false ;
228+
229+ // Prefer the creation-time/path identity. If the initial CIM capture failed,
230+ // the live ChildProcess handle and its PID are still positive ownership of
231+ // this test's launcher, so terminate that exact process tree as a fallback.
232+ if ( ownedLauncher ) {
233+ try {
234+ if ( sameProcess ( inspectWindowsProcessIdentity ( ownedLauncher . pid ) , ownedLauncher ) ) {
235+ killProxy ( ownedLauncher . pid ) ;
236+ launcherTreeStopped = true ;
203237 }
204- if ( sameProcess ( windowsProcessIdentity ( identity . pid ) , identity ) ) {
205- cleanupErrors . push ( `owned ${ label } PID ${ identity . pid } remained after bounded cleanup` ) ;
238+ } catch ( error ) {
239+ cleanupErrors . push ( `launcher cleanup failed: ${ String ( error ) } ` ) ;
240+ }
241+ }
242+ if ( ! launcherTreeStopped && launcher && launcherPid && launcher . exitCode === null && launcher . signalCode === null ) {
243+ try {
244+ killProxy ( launcherPid ) ;
245+ launcherTreeStopped = true ;
246+ } catch ( error ) {
247+ cleanupErrors . push ( `launcher tree fallback failed: ${ String ( error ) } ` ) ;
248+ }
249+ }
250+
251+ // The launcher tree kill normally removes the Bun child. Retain the
252+ // identity-verified proxy fallback in case the launcher exited first.
253+ if ( ownedProxy ) {
254+ try {
255+ if ( sameProcess ( inspectWindowsProcessIdentity ( ownedProxy . pid ) , ownedProxy ) ) {
256+ killProxy ( ownedProxy . pid ) ;
206257 }
258+ } catch ( error ) {
259+ cleanupErrors . push ( `proxy cleanup failed: ${ String ( error ) } ` ) ;
207260 }
261+ }
262+
263+ // Inspection failures are reported, but never prevent the remaining
264+ // process checks or fixture cleanup from running.
265+ for ( const [ label , identity ] of [
266+ [ "launcher" , ownedLauncher ] ,
267+ [ "proxy" , ownedProxy ] ,
268+ ] as const ) {
269+ if ( ! identity ) continue ;
208270 try {
209- removeTree ( root ) ;
271+ if ( sameProcess ( inspectWindowsProcessIdentity ( identity . pid ) , identity ) ) {
272+ cleanupErrors . push ( `owned ${ label } PID ${ identity . pid } remained after bounded cleanup` ) ;
273+ }
210274 } catch ( error ) {
211- cleanupErrors . push ( `fixture cleanup failed: ${ String ( error ) } ` ) ;
275+ cleanupErrors . push ( `${ label } cleanup verification failed: ${ String ( error ) } ` ) ;
212276 }
213- if ( cleanupErrors . length > 0 ) throw new Error ( cleanupErrors . join ( "; " ) ) ;
214277 }
278+ if ( port !== null ) {
279+ const lingeringProxy = await healthAt ( port ) ;
280+ if ( lingeringProxy ) {
281+ cleanupErrors . push ( `OpenCodex proxy PID ${ lingeringProxy . pid } remained on owned port ${ port } ` ) ;
282+ }
283+ }
284+ try {
285+ removeTree ( root ) ;
286+ } catch ( error ) {
287+ cleanupErrors . push ( `fixture cleanup failed: ${ String ( error ) } ` ) ;
288+ }
289+
290+ if ( hasPrimaryError ) {
291+ if ( cleanupErrors . length > 0 ) console . error ( `additional cleanup errors: ${ cleanupErrors . join ( "; " ) } ` ) ;
292+ throw primaryError ;
293+ }
294+ if ( cleanupErrors . length > 0 ) throw new Error ( cleanupErrors . join ( "; " ) ) ;
295+ if ( ! runtimePath ) throw new Error ( "proxy runtime path was not captured" ) ;
296+ return runtimePath ;
297+ }
298+
299+ function isolatedLauncherEnv ( root : string , override : string ) : NodeJS . ProcessEnv {
300+ const opencodexHome = join ( root , "opencodex" ) ;
301+ const codexHome = join ( root , "codex" ) ;
302+ const grokHome = join ( root , "grok" ) ;
303+ mkdirSync ( opencodexHome , { recursive : true } ) ;
304+ mkdirSync ( codexHome , { recursive : true } ) ;
305+ mkdirSync ( grokHome , { recursive : true } ) ;
306+ return {
307+ ...process . env ,
308+ HOME : root ,
309+ USERPROFILE : root ,
310+ OPENCODEX_HOME : opencodexHome ,
311+ CODEX_HOME : codexHome ,
312+ GROK_HOME : grokHome ,
313+ OPENCODEX_BUN_PATH : override ,
314+ } ;
215315}
216316
317+ describe . skipIf ( ! nodeAvailable ) ( "ocx npm launcher relative Bun override" , ( ) => {
318+ test ( "resolves a valid bare relative override before spawning" , ( ) => {
319+ const root = mkdtempSync ( join ( tmpdir ( ) , "ocx-launcher-relative-" ) ) ;
320+ try {
321+ const overrideName = `custom-bun${ process . platform === "win32" ? ".exe" : "" } ` ;
322+ const override = join ( root , overrideName ) ;
323+ copyFileSync ( process . execPath , override ) ;
324+ chmodSync ( override , 0o755 ) ;
325+
326+ const result = spawnSync ( "node" , [ BIN_OCX , "--version" ] , {
327+ cwd : root ,
328+ encoding : "utf8" ,
329+ timeout : 30_000 ,
330+ windowsHide : true ,
331+ env : isolatedLauncherEnv ( root , overrideName ) ,
332+ } ) ;
333+
334+ expect ( result . status ) . toBe ( 0 ) ;
335+ expect ( result . stderr ) . not . toContain ( "OPENCODEX_BUN_PATH is missing" ) ;
336+ } finally {
337+ removeTree ( root ) ;
338+ }
339+ } , 60_000 ) ;
340+
341+ test ( "warns without exposing the rejected override path before bundled fallback" , ( ) => {
342+ const root = mkdtempSync ( join ( tmpdir ( ) , "ocx-launcher-invalid-" ) ) ;
343+ try {
344+ const overrideName = `stub-bun${ process . platform === "win32" ? ".exe" : "" } ` ;
345+ writeFileSync ( join ( root , overrideName ) , "not a Bun executable" , "utf8" ) ;
346+
347+ const result = spawnSync ( "node" , [ BIN_OCX , "--version" ] , {
348+ cwd : root ,
349+ encoding : "utf8" ,
350+ timeout : 30_000 ,
351+ windowsHide : true ,
352+ env : isolatedLauncherEnv ( root , overrideName ) ,
353+ } ) ;
354+
355+ expect ( result . status ) . toBe ( 0 ) ;
356+ expect ( result . stderr ) . toContain ( "OPENCODEX_BUN_PATH is missing, unreadable, or not a complete Bun binary" ) ;
357+ expect ( result . stderr ) . not . toContain ( root ) ;
358+ } finally {
359+ removeTree ( root ) ;
360+ }
361+ } , 60_000 ) ;
362+ } ) ;
363+
217364describe . skipIf ( ! runnable ) ( "ocx npm launcher effective Bun runtime" , ( ) => {
218365 test ( "uses a valid OPENCODEX_BUN_PATH for the actual proxy process" , async ( ) => {
219366 const root = mkdtempSync ( join ( tmpdir ( ) , "ocx-launcher-runtime-copy-" ) ) ;
0 commit comments