diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx index bac69ad3eb6..8368ed9c9cd 100644 --- a/mobile/app/h/[hostId]/edit.tsx +++ b/mobile/app/h/[hostId]/edit.tsx @@ -109,7 +109,7 @@ export default function EditHostScreen() { ...(willRename ? { name: nextName } : {}), ...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {}) } - let reconnectProfile: HostProfile | null = hostProfileAfterEdit(host, updates) + let reconnectProfile: HostProfile | null | undefined = hostProfileAfterEdit(host, updates) savingRef.current = true setSaving(true) @@ -132,8 +132,13 @@ export default function EditHostScreen() { // elsewhere in the app picks up the fresh state regardless. const hostLoadRevision = getHostListLoadRevision() const hosts = await loadHosts() - primeHosts(hosts, hostLoadRevision) - reconnectProfile = hosts.find(({ id }) => id === host.id) ?? null + if (hostLoadRevision === getHostListLoadRevision()) { + primeHosts(hosts, hostLoadRevision) + reconnectProfile = hosts.find(({ id }) => id === host.id) ?? null + } else { + // A concurrent publication already primed a newer reconnect profile. + reconnectProfile = undefined + } } catch { // best-effort re-prime; persisted data is unaffected } @@ -142,7 +147,7 @@ export default function EditHostScreen() { setSaving(false) router.back() - if (nextEndpoint !== undefined && reconnectProfile) { + if (nextEndpoint !== undefined && reconnectProfile !== null) { // Why: reconnect is a follow-on side effect of a save that already // committed — its failure or a hang must not be reported as a save // failure or block navigating back. diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts index 73cb1aa0bd6..00f9f497227 100644 --- a/mobile/src/host-edit-save-flow.test.ts +++ b/mobile/src/host-edit-save-flow.test.ts @@ -7,6 +7,7 @@ const dependencies = vi.hoisted(() => ({ alert: vi.fn(), back: vi.fn(), forceReconnectHost: vi.fn(), + getHostListLoadRevision: vi.fn(() => 0), loadHosts: vi.fn(), primeHosts: vi.fn(), updateHostNameAndEndpoint: vi.fn(), @@ -44,6 +45,10 @@ vi.mock('./transport/host-store', () => ({ updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint })) +vi.mock('./transport/host-list-load-sharing', () => ({ + getHostListLoadRevision: dependencies.getHostListLoadRevision +})) + vi.mock('./transport/client-context', () => ({ useForceReconnect: () => dependencies.forceReconnectHost, usePrimeHosts: () => dependencies.primeHosts @@ -142,6 +147,7 @@ describe('edit host handleSave', () => { dependencies.alert.mockReset() dependencies.back.mockReset() dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) + dependencies.getHostListLoadRevision.mockReset().mockReturnValue(0) dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE]) dependencies.primeHosts.mockReset() dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined) @@ -285,6 +291,20 @@ describe('edit host handleSave', () => { act(() => renderer.unmount()) }) + it('uses the newer cached profile when the persisted snapshot is invalidated mid-load', async () => { + const staleHost = { ...HOST_FIXTURE, endpoint: 'ws://192.168.1.20:6768' } + dependencies.loadHosts.mockResolvedValueOnce([HOST_FIXTURE]).mockResolvedValueOnce([staleHost]) + dependencies.getHostListLoadRevision.mockReturnValueOnce(1).mockReturnValueOnce(2) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Address', '192.168.1.20:6768') + await pressSave(renderer) + + expect(dependencies.primeHosts).not.toHaveBeenCalled() + expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1', undefined) + + act(() => renderer.unmount()) + }) + it('ignores a second Save trigger while a save is already in flight', async () => { let resolveSave: () => void = () => {} dependencies.updateHostNameAndEndpoint.mockImplementationOnce( @@ -322,6 +342,7 @@ describe('edit host load() error states', () => { dependencies.alert.mockReset() dependencies.back.mockReset() dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) + dependencies.getHostListLoadRevision.mockReset().mockReturnValue(0) dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE]) dependencies.primeHosts.mockReset() dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined) diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts index 99cf79a80cb..dee76b3ff04 100644 --- a/mobile/src/transport/rpc-client-request-deadline.test.ts +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -356,6 +356,34 @@ describe('mobile rpc-client request deadline', () => { client.close() }) + it('keeps late activity-probe evidence across a queued probe handoff', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + client.notifyForeground() + const firstProbe = sentRequest(socket, 'status.get') + const stalled = client.sendRequest('browser.screenshot', {}, { timeoutMs: 100 }) + const stalledOutcome = stalled.catch((error: unknown) => error) + const healthy = client.sendRequest('speech.models.list', {}, { timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(0) + const healthyRequest = sentRequest(socket, 'speech.models.list') + socket.receive(`encrypted:${JSON.stringify({ id: healthyRequest.id, ok: true, result: {} })}`) + + await expect(healthy).resolves.toMatchObject({ ok: true }) + await vi.advanceTimersByTimeAsync(100) + await expect(stalledOutcome).resolves.toMatchObject({ + message: 'Request timed out: browser.screenshot' + }) + await vi.advanceTimersByTimeAsync(7_900) + expect(sentRequests(socket, 'status.get')).toHaveLength(2) + socket.receive(`encrypted:${JSON.stringify({ id: firstProbe.id, ok: true, result: {} })}`) + + await vi.advanceTimersByTimeAsync(8_000) + expect(client.getState()).toBe('connected') + expect(socket.close).not.toHaveBeenCalled() + client.close() + }) + it('keeps Force Reconnect inside its deadline after a late connection', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const verification = verifyForceReconnectRpcHealth(client) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 2d2a3906513..b70d9a16335 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -798,6 +798,7 @@ export function connect( const timeout = setTimeout(() => { timedOut = true pending.delete(id) + timedOutControlRequestIds.add(id) const controlResponded = controlResponseSequence > probeControlResponseSequence activityProbeFollowUp.finish(probeWs) if (controlResponded) {