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:
parent
7ba433209c
commit
89a9d4fbda
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue