From 2141933c081da5645375c6342da084d6cab864ea Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 4 Feb 2026 15:32:24 +0800 Subject: [PATCH 1/2] fix: prevent WebSocket reconnect race condition on app resume Track the 500ms setTimeout in immediateReconnect() so subsequent calls cancel any pending timer. This fixes a race condition when both app resume and network status change trigger immediateReconnect() in quick succession, which could leave the connection stuck in "reconnecting" state. Also clear the timer in pauseHeartbeat() to prevent connections from opening while the app is in the background. --- src/lib/transport/WebSocketTransport.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/lib/transport/WebSocketTransport.ts b/src/lib/transport/WebSocketTransport.ts index 2d54f34..d268d57 100644 --- a/src/lib/transport/WebSocketTransport.ts +++ b/src/lib/transport/WebSocketTransport.ts @@ -52,6 +52,7 @@ export class WebSocketTransport implements Transport { private pingTimeoutId?: ReturnType; private pongTimeoutId?: ReturnType; private reconnectTimer?: ReturnType; + private immediateReconnectTimer?: ReturnType; private connectionTimer?: ReturnType; private isDestroyed = false; private _hasConnectedBefore = false; @@ -155,6 +156,13 @@ export class WebSocketTransport implements Transport { this.reconnectTimer = undefined; } + // Clear any pending immediate reconnect timer to prevent race conditions + // when multiple reconnection triggers fire in quick succession + if (this.immediateReconnectTimer) { + clearTimeout(this.immediateReconnectTimer); + this.immediateReconnectTimer = undefined; + } + this.reconnectAttempts = 0; this.cleanup(); @@ -167,7 +175,9 @@ export class WebSocketTransport implements Transport { } // Brief delay to allow network stack to be ready after app resume - setTimeout(() => { + // Track this timeout so it can be cancelled by subsequent calls + this.immediateReconnectTimer = setTimeout(() => { + this.immediateReconnectTimer = undefined; if (this.isDestroyed) return; this.connect(); }, 500); @@ -177,6 +187,12 @@ export class WebSocketTransport implements Transport { logger.debug(`[Transport:${this.deviceId}] Pausing heartbeat`); this.heartbeatPaused = true; this.heartReset(); + + // Cancel any pending immediate reconnect to prevent connections opening in background + if (this.immediateReconnectTimer) { + clearTimeout(this.immediateReconnectTimer); + this.immediateReconnectTimer = undefined; + } } resumeHeartbeat(): void { @@ -430,6 +446,11 @@ export class WebSocketTransport implements Transport { this.reconnectTimer = undefined; } + if (this.immediateReconnectTimer) { + clearTimeout(this.immediateReconnectTimer); + this.immediateReconnectTimer = undefined; + } + if (this.ws) { this.ws.onopen = null; this.ws.onclose = null; From 97aa92f28f110f4475be4764a9d1a74a6a16b89e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 4 Feb 2026 15:43:09 +0800 Subject: [PATCH 2/2] test: add tests for immediate reconnect timer cancellation Verify that: - Multiple rapid immediateReconnect() calls don't create multiple connection attempts (second call cancels first timer) - pauseHeartbeat() cancels any pending immediate reconnect to prevent connections opening while the app is backgrounded --- .../WebSocketTransport.lifecycle.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/__tests__/unit/lib/transport/WebSocketTransport.lifecycle.test.ts b/src/__tests__/unit/lib/transport/WebSocketTransport.lifecycle.test.ts index 52e81e0..78f1afc 100644 --- a/src/__tests__/unit/lib/transport/WebSocketTransport.lifecycle.test.ts +++ b/src/__tests__/unit/lib/transport/WebSocketTransport.lifecycle.test.ts @@ -701,6 +701,68 @@ describe("WebSocketTransport lifecycle", () => { transport.destroy(); }); + + it("should cancel pending immediate reconnect when called multiple times", () => { + const transport = new WebSocketTransport({ + deviceId: "test-device", + url: "ws://localhost:7497", + }); + + transport.connect(); + MockWebSocket.getLatest()!.simulateOpen(); + MockWebSocket.getLatest()!.simulateClose(); + + const instancesAfterClose = MockWebSocket.instances.length; + + // Trigger immediate reconnect twice in quick succession + // This simulates both app resume and network status change firing + transport.immediateReconnect(); + vi.advanceTimersByTime(100); // Advance partway through first 500ms delay + transport.immediateReconnect(); // Second call should cancel first timer + + // Advance past first timer's original deadline (500ms from first call) + vi.advanceTimersByTime(400); + + // Should not have created a new WebSocket yet (first timer was cancelled) + expect(MockWebSocket.instances.length).toBe(instancesAfterClose); + + // Advance remaining time for second timer (500ms from second call, minus 400ms already advanced) + vi.advanceTimersByTime(100); + + // Now should have created exactly one new WebSocket + expect(MockWebSocket.instances.length).toBe(instancesAfterClose + 1); + + transport.destroy(); + }); + + it("should be cancelled by pauseHeartbeat", () => { + const transport = new WebSocketTransport({ + deviceId: "test-device", + url: "ws://localhost:7497", + }); + + transport.connect(); + MockWebSocket.getLatest()!.simulateOpen(); + MockWebSocket.getLatest()!.simulateClose(); + + const instancesAfterClose = MockWebSocket.instances.length; + + // Trigger immediate reconnect + transport.immediateReconnect(); + vi.advanceTimersByTime(100); // Advance partway through 500ms delay + + // Pause heartbeat (simulating app going to background again) + // This should cancel the pending immediate reconnect + transport.pauseHeartbeat(); + + // Advance past the original reconnect deadline + vi.advanceTimersByTime(500); + + // Should not have created a new WebSocket (timer was cancelled) + expect(MockWebSocket.instances.length).toBe(instancesAfterClose); + + transport.destroy(); + }); }); describe("send", () => {