From f786504cb135b0318f0075f55df5feb7c78aeb7a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 15 Sep 2026 02:06:50 +0200 Subject: [PATCH] fix(client): clear the scheduled retry when a connect attempt opens a transport An explicit connect() during reconnect backoff opened a new transport but left the scheduled retry armed. When it fired on the healthy connection it disposed the transport and reconnected, dropping requests in flight. This is why owned-subscriptions-old-daemon could still fail after #4858 added the explicit reconnect. attemptConnect now owns the rule ensureConnected already applied; the new unit test fails on the old client. --- packages/client/src/daemon-client.test.ts | 43 +++++++++++++++++++++++ packages/client/src/daemon-client.ts | 10 +++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 4d36bb686d..9ce4ec0df2 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -989,6 +989,49 @@ test("ensureConnected reconnects immediately without leaving the scheduled retry } }); +test("connect during a scheduled retry reconnects immediately without leaving the retry armed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const third = createMockTransport(); + const transports = [first, second, third]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_explicit_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "daemon restarted" }); + expect(client.getConnectionState().status).toBe("disconnected"); + + const reconnect = client.connect(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + + second.triggerOpen(); + await reconnect; + expect(client.getConnectionState().status).toBe("connected"); + await vi.advanceTimersByTimeAsync(1_500); + + expect(client.getConnectionState().status).toBe("connected"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + test("disabling reconnect cancels a pending retry until explicitly resumed", async () => { useHeartbeatClock(); try { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 458ccf5c92..ff13a98a6a 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1255,6 +1255,12 @@ export class DaemonClient { if (this.connectionState.status === "connecting") { return; } + // This attempt supersedes any retry the last disconnect scheduled. Left + // armed, that retry would tear down the connection this attempt opens. + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } const headers: Record = {}; const password = normalizePassword(this.config.password); @@ -1464,10 +1470,6 @@ export class DaemonClient { ) { return; } - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } if (this.connectPromise) { this.attemptConnect(); return;