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", () => { 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;