From 991d877c19377dc9fdb199b2f5379dafd5480ff8 Mon Sep 17 00:00:00 2001 From: Abdoulaye Niangaly Date: Wed, 7 Oct 2026 19:10:54 +0000 Subject: [PATCH] fix(client-runtime): a slow first config snapshot no longer drops a working connection The supervisor raced every connect attempt against one 15 s budget that also covered the first config snapshot. Once the session is synchronizing, the attempt now gets a separate 2 minute bound for the snapshot. Dead sockets still fail early through the socket open timeout and ping check. Co-Authored-By: Claude Sonnet 5.5 --- .../src/connection/supervisor.test.ts | 80 ++++++++++++++++++- .../src/connection/supervisor.ts | 23 +++++- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 37adf87c1b47..597be7176ef6 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -488,7 +488,7 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("retries when a session never becomes ready", () => + it.effect("retries when a session never becomes ready within the snapshot bound", () => Effect.gen(function* () { const harness = yield* makeHarness({ ready: () => Effect.never, @@ -501,8 +501,14 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "connecting" && state.stage === "synchronizing", ); - yield* TestClock.adjust("14 seconds"); - expect((yield* SubscriptionRef.get(supervisor.state)).stage).toBe("synchronizing"); + // The setup budget has passed, but a synchronizing session keeps going. + yield* TestClock.adjust("15 seconds"); + yield* TestClock.adjust("119 seconds"); + expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ + phase: "connecting", + stage: "synchronizing", + }); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); yield* TestClock.adjust("1 second"); const retrying = yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); @@ -520,6 +526,74 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect( + "keeps a working connection while the first snapshot takes longer than the setup budget", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + ready: () => Effect.sleep("40 seconds"), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "synchronizing", + ); + yield* TestClock.adjust("20 seconds"); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connecting"); + + yield* TestClock.adjust("20 seconds"); + const connected = yield* awaitState( + supervisor.state, + (state) => state.phase === "connected", + ); + + expect(connected.attempt).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "reconnects as soon as a synchronizing session fails instead of waiting out the bound", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + ready: (attempt) => + attempt === 1 + ? Effect.sleep("20 seconds").pipe( + Effect.andThen( + Effect.fail( + new ConnectionTransientError({ + reason: "transport", + detail: "Test environment stopped responding.", + }), + ), + ), + ) + : Effect.void, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "synchronizing", + ); + yield* TestClock.adjust("20 seconds"); + const retrying = yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + + expect(retrying.lastFailure).toMatchObject({ + reason: "transport", + message: "Test environment stopped responding.", + }); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("interrupts and releases a connection attempt when setup times out", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index b710718e32ad..32c4a98ae726 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -36,6 +36,13 @@ const RETRY_BASE_DELAY_MS = 1_000; const RETRY_MAX_DELAY_MS = 300_000; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const establishmentTimeout = Duration.fromInputUnsafe(CONNECTION_ESTABLISHMENT_TIMEOUT); +// Extra time for the first config snapshot once the session is synchronizing. +// A server that is busy building it must not lose a socket that is working, and +// a dead socket never gets this far: the open timeout and the ping/pong check +// fail the session first. This is only the hard bound for a handler that stays +// silent behind a live connection. +const FIRST_SNAPSHOT_TIMEOUT = "2 minutes"; +const firstSnapshotTimeout = Duration.fromInputUnsafe(FIRST_SNAPSHOT_TIMEOUT); const CONNECTION_PROBE_TIMEOUT = "15 seconds"; // Mobile resumes, explicit retries, and offline events want a fast answer: // the user is waiting, or the network may be gone. @@ -274,6 +281,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); + // Whether the running attempt has a session and is waiting for its first + // config snapshot. Reset by the next route's earlier stages. + const synchronizing = yield* Ref.make(false); // Set while a probe of the live session is running, and kept when it fails // or times out: something asked whether the connection still works and it // closed or failed before answering, so the follow-up reconnect skips the @@ -414,6 +424,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( if ("prepared" in progress) { yield* SubscriptionRef.set(prepared, Option.some(progress.prepared)); } + yield* Ref.set(synchronizing, progress.stage === "synchronizing"); yield* setState( connectingState(yield* Ref.get(intent), generation, attempt, lastFailure, progress.stage), ); @@ -694,10 +705,18 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( resetRetry, })), ), - // Each route may use the full setup time before the next is tried. + // Each route may use the full setup time before the next is tried. An + // attempt that spent it on credentials, the session or the socket fails + // then; one already waiting for the first snapshot gets a longer bound. Effect.sleep( Duration.times(establishmentTimeout, connectionRoutes(yield* Ref.get(currentEntry)).length), - ).pipe(Effect.as({ _tag: "TimedOut" })), + ).pipe( + Effect.andThen(Ref.get(synchronizing)), + Effect.flatMap((waitingForSnapshot) => + waitingForSnapshot ? Effect.sleep(firstSnapshotTimeout) : Effect.void, + ), + Effect.as({ _tag: "TimedOut" }), + ), ]); if (establishment._tag === "Interrupted") {