diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 7a723ba82d3..957d385a823 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -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([]) diff --git a/mobile/src/transport/host-force-reconnect.test.ts b/mobile/src/transport/host-force-reconnect.test.ts index 9f013367756..5230a29aa32 100644 --- a/mobile/src/transport/host-force-reconnect.test.ts +++ b/mobile/src/transport/host-force-reconnect.test.ts @@ -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) }) }) diff --git a/mobile/src/transport/host-force-reconnect.ts b/mobile/src/transport/host-force-reconnect.ts index 9b1fa8f0bab..154418a75e6 100644 --- a/mobile/src/transport/host-force-reconnect.ts +++ b/mobile/src/transport/host-force-reconnect.ts @@ -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 } }