fix(mobile): preserve reconnect revision evidence

This commit is contained in:
Brennan Benson
2026-08-01 08:50:18 -07:00
parent e9926b7edd
commit c2df7d7206
4 changed files with 59 additions and 4 deletions
+9 -4
View File
@@ -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.
+21
View File
@@ -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)
@@ -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)
+1
View File
@@ -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) {