Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
23 changes: 22 additions & 1 deletion src/lib/transport/WebSocketTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class WebSocketTransport implements Transport {
private pingTimeoutId?: ReturnType<typeof setTimeout>;
private pongTimeoutId?: ReturnType<typeof setTimeout>;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private immediateReconnectTimer?: ReturnType<typeof setTimeout>;
private connectionTimer?: ReturnType<typeof setTimeout>;
private isDestroyed = false;
private _hasConnectedBefore = false;
Expand Down Expand Up @@ -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();

Expand All @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down