From 89a9d4fbda99de5a93086c2948b844faabb793e1 Mon Sep 17 00:00:00 2001 From: Appcaster Date: Fri, 31 Jul 2026 05:30:28 +0900 Subject: [PATCH] fix(mobile): recover when half-open sockets omit close events (#11368) * fix(mobile): recover half-open RPC sockets * test(mobile): assert reconnect attempt reset * fix(mobile): coalesce half-open recovery probes --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- mobile/src/transport/rpc-client.test.ts | 41 +++++++++++++++++++++++++ mobile/src/transport/rpc-client.ts | 12 +++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 0b6d5c84a..67e920ff1 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -707,6 +707,47 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + it('reconnects when the half-open socket omits its close callback', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + openAndAuthenticate(socket) + socket.emitCloseOnClose = false + + client.notifyForeground() + await vi.advanceTimersByTimeAsync(8_000) + + expect(socket.close).toHaveBeenCalledTimes(1) + expect(client.getState()).toBe('reconnecting') + socket.onclose?.() + expect(client.getState()).toBe('reconnecting') + + await vi.advanceTimersByTimeAsync(500) + expect(mockSockets).toHaveLength(2) + openAndAuthenticate(mockSockets[1]!) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) + + await vi.advanceTimersByTimeAsync(1_000) + expect(mockSockets).toHaveLength(2) + + client.close() + }) + + it('coalesces repeated foreground probes while one probe is pending', async () => { + const client = connectAuthenticated().client + const socket = mockSockets[0]! + client.notifyForeground() + client.notifyForeground() + client.notifyForeground() + expect(sentRequests(socket, 'status.get')).toHaveLength(1) + await vi.advanceTimersByTimeAsync(8_000) + expect(socket.close).toHaveBeenCalledTimes(1) + expect(client.getState()).toBe('reconnecting') + + await vi.advanceTimersByTimeAsync(500) + expect(mockSockets).toHaveLength(2) + client.close() + }) it('keeps a healthy connection when the foreground probe is answered', async () => { const { client, socket } = connectAuthenticated() diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 1f026e550..30f6d7a42 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -161,6 +161,7 @@ export function connect( let connectTimer: ReturnType | null = null let handshakeTimer: ReturnType | null = null let activityProbeTimer: ReturnType | null = null + let activityProbeInFlight = false let intentionallyClosed = false // Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair. let authRejectionCount = 0 @@ -768,15 +769,17 @@ export function connect( // Why: app-level liveness probe (see ACTIVITY_PROBE_INTERVAL_MS) — force-closes the WS on failure so onclose reconnects. function runActivityProbe() { - if (state !== 'connected' || !ws) { + if (state !== 'connected' || !ws || activityProbeInFlight) { return } + activityProbeInFlight = true const probeWs = ws const id = nextId() const probeInboundSequence = inboundSequence let timedOut = false const timeout = setTimeout(() => { timedOut = true + activityProbeInFlight = false pending.delete(id) if (inboundSequence > probeInboundSequence) { return @@ -785,6 +788,10 @@ export function connect( // Why: stale probe timers must not close a replacement socket. if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) { probeWs.close() + // Why: React Native can omit onclose for a wedged iOS transport. + if (probeWs === ws) { + handleSocketClosed(probeWs, { timedOut: true }) + } } }, 8_000) pending.set(id, { @@ -792,16 +799,19 @@ export function connect( if (timedOut) { return } + activityProbeInFlight = false clearTimeout(timeout) }, reject: () => { if (timedOut) { return } + activityProbeInFlight = false clearTimeout(timeout) } }) if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) { + activityProbeInFlight = false clearTimeout(timeout) pending.delete(id) }