Skip to content

Commit dbebc38

Browse files
Ignore stale WebSocket lifecycle events after reconnect (#2372)
1 parent 5cf83ff commit dbebc38

3 files changed

Lines changed: 85 additions & 1 deletion

File tree

apps/web/src/rpc/protocol.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "./wsConnectionState";
1919

2020
export interface WsProtocolLifecycleHandlers {
21+
readonly isActive?: () => boolean;
2122
readonly onAttempt?: (socketUrl: string) => void;
2223
readonly onOpen?: () => void;
2324
readonly onError?: (message: string) => void;
@@ -49,6 +50,7 @@ function resolveWsRpcSocketUrl(rawUrl: string): string {
4950

5051
function defaultLifecycleHandlers(): Required<WsProtocolLifecycleHandlers> {
5152
return {
53+
isActive: () => true,
5254
onAttempt: recordWsConnectionAttempt,
5355
onOpen: recordWsConnectionOpened,
5456
onError: (message) => {
@@ -66,21 +68,35 @@ function composeLifecycleHandlers(
6668
handlers?: WsProtocolLifecycleHandlers,
6769
): Required<WsProtocolLifecycleHandlers> {
6870
const defaults = defaultLifecycleHandlers();
71+
const isActive = handlers?.isActive ?? (() => true);
6972

7073
return {
74+
isActive,
7175
onAttempt: (socketUrl) => {
76+
if (!isActive()) {
77+
return;
78+
}
7279
defaults.onAttempt(socketUrl);
7380
handlers?.onAttempt?.(socketUrl);
7481
},
7582
onOpen: () => {
83+
if (!isActive()) {
84+
return;
85+
}
7686
defaults.onOpen();
7787
handlers?.onOpen?.();
7888
},
7989
onError: (message) => {
90+
if (!isActive()) {
91+
return;
92+
}
8093
defaults.onError(message);
8194
handlers?.onError?.(message);
8295
},
8396
onClose: (details) => {
97+
if (!isActive()) {
98+
return;
99+
}
84100
defaults.onClose(details);
85101
handlers?.onClose?.(details);
86102
},

apps/web/src/rpc/wsTransport.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ describe("WsTransport", () => {
324324
const secondSocket = getSocket();
325325
expect(secondSocket).not.toBe(firstSocket);
326326
expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED);
327+
expect(getWsConnectionStatus()).toMatchObject({
328+
closeCode: null,
329+
closeReason: null,
330+
phase: "connecting",
331+
});
327332

328333
const requestPromise = transport.request((client) =>
329334
client[WS_METHODS.serverUpsertKeybinding]({
@@ -361,6 +366,58 @@ describe("WsTransport", () => {
361366
await transport.dispose();
362367
});
363368

369+
it("ignores stale socket lifecycle events after a reconnect starts a new session", async () => {
370+
const onClose = vi.fn();
371+
const transport = createTransport("ws://localhost:3020", { onClose });
372+
373+
await waitFor(() => {
374+
expect(sockets).toHaveLength(1);
375+
});
376+
377+
const firstSocket = getSocket();
378+
firstSocket.open();
379+
380+
await waitFor(() => {
381+
expect(getWsConnectionStatus()).toMatchObject({
382+
hasConnected: true,
383+
phase: "connected",
384+
});
385+
});
386+
387+
await transport.reconnect();
388+
389+
await waitFor(() => {
390+
expect(sockets).toHaveLength(2);
391+
});
392+
393+
expect(onClose).not.toHaveBeenCalled();
394+
expect(getWsConnectionStatus()).toMatchObject({
395+
closeCode: null,
396+
closeReason: null,
397+
phase: "connecting",
398+
});
399+
400+
const secondSocket = getSocket();
401+
secondSocket.open();
402+
403+
await waitFor(() => {
404+
expect(getWsConnectionStatus()).toMatchObject({
405+
phase: "connected",
406+
});
407+
});
408+
409+
firstSocket.close(1006, "stale close");
410+
411+
expect(onClose).not.toHaveBeenCalled();
412+
expect(getWsConnectionStatus()).toMatchObject({
413+
closeCode: null,
414+
closeReason: null,
415+
phase: "connected",
416+
});
417+
418+
await transport.dispose();
419+
});
420+
364421
it("marks unary requests as slow until the first server ack arrives", async () => {
365422
const slowAckThresholdMs = 25;
366423
setSlowRpcAckThresholdMsForTests(slowAckThresholdMs);

apps/web/src/rpc/wsTransport.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export class WsTransport {
5353
private disposed = false;
5454
private hasReportedTransportDisconnect = false;
5555
private reconnectChain: Promise<void> = Promise.resolve();
56+
private nextSessionId = 0;
57+
private activeSessionId = 0;
5658
private session: TransportSession;
5759

5860
constructor(
@@ -215,8 +217,17 @@ export class WsTransport {
215217
}
216218

217219
private createSession(): TransportSession {
220+
const sessionId = this.nextSessionId + 1;
221+
this.nextSessionId = sessionId;
222+
this.activeSessionId = sessionId;
218223
const runtime = ManagedRuntime.make(
219-
Layer.mergeAll(createWsRpcProtocolLayer(this.url, this.lifecycleHandlers), ClientTracingLive),
224+
Layer.mergeAll(
225+
createWsRpcProtocolLayer(this.url, {
226+
...this.lifecycleHandlers,
227+
isActive: () => !this.disposed && this.activeSessionId === sessionId,
228+
}),
229+
ClientTracingLive,
230+
),
220231
);
221232
const clientScope = runtime.runSync(Scope.make());
222233
return {

0 commit comments

Comments
 (0)