diff --git a/src/v1/signaling.test.ts b/src/v1/signaling.test.ts index c34f038..88d03c0 100644 --- a/src/v1/signaling.test.ts +++ b/src/v1/signaling.test.ts @@ -197,6 +197,40 @@ describe("Signaling disconnect", () => { expect((signaling as any).isReady).toBe(false); }); + // The client is built with `reconnect: true, max_reconnects: 0` and + // rpc-websockets only skips reconnecting on close code exactly 1000. Without + // this, a gateway-initiated 1001 close leaves an endless reconnect loop + // behind a client whose listeners have already been removed — an inert + // connection on the gateway that neither side ever reaps. + test("should disable auto-reconnect before closing on disconnect", async () => { + const signaling = new Signaling(); + await signaling.connect({ endpointToken: "test-token" }); + + const ws = (signaling as any).ws; + const callOrder: string[] = []; + ws.setAutoReconnect.mockImplementation(() => callOrder.push("setAutoReconnect")); + ws.close.mockImplementation(() => callOrder.push("close")); + + signaling.disconnect(); + + expect(ws.setAutoReconnect).toHaveBeenCalledWith(false); + expect(callOrder).toEqual(["setAutoReconnect", "close"]); + }); + + test("should still close when disabling auto-reconnect throws", async () => { + const signaling = new Signaling(); + await signaling.connect({ endpointToken: "test-token" }); + + const ws = (signaling as any).ws; + ws.setAutoReconnect.mockImplementation(() => { + throw new Error("socket already gone"); + }); + + expect(() => signaling.disconnect()).not.toThrow(); + expect(ws.close).toHaveBeenCalled(); + expect((signaling as any).ws).toBeNull(); + }); + test("should handle disconnect with diagnosticsBatcher", async () => { const diagnosticsBatcher = new DiagnosticsBatcher(); const shutdownSpy = jest.spyOn(diagnosticsBatcher, "shutdown"); diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index 6333d21..4c2f34c 100644 --- a/src/v1/signaling.ts +++ b/src/v1/signaling.ts @@ -143,6 +143,25 @@ class Signaling extends EventEmitter { logger.error("Error sending diagnostics... websocket may be disconnected", err); } } + // Stop auto-reconnect BEFORE closing. The client is constructed with + // `reconnect: true, max_reconnects: 0` (unlimited), and rpc-websockets + // only skips reconnecting when the close code is exactly 1000 — so any + // other code (notably 1001/StatusGoingAway, which the gateway sends + // whenever it wants the device to come back) puts the client into an + // endless reconnect loop. That loop outlives this disconnect: because + // removeAllListeners() has already run, the reconnected socket has no + // "open" handler, so it never calls setMediaPreferences, never creates + // peer connections, and never answers the heartbeat — an inert + // connection on the gateway that nothing on either side reaps. + // + // Reconnect stays enabled for the lifetime of a live connection (that is + // the point of it); it is only disabled here, where the caller has asked + // to disconnect and we are tearing this client down for good. + try { + this.ws.setAutoReconnect(false); + } catch (err) { + logger.error("Error disabling auto-reconnect", err); + } this.ws.removeAllListeners(); try { this.ws.close();