@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1111import { startCodexAttemptThread } from "./attempt-startup.js" ;
1212import { CodexAppServerClient } from "./client.js" ;
1313import { type CodexPluginConfig , resolveCodexAppServerRuntimeOptions } from "./config.js" ;
14+ import { threadStartResult } from "./run-attempt-test-harness.js" ;
1415import { testCodexAppServerBindingStore } from "./session-binding.test-helpers.js" ;
1516import {
1617 clearSharedCodexAppServerClient ,
@@ -391,4 +392,88 @@ describe("startCodexAttemptThread", () => {
391392 expect ( ( error as Error ) . message ) . toBe ( "plugin/list timed out" ) ;
392393 expect ( harness . process . stdin . destroyed ) . toBe ( true ) ;
393394 } ) ;
395+
396+ it ( "retries startup across transient connection-close failures with a backoff (#83959)" , async ( ) => {
397+ // The app-server connection closes during startup on the first few attempts
398+ // while the replacement process is still warming up. Startup must survive
399+ // those transient closes (with a bounded backoff between attempts) instead
400+ // of exhausting the retry budget before the server is ready.
401+ const closeFailuresBeforeSuccess = 4 ;
402+ let factoryInvocations = 0 ;
403+ const backoffDelays : number [ ] = [ ] ;
404+ const backoffModule = await import ( "openclaw/plugin-sdk/runtime-env" ) ;
405+ const sleepSpy = vi
406+ . spyOn ( backoffModule , "sleepWithAbort" )
407+ . mockImplementation ( async ( ms : number ) => {
408+ backoffDelays . push ( ms ) ;
409+ } ) ;
410+
411+ const { harness, run } = startThreadWithHarness ( 30_000 , new AbortController ( ) . signal , {
412+ attemptClientFactory :
413+ ( ) =>
414+ async ( ...args ) => {
415+ factoryInvocations += 1 ;
416+ if ( factoryInvocations <= closeFailuresBeforeSuccess ) {
417+ throw new Error ( "codex app-server client is closed" ) ;
418+ }
419+ // On the recovering attempt, delegate to the default leased factory so
420+ // the shared-client + initialize handshake path matches a real startup.
421+ return getLeasedSharedCodexAppServerClient ( ...args ) ;
422+ } ,
423+ } ) ;
424+
425+ const settled = run . then (
426+ ( result ) => result ,
427+ ( error : unknown ) => error ,
428+ ) ;
429+ // The successful attempt still needs to answer the initialize handshake.
430+ await answerInitialize ( harness ) ;
431+ const threadStart = await waitForThreadStart ( harness ) ;
432+ harness . send ( { id : threadStart . id , result : threadStartResult ( "recovered-thread" ) } ) ;
433+
434+ const result = await settled ;
435+ expect ( result ) . not . toBeInstanceOf ( Error ) ;
436+ expect ( factoryInvocations ) . toBe ( closeFailuresBeforeSuccess + 1 ) ;
437+ // A backoff was awaited before every retry (one less than the number of
438+ // failed attempts, since the last failure is followed by the successful attempt).
439+ expect ( backoffDelays . length ) . toBe ( closeFailuresBeforeSuccess ) ;
440+ // Backoff is bounded and non-negative.
441+ for ( const delay of backoffDelays ) {
442+ expect ( delay ) . toBeGreaterThanOrEqual ( 0 ) ;
443+ expect ( delay ) . toBeLessThanOrEqual ( 4_000 ) ;
444+ }
445+ sleepSpy . mockRestore ( ) ;
446+ } ) ;
447+
448+ it ( "surfaces a distinct startup-exhausted error when connection-close persists (#83959)" , async ( ) => {
449+ // When the app-server connection keeps closing through the entire bounded
450+ // retry window, startup must fail with a distinguishable exhaustion error
451+ // (not the raw "client is closed" message) so callers can tell a startup
452+ // lifecycle failure apart from a mid-turn client close.
453+ const backoffModule = await import ( "openclaw/plugin-sdk/runtime-env" ) ;
454+ const sleepSpy = vi . spyOn ( backoffModule , "sleepWithAbort" ) . mockImplementation ( async ( ) => { } ) ;
455+
456+ let factoryInvocations = 0 ;
457+ const { run } = startThreadWithHarness ( 30_000 , new AbortController ( ) . signal , {
458+ attemptClientFactory : ( ) => async ( ) => {
459+ factoryInvocations += 1 ;
460+ throw new Error ( "codex app-server client is closed" ) ;
461+ } ,
462+ } ) ;
463+
464+ const error = await run . then (
465+ ( ) => undefined ,
466+ ( err : unknown ) => err as Error ,
467+ ) ;
468+ expect ( error ) . toBeInstanceOf ( Error ) ;
469+ // Distinct exhaustion marker, not the raw connection-closed message.
470+ expect ( error ?. message ) . not . toBe ( "codex app-server client is closed" ) ;
471+ expect ( error ?. message . toLowerCase ( ) ) . toContain ( "startup" ) ;
472+ expect ( error ?. message . toLowerCase ( ) ) . toContain ( "exhaust" ) ;
473+ // The original connection-closed cause is preserved for diagnostics.
474+ expect ( ( error as { cause ?: unknown } ) ?. cause ) . toBeInstanceOf ( Error ) ;
475+ // Bounded: the loop did not retry forever.
476+ expect ( factoryInvocations ) . toBeLessThanOrEqual ( 8 ) ;
477+ sleepSpy . mockRestore ( ) ;
478+ } ) ;
394479} ) ;
0 commit comments