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>
This commit is contained in:
Appcaster 2026-07-31 05:30:28 +09:00 committed by GitHub
parent 7ba433209c
commit 89a9d4fbda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 52 additions and 1 deletions

View File

@ -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()

View File

@ -161,6 +161,7 @@ export function connect(
let connectTimer: ReturnType<typeof setTimeout> | null = null
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
let activityProbeTimer: ReturnType<typeof setInterval> | 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)
}