diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 2ee3b086758..211b01b5add 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -92,6 +92,98 @@ function defaultState(): ConnectionState { class ConnectionManagerImpl { #connections = new Map(); + constructor() { + // Auto-reconnect otherwise relies entirely on the browser firing + // `onclose` plus a `setTimeout` backoff. Both are unreliable across a + // backgrounded/frozen tab: the close event may never be delivered (the + // socket dies while the event loop is suspended), and background timers + // are heavily throttled or paused, so a scheduled reconnect can stall + // indefinitely and never resume when the window is refocused. + // + // These listeners make the manager proactively re-check liveness when the + // page comes back to the foreground / the network returns, bringing any + // stalled reconnect forward and rebuilding sockets that died silently. + if ( + typeof document !== 'undefined' && + typeof document.addEventListener === 'function' + ) { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + this.#handleResume(); + } + }); + } + if ( + typeof window !== 'undefined' && + typeof window.addEventListener === 'function' + ) { + window.addEventListener('focus', this.#handleResume); + window.addEventListener('online', this.#handleResume); + // `pageshow` fires on bfcache restores, where `visibilitychange` may not. + window.addEventListener('pageshow', this.#handleResume); + } + } + + /** + * Called when the page is likely resuming from a background/frozen state: + * the tab became visible, the window regained focus, the network came back, + * or a bfcache page was restored. For each retained connection this brings a + * stalled reconnect forward immediately (resetting backoff) and rebuilds any + * socket that died silently while we were hidden. + */ + #handleResume = (): void => { + for (const managed of this.#connections.values()) { + if (managed.refCount <= 0 || managed.pendingRelease) { + continue; + } + + // A reconnect was scheduled but its timer is stuck behind background + // timer throttling / page freezing. Fire it now and reset backoff so we + // reconnect promptly instead of waiting out a (capped 30s, possibly + // paused) delay. + if (managed.reconnectTimer && !managed.connection) { + clearTimeout(managed.reconnectTimer); + managed.reconnectTimer = null; + managed.reconnectAttempt = 0; + if (managed.builder) { + this.#buildManagedConnection(managed, managed.builder); + } + continue; + } + + // We believe we're connected, but the socket may have died silently. + this.#reviveIfZombie(managed); + } + }; + + /** + * If `managed` holds a connection whose socket has entered CLOSING/CLOSED + * without a clean `onclose` (see {@link DbConnectionImpl.isSocketClosed}), + * for example because it was torn down while the tab was frozen, tear it down + * and build a fresh one immediately, resetting backoff. + */ + #reviveIfZombie(managed: ManagedConnection): void { + const connection = managed.connection; + if ( + !connection || + connection.isDisconnectRequested || + !connection.isSocketClosed + ) { + return; + } + + this.#detachCallbacks(managed, connection); + managed.connection = undefined; + // Close the dead socket in case it is only CLOSING; callbacks are already + // detached, so this won't trigger a duplicate reconnect. + connection.disconnect(); + this.#updateState(managed, { isActive: false }); + managed.reconnectAttempt = 0; + if (managed.builder) { + this.#buildManagedConnection(managed, managed.builder); + } + } + /** Generates a unique key for a connection based on URI and module name. */ static getKey(uri: string, moduleName: string): string { return `${uri}::${moduleName}`; diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index f462c88a56e..e9171a8d8fb 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -167,6 +167,28 @@ export class DbConnectionImpl */ isDisconnectRequested = false; + /** + * Whether the underlying websocket has entered `CLOSING` (2) or `CLOSED` + * (3). This becomes true even when the browser never delivered an + * `onclose` event, for example if the socket was torn down while the tab was + * frozen or the machine was asleep. The `ConnectionManager` uses this to detect + * such "zombie" connections when the page resumes and to force a reconnect. + * + * Returns false while the socket is still `CONNECTING`/`OPEN`, or before + * the socket has been created. + */ + get isSocketClosed(): boolean { + const ws = this.ws; + if (!ws) { + return false; + } + // Only reached from the browser-only resume handlers, where the + // `WebSocket` global (and its CLOSING/CLOSED constants) always exists. + return ( + ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED + ); + } + /** * This connection's public identity. */ diff --git a/crates/bindings-typescript/src/sdk/websocket_decompress_adapter.ts b/crates/bindings-typescript/src/sdk/websocket_decompress_adapter.ts index 8e7c26b22e7..789af8d0fbc 100644 --- a/crates/bindings-typescript/src/sdk/websocket_decompress_adapter.ts +++ b/crates/bindings-typescript/src/sdk/websocket_decompress_adapter.ts @@ -5,6 +5,9 @@ export class WebsocketDecompressAdapter implements WebSocketAdapter { get protocol(): string { return this.#ws.protocol; } + get readyState(): number { + return this.#ws.readyState; + } set onclose(handler: (ev: CloseEvent) => void) { this.#ws.onclose = handler; } diff --git a/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts b/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts index e04f411c7cc..257f9cb2806 100644 --- a/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts +++ b/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts @@ -11,6 +11,12 @@ import { class WebsocketTestAdapter implements WebSocketAdapter { protocol: string = ''; + // WebSocket.CLOSED (3) / WebSocket.OPEN (1). Uses literals rather than the + // `WebSocket` global, which is not defined when these tests run under Node. + get readyState(): number { + return this.closed ? 3 : 1; + } + messageQueue: Uint8Array[]; outgoingMessages: ClientMessage[]; closed: boolean; diff --git a/crates/bindings-typescript/src/sdk/ws.ts b/crates/bindings-typescript/src/sdk/ws.ts index 1c3172ad924..99d1b688ece 100644 --- a/crates/bindings-typescript/src/sdk/ws.ts +++ b/crates/bindings-typescript/src/sdk/ws.ts @@ -28,6 +28,18 @@ async function resolveWS(): Promise { export interface WebSocketAdapter { readonly protocol: string; + /** + * The underlying socket's `readyState`, using the standard `WebSocket` + * constants: `CONNECTING` (0), `OPEN` (1), `CLOSING` (2), `CLOSED` (3). Used + * to detect sockets that died silently, for example after the tab was frozen + * or the machine slept, without a clean `onclose` having fired. + * + * Optional so existing custom adapters stay valid without changes. When an + * adapter does not expose it, the resume handler cannot detect a + * silently-closed socket for that adapter and leaves it to the normal + * `onclose` path. + */ + readonly readyState?: number; send(msg: Uint8Array): void; close(): void; diff --git a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts new file mode 100644 index 00000000000..ea24ea6887e --- /dev/null +++ b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ConnectionId } from '../src'; +import { connectionManagerReconnectDelayMs } from '../src/sdk/connection_manager.ts'; + +// These tests exercise the page-resume + zombie-socket liveness recovery in the +// ConnectionManager. That logic wires itself to `document`/`window` events in +// its constructor, so each test installs minimal DOM stubs and re-imports the +// module to get a fresh singleton bound to those stubs. + +type ErrorContextInterface = { isActive: boolean }; + +class MockConnection { + isActive = false; + identity = undefined; + token = undefined; + connectionId = ConnectionId.random(); + isDisconnectRequested = false; + disconnected = false; + // Controls the `isSocketClosed` signal the manager reads to detect a socket + // that died silently (CLOSING/CLOSED without a clean `onclose`). + socketClosed = false; + + #onConnect = new Set<(conn: MockConnection) => void>(); + #onDisconnect = new Set< + (ctx: ErrorContextInterface, error?: Error) => void + >(); + #onConnectError = new Set< + (ctx: ErrorContextInterface, error: Error) => void + >(); + + get isSocketClosed(): boolean { + return this.socketClosed; + } + + disconnect(): void { + this.isDisconnectRequested = true; + this.disconnected = true; + this.isActive = false; + } + + removeOnConnect(cb: (conn: MockConnection) => void): void { + this.#onConnect.delete(cb); + } + removeOnDisconnect( + cb: (ctx: ErrorContextInterface, error?: Error) => void + ): void { + this.#onDisconnect.delete(cb); + } + removeOnConnectError( + cb: (ctx: ErrorContextInterface, error: Error) => void + ): void { + this.#onConnectError.delete(cb); + } + + register( + type: 'connect' | 'disconnect' | 'connectError', + cb: (...args: any[]) => void + ): void { + if (type === 'connect') this.#onConnect.add(cb); + else if (type === 'disconnect') this.#onDisconnect.add(cb); + else this.#onConnectError.add(cb); + } + + simulateConnect(): void { + this.isActive = true; + for (const cb of this.#onConnect) cb(this); + } + simulateDisconnect(error?: Error): void { + this.isActive = false; + for (const cb of this.#onDisconnect) + cb(this as unknown as ErrorContextInterface, error); + } +} + +class MockBuilder { + buildCount = 0; + connections: MockConnection[] = []; + + #onConnect = new Set<(conn: MockConnection) => void>(); + #onDisconnect = new Set< + (ctx: ErrorContextInterface, error?: Error) => void + >(); + #onConnectError = new Set< + (ctx: ErrorContextInterface, error: Error) => void + >(); + + build(): MockConnection { + const connection = new MockConnection(); + this.buildCount += 1; + this.connections.push(connection); + for (const cb of this.#onConnect) connection.register('connect', cb); + for (const cb of this.#onDisconnect) connection.register('disconnect', cb); + for (const cb of this.#onConnectError) + connection.register('connectError', cb); + return connection; + } + + onConnect(cb: (conn: MockConnection) => void): MockBuilder { + this.#onConnect.add(cb); + for (const c of this.connections) c.register('connect', cb); + return this; + } + onDisconnect( + cb: (ctx: ErrorContextInterface, error?: Error) => void + ): MockBuilder { + this.#onDisconnect.add(cb); + for (const c of this.connections) c.register('disconnect', cb); + return this; + } + onConnectError( + cb: (ctx: ErrorContextInterface, error: Error) => void + ): MockBuilder { + this.#onConnectError.add(cb); + for (const c of this.connections) c.register('connectError', cb); + return this; + } +} + +let keyCounter = 0; +function nextKey(): string { + keyCounter += 1; + return `connection-manager-liveness-${keyCounter}`; +} + +type DocStub = { + visibilityState: 'visible' | 'hidden'; + addEventListener: (ev: string, h: () => void) => void; +}; + +let ConnectionManager: typeof import('../src/sdk/connection_manager.ts').ConnectionManager; +let doc: DocStub; +let listeners: Record void>>; + +function retain(key: string, builder: MockBuilder): MockConnection { + return ConnectionManager.retain( + key, + builder as any + ) as unknown as MockConnection; +} + +function fire(name: string): void { + for (const h of listeners[name] ?? []) h(); +} + +async function loadManager(): Promise { + listeners = {}; + doc = { + visibilityState: 'visible', + addEventListener: (ev, h) => { + (listeners[`doc:${ev}`] ??= []).push(h); + }, + }; + const win = { + addEventListener: (ev: string, h: () => void) => { + (listeners[`win:${ev}`] ??= []).push(h); + }, + }; + (globalThis as any).document = doc; + (globalThis as any).window = win; + vi.resetModules(); + ({ ConnectionManager } = await import('../src/sdk/connection_manager.ts')); +} + +describe('ConnectionManager liveness recovery', () => { + beforeEach(async () => { + // Fake timers let us drive the reconnect backoff deterministically. + vi.useFakeTimers(); + await loadManager(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (globalThis as any).document; + delete (globalThis as any).window; + }); + + test('registers resume + network listeners on construction', () => { + expect(listeners['doc:visibilitychange']?.length).toBe(1); + expect(listeners['win:focus']?.length).toBe(1); + expect(listeners['win:online']?.length).toBe(1); + expect(listeners['win:pageshow']?.length).toBe(1); + }); + + test('revives a silently-dead socket when the network returns', () => { + const key = nextKey(); + const builder = new MockBuilder(); + const first = retain(key, builder); + first.simulateConnect(); + expect(ConnectionManager.getSnapshot(key)?.isActive).toBe(true); + + // Socket dies while backgrounded: no disconnect event ever fires, but the + // underlying readyState is now CLOSED. + first.socketClosed = true; + expect(ConnectionManager.getConnection(key)).toBe(first); + + fire('win:online'); + + expect(builder.buildCount).toBe(2); + expect(ConnectionManager.getConnection(key)).toBe(builder.connections[1]); + ConnectionManager.release(key); + }); + + test('does not rebuild a healthy connection on resume', () => { + const key = nextKey(); + const builder = new MockBuilder(); + const first = retain(key, builder); + first.simulateConnect(); + + fire('win:focus'); + fire('doc:visibilitychange'); + + expect(builder.buildCount).toBe(1); + expect(ConnectionManager.getConnection(key)).toBe(first); + ConnectionManager.release(key); + }); + + test('does not revive a connection that was intentionally disconnected', () => { + const key = nextKey(); + const builder = new MockBuilder(); + const first = retain(key, builder); + first.simulateConnect(); + first.isDisconnectRequested = true; + first.socketClosed = true; + + fire('win:online'); + + expect(builder.buildCount).toBe(1); + ConnectionManager.release(key); + }); + + test('brings a stalled reconnect forward on resume and resets backoff', () => { + const key = nextKey(); + const builder = new MockBuilder(); + const first = retain(key, builder); + first.simulateDisconnect(); + + // The reconnect timer is scheduled but has not fired yet (simulating a + // background tab whose timers are throttled/frozen). + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0) - 1); + expect(builder.buildCount).toBe(1); + + // Regaining focus rebuilds immediately instead of waiting out the delay. + fire('doc:visibilitychange'); + expect(builder.buildCount).toBe(2); + + // Backoff was reset: the next failure reconnects after the base delay. + builder.connections[1].simulateDisconnect(); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); + expect(builder.buildCount).toBe(3); + + ConnectionManager.release(key); + }); + + test('visibilitychange while still hidden does not reconnect', () => { + const key = nextKey(); + const builder = new MockBuilder(); + const first = retain(key, builder); + first.simulateDisconnect(); + + doc.visibilityState = 'hidden'; + fire('doc:visibilitychange'); + + expect(builder.buildCount).toBe(1); + ConnectionManager.release(key); + }); +});