From a11d9a4aa04dec0149a3c32b50fcfbbab0580c7a Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 24 Aug 2026 11:55:38 -0400 Subject: [PATCH] fix(mobile): stabilize offline sensing fallback --- .../src/__tests__/services/ws.service.test.ts | 67 +++++++++++++++++++ ui/mobile/src/services/ws.service.ts | 32 ++++++--- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/ui/mobile/src/__tests__/services/ws.service.test.ts b/ui/mobile/src/__tests__/services/ws.service.test.ts index 16c1bf601..cd51350c4 100644 --- a/ui/mobile/src/__tests__/services/ws.service.test.ts +++ b/ui/mobile/src/__tests__/services/ws.service.test.ts @@ -111,6 +111,73 @@ describe('WsService', () => { }); }); + describe('connection fallback', () => { + it('uses simulation when an active server connection closes normally', () => { + const OrigWebSocket = globalThis.WebSocket; + let socket: any; + + class MockWebSocket { + static OPEN = 1; + static CONNECTING = 0; + readyState = 0; + onopen: (() => void) | null = null; + onclose: ((event: { code: number }) => void) | null = null; + onerror: (() => void) | null = null; + onmessage: (() => void) | null = null; + close() {} + constructor() { + socket = this; + } + } + + globalThis.WebSocket = MockWebSocket as any; + try { + const ws = createWsService(); + ws.connect('http://localhost:3000'); + socket.onclose?.({ code: 1000 }); + + expect(ws.getStatus()).toBe('simulated'); + ws.disconnect(); + } finally { + globalThis.WebSocket = OrigWebSocket; + } + }); + + it('preserves retry progress instead of restarting the first attempt', () => { + const OrigWebSocket = globalThis.WebSocket; + const sockets: any[] = []; + + class MockWebSocket { + static OPEN = 1; + static CONNECTING = 0; + readyState = 0; + onopen: (() => void) | null = null; + onclose: ((event: { code: number }) => void) | null = null; + onerror: (() => void) | null = null; + onmessage: (() => void) | null = null; + close() {} + constructor() { + sockets.push(this); + } + } + + globalThis.WebSocket = MockWebSocket as any; + try { + const ws = createWsService(); + ws.connect('http://localhost:3000'); + sockets[0].onclose?.({ code: 1006 }); + jest.runOnlyPendingTimers(); + sockets[1].onclose?.({ code: 1006 }); + + expect(ws.getStatus()).toBe('simulated'); + expect(sockets).toHaveLength(2); + ws.disconnect(); + } finally { + globalThis.WebSocket = OrigWebSocket; + } + }); + }); + describe('subscribe and unsubscribe', () => { it('adds a listener and returns an unsubscribe function', () => { const ws = createWsService(); diff --git a/ui/mobile/src/services/ws.service.ts b/ui/mobile/src/services/ws.service.ts index b79dfb55c..b42448328 100644 --- a/ui/mobile/src/services/ws.service.ts +++ b/ui/mobile/src/services/ws.service.ts @@ -17,9 +17,16 @@ class WsService { private status: ConnectionStatus = 'disconnected'; connect(url: string): void { + const wasActive = this.active; + const targetChanged = url !== this.targetUrl; this.targetUrl = url; this.active = true; - this.reconnectAttempt = 0; + + // A scheduled reconnect must preserve its attempt count. Resetting it on + // every call traps an unavailable installation in an infinite first retry. + if (!wasActive || targetChanged) { + this.reconnectAttempt = 0; + } if (!url) { this.handleStatusChange('simulated'); @@ -31,10 +38,18 @@ class WsService { return; } - this.handleStatusChange('connecting'); + this.openConnection(); + } + + private openConnection(): void { + // When simulation is already providing frames, keep that honest status in + // the UI while the real endpoint is probed in the background. + if (!this.simulationTimer) { + this.handleStatusChange('connecting'); + } try { - const endpoint = this.buildWsUrl(url); + const endpoint = this.buildWsUrl(this.targetUrl); const socket = new WebSocket(endpoint); this.ws = socket; @@ -64,10 +79,8 @@ class WsService { this.handleStatusChange('disconnected'); return; } - if (evt.code === 1000) { - this.handleStatusChange('disconnected'); - return; - } + // A server is allowed to close with 1000. It is only an intentional + // client disconnect when `active` is false (handled above). this.scheduleReconnect(); }; } catch { @@ -125,12 +138,13 @@ class WsService { const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)]; this.reconnectAttempt += 1; + this.startSimulation(); + this.handleStatusChange('simulated'); this.clearReconnectTimer(); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; - this.connect(this.targetUrl); + this.openConnection(); }, delay); - this.startSimulation(); } private startSimulation(): void {