diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 11b6419cb..14ccecc5d 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -479,4 +479,29 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + + it('rejects requests waiting for reconnect after the retry cap', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + socket.close() + + const waitingRequestError = client.sendRequest('status.get').then( + () => null, + (error: Error) => error + ) + await vi.runAllTimersAsync() + + expect(client.getState()).toBe('reconnecting') + expect(client.getReconnectAttempt()).toBe(12) + await expect(waitingRequestError).resolves.toMatchObject({ + message: 'Connection retry limit reached' + }) + await expect(client.sendRequest('status.get')).rejects.toThrow('Connection retry limit reached') + + client.close() + }) }) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 995169f9b..03fa7d547 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -199,6 +199,13 @@ export function connect( // in the logs. let stateEnteredAt = Date.now() + function rejectConnectWaiters(reason: string) { + const error = new Error(reason) + for (const waiter of connectWaiters.splice(0)) { + waiter.reject(error) + } + } + function setState(next: ConnectionState) { if (state === next) return const prev = state @@ -218,7 +225,7 @@ export function connect( } else if (next === 'disconnected' || next === 'auth-failed') { const reason = next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed' - for (const w of connectWaiters.splice(0)) w.reject(new Error(reason)) + rejectConnectWaiters(reason) } for (const listener of stateListeners) { listener(next) @@ -239,6 +246,11 @@ export function connect( function waitForConnected(): Promise { if (state === 'connected') return Promise.resolve() if (intentionallyClosed) return Promise.reject(new Error('Client closed')) + if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS && !reconnectTimer) { + // Why: after the retry cap there is no future state transition to + // release callers waiting before their per-request timeout starts. + return Promise.reject(new Error('Connection retry limit reached')) + } return new Promise((resolve, reject) => { connectWaiters.push({ resolve, reject }) }) @@ -699,6 +711,7 @@ export function connect( reason: 'give-up-cap', endpoint: redactedEndpoint(endpoint) }) + rejectConnectWaiters('Connection retry limit reached') return } const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!