From 975e6f06e9b1b7931edb4aea6a162eff11c857ab Mon Sep 17 00:00:00 2001 From: Gujiassh Date: Thu, 12 Mar 2026 21:00:35 +0900 Subject: [PATCH] fix(mobile): clean up sockets when simulation starts When the server URL is cleared, close the active WebSocket and cancel pending reconnects so simulation mode does not race with stale network state. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../src/__tests__/services/ws.service.test.ts | 56 +++++++++++++++++++ ui/mobile/src/services/ws.service.ts | 7 +++ 2 files changed, 63 insertions(+) diff --git a/ui/mobile/src/__tests__/services/ws.service.test.ts b/ui/mobile/src/__tests__/services/ws.service.test.ts index 5342b940..7f2dade7 100644 --- a/ui/mobile/src/__tests__/services/ws.service.test.ts +++ b/ui/mobile/src/__tests__/services/ws.service.test.ts @@ -109,6 +109,62 @@ describe('WsService', () => { expect(ws.getStatus()).toBe('simulated'); ws.disconnect(); }); + + it('cleans up active sockets and pending reconnects when switching to simulation mode', () => { + const sockets: MockWebSocketInstance[] = []; + const OrigWebSocket = globalThis.WebSocket; + + class MockWebSocket { + static OPEN = 1; + static CONNECTING = 0; + static CLOSED = 3; + readyState = MockWebSocket.OPEN; + onopen: (() => void) | null = null; + onclose: ((event: { code: number }) => void) | null = null; + onerror: (() => void) | null = null; + onmessage: (() => void) | null = null; + close = jest.fn((code?: number) => { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({ code: code ?? 1000 }); + }); + + constructor(_url: string) { + sockets.push(this as unknown as MockWebSocketInstance); + } + } + + type MockWebSocketInstance = { + onclose: ((event: { code: number }) => void) | null; + close: jest.Mock; + }; + + globalThis.WebSocket = MockWebSocket as any; + + try { + const ws = createWsService(); + ws.connect('http://localhost:3000'); + expect(sockets).toHaveLength(1); + + ws.connect(''); + expect(sockets[0].close).toHaveBeenCalledWith(1000, 'switch to simulation'); + expect(ws.getStatus()).toBe('simulated'); + ws.disconnect(); + + const wsWithReconnect = createWsService(); + wsWithReconnect.connect('http://localhost:3000'); + expect(sockets).toHaveLength(2); + + sockets[1].onclose?.({ code: 1006 }); + wsWithReconnect.connect(''); + + jest.advanceTimersByTime(60_000); + expect(sockets).toHaveLength(2); + expect(wsWithReconnect.getStatus()).toBe('simulated'); + wsWithReconnect.disconnect(); + } finally { + globalThis.WebSocket = OrigWebSocket; + } + }); }); describe('subscribe and unsubscribe', () => { diff --git a/ui/mobile/src/services/ws.service.ts b/ui/mobile/src/services/ws.service.ts index 8cd398ba..9eb76f9f 100644 --- a/ui/mobile/src/services/ws.service.ts +++ b/ui/mobile/src/services/ws.service.ts @@ -22,6 +22,13 @@ class WsService { this.reconnectAttempt = 0; if (!url) { + this.clearReconnectTimer(); + if (this.ws) { + const socket = this.ws; + this.ws = null; + socket.onclose = null; + socket.close(1000, 'switch to simulation'); + } this.handleStatusChange('simulated'); this.startSimulation(); return;