From 8f1eedb25f948d7aa9374c5af0647286fc1bd67b Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 30 Sep 2026 21:03:59 +0000 Subject: [PATCH 1/9] feat(websocket): flush the log buffer when a server is unreachable An unreachable server keeps retrying with backoff and never reaches a terminal failure reason, so the connection log buffer was never flushed and a user reproducing a "hangs on connecting" problem got no buffered detail in their support bundle. Track consecutive failed connect attempts on ReconnectingWebSocket and, once they reach a threshold (6, ~15s of retrying with the default backoff: past a transient blip, before the 30s cap), flush the buffer once with an "unreachable" reason and emit a connection.unreachable telemetry event. The `=== N` check keeps it to one flush per outage; a successful open resets the counter so a later outage flushes again. Closes #1112. --- CHANGELOG.md | 9 + src/instrumentation/EVENTS.md | 15 +- src/instrumentation/websocket.ts | 15 +- src/websocket/reconnectingWebSocket.ts | 32 ++++ test/unit/instrumentation/websocket.test.ts | 17 ++ .../websocket/reconnectingWebSocket.test.ts | 157 ++++++++++++++++++ 6 files changed, 242 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 464de98384..7724148a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ from published versions since it shows up in the VS Code extension changelog tab and is confusing to users. Add it back between releases if needed. --> +## Unreleased + +### Added + +- Flush the buffered connection logs into the Coder output channel after several + consecutive failed reconnect attempts, so the detail leading up to a "hangs on + connecting" problem is captured even when the server is simply unreachable and + the socket never reaches a terminal failure. + ## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23 ### Added diff --git a/src/instrumentation/EVENTS.md b/src/instrumentation/EVENTS.md index 9c5c5f42e5..766c6fadcb 100644 --- a/src/instrumentation/EVENTS.md +++ b/src/instrumentation/EVENTS.md @@ -16,7 +16,7 @@ new ones live in `CONVENTIONS.md`. | [Remote setup](#remote-setup) | `remote.setup` | | [SSH](#ssh) | `ssh.process.discovered`, `ssh.process.lost`, `ssh.process.recovered`, `ssh.process.replaced`, `ssh.process.disposed`, `ssh.network.sampled` | | [HTTP](#http) | `http.requests` | -| [WebSocket connections](#websocket-connections) | `connection.state_transitioned`, `connection.opened`, `connection.dropped`, `connection.reconnect_resolved` | +| [WebSocket connections](#websocket-connections) | `connection.state_transitioned`, `connection.opened`, `connection.dropped`, `connection.reconnect_resolved`, `connection.unreachable` | | [Workspace](#workspace) | `workspace.start.triggered`, `workspace.update.triggered`, `workspace.start.prompted`, `workspace.update.prompted`, `workspace.open`, `workspace.picker.prompted`, `workspace.dev_container.open`, `workspace.state_transitioned`, `workspace.agent.state_transitioned` | Signal kinds, which each category groups its events by: @@ -428,7 +428,7 @@ Emitted by `WebSocketTelemetry`. These events share one value set, **ConnectionStateReason**: `initial_connect`, `manual_reconnect`, `certificate_refresh`, `scheduled_reconnect`, `open`, `disconnect`, `dispose`, `unrecoverable_close`, `unrecoverable_http`, -`certificate_error`, `connection_error`, `unexpected_close`. +`certificate_error`, `connection_error`, `unexpected_close`, `unreachable`. ### Logs @@ -471,6 +471,17 @@ success or termination). | `max_backoff_ms` (measurement) | largest backoff scheduled | | `total_duration_ms` (measurement) | cycle wall time | +#### `connection.unreachable` + +Emitted once when the reconnect loop has failed enough consecutive times to +treat the server as unreachable (also flushes the connection log buffer). The +counter resets on a successful open, so a later outage emits again. + +| Attribute | Values | +| ------------------------ | ---------------------------------------- | +| `route` | normalized route | +| `attempts` (measurement) | consecutive failed attempts at the flush | + ## Workspace Emitted by `WorkspaceOperationTelemetry` (start and update), diff --git a/src/instrumentation/websocket.ts b/src/instrumentation/websocket.ts index 98c54bab6f..cc300e0b1c 100644 --- a/src/instrumentation/websocket.ts +++ b/src/instrumentation/websocket.ts @@ -16,7 +16,8 @@ export type ConnectionStateReason = | "unrecoverable_http" | "certificate_error" | "connection_error" - | "unexpected_close"; + | "unexpected_close" + | "unreachable"; export type ConnectionDropCause = | "manual_disconnect" @@ -127,6 +128,18 @@ export class WebSocketTelemetry { } } + /** + * The reconnect loop has failed enough consecutive times to treat the server + * as unreachable. Surfaced so Support can query sustained unreachability. + */ + public unreachable(route: string, attempts: number): void { + this.#telemetry.log( + "connection.unreachable", + { route: normalizeRoute(route) }, + { attempts }, + ); + } + public reset(): void { this.#connectStartedAtMs = undefined; this.#connectionOpenedAtMs = undefined; diff --git a/src/websocket/reconnectingWebSocket.ts b/src/websocket/reconnectingWebSocket.ts index f67a16d13e..d116baf100 100644 --- a/src/websocket/reconnectingWebSocket.ts +++ b/src/websocket/reconnectingWebSocket.ts @@ -114,6 +114,15 @@ export type SocketFactory = () => Promise>; /** Default failure callback for callers that do not observe connection failures. */ const NOOP_CONNECTION_FAILURE = (): void => undefined; +/** + * Consecutive failed reconnect attempts before the buffer is flushed once and + * the server is treated as unreachable. With the default backoff (250ms + * doubling to a 30s cap) the 6th attempt lands after ~15s of retrying: past a + * transient blip of one or two retries, before the 30s cap, and before a user + * reproducing a "hangs on connecting" issue would typically give up. + */ +const MAX_RECONNECT_FAILURES_BEFORE_FLUSH = 6; + export interface ReconnectingWebSocketOptions { initialBackoffMs?: number; maxBackoffMs?: number; @@ -152,6 +161,9 @@ export class ReconnectingWebSocket< #lastRoute: string; #backoffMs: number; #reconnectTimeoutId: NodeJS.Timeout | null = null; + // Consecutive failed connect attempts in the current outage. Reset on a + // successful open, so it only grows while the server stays unreachable. + #consecutiveConnectFailures = 0; #state: ConnectionState = ConnectionState.IDLE; #certRefreshAttempted = false; // Tracks if cert refresh was already attempted this connection cycle readonly #onDispose?: () => void; @@ -275,6 +287,7 @@ export class ReconnectingWebSocket< if (this.#state === ConnectionState.DISCONNECTED) { this.#backoffMs = this.#options.initialBackoffMs; this.#certRefreshAttempted = false; // User-initiated reconnect, allow retry + this.#consecutiveConnectFailures = 0; } if (this.#reconnectTimeoutId !== null) { @@ -375,6 +388,7 @@ export class ReconnectingWebSocket< // Reset backoff on successful connection this.#backoffMs = this.#options.initialBackoffMs; this.#certRefreshAttempted = false; + this.#consecutiveConnectFailures = 0; this.executeHandlers("open", event); }); @@ -450,6 +464,24 @@ export class ReconnectingWebSocket< if (!this.#dispatch({ type: "SCHEDULE_RETRY" }, reason)) { return; } + + // Each scheduled retry is one failed attempt. Once the loop has failed + // enough times against an unreachable server it never reaches a terminal + // reason, so flush the buffer once here; the `=== N` check keeps it to one + // flush per outage, and a successful open resets the counter. + this.#consecutiveConnectFailures += 1; + if ( + this.#consecutiveConnectFailures === MAX_RECONNECT_FAILURES_BEFORE_FLUSH + ) { + this.#logger.warn( + `Server unreachable after ${this.#consecutiveConnectFailures} reconnect attempts for ${this.#route}`, + ); + this.#telemetry.unreachable( + this.#route, + this.#consecutiveConnectFailures, + ); + this.#options.onConnectionFailure("unreachable", this.#route); + } const jitter = this.#backoffMs * this.#options.jitterFactor * (Math.random() * 2 - 1); const delayMs = Math.max(0, this.#backoffMs + jitter); diff --git a/test/unit/instrumentation/websocket.test.ts b/test/unit/instrumentation/websocket.test.ts index c461cc4f70..cf70885a00 100644 --- a/test/unit/instrumentation/websocket.test.ts +++ b/test/unit/instrumentation/websocket.test.ts @@ -30,6 +30,23 @@ describe("WebSocketTelemetry", () => { }); }); + describe("unreachable", () => { + it("emits connection.unreachable with the normalized route and attempts", () => { + const { ws, sink } = setup(); + + ws.unreachable( + "wss://coder.example.com/api/v2/workspaces/123e4567-e89b-12d3-a456-426614174000/watch-ws?token=secret", + 6, + ); + + const [event] = sink.eventsNamed("connection.unreachable"); + expect(event).toMatchObject({ + properties: { route: "/api/v2/workspaces/{id}/watch-ws" }, + measurements: { attempts: 6 }, + }); + }); + }); + describe("opened", () => { it("emits connection.opened with route and connect duration", () => { const { ws, sink } = setup(); diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts index 7d841ba7c5..c6276e6ab6 100644 --- a/test/unit/websocket/reconnectingWebSocket.test.ts +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -880,6 +880,163 @@ describe("ReconnectingWebSocket", () => { ws.close(); }); }); + + describe("Unreachable server", () => { + // Mirrors MAX_RECONNECT_FAILURES_BEFORE_FLUSH in the implementation. + const FAILURES_BEFORE_FLUSH = 6; + // Pathname of the mock socket's URL, which becomes the logged route. + const ROUTE = "/api/test"; + + async function setupUnreachable( + options: { telemetry?: TelemetryReporter } = {}, + ) { + const sockets: MockSocket[] = []; + let failing = false; + const factory = vi.fn(() => { + if (failing) { + return Promise.reject(new Error("connect ECONNREFUSED")); + } + const socket = createMockSocket(); + sockets.push(socket); + return Promise.resolve(socket); + }); + const onConnectionFailure = + vi.fn<(reason: ConnectionStateReason, route: string) => void>(); + // Constant backoff and no jitter, so each timer advance is exactly one + // failed attempt. + const ws = await ReconnectingWebSocket.create( + factory, + createMockLogger(), + { + telemetry: options.telemetry ?? NOOP_TELEMETRY_REPORTER, + route: "/api/v2/test", + onCertificateRefreshNeeded: () => Promise.resolve(false), + onConnectionFailure, + initialBackoffMs: 100, + maxBackoffMs: 100, + jitterFactor: 0, + }, + ); + sockets[0].fireOpen(); + + // Drop the healthy socket and make every reconnect fail. The close is + // the first failed attempt; each advance is the next. + const startOutage = (): void => { + failing = true; + sockets[sockets.length - 1].fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Connection lost", + }); + }; + const failNextAttempt = async (): Promise => { + await vi.advanceTimersByTimeAsync(100); + }; + const recover = async (): Promise => { + failing = false; + await vi.advanceTimersByTimeAsync(100); + sockets[sockets.length - 1].fireOpen(); + }; + return { + ws, + sockets, + onConnectionFailure, + startOutage, + failNextAttempt, + recover, + }; + } + + it("flushes once with the unreachable reason after N failed attempts", async () => { + const { ws, onConnectionFailure, startOutage, failNextAttempt } = + await setupUnreachable(); + + startOutage(); // attempt 1 + for (let i = 0; i < FAILURES_BEFORE_FLUSH - 2; i++) { + await failNextAttempt(); // through attempt N-1 + } + expect(onConnectionFailure).not.toHaveBeenCalled(); + + await failNextAttempt(); // attempt N + expect(onConnectionFailure).toHaveBeenCalledTimes(1); + expect(onConnectionFailure).toHaveBeenCalledWith("unreachable", ROUTE); + + ws.close(); + }); + + it("does not flush again while the server stays unreachable", async () => { + const { ws, onConnectionFailure, startOutage, failNextAttempt } = + await setupUnreachable(); + + startOutage(); + for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { + await failNextAttempt(); + } + expect(onConnectionFailure).toHaveBeenCalledTimes(1); + + for (let i = 0; i < 5; i++) { + await failNextAttempt(); + } + expect(onConnectionFailure).toHaveBeenCalledTimes(1); + + ws.close(); + }); + + it("flushes again after a successful open resets the counter", async () => { + const { ws, onConnectionFailure, startOutage, failNextAttempt, recover } = + await setupUnreachable(); + + startOutage(); + for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { + await failNextAttempt(); + } + expect(onConnectionFailure).toHaveBeenCalledTimes(1); + + await recover(); + + startOutage(); + for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { + await failNextAttempt(); + } + expect(onConnectionFailure).toHaveBeenCalledTimes(2); + + ws.close(); + }); + + it("does not flush a transient outage that recovers before N", async () => { + const { ws, onConnectionFailure, startOutage, failNextAttempt, recover } = + await setupUnreachable(); + + startOutage(); + await failNextAttempt(); + await failNextAttempt(); + await recover(); + + expect(onConnectionFailure).not.toHaveBeenCalled(); + ws.close(); + }); + + it("emits connection.unreachable once at the flush", async () => { + enableLocalTelemetry(); + const sink = new TestSink(); + const telemetry = createTestTelemetryService(sink); + const { ws, startOutage, failNextAttempt } = await setupUnreachable({ + telemetry, + }); + + startOutage(); + for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { + await failNextAttempt(); + } + + expect(sink.eventsNamed("connection.unreachable")).toMatchObject([ + { + properties: { route: ROUTE }, + measurements: { attempts: FAILURES_BEFORE_FLUSH }, + }, + ]); + ws.close(); + }); + }); }); type MockSocket = UnidirectionalStream & { From b512dd956cd4f2fe0ca51b031814dfa9b25f762a Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 1 Oct 2026 12:36:45 -0700 Subject: [PATCH 2/9] refactor: access last socket with at(-1) --- test/unit/websocket/reconnectingWebSocket.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts index c6276e6ab6..199e39aecb 100644 --- a/test/unit/websocket/reconnectingWebSocket.test.ts +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -923,7 +923,7 @@ describe("ReconnectingWebSocket", () => { // the first failed attempt; each advance is the next. const startOutage = (): void => { failing = true; - sockets[sockets.length - 1].fireClose({ + sockets.at(-1)?.fireClose({ code: WebSocketCloseCode.ABNORMAL, reason: "Connection lost", }); @@ -934,7 +934,7 @@ describe("ReconnectingWebSocket", () => { const recover = async (): Promise => { failing = false; await vi.advanceTimersByTimeAsync(100); - sockets[sockets.length - 1].fireOpen(); + sockets.at(-1)?.fireOpen(); }; return { ws, From cedad00cfd591edc3067e2875fa5c04346690fda Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 7 Oct 2026 18:21:42 +0000 Subject: [PATCH 3/9] docs(instrumentation): describe connection.unreachable without the buffer flush The buffer flush is a logging concern, not part of the telemetry event, so the EVENTS.md entry only describes when the event fires and its attributes. --- src/instrumentation/EVENTS.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/instrumentation/EVENTS.md b/src/instrumentation/EVENTS.md index 766c6fadcb..7f434b27f6 100644 --- a/src/instrumentation/EVENTS.md +++ b/src/instrumentation/EVENTS.md @@ -474,13 +474,13 @@ success or termination). #### `connection.unreachable` Emitted once when the reconnect loop has failed enough consecutive times to -treat the server as unreachable (also flushes the connection log buffer). The -counter resets on a successful open, so a later outage emits again. +treat the server as unreachable. The counter resets on a successful open, so a +later outage emits again. -| Attribute | Values | -| ------------------------ | ---------------------------------------- | -| `route` | normalized route | -| `attempts` (measurement) | consecutive failed attempts at the flush | +| Attribute | Values | +| ------------------------ | --------------------------- | +| `route` | normalized route | +| `attempts` (measurement) | consecutive failed attempts | ## Workspace From 665a6461e1e850f5accb0b72d4b2ec7d110c461c Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 7 Oct 2026 18:22:33 +0000 Subject: [PATCH 4/9] fix(websocket): make it clear an unreachable server keeps retrying Reaching the unreachable threshold only flushes the buffer; the loop keeps retrying at the backoff cap and never gives up, so a socket recovers after a sleep or long outage. The onConnectionFailure doc still said it fires only on terminal failures, and the warning read like the socket gave up. Reword both, note it in EVENTS.md and the changelog, and pin the behavior with a five-minute outage test that stays in AWAITING_RETRY without re-flushing and then reconnects. --- CHANGELOG.md | 3 ++- src/instrumentation/EVENTS.md | 4 ++-- src/websocket/reconnectingWebSocket.ts | 7 +++++-- test/unit/websocket/reconnectingWebSocket.test.ts | 14 +++++++++----- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5db64172..513f273fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ - Flush the buffered connection logs into the Coder output channel after several consecutive failed reconnect attempts, so the detail leading up to a "hangs on connecting" problem is captured even when the server is simply unreachable and - the socket never reaches a terminal failure. + the socket never reaches a terminal failure. The extension keeps retrying until + the server is reachable again. ## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23 diff --git a/src/instrumentation/EVENTS.md b/src/instrumentation/EVENTS.md index 7f434b27f6..bf54939ca2 100644 --- a/src/instrumentation/EVENTS.md +++ b/src/instrumentation/EVENTS.md @@ -474,8 +474,8 @@ success or termination). #### `connection.unreachable` Emitted once when the reconnect loop has failed enough consecutive times to -treat the server as unreachable. The counter resets on a successful open, so a -later outage emits again. +treat the server as unreachable. The socket keeps retrying, and the counter +resets on a successful open, so a later outage emits again. | Attribute | Values | | ------------------------ | --------------------------- | diff --git a/src/websocket/reconnectingWebSocket.ts b/src/websocket/reconnectingWebSocket.ts index d116baf100..3a04d9b634 100644 --- a/src/websocket/reconnectingWebSocket.ts +++ b/src/websocket/reconnectingWebSocket.ts @@ -132,7 +132,10 @@ export interface ReconnectingWebSocketOptions { route: string; /** Callback invoked when a refreshable certificate error is detected. Returns true if refresh succeeded. */ onCertificateRefreshNeeded: () => Promise; - /** Callback invoked when the connection fails terminally (not a transient drop). */ + /** + * Callback invoked on a terminal failure, or once per outage when the server + * stays unreachable. Retrying continues in the unreachable case. + */ onConnectionFailure?: (reason: ConnectionStateReason, route: string) => void; } @@ -474,7 +477,7 @@ export class ReconnectingWebSocket< this.#consecutiveConnectFailures === MAX_RECONNECT_FAILURES_BEFORE_FLUSH ) { this.#logger.warn( - `Server unreachable after ${this.#consecutiveConnectFailures} reconnect attempts for ${this.#route}`, + `Server unreachable after ${this.#consecutiveConnectFailures} reconnect attempts for ${this.#route}, still retrying`, ); this.#telemetry.unreachable( this.#route, diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts index 199e39aecb..68ee9e0b94 100644 --- a/test/unit/websocket/reconnectingWebSocket.test.ts +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -963,8 +963,8 @@ describe("ReconnectingWebSocket", () => { ws.close(); }); - it("does not flush again while the server stays unreachable", async () => { - const { ws, onConnectionFailure, startOutage, failNextAttempt } = + it("keeps retrying through a long outage without flushing again, then recovers", async () => { + const { ws, onConnectionFailure, startOutage, failNextAttempt, recover } = await setupUnreachable(); startOutage(); @@ -973,9 +973,13 @@ describe("ReconnectingWebSocket", () => { } expect(onConnectionFailure).toHaveBeenCalledTimes(1); - for (let i = 0; i < 5; i++) { - await failNextAttempt(); - } + // A long outage (sleep, network loss) never gives up or re-flushes. + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(ws.state).toBe(ConnectionState.AWAITING_RETRY); + expect(onConnectionFailure).toHaveBeenCalledTimes(1); + + await recover(); + expect(ws.state).toBe(ConnectionState.CONNECTED); expect(onConnectionFailure).toHaveBeenCalledTimes(1); ws.close(); From c3a01c013dfb243fc46fe1664ddca46d4c5661e5 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 7 Oct 2026 11:57:28 -0700 Subject: [PATCH 5/9] docs: rm yap --- src/websocket/reconnectingWebSocket.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/websocket/reconnectingWebSocket.ts b/src/websocket/reconnectingWebSocket.ts index 3a04d9b634..914d6af7ff 100644 --- a/src/websocket/reconnectingWebSocket.ts +++ b/src/websocket/reconnectingWebSocket.ts @@ -116,10 +116,7 @@ const NOOP_CONNECTION_FAILURE = (): void => undefined; /** * Consecutive failed reconnect attempts before the buffer is flushed once and - * the server is treated as unreachable. With the default backoff (250ms - * doubling to a 30s cap) the 6th attempt lands after ~15s of retrying: past a - * transient blip of one or two retries, before the 30s cap, and before a user - * reproducing a "hangs on connecting" issue would typically give up. + * the server is treated as unreachable. */ const MAX_RECONNECT_FAILURES_BEFORE_FLUSH = 6; @@ -164,8 +161,6 @@ export class ReconnectingWebSocket< #lastRoute: string; #backoffMs: number; #reconnectTimeoutId: NodeJS.Timeout | null = null; - // Consecutive failed connect attempts in the current outage. Reset on a - // successful open, so it only grows while the server stays unreachable. #consecutiveConnectFailures = 0; #state: ConnectionState = ConnectionState.IDLE; #certRefreshAttempted = false; // Tracks if cert refresh was already attempted this connection cycle From 491effb45e0aebbc91fb7e49ddd30e44867af86a Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 7 Oct 2026 19:23:41 +0000 Subject: [PATCH 6/9] test(websocket): name the unreachable tests' backoff The 100ms used for the backoff options and every timer advance was the same value; a BACKOFF_MS constant makes it clear each advance is one failed attempt. --- test/unit/websocket/reconnectingWebSocket.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts index 68ee9e0b94..ce0d88c326 100644 --- a/test/unit/websocket/reconnectingWebSocket.test.ts +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -886,6 +886,8 @@ describe("ReconnectingWebSocket", () => { const FAILURES_BEFORE_FLUSH = 6; // Pathname of the mock socket's URL, which becomes the logged route. const ROUTE = "/api/test"; + // Constant backoff, so advancing by it is exactly one failed attempt. + const BACKOFF_MS = 100; async function setupUnreachable( options: { telemetry?: TelemetryReporter } = {}, @@ -902,8 +904,6 @@ describe("ReconnectingWebSocket", () => { }); const onConnectionFailure = vi.fn<(reason: ConnectionStateReason, route: string) => void>(); - // Constant backoff and no jitter, so each timer advance is exactly one - // failed attempt. const ws = await ReconnectingWebSocket.create( factory, createMockLogger(), @@ -912,8 +912,8 @@ describe("ReconnectingWebSocket", () => { route: "/api/v2/test", onCertificateRefreshNeeded: () => Promise.resolve(false), onConnectionFailure, - initialBackoffMs: 100, - maxBackoffMs: 100, + initialBackoffMs: BACKOFF_MS, + maxBackoffMs: BACKOFF_MS, jitterFactor: 0, }, ); @@ -929,11 +929,11 @@ describe("ReconnectingWebSocket", () => { }); }; const failNextAttempt = async (): Promise => { - await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(BACKOFF_MS); }; const recover = async (): Promise => { failing = false; - await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(BACKOFF_MS); sockets.at(-1)?.fireOpen(); }; return { From 7fdf5a0ea2ba947911eeea32334f55a8561b536f Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 7 Oct 2026 19:24:05 +0000 Subject: [PATCH 7/9] test(websocket): build the unreachable setup on the shared helpers setupUnreachable hand-rolled a failing factory and called ReconnectingWebSocket.create directly. Forward the backoff and jitter options through FactoryOptions/fromFactory and build it on createReconnectingWebSocketWithErrorControl, toggling failures with setFactoryError. --- .../websocket/reconnectingWebSocket.test.ts | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts index ce0d88c326..0e9b7be081 100644 --- a/test/unit/websocket/reconnectingWebSocket.test.ts +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -892,37 +892,22 @@ describe("ReconnectingWebSocket", () => { async function setupUnreachable( options: { telemetry?: TelemetryReporter } = {}, ) { - const sockets: MockSocket[] = []; - let failing = false; - const factory = vi.fn(() => { - if (failing) { - return Promise.reject(new Error("connect ECONNREFUSED")); - } - const socket = createMockSocket(); - sockets.push(socket); - return Promise.resolve(socket); - }); const onConnectionFailure = vi.fn<(reason: ConnectionStateReason, route: string) => void>(); - const ws = await ReconnectingWebSocket.create( - factory, - createMockLogger(), - { - telemetry: options.telemetry ?? NOOP_TELEMETRY_REPORTER, - route: "/api/v2/test", - onCertificateRefreshNeeded: () => Promise.resolve(false), + const { ws, sockets, setFactoryError } = + await createReconnectingWebSocketWithErrorControl({ + ...options, onConnectionFailure, initialBackoffMs: BACKOFF_MS, maxBackoffMs: BACKOFF_MS, jitterFactor: 0, - }, - ); + }); sockets[0].fireOpen(); // Drop the healthy socket and make every reconnect fail. The close is // the first failed attempt; each advance is the next. const startOutage = (): void => { - failing = true; + setFactoryError(new Error("connect ECONNREFUSED")); sockets.at(-1)?.fireClose({ code: WebSocketCloseCode.ABNORMAL, reason: "Connection lost", @@ -932,7 +917,7 @@ describe("ReconnectingWebSocket", () => { await vi.advanceTimersByTimeAsync(BACKOFF_MS); }; const recover = async (): Promise => { - failing = false; + setFactoryError(null); await vi.advanceTimersByTimeAsync(BACKOFF_MS); sockets.at(-1)?.fireOpen(); }; @@ -1121,6 +1106,9 @@ interface FactoryOptions { onConnectionFailure?: (reason: ConnectionStateReason, route: string) => void; route?: string; telemetry?: TelemetryReporter; + initialBackoffMs?: number; + maxBackoffMs?: number; + jitterFactor?: number; } async function createReconnectingWebSocket( @@ -1187,6 +1175,9 @@ async function fromFactory( onCertificateRefreshNeeded: options.onCertificateRefreshNeeded ?? (() => Promise.resolve(false)), onConnectionFailure: options.onConnectionFailure ?? vi.fn(), + initialBackoffMs: options.initialBackoffMs, + maxBackoffMs: options.maxBackoffMs, + jitterFactor: options.jitterFactor, }, options.onDispose, ); From 3a77992f3e92af3b0b765d2fe5ad4fd18638b9d6 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 7 Oct 2026 19:24:43 +0000 Subject: [PATCH 8/9] test(websocket): share the reach-the-flush loop across unreachable tests Four tests repeated startOutage() plus N-1 failed attempts. A failUntilFlush() helper replaces the loop; the threshold test keeps its explicit loop because it asserts nothing flushes before N. --- .../websocket/reconnectingWebSocket.test.ts | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts index 0e9b7be081..bcda57c12b 100644 --- a/test/unit/websocket/reconnectingWebSocket.test.ts +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -916,6 +916,13 @@ describe("ReconnectingWebSocket", () => { const failNextAttempt = async (): Promise => { await vi.advanceTimersByTimeAsync(BACKOFF_MS); }; + // Fail exactly enough attempts to reach the flush. + const failUntilFlush = async (): Promise => { + startOutage(); + for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { + await failNextAttempt(); + } + }; const recover = async (): Promise => { setFactoryError(null); await vi.advanceTimersByTimeAsync(BACKOFF_MS); @@ -927,6 +934,7 @@ describe("ReconnectingWebSocket", () => { onConnectionFailure, startOutage, failNextAttempt, + failUntilFlush, recover, }; } @@ -949,13 +957,10 @@ describe("ReconnectingWebSocket", () => { }); it("keeps retrying through a long outage without flushing again, then recovers", async () => { - const { ws, onConnectionFailure, startOutage, failNextAttempt, recover } = + const { ws, onConnectionFailure, failUntilFlush, recover } = await setupUnreachable(); - startOutage(); - for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { - await failNextAttempt(); - } + await failUntilFlush(); expect(onConnectionFailure).toHaveBeenCalledTimes(1); // A long outage (sleep, network loss) never gives up or re-flushes. @@ -971,21 +976,15 @@ describe("ReconnectingWebSocket", () => { }); it("flushes again after a successful open resets the counter", async () => { - const { ws, onConnectionFailure, startOutage, failNextAttempt, recover } = + const { ws, onConnectionFailure, failUntilFlush, recover } = await setupUnreachable(); - startOutage(); - for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { - await failNextAttempt(); - } + await failUntilFlush(); expect(onConnectionFailure).toHaveBeenCalledTimes(1); await recover(); - startOutage(); - for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { - await failNextAttempt(); - } + await failUntilFlush(); expect(onConnectionFailure).toHaveBeenCalledTimes(2); ws.close(); @@ -1008,14 +1007,11 @@ describe("ReconnectingWebSocket", () => { enableLocalTelemetry(); const sink = new TestSink(); const telemetry = createTestTelemetryService(sink); - const { ws, startOutage, failNextAttempt } = await setupUnreachable({ + const { ws, failUntilFlush } = await setupUnreachable({ telemetry, }); - startOutage(); - for (let i = 0; i < FAILURES_BEFORE_FLUSH - 1; i++) { - await failNextAttempt(); - } + await failUntilFlush(); expect(sink.eventsNamed("connection.unreachable")).toMatchObject([ { From 96303b084ad2fed601d06fa03808a6381807bb99 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 7 Oct 2026 19:25:13 +0000 Subject: [PATCH 9/9] test(websocket): only check the unreachable event is emitted The event's route and attempts are covered by the WebSocketTelemetry unit test, so the reconnecting test just checks the socket emits it at the flush. --- test/unit/websocket/reconnectingWebSocket.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts index bcda57c12b..7e9ee90cbe 100644 --- a/test/unit/websocket/reconnectingWebSocket.test.ts +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -1003,7 +1003,7 @@ describe("ReconnectingWebSocket", () => { ws.close(); }); - it("emits connection.unreachable once at the flush", async () => { + it("emits connection.unreachable at the flush", async () => { enableLocalTelemetry(); const sink = new TestSink(); const telemetry = createTestTelemetryService(sink); @@ -1013,12 +1013,7 @@ describe("ReconnectingWebSocket", () => { await failUntilFlush(); - expect(sink.eventsNamed("connection.unreachable")).toMatchObject([ - { - properties: { route: ROUTE }, - measurements: { attempts: FAILURES_BEFORE_FLUSH }, - }, - ]); + expect(sink.eventsNamed("connection.unreachable")).toHaveLength(1); ws.close(); }); });