| author | |
| committer | |
| log | 528bb2a1fa318962437281e68723bf0bb5a589d7 |
| tree | a9f7cbfa2212d493f7bc24ba03fa1b2edff91c64 |
| parent | 869789b4edd7ff71ed717feb1dc1935aef36e5ad |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>8 files changed, 237 insertions(+), 83 deletions(-)
apps/web/src/components/WebSocketConnectionSurface.logic.test.ts+31-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import type { WsConnectionStatus } from "../rpc/wsConnectionState"; | 3 | import type { WsConnectionStatus } from "../rpc/wsConnectionState"; |
| 4 | import { shouldAutoReconnect } from "./WebSocketConnectionSurface"; | 4 | import { shouldAutoReconnect, shouldRestartStalledReconnect } from "./WebSocketConnectionSurface"; |
| 5 | 5 | ||
| 6 | function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionStatus { | 6 | function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionStatus { |
| 7 | return { | 7 | return { |
| ... | @@ -80,4 +80,34 @@ describe("WebSocketConnectionSurface.logic", () => { | ... | @@ -80,4 +80,34 @@ describe("WebSocketConnectionSurface.logic", () => { |
| 80 | ), | 80 | ), |
| 81 | ).toBe(true); | 81 | ).toBe(true); |
| 82 | }); | 82 | }); |
| 83 | |||
| 84 | it("restarts a stalled reconnect window after the scheduled retry time passes", () => { | ||
| 85 | expect( | ||
| 86 | shouldRestartStalledReconnect( | ||
| 87 | makeStatus({ | ||
| 88 | hasConnected: true, | ||
| 89 | nextRetryAt: "2026-04-03T20:00:01.000Z", | ||
| 90 | online: true, | ||
| 91 | phase: "disconnected", | ||
| 92 | reconnectAttemptCount: 3, | ||
| 93 | reconnectPhase: "waiting", | ||
| 94 | }), | ||
| 95 | "2026-04-03T20:00:01.000Z", | ||
| 96 | ), | ||
| 97 | ).toBe(true); | ||
| 98 | |||
| 99 | expect( | ||
| 100 | shouldRestartStalledReconnect( | ||
| 101 | makeStatus({ | ||
| 102 | hasConnected: true, | ||
| 103 | nextRetryAt: "2026-04-03T20:00:01.000Z", | ||
| 104 | online: true, | ||
| 105 | phase: "disconnected", | ||
| 106 | reconnectAttemptCount: 3, | ||
| 107 | reconnectPhase: "attempting", | ||
| 108 | }), | ||
| 109 | "2026-04-03T20:00:01.000Z", | ||
| 110 | ), | ||
| 111 | ).toBe(false); | ||
| 112 | }); | ||
| 83 | }); | 113 | }); |
apps/web/src/components/WebSocketConnectionSurface.tsx+18-2| ... | @@ -2,7 +2,6 @@ import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "rea | ... | @@ -2,7 +2,6 @@ import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "rea |
| 2 | 2 | ||
| 3 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; | 3 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; |
| 4 | import { | 4 | import { |
| 5 | exhaustWsReconnectIfStillWaiting, | ||
| 6 | getWsConnectionStatus, | 5 | getWsConnectionStatus, |
| 7 | getWsConnectionUiState, | 6 | getWsConnectionUiState, |
| 8 | setBrowserOnlineStatus, | 7 | setBrowserOnlineStatus, |
| ... | @@ -105,6 +104,18 @@ export function shouldAutoReconnect( | ... | @@ -105,6 +104,18 @@ export function shouldAutoReconnect( |
| 105 | ); | 104 | ); |
| 106 | } | 105 | } |
| 107 | 106 | ||
| 107 | export function shouldRestartStalledReconnect( | ||
| 108 | status: WsConnectionStatus, | ||
| 109 | expectedNextRetryAt: string, | ||
| 110 | ): boolean { | ||
| 111 | return ( | ||
| 112 | status.reconnectPhase === "waiting" && | ||
| 113 | status.nextRetryAt === expectedNextRetryAt && | ||
| 114 | status.online && | ||
| 115 | status.hasConnected | ||
| 116 | ); | ||
| 117 | } | ||
| 118 | |||
| 108 | export function WebSocketConnectionCoordinator() { | 119 | export function WebSocketConnectionCoordinator() { |
| 109 | const status = useWsConnectionStatus(); | 120 | const status = useWsConnectionStatus(); |
| 110 | const [nowMs, setNowMs] = useState(() => Date.now()); | 121 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| ... | @@ -205,7 +216,12 @@ export function WebSocketConnectionCoordinator() { | ... | @@ -205,7 +216,12 @@ export function WebSocketConnectionCoordinator() { |
| 205 | const nextRetryAt = status.nextRetryAt; | 216 | const nextRetryAt = status.nextRetryAt; |
| 206 | const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; | 217 | const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; |
| 207 | const timeoutId = window.setTimeout(() => { | 218 | const timeoutId = window.setTimeout(() => { |
| 208 | exhaustWsReconnectIfStillWaiting(nextRetryAt); | 219 | const currentStatus = getWsConnectionStatus(); |
| 220 | if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) { | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | |||
| 224 | runReconnect(false); | ||
| 209 | }, timeoutMs); | 225 | }, timeoutMs); |
| 210 | 226 | ||
| 211 | return () => { | 227 | return () => { |
apps/web/src/environments/runtime/connection.test.ts+59| ... | @@ -100,6 +100,7 @@ function createTestClient(options?: { | ... | @@ -100,6 +100,7 @@ function createTestClient(options?: { |
| 100 | return { | 100 | return { |
| 101 | client, | 101 | client, |
| 102 | getSnapshot, | 102 | getSnapshot, |
| 103 | replayEvents, | ||
| 103 | emitWelcome: (environmentId: EnvironmentId) => { | 104 | emitWelcome: (environmentId: EnvironmentId) => { |
| 104 | for (const listener of lifecycleListeners) { | 105 | for (const listener of lifecycleListeners) { |
| 105 | listener({ | 106 | listener({ |
| ... | @@ -227,6 +228,64 @@ describe("createEnvironmentConnection", () => { | ... | @@ -227,6 +228,64 @@ describe("createEnvironmentConnection", () => { |
| 227 | await connection.dispose(); | 228 | await connection.dispose(); |
| 228 | }); | 229 | }); |
| 229 | 230 | ||
| 231 | it("retries replay recovery after transport disconnects during resubscribe", async () => { | ||
| 232 | const environmentId = EnvironmentId.makeUnsafe("env-1"); | ||
| 233 | let replayAttempts = 0; | ||
| 234 | const applyEventBatch = vi.fn(); | ||
| 235 | const { client, replayEvents, triggerDomainResubscribe } = createTestClient({ | ||
| 236 | replayEvents: async () => { | ||
| 237 | replayAttempts += 1; | ||
| 238 | if (replayAttempts === 1) { | ||
| 239 | throw new Error("SocketCloseError: 1006"); | ||
| 240 | } | ||
| 241 | |||
| 242 | return [ | ||
| 243 | { | ||
| 244 | sequence: 2, | ||
| 245 | type: "thread.created", | ||
| 246 | payload: {}, | ||
| 247 | }, | ||
| 248 | ]; | ||
| 249 | }, | ||
| 250 | }); | ||
| 251 | |||
| 252 | const connection = createEnvironmentConnection({ | ||
| 253 | kind: "saved", | ||
| 254 | knownEnvironment: { | ||
| 255 | id: "env-1", | ||
| 256 | label: "Remote env", | ||
| 257 | source: "manual", | ||
| 258 | target: { | ||
| 259 | httpBaseUrl: "http://example.test", | ||
| 260 | wsBaseUrl: "ws://example.test", | ||
| 261 | }, | ||
| 262 | environmentId, | ||
| 263 | }, | ||
| 264 | client, | ||
| 265 | applyEventBatch, | ||
| 266 | syncSnapshot: vi.fn(), | ||
| 267 | applyTerminalEvent: vi.fn(), | ||
| 268 | }); | ||
| 269 | |||
| 270 | await Promise.resolve(); | ||
| 271 | await Promise.resolve(); | ||
| 272 | |||
| 273 | triggerDomainResubscribe(); | ||
| 274 | |||
| 275 | await vi.waitFor(() => { | ||
| 276 | expect(replayEvents).toHaveBeenCalledTimes(2); | ||
| 277 | expect(applyEventBatch).toHaveBeenCalledWith( | ||
| 278 | [ | ||
| 279 | expect.objectContaining({ | ||
| 280 | sequence: 2, | ||
| 281 | }), | ||
| 282 | ], | ||
| 283 | environmentId, | ||
| 284 | ); | ||
| 285 | }); | ||
| 286 | |||
| 287 | await connection.dispose(); | ||
| 288 | }); | ||
| 230 | it("swallows replay recovery failures triggered by resubscribe", async () => { | 289 | it("swallows replay recovery failures triggered by resubscribe", async () => { |
| 231 | const environmentId = EnvironmentId.makeUnsafe("env-1"); | 290 | const environmentId = EnvironmentId.makeUnsafe("env-1"); |
| 232 | const snapshotError = new Error("snapshot failed"); | 291 | const snapshotError = new Error("snapshot failed"); |
apps/web/src/environments/runtime/connection.ts+36-2| ... | @@ -16,10 +16,13 @@ import { | ... | @@ -16,10 +16,13 @@ import { |
| 16 | createOrchestrationRecoveryCoordinator, | 16 | createOrchestrationRecoveryCoordinator, |
| 17 | type ReplayRetryTracker, | 17 | type ReplayRetryTracker, |
| 18 | } from "../../orchestrationRecovery"; | 18 | } from "../../orchestrationRecovery"; |
| 19 | import { isTransportConnectionErrorMessage } from "~/rpc/transportError"; | ||
| 19 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | 20 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; |
| 20 | 21 | ||
| 21 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; | 22 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; |
| 22 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; | 23 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; |
| 24 | const RECOVERY_TRANSPORT_RETRY_DELAY_MS = 250; | ||
| 25 | const MAX_RECOVERY_TRANSPORT_RETRIES = 20; | ||
| 23 | 26 | ||
| 24 | export interface EnvironmentConnection { | 27 | export interface EnvironmentConnection { |
| 25 | readonly kind: "primary" | "saved"; | 28 | readonly kind: "primary" | "saved"; |
| ... | @@ -128,6 +131,30 @@ export function createEnvironmentConnection( | ... | @@ -128,6 +131,30 @@ export function createEnvironmentConnection( |
| 128 | queueMicrotask(flushPendingDomainEvents); | 131 | queueMicrotask(flushPendingDomainEvents); |
| 129 | }; | 132 | }; |
| 130 | 133 | ||
| 134 | const retryTransportRecoveryOperation = async <T>(operation: () => Promise<T>): Promise<T> => { | ||
| 135 | for (let attempt = 0; ; attempt += 1) { | ||
| 136 | try { | ||
| 137 | return await operation(); | ||
| 138 | } catch (error) { | ||
| 139 | const message = error instanceof Error ? error.message : String(error); | ||
| 140 | if ( | ||
| 141 | disposed || | ||
| 142 | !isTransportConnectionErrorMessage(message) || | ||
| 143 | attempt >= MAX_RECOVERY_TRANSPORT_RETRIES - 1 | ||
| 144 | ) { | ||
| 145 | throw error; | ||
| 146 | } | ||
| 147 | |||
| 148 | await new Promise<void>((resolve) => { | ||
| 149 | setTimeout(resolve, RECOVERY_TRANSPORT_RETRY_DELAY_MS); | ||
| 150 | }); | ||
| 151 | |||
| 152 | if (disposed) { | ||
| 153 | throw error; | ||
| 154 | } | ||
| 155 | } | ||
| 156 | } | ||
| 157 | }; | ||
| 131 | const scheduleReplayRecovery = (reason: "sequence-gap" | "resubscribe") => { | 158 | const scheduleReplayRecovery = (reason: "sequence-gap" | "resubscribe") => { |
| 132 | void runReplayRecovery(reason).catch(() => undefined); | 159 | void runReplayRecovery(reason).catch(() => undefined); |
| 133 | }; | 160 | }; |
| ... | @@ -139,7 +166,9 @@ export function createEnvironmentConnection( | ... | @@ -139,7 +166,9 @@ export function createEnvironmentConnection( |
| 139 | 166 | ||
| 140 | const fromSequenceExclusive = recovery.getState().latestSequence; | 167 | const fromSequenceExclusive = recovery.getState().latestSequence; |
| 141 | try { | 168 | try { |
| 142 | const events = await input.client.orchestration.replayEvents({ fromSequenceExclusive }); | 169 | const events = await retryTransportRecoveryOperation(() => |
| 170 | input.client.orchestration.replayEvents({ fromSequenceExclusive }), | ||
| 171 | ); | ||
| 143 | if (!disposed) { | 172 | if (!disposed) { |
| 144 | const nextEvents = recovery.markEventBatchApplied(events); | 173 | const nextEvents = recovery.markEventBatchApplied(events); |
| 145 | if (nextEvents.length > 0) { | 174 | if (nextEvents.length > 0) { |
| ... | @@ -149,6 +178,9 @@ export function createEnvironmentConnection( | ... | @@ -149,6 +178,9 @@ export function createEnvironmentConnection( |
| 149 | } catch { | 178 | } catch { |
| 150 | replayRetryTracker = null; | 179 | replayRetryTracker = null; |
| 151 | recovery.failReplayRecovery(); | 180 | recovery.failReplayRecovery(); |
| 181 | if (disposed) { | ||
| 182 | return; | ||
| 183 | } | ||
| 152 | await snapshotBootstrap.ensureSnapshotRecovery("replay-failed"); | 184 | await snapshotBootstrap.ensureSnapshotRecovery("replay-failed"); |
| 153 | return; | 185 | return; |
| 154 | } | 186 | } |
| ... | @@ -198,7 +230,9 @@ export function createEnvironmentConnection( | ... | @@ -198,7 +230,9 @@ export function createEnvironmentConnection( |
| 198 | } | 230 | } |
| 199 | 231 | ||
| 200 | try { | 232 | try { |
| 201 | const snapshot = await input.client.orchestration.getSnapshot(); | 233 | const snapshot = await retryTransportRecoveryOperation(() => |
| 234 | input.client.orchestration.getSnapshot(), | ||
| 235 | ); | ||
| 202 | if (!disposed) { | 236 | if (!disposed) { |
| 203 | input.syncSnapshot(snapshot, environmentId); | 237 | input.syncSnapshot(snapshot, environmentId); |
| 204 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { | 238 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { |
apps/web/src/rpc/protocol.ts+26-4| ... | @@ -62,14 +62,36 @@ function defaultLifecycleHandlers(): Required<WsProtocolLifecycleHandlers> { | ... | @@ -62,14 +62,36 @@ function defaultLifecycleHandlers(): Required<WsProtocolLifecycleHandlers> { |
| 62 | }; | 62 | }; |
| 63 | } | 63 | } |
| 64 | 64 | ||
| 65 | function composeLifecycleHandlers( | ||
| 66 | handlers?: WsProtocolLifecycleHandlers, | ||
| 67 | ): Required<WsProtocolLifecycleHandlers> { | ||
| 68 | const defaults = defaultLifecycleHandlers(); | ||
| 69 | |||
| 70 | return { | ||
| 71 | onAttempt: (socketUrl) => { | ||
| 72 | defaults.onAttempt(socketUrl); | ||
| 73 | handlers?.onAttempt?.(socketUrl); | ||
| 74 | }, | ||
| 75 | onOpen: () => { | ||
| 76 | defaults.onOpen(); | ||
| 77 | handlers?.onOpen?.(); | ||
| 78 | }, | ||
| 79 | onError: (message) => { | ||
| 80 | defaults.onError(message); | ||
| 81 | handlers?.onError?.(message); | ||
| 82 | }, | ||
| 83 | onClose: (details) => { | ||
| 84 | defaults.onClose(details); | ||
| 85 | handlers?.onClose?.(details); | ||
| 86 | }, | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 65 | export function createWsRpcProtocolLayer( | 90 | export function createWsRpcProtocolLayer( |
| 66 | url: WsRpcProtocolSocketUrlProvider, | 91 | url: WsRpcProtocolSocketUrlProvider, |
| 67 | handlers?: WsProtocolLifecycleHandlers, | 92 | handlers?: WsProtocolLifecycleHandlers, |
| 68 | ) { | 93 | ) { |
| 69 | const lifecycle = { | 94 | const lifecycle = composeLifecycleHandlers(handlers); |
| 70 | ...defaultLifecycleHandlers(), | ||
| 71 | ...handlers, | ||
| 72 | }; | ||
| 73 | const resolvedUrl = | 95 | const resolvedUrl = |
| 74 | typeof url === "function" | 96 | typeof url === "function" |
| 75 | ? Effect.promise(() => url()).pipe( | 97 | ? Effect.promise(() => url()).pipe( |
apps/web/src/rpc/wsConnectionState.test.ts-37| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | exhaustWsReconnectIfStillWaiting, | ||
| 5 | getWsConnectionStatus, | 4 | getWsConnectionStatus, |
| 6 | getWsReconnectDelayMsForRetry, | 5 | getWsReconnectDelayMsForRetry, |
| 7 | getWsConnectionUiState, | 6 | getWsConnectionUiState, |
| ... | @@ -73,40 +72,4 @@ describe("wsConnectionState", () => { | ... | @@ -73,40 +72,4 @@ describe("wsConnectionState", () => { |
| 73 | reconnectPhase: "exhausted", | 72 | reconnectPhase: "exhausted", |
| 74 | }); | 73 | }); |
| 75 | }); | 74 | }); |
| 76 | |||
| 77 | it("can exhaust a stalled final retry window when no new attempt starts", () => { | ||
| 78 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | ||
| 79 | recordWsConnectionOpened(); | ||
| 80 | |||
| 81 | for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS - 1; attempt += 1) { | ||
| 82 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | ||
| 83 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); | ||
| 84 | } | ||
| 85 | |||
| 86 | const finalRetryDelayMs = getWsReconnectDelayMsForRetry(WS_RECONNECT_MAX_ATTEMPTS - 2); | ||
| 87 | if (finalRetryDelayMs === null) { | ||
| 88 | throw new Error("Expected a final retry delay."); | ||
| 89 | } | ||
| 90 | |||
| 91 | const statusBeforeExhaust = getWsConnectionStatus(); | ||
| 92 | expect(statusBeforeExhaust).toMatchObject({ | ||
| 93 | nextRetryAt: new Date(Date.now() + finalRetryDelayMs).toISOString(), | ||
| 94 | reconnectAttemptCount: 7, | ||
| 95 | reconnectPhase: "waiting", | ||
| 96 | }); | ||
| 97 | |||
| 98 | const nextRetryAt = statusBeforeExhaust.nextRetryAt; | ||
| 99 | if (!nextRetryAt) { | ||
| 100 | throw new Error("Expected a scheduled retry."); | ||
| 101 | } | ||
| 102 | |||
| 103 | vi.setSystemTime(new Date(Date.now() + finalRetryDelayMs + 1_000)); | ||
| 104 | exhaustWsReconnectIfStillWaiting(nextRetryAt); | ||
| 105 | |||
| 106 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 107 | nextRetryAt: null, | ||
| 108 | reconnectAttemptCount: WS_RECONNECT_MAX_ATTEMPTS, | ||
| 109 | reconnectPhase: "exhausted", | ||
| 110 | }); | ||
| 111 | }); | ||
| 112 | }); | 75 | }); |
apps/web/src/rpc/wsConnectionState.ts-20| ... | @@ -149,26 +149,6 @@ export function resetWsReconnectBackoff(): WsConnectionStatus { | ... | @@ -149,26 +149,6 @@ export function resetWsReconnectBackoff(): WsConnectionStatus { |
| 149 | })); | 149 | })); |
| 150 | } | 150 | } |
| 151 | 151 | ||
| 152 | export function exhaustWsReconnectIfStillWaiting(expectedNextRetryAt: string): WsConnectionStatus { | ||
| 153 | return updateWsConnectionStatus((current) => { | ||
| 154 | if ( | ||
| 155 | current.reconnectPhase !== "waiting" || | ||
| 156 | current.nextRetryAt !== expectedNextRetryAt || | ||
| 157 | !current.online || | ||
| 158 | !current.hasConnected | ||
| 159 | ) { | ||
| 160 | return current; | ||
| 161 | } | ||
| 162 | |||
| 163 | return { | ||
| 164 | ...current, | ||
| 165 | nextRetryAt: null, | ||
| 166 | reconnectAttemptCount: current.reconnectMaxAttempts, | ||
| 167 | reconnectPhase: "exhausted", | ||
| 168 | }; | ||
| 169 | }); | ||
| 170 | } | ||
| 171 | |||
| 172 | export function resetWsConnectionStateForTests(): void { | 152 | export function resetWsConnectionStateForTests(): void { |
| 173 | appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS); | 153 | appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS); |
| 174 | } | 154 | } |
apps/web/src/rpc/wsTransport.test.ts+67-17| ... | @@ -83,6 +83,7 @@ class MockWebSocket { | ... | @@ -83,6 +83,7 @@ class MockWebSocket { |
| 83 | 83 | ||
| 84 | const originalWebSocket = globalThis.WebSocket; | 84 | const originalWebSocket = globalThis.WebSocket; |
| 85 | const originalFetch = globalThis.fetch; | 85 | const originalFetch = globalThis.fetch; |
| 86 | const transports: WsTransport[] = []; | ||
| 86 | 87 | ||
| 87 | function getSocket(): MockWebSocket { | 88 | function getSocket(): MockWebSocket { |
| 88 | const socket = sockets.at(-1); | 89 | const socket = sockets.at(-1); |
| ... | @@ -107,9 +108,16 @@ async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> | ... | @@ -107,9 +108,16 @@ async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> |
| 107 | } | 108 | } |
| 108 | } | 109 | } |
| 109 | 110 | ||
| 111 | function createTransport(...args: ConstructorParameters<typeof WsTransport>): WsTransport { | ||
| 112 | const transport = new WsTransport(...args); | ||
| 113 | transports.push(transport); | ||
| 114 | return transport; | ||
| 115 | } | ||
| 116 | |||
| 110 | beforeEach(() => { | 117 | beforeEach(() => { |
| 111 | vi.useRealTimers(); | 118 | vi.useRealTimers(); |
| 112 | sockets.length = 0; | 119 | sockets.length = 0; |
| 120 | transports.length = 0; | ||
| 113 | resetRequestLatencyStateForTests(); | 121 | resetRequestLatencyStateForTests(); |
| 114 | resetWsConnectionStateForTests(); | 122 | resetWsConnectionStateForTests(); |
| 115 | 123 | ||
| ... | @@ -134,6 +142,8 @@ beforeEach(() => { | ... | @@ -134,6 +142,8 @@ beforeEach(() => { |
| 134 | }); | 142 | }); |
| 135 | 143 | ||
| 136 | afterEach(async () => { | 144 | afterEach(async () => { |
| 145 | await Promise.allSettled(transports.map((transport) => transport.dispose())); | ||
| 146 | transports.length = 0; | ||
| 137 | globalThis.WebSocket = originalWebSocket; | 147 | globalThis.WebSocket = originalWebSocket; |
| 138 | globalThis.fetch = originalFetch; | 148 | globalThis.fetch = originalFetch; |
| 139 | resetRequestLatencyStateForTests(); | 149 | resetRequestLatencyStateForTests(); |
| ... | @@ -144,7 +154,7 @@ afterEach(async () => { | ... | @@ -144,7 +154,7 @@ afterEach(async () => { |
| 144 | 154 | ||
| 145 | describe("WsTransport", () => { | 155 | describe("WsTransport", () => { |
| 146 | it("normalizes root websocket urls to /ws and preserves query params", async () => { | 156 | it("normalizes root websocket urls to /ws and preserves query params", async () => { |
| 147 | const transport = new WsTransport("ws://localhost:3020/?token=secret-token"); | 157 | const transport = createTransport("ws://localhost:3020/?token=secret-token"); |
| 148 | 158 | ||
| 149 | await waitFor(() => { | 159 | await waitFor(() => { |
| 150 | expect(sockets).toHaveLength(1); | 160 | expect(sockets).toHaveLength(1); |
| ... | @@ -155,7 +165,7 @@ describe("WsTransport", () => { | ... | @@ -155,7 +165,7 @@ describe("WsTransport", () => { |
| 155 | }); | 165 | }); |
| 156 | 166 | ||
| 157 | it("uses an explicit secure websocket base url", async () => { | 167 | it("uses an explicit secure websocket base url", async () => { |
| 158 | const transport = new WsTransport("wss://app.example.com"); | 168 | const transport = createTransport("wss://app.example.com"); |
| 159 | 169 | ||
| 160 | await waitFor(() => { | 170 | await waitFor(() => { |
| 161 | expect(sockets).toHaveLength(1); | 171 | expect(sockets).toHaveLength(1); |
| ... | @@ -166,7 +176,7 @@ describe("WsTransport", () => { | ... | @@ -166,7 +176,7 @@ describe("WsTransport", () => { |
| 166 | }); | 176 | }); |
| 167 | 177 | ||
| 168 | it("uses an explicit insecure websocket base url for remote backends", async () => { | 178 | it("uses an explicit insecure websocket base url for remote backends", async () => { |
| 169 | const transport = new WsTransport("ws://192.168.1.44:3773"); | 179 | const transport = createTransport("ws://192.168.1.44:3773"); |
| 170 | 180 | ||
| 171 | await waitFor(() => { | 181 | await waitFor(() => { |
| 172 | expect(sockets).toHaveLength(1); | 182 | expect(sockets).toHaveLength(1); |
| ... | @@ -177,7 +187,7 @@ describe("WsTransport", () => { | ... | @@ -177,7 +187,7 @@ describe("WsTransport", () => { |
| 177 | }); | 187 | }); |
| 178 | 188 | ||
| 179 | it("supports async websocket url providers", async () => { | 189 | it("supports async websocket url providers", async () => { |
| 180 | const transport = new WsTransport(async () => "wss://remote.example.com/?wsToken=dynamic"); | 190 | const transport = createTransport(async () => "wss://remote.example.com/?wsToken=dynamic"); |
| 181 | 191 | ||
| 182 | await waitFor(() => { | 192 | await waitFor(() => { |
| 183 | expect(sockets).toHaveLength(1); | 193 | expect(sockets).toHaveLength(1); |
| ... | @@ -188,7 +198,7 @@ describe("WsTransport", () => { | ... | @@ -188,7 +198,7 @@ describe("WsTransport", () => { |
| 188 | }); | 198 | }); |
| 189 | 199 | ||
| 190 | it("tracks initial connection failures for the app error state", async () => { | 200 | it("tracks initial connection failures for the app error state", async () => { |
| 191 | const transport = new WsTransport("ws://localhost:3020"); | 201 | const transport = createTransport("ws://localhost:3020"); |
| 192 | 202 | ||
| 193 | await waitFor(() => { | 203 | await waitFor(() => { |
| 194 | expect(sockets).toHaveLength(1); | 204 | expect(sockets).toHaveLength(1); |
| ... | @@ -219,7 +229,7 @@ describe("WsTransport", () => { | ... | @@ -219,7 +229,7 @@ describe("WsTransport", () => { |
| 219 | }); | 229 | }); |
| 220 | 230 | ||
| 221 | it("surfaces reconnecting state after a live socket disconnects", async () => { | 231 | it("surfaces reconnecting state after a live socket disconnects", async () => { |
| 222 | const transport = new WsTransport("ws://localhost:3020"); | 232 | const transport = createTransport("ws://localhost:3020"); |
| 223 | 233 | ||
| 224 | await waitFor(() => { | 234 | await waitFor(() => { |
| 225 | expect(sockets).toHaveLength(1); | 235 | expect(sockets).toHaveLength(1); |
| ... | @@ -248,8 +258,48 @@ describe("WsTransport", () => { | ... | @@ -248,8 +258,48 @@ describe("WsTransport", () => { |
| 248 | await transport.dispose(); | 258 | await transport.dispose(); |
| 249 | }); | 259 | }); |
| 250 | 260 | ||
| 261 | it("composes custom lifecycle handlers with default websocket state tracking", async () => { | ||
| 262 | const onOpen = vi.fn(); | ||
| 263 | const onClose = vi.fn(); | ||
| 264 | const transport = createTransport("ws://localhost:3020", { | ||
| 265 | onOpen, | ||
| 266 | onClose, | ||
| 267 | }); | ||
| 268 | |||
| 269 | await waitFor(() => { | ||
| 270 | expect(sockets).toHaveLength(1); | ||
| 271 | }); | ||
| 272 | |||
| 273 | const socket = getSocket(); | ||
| 274 | socket.open(); | ||
| 275 | |||
| 276 | await waitFor(() => { | ||
| 277 | expect(onOpen).toHaveBeenCalledOnce(); | ||
| 278 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 279 | hasConnected: true, | ||
| 280 | phase: "connected", | ||
| 281 | }); | ||
| 282 | }); | ||
| 283 | |||
| 284 | socket.close(1012, "service restart"); | ||
| 285 | |||
| 286 | await waitFor(() => { | ||
| 287 | expect(onClose).toHaveBeenCalledWith({ | ||
| 288 | code: 1012, | ||
| 289 | reason: "service restart", | ||
| 290 | }); | ||
| 291 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 292 | attemptCount: 2, | ||
| 293 | closeReason: "service restart", | ||
| 294 | phase: "connecting", | ||
| 295 | }); | ||
| 296 | }, 2_000); | ||
| 297 | |||
| 298 | await transport.dispose(); | ||
| 299 | }); | ||
| 300 | |||
| 251 | it("reconnects the websocket session without disposing the transport", async () => { | 301 | it("reconnects the websocket session without disposing the transport", async () => { |
| 252 | const transport = new WsTransport("ws://localhost:3020"); | 302 | const transport = createTransport("ws://localhost:3020"); |
| 253 | 303 | ||
| 254 | await waitFor(() => { | 304 | await waitFor(() => { |
| 255 | expect(sockets).toHaveLength(1); | 305 | expect(sockets).toHaveLength(1); |
| ... | @@ -314,7 +364,7 @@ describe("WsTransport", () => { | ... | @@ -314,7 +364,7 @@ describe("WsTransport", () => { |
| 314 | it("marks unary requests as slow until the first server ack arrives", async () => { | 364 | it("marks unary requests as slow until the first server ack arrives", async () => { |
| 315 | const slowAckThresholdMs = 25; | 365 | const slowAckThresholdMs = 25; |
| 316 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); | 366 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); |
| 317 | const transport = new WsTransport("ws://localhost:3020"); | 367 | const transport = createTransport("ws://localhost:3020"); |
| 318 | 368 | ||
| 319 | const requestPromise = transport.request((client) => | 369 | const requestPromise = transport.request((client) => |
| 320 | client[WS_METHODS.serverUpsertKeybinding]({ | 370 | client[WS_METHODS.serverUpsertKeybinding]({ |
| ... | @@ -368,7 +418,7 @@ describe("WsTransport", () => { | ... | @@ -368,7 +418,7 @@ describe("WsTransport", () => { |
| 368 | }, 5_000); | 418 | }, 5_000); |
| 369 | 419 | ||
| 370 | it("sends unary RPC requests and resolves successful exits", async () => { | 420 | it("sends unary RPC requests and resolves successful exits", async () => { |
| 371 | const transport = new WsTransport("ws://localhost:3020"); | 421 | const transport = createTransport("ws://localhost:3020"); |
| 372 | 422 | ||
| 373 | const requestPromise = transport.request((client) => | 423 | const requestPromise = transport.request((client) => |
| 374 | client[WS_METHODS.serverUpsertKeybinding]({ | 424 | client[WS_METHODS.serverUpsertKeybinding]({ |
| ... | @@ -426,7 +476,7 @@ describe("WsTransport", () => { | ... | @@ -426,7 +476,7 @@ describe("WsTransport", () => { |
| 426 | }); | 476 | }); |
| 427 | 477 | ||
| 428 | it("delivers stream chunks to subscribers", async () => { | 478 | it("delivers stream chunks to subscribers", async () => { |
| 429 | const transport = new WsTransport("ws://localhost:3020"); | 479 | const transport = createTransport("ws://localhost:3020"); |
| 430 | const listener = vi.fn(); | 480 | const listener = vi.fn(); |
| 431 | 481 | ||
| 432 | const unsubscribe = transport.subscribe( | 482 | const unsubscribe = transport.subscribe( |
| ... | @@ -481,7 +531,7 @@ describe("WsTransport", () => { | ... | @@ -481,7 +531,7 @@ describe("WsTransport", () => { |
| 481 | }); | 531 | }); |
| 482 | 532 | ||
| 483 | it("re-subscribes stream listeners after the stream exits", async () => { | 533 | it("re-subscribes stream listeners after the stream exits", async () => { |
| 484 | const transport = new WsTransport("ws://localhost:3020"); | 534 | const transport = createTransport("ws://localhost:3020"); |
| 485 | const listener = vi.fn(); | 535 | const listener = vi.fn(); |
| 486 | const onResubscribe = vi.fn(); | 536 | const onResubscribe = vi.fn(); |
| 487 | 537 | ||
| ... | @@ -590,7 +640,7 @@ describe("WsTransport", () => { | ... | @@ -590,7 +640,7 @@ describe("WsTransport", () => { |
| 590 | }); | 640 | }); |
| 591 | 641 | ||
| 592 | it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { | 642 | it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { |
| 593 | const transport = new WsTransport("ws://localhost:3020"); | 643 | const transport = createTransport("ws://localhost:3020"); |
| 594 | const listener = vi.fn(); | 644 | const listener = vi.fn(); |
| 595 | const onResubscribe = vi.fn(); | 645 | const onResubscribe = vi.fn(); |
| 596 | 646 | ||
| ... | @@ -636,7 +686,7 @@ describe("WsTransport", () => { | ... | @@ -636,7 +686,7 @@ describe("WsTransport", () => { |
| 636 | }); | 686 | }); |
| 637 | 687 | ||
| 638 | it("does not retry stream subscriptions after application-level failures", async () => { | 688 | it("does not retry stream subscriptions after application-level failures", async () => { |
| 639 | const transport = new WsTransport("ws://localhost:3020"); | 689 | const transport = createTransport("ws://localhost:3020"); |
| 640 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | 690 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); |
| 641 | let attempts = 0; | 691 | let attempts = 0; |
| 642 | 692 | ||
| ... | @@ -675,7 +725,7 @@ describe("WsTransport", () => { | ... | @@ -675,7 +725,7 @@ describe("WsTransport", () => { |
| 675 | }); | 725 | }); |
| 676 | 726 | ||
| 677 | it("keeps retrying stream subscriptions after transport failures", async () => { | 727 | it("keeps retrying stream subscriptions after transport failures", async () => { |
| 678 | const transport = new WsTransport("ws://localhost:3020"); | 728 | const transport = createTransport("ws://localhost:3020"); |
| 679 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | 729 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); |
| 680 | let attempts = 0; | 730 | let attempts = 0; |
| 681 | 731 | ||
| ... | @@ -708,7 +758,7 @@ describe("WsTransport", () => { | ... | @@ -708,7 +758,7 @@ describe("WsTransport", () => { |
| 708 | }); | 758 | }); |
| 709 | 759 | ||
| 710 | it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { | 760 | it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { |
| 711 | const transport = new WsTransport("ws://localhost:3020"); | 761 | const transport = createTransport("ws://localhost:3020"); |
| 712 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | 762 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); |
| 713 | 763 | ||
| 714 | const unsubscribeA = transport.subscribe( | 764 | const unsubscribeA = transport.subscribe( |
| ... | @@ -741,7 +791,7 @@ describe("WsTransport", () => { | ... | @@ -741,7 +791,7 @@ describe("WsTransport", () => { |
| 741 | }); | 791 | }); |
| 742 | 792 | ||
| 743 | it("streams finite request events without re-subscribing", async () => { | 793 | it("streams finite request events without re-subscribing", async () => { |
| 744 | const transport = new WsTransport("ws://localhost:3020"); | 794 | const transport = createTransport("ws://localhost:3020"); |
| 745 | const listener = vi.fn(); | 795 | const listener = vi.fn(); |
| 746 | 796 | ||
| 747 | await waitFor(() => { | 797 | await waitFor(() => { |
| ... | @@ -857,7 +907,7 @@ describe("WsTransport", () => { | ... | @@ -857,7 +907,7 @@ describe("WsTransport", () => { |
| 857 | exportIntervalMs: 10, | 907 | exportIntervalMs: 10, |
| 858 | }); | 908 | }); |
| 859 | 909 | ||
| 860 | const transport = new WsTransport("ws://localhost:3020"); | 910 | const transport = createTransport("ws://localhost:3020"); |
| 861 | const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); | 911 | const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); |
| 862 | 912 | ||
| 863 | await waitFor(() => { | 913 | await waitFor(() => { |