Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions crates/bindings-typescript/src/sdk/connection_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,98 @@ function defaultState(): ConnectionState {
class ConnectionManagerImpl {
#connections = new Map<string, ManagedConnection>();

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}`;
Expand Down
22 changes: 22 additions & 0 deletions crates/bindings-typescript/src/sdk/db_connection_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,28 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
*/
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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions crates/bindings-typescript/src/sdk/websocket_test_adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer>[];
outgoingMessages: ClientMessage[];
closed: boolean;
Expand Down
12 changes: 12 additions & 0 deletions crates/bindings-typescript/src/sdk/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ async function resolveWS(): Promise<typeof WebSocket> {

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<ArrayBuffer>): void;
close(): void;

Expand Down
Loading