@@ -14,8 +14,10 @@ import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./c
1414import { testCodexAppServerBindingStore } from "./session-binding.test-helpers.js" ;
1515import {
1616 clearSharedCodexAppServerClient ,
17+ clearSharedCodexAppServerClientAndWait ,
1718 getLeasedSharedCodexAppServerClient ,
1819 releaseLeasedSharedCodexAppServerClient ,
20+ type CodexAppServerClientFactory ,
1921} from "./shared-client.js" ;
2022import { createClientHarness , createCodexTestModel } from "./test-support.js" ;
2123
@@ -85,19 +87,18 @@ function startThreadWithHarness(
8587 signal = new AbortController ( ) . signal ,
8688 overrides ?: {
8789 pluginConfig ?: CodexPluginConfig ;
88- attemptClientFactory ?: (
89- harness : ClientHarness ,
90- ) => Parameters < typeof startCodexAttemptThread > [ 0 ] [ "attemptClientFactory" ] ;
90+ attemptClientFactory ?: ( harness : ClientHarness ) => CodexAppServerClientFactory ;
91+ buildAttemptParams ?: ( ) => EmbeddedRunAttemptParams ;
9192 harness ?: ClientHarness ;
9293 paths ?: AttemptPaths ;
9394 skipStartSpy ?: boolean ;
9495 } ,
9596) {
9697 const harness = overrides ?. harness ?? createClientHarness ( ) ;
9798 const paths = overrides ?. paths ?? createAttemptPaths ( ) ;
98- if ( ! overrides ?. skipStartSpy ) {
99- vi . spyOn ( CodexAppServerClient , "start" ) . mockReturnValue ( harness . client ) ;
100- }
99+ const startSpy = overrides ?. skipStartSpy
100+ ? undefined
101+ : vi . spyOn ( CodexAppServerClient , "start" ) . mockReturnValue ( harness . client ) ;
101102 const effectivePluginConfig = overrides ?. pluginConfig ?? pluginConfig ;
102103
103104 const run = startCodexAttemptThread ( {
@@ -112,7 +113,7 @@ function startThreadWithHarness(
112113 startupEnvApiKeyCacheKey : undefined ,
113114 agentDir : paths . agentDir ,
114115 config : undefined ,
115- buildAttemptParams : ( ) => createAttemptParams ( paths ) ,
116+ buildAttemptParams : overrides ?. buildAttemptParams ?? ( ( ) => createAttemptParams ( paths ) ) ,
116117 sessionAgentId : "agent-1" ,
117118 effectiveWorkspace : paths . workspaceDir ,
118119 effectiveCwd : paths . cwd ,
@@ -132,7 +133,7 @@ function startThreadWithHarness(
132133 spawnedBy : undefined ,
133134 } ) ;
134135
135- return { harness, run } ;
136+ return { harness, run, startSpy } ;
136137}
137138
138139async function answerInitialize ( harness : ClientHarness ) : Promise < void > {
@@ -166,6 +167,15 @@ async function waitForThreadStart(harness: ClientHarness): Promise<{ id?: number
166167 return waitForRequest ( harness , "thread/start" ) ;
167168}
168169
170+ function isProcessAlive ( pid : number ) : boolean {
171+ try {
172+ process . kill ( pid , 0 ) ;
173+ return true ;
174+ } catch {
175+ return false ;
176+ }
177+ }
178+
169179describe ( "startCodexAttemptThread" , ( ) => {
170180 beforeEach ( ( ) => {
171181 vi . useRealTimers ( ) ;
@@ -291,7 +301,13 @@ describe("startCodexAttemptThread", () => {
291301 } ) ;
292302
293303 it ( "closes the shared app-server when startup times out during initialize" , async ( ) => {
294- const { harness, run } = startThreadWithHarness ( 500 ) ;
304+ const initializeTimeoutPluginConfig = {
305+ ...pluginConfig ,
306+ appServer : { command : "codex" , requestTimeoutMs : 100 } ,
307+ } satisfies CodexPluginConfig ;
308+ const { harness, run } = startThreadWithHarness ( 500 , new AbortController ( ) . signal , {
309+ pluginConfig : initializeTimeoutPluginConfig ,
310+ } ) ;
295311 const runError = run . then (
296312 ( ) => undefined ,
297313 ( error : unknown ) => error ,
@@ -302,7 +318,7 @@ describe("startCodexAttemptThread", () => {
302318
303319 const error = await runError ;
304320 expect ( error ) . toBeInstanceOf ( Error ) ;
305- expect ( ( error as Error ) . message ) . toBe ( "codex app-server startup timed out" ) ;
321+ expect ( ( error as Error ) . message ) . toBe ( "codex app-server initialize timed out" ) ;
306322 await vi . waitFor ( ( ) => expect ( harness . stdinDestroyed ) . toBe ( true ) , {
307323 interval : 1 ,
308324 timeout : 1_000 ,
@@ -312,20 +328,142 @@ describe("startCodexAttemptThread", () => {
312328 ) . toBe ( false ) ;
313329 } ) ;
314330
331+ it ( "does not retire shared startup when this attempt's initialize wait expires" , async ( ) => {
332+ const sharedInitializePluginConfig = {
333+ ...pluginConfig ,
334+ appServer : { command : "codex" , requestTimeoutMs : 100 } ,
335+ } satisfies CodexPluginConfig ;
336+ const appServer = resolveCodexAppServerRuntimeOptions ( {
337+ pluginConfig : sharedInitializePluginConfig ,
338+ } ) ;
339+ const paths = createAttemptPaths ( ) ;
340+ const { harness, run, startSpy } = startThreadWithHarness ( 1_000 , new AbortController ( ) . signal , {
341+ pluginConfig : sharedInitializePluginConfig ,
342+ paths,
343+ } ) ;
344+ await waitForRequest ( harness , "initialize" ) ;
345+ const peerAcquire = getLeasedSharedCodexAppServerClient ( {
346+ startOptions : appServer . start ,
347+ agentDir : paths . agentDir ,
348+ timeoutMs : 1_000 ,
349+ } ) ;
350+
351+ await expect ( run ) . rejects . toThrow ( "codex app-server initialize timed out" ) ;
352+ expect ( harness . stdinDestroyed ) . toBe ( false ) ;
353+ await answerInitialize ( harness ) ;
354+ await expect ( peerAcquire ) . resolves . toBe ( harness . client ) ;
355+ await expect (
356+ getLeasedSharedCodexAppServerClient ( {
357+ startOptions : appServer . start ,
358+ agentDir : paths . agentDir ,
359+ timeoutMs : 1_000 ,
360+ } ) ,
361+ ) . resolves . toBe ( harness . client ) ;
362+ expect ( startSpy ) . toHaveBeenCalledTimes ( 1 ) ;
363+ expect ( releaseLeasedSharedCodexAppServerClient ( harness . client ) ) . toBe ( true ) ;
364+ expect ( releaseLeasedSharedCodexAppServerClient ( harness . client ) ) . toBe ( true ) ;
365+ } ) ;
366+
367+ it ( "bounds a real stdio initialize request and cleans up the child" , async ( ) => {
368+ const paths = createAttemptPaths ( ) ;
369+ const root = path . dirname ( paths . agentDir ) ;
370+ const fixturePath = path . join ( root , "stall-initialize.mjs" ) ;
371+ const requestLogPath = path . join ( root , "requests.log" ) ;
372+ const pidPath = path . join ( root , "child.pid" ) ;
373+ await fs . mkdir ( root , { recursive : true } ) ;
374+ await fs . writeFile (
375+ fixturePath ,
376+ [
377+ 'import fs from "node:fs";' ,
378+ 'import readline from "node:readline";' ,
379+ "const [requestLogPath, pidPath] = process.argv.slice(2);" ,
380+ 'fs.writeFileSync(pidPath, String(process.pid), "utf8");' ,
381+ "const lines = readline.createInterface({ input: process.stdin });" ,
382+ 'lines.on("line", (line) => {' ,
383+ " const message = JSON.parse(line);" ,
384+ ' fs.appendFileSync(requestLogPath, `${String(message.method)}\\n`, "utf8");' ,
385+ "});" ,
386+ "setInterval(() => undefined, 1000);" ,
387+ ] . join ( "\n" ) ,
388+ "utf8" ,
389+ ) ;
390+ const stdioPluginConfig = {
391+ appServer : {
392+ transport : "stdio" ,
393+ command : process . execPath ,
394+ args : [ fixturePath , requestLogPath , pidPath ] ,
395+ requestTimeoutMs : 2_000 ,
396+ } ,
397+ } satisfies CodexPluginConfig ;
398+ let childPid : number | undefined ;
399+
400+ try {
401+ const { run } = startThreadWithHarness ( 5_000 , new AbortController ( ) . signal , {
402+ pluginConfig : stdioPluginConfig ,
403+ paths,
404+ skipStartSpy : true ,
405+ } ) ;
406+
407+ await expect ( run ) . rejects . toThrow ( "codex app-server initialize timed out" ) ;
408+
409+ const requestMethods = ( await fs . readFile ( requestLogPath , "utf8" ) ) . trim ( ) . split ( / \r ? \n / u) ;
410+ expect ( requestMethods ) . toEqual ( [ "initialize" ] ) ;
411+ childPid = Number . parseInt ( await fs . readFile ( pidPath , "utf8" ) , 10 ) ;
412+ expect ( childPid ) . toBeGreaterThan ( 0 ) ;
413+ const observedPid = childPid ;
414+ await vi . waitFor ( ( ) => expect ( isProcessAlive ( observedPid ) ) . toBe ( false ) , {
415+ interval : 25 ,
416+ timeout : 3_000 ,
417+ } ) ;
418+ } finally {
419+ await clearSharedCodexAppServerClientAndWait ( {
420+ exitTimeoutMs : 3_000 ,
421+ forceKillDelayMs : 100 ,
422+ } ) ;
423+ if ( childPid && isProcessAlive ( childPid ) ) {
424+ try {
425+ process . kill ( childPid , "SIGKILL" ) ;
426+ } catch {
427+ // The child can exit between the liveness probe and fallback kill.
428+ }
429+ }
430+ }
431+ } ) ;
432+
433+ it ( "cleans up a client surfaced by a factory that later rejects" , async ( ) => {
434+ const { harness, run } = startThreadWithHarness ( 5_000 , new AbortController ( ) . signal , {
435+ attemptClientFactory : ( factoryHarness ) => async ( options ) => {
436+ options ?. onStartedClient ?.( factoryHarness . client ) ;
437+ throw new Error ( "custom initialize failed" ) ;
438+ } ,
439+ } ) ;
440+
441+ await expect ( run ) . rejects . toThrow ( "custom initialize failed" ) ;
442+ expect ( harness . stdinDestroyed ) . toBe ( true ) ;
443+ } ) ;
444+
315445 it ( "closes a startup client that arrives after startup timeout" , async ( ) => {
316446 let observedFactoryOptions :
317447 | {
318448 onStartedClient ?: ( client : CodexAppServerClient ) => void ;
319449 abandonSignal ?: AbortSignal ;
450+ timeoutMs ?: number ;
320451 }
321452 | undefined ;
453+ let factoryCalls = 0 ;
322454 let resolveFactoryDone : ( ) => void = ( ) => undefined ;
323455 const factoryDone = new Promise < void > ( ( resolve ) => {
324456 resolveFactoryDone = resolve ;
325457 } ) ;
458+ const delayedFactoryPluginConfig = {
459+ ...pluginConfig ,
460+ appServer : { command : "codex" , requestTimeoutMs : 2_500 } ,
461+ } satisfies CodexPluginConfig ;
326462 const { harness, run } = startThreadWithHarness ( 100 , new AbortController ( ) . signal , {
463+ pluginConfig : delayedFactoryPluginConfig ,
327464 attemptClientFactory : ( factoryHarness ) => async ( options ) => {
328465 try {
466+ factoryCalls += 1 ;
329467 observedFactoryOptions = options ;
330468 await new Promise < void > ( ( resolve ) => {
331469 setTimeout ( resolve , 250 ) ;
@@ -350,6 +488,8 @@ describe("startCodexAttemptThread", () => {
350488 ) . toBe ( false ) ;
351489 expect ( observedFactoryOptions ?. onStartedClient ) . toBeTypeOf ( "function" ) ;
352490 expect ( observedFactoryOptions ?. abandonSignal ?. aborted ) . toBe ( true ) ;
491+ expect ( observedFactoryOptions ?. timeoutMs ) . toBe ( 2_500 ) ;
492+ expect ( factoryCalls ) . toBe ( 1 ) ;
353493 } ) ;
354494
355495 it ( "clears the shared app-server when cancellation abandons an in-flight thread request" , async ( ) => {
0 commit comments