fix(mobile): keep an unhealthy Force Reconnect replacement

This commit is contained in:
Brennan Benson
2026-08-03 20:20:19 -07:00
parent eb287ddf3e
commit 9159b4b190
3 changed files with 36 additions and 6 deletions
@@ -739,6 +739,32 @@ describe('useHostClient', () => {
harness.unmount()
})
it('keeps a live replacement bound when Force Reconnect reports it unhealthy', async () => {
const stale = makeFakeClient('connected')
const fresh = makeFakeClient('connected')
fresh.sendRequest = vi.fn(async () => {
throw new Error('Application RPC channel is still not responding')
})
connectMock.mockReturnValueOnce(stale).mockReturnValueOnce(fresh)
loadHostsMock.mockResolvedValue([HOST])
const harness = await renderHarness(HOST.id)
await act(async () => {
await expect(harness.forceReconnect(HOST.id)).rejects.toThrow(
'Application RPC channel is still not responding'
)
})
// Regression: a rejected health check used to close and drop the
// replacement, leaving a 'Disconnected' host with no retry loop and no
// Reconnect affordance — the failure had to stay recoverable in-app.
expect(fresh.closeMock).not.toHaveBeenCalled()
expect(harness.hook.client).toBe(fresh)
expect(harness.hook.state).toBe('connected')
harness.unmount()
})
it('reports disconnected instead of hanging when the host id is unknown', async () => {
loadHostsMock.mockResolvedValue([])
@@ -51,7 +51,7 @@ describe('HostForceReconnectCoordinator', () => {
await expect(reconnect).resolves.toBeUndefined()
})
it('retires a replacement that fails RPC health verification', async () => {
it('keeps a replacement that fails RPC health verification', async () => {
const close = vi.fn()
const unsubState = vi.fn()
const client = {
@@ -85,9 +85,11 @@ describe('HostForceReconnectCoordinator', () => {
})
).rejects.toThrow('health failed')
expect(unsubState).toHaveBeenCalledOnce()
expect(close).toHaveBeenCalledOnce()
expect(removeEntry).toHaveBeenCalledWith(fresh)
expect(current).toBeUndefined()
// Regression: retiring the replacement left the host with no client and no
// retry loop, so a failed Force Reconnect was unrecoverable in-app.
expect(unsubState).not.toHaveBeenCalled()
expect(close).not.toHaveBeenCalled()
expect(removeEntry).not.toHaveBeenCalledWith(fresh)
expect(current).toBe(fresh)
})
})
+3 -1
View File
@@ -109,8 +109,10 @@ export class HostForceReconnectCoordinator {
try {
await verifyForceReconnectRpcHealth(fresh.client, deadline)
} catch (error) {
// Why: report the unhealthy verdict but keep the replacement — retiring it
// strands the host with no client, no retry loop, and a 'Disconnected'
// verdict that hides the Reconnect button the user needs to try again.
if (!this.wasCancelled(operation.hostId, generation)) {
this.retireCurrentEntry(operation, fresh)
throw error
}
}