From d12be9336f09e28d8aac16a02e88ee56ba42ae97 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:27:35 -0400 Subject: [PATCH] fix(client-runtime): reset tolerant reconnect budget across a network drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #25 (closes the last review nit on #21). The adaptive reconnect budget added in #25 arms a "tolerant" 45s establishment timeout after a backend-reached slow timeout. Three of the four run-loop branches that step away from an attempt already reset that budget, but the `network === "offline"` branch did not. A network drop is not backend slowness, so carrying the enlarged budget across the outage let the first attempt after the network returned wait 45s instead of the normal 15s. Reset `tolerant`/`consecutiveSlowTimeouts` in the offline branch (keeping `failureCount` so backoff continuity survives a transient blip) and add a regression test that arms the tolerant budget, blips offline→online, and asserts the reconnect times out on the normal 15s budget. Co-Authored-By: Claude Opus 4.8 --- .../src/connection/supervisor.test.ts | 37 +++++++++++++++++++ .../src/connection/supervisor.ts | 6 +++ 2 files changed, 43 insertions(+) diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index fcdbf7be3fa..ebdf20b5a16 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1021,6 +1021,43 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("resets the tolerant budget across a network drop", () => + Effect.gen(function* () { + // A slow timeout arms the tolerant 45s budget. A network drop is not + // backend slowness, so the reconnect after the network returns must start + // fresh on the normal 15s budget rather than carry the enlarged one. + const harness = yield* makeHarness({ ready: () => Effect.never }); + 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" && state.attempt === 1, + ); + yield* TestClock.adjust("15 seconds"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + // Network blips offline (which resets the tolerant classification) then back. + yield* harness.setNetworkStatus("offline"); + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + yield* harness.setNetworkStatus("online"); + + // The reconnect must time out at the normal 15s budget — a leaked tolerant + // flag would keep it "connecting" until 45s. + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "synchronizing", + ); + yield* TestClock.adjust("15 seconds"); + yield* eventuallyState(supervisor.state, (state) => state.phase === "backoff"); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("reconnects a relay session on a credentials change during a background probe", () => Effect.gen(function* () { // The first session's heartbeat probe stalls; a credentials change that diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index fac4660586c..b109cf1d759 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -698,6 +698,12 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( continue; } if (currentIntent.network === "offline") { + // A network drop is not backend slowness — don't carry a "slow" budget + // across the outage, so the first attempt after the network returns + // starts fresh on the normal establishment budget. (failureCount is kept + // so backoff continuity survives a transient blip.) + tolerant = false; + consecutiveSlowTimeouts = 0; yield* clearLease; yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure)); yield* waitForSignal;