History

Ignore stale WebSocket lifecycle events after reconnect (#2372)

Julius Marminge committed 6 months agov0.0.22-nightly.20260427.140

Commit dbebc381 parent 5cf83ff3 files · +85 −1Browse files

Unified · Split

3 files
  1. apps/web/src/rpc/protocol.ts +16 −0
  2. apps/web/src/rpc/wsTransport.test.ts +57 −0
  3. apps/web/src/rpc/wsTransport.ts +12 −1
apps/web/src/rpc/protocol.ts+16−0
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
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 { · 25 unmodified lines
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( · 11 unmodified lines
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−0
@@ -324,6 +324,11 @@ describe("WsTransport", () => { · 323 unmodified lines
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", () => { · 31 unmodified lines
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 { · 52 unmodified lines
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 { · 156 unmodified lines
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 {