authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-27 09:50:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-27 09:50:19-07:00
logdbebc387dd458dd7062380ccb862a5cdac7aba66
treea301a81bf3465a8df662bdb5a20d43fae0407a0a
parent5cf83ffe8f9d5eabd1c17721bd1f9597c97d98fe
signature Signed by PGP key B5690EEEBB952194

Ignore stale WebSocket lifecycle events after reconnect (#2372)


3 files changed, 85 insertions(+), 1 deletions(-)

apps/web/src/rpc/protocol.ts+16
......@@ -18,6 +18,7 @@ import {
1818} from "./wsConnectionState";
1919
2020export 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
5051function 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+57
......@@ -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+12-1
......@@ -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 {