From c5957e7d4d3329f6fcab542ad39cdb231cd8930f Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:32:59 +0000 Subject: [PATCH] fix(mobile): notice a silent drop while dictating within seconds (#35834) An overdue chunk reply now asks the connection for one decisive liveness probe instead of waiting for the idle watchdog, so the resend lands inside the host's grace. Native clients probe directly; the OTA page asks the shell with a new notify that older shells refuse with a diagnostic only. --- .../src/hooks/mobile-dictation-audio-chunk.ts | 17 +++-- .../hooks/mobile-dictation-reconnect.test.tsx | 71 +++++++++++++++---- .../bridge-host-notifications.test.ts | 18 +++++ .../mobile-web-shell/bridge-host-notify.ts | 5 ++ .../bridge-client-notifications.test.ts | 13 ++++ .../bridge/bridge-client-notifications.ts | 5 ++ .../bridge/bridge-liveness-probe-notify.ts | 9 +++ .../bridge/bridge-notify-envelope.ts | 2 + .../bridge/bridge-notify-grants.ts | 4 +- .../bridge/bridge-rpc-client.ts | 1 + mobile/src/transport/direct-rpc-client.ts | 4 ++ .../execution-host-scoped-rpc-client.ts | 1 + .../mobile-relay-rpc-session-liveness.test.ts | 19 +++++ .../src/transport/mobile-relay-rpc-session.ts | 11 +-- mobile/src/transport/rpc-client.ts | 2 + .../rpc-session-liveness-integration.test.ts | 43 +++++++++++ .../rpc-session-liveness-watchdog.ts | 17 +++++ .../transport/stable-logical-rpc-client.ts | 5 ++ 18 files changed, 223 insertions(+), 24 deletions(-) create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-liveness-probe-notify.ts diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts index 41bfeb7b0f1..8e7390ce8ac 100644 --- a/mobile/src/hooks/mobile-dictation-audio-chunk.ts +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -9,6 +9,7 @@ import { MOBILE_DICTATION_RECONNECT_GRACE_MS } from '../../../src/shared/rpc-con import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { rpcPayloadMember } from '../transport/rpc-reader-payload' import { waitForRpcClientReconnected } from '../transport/rpc-client-reconnect-wait' +import { DECISIVE_PROBE_TIMEOUT_MS } from '../transport/rpc-session-liveness-watchdog' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isLogicalClientSuspendedError } from '../transport/logical-client-suspended-error' import type { @@ -141,15 +142,21 @@ export function enqueueMobileDictationAudioChunk( sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE, seq: queue.pendingAudioBudget.nextSeq() } + const hostDedupesResends = queue.pendingAudioBudget.hostDedupesResends const sendChunk = sendAcrossConnectionMove( client, - () => + () => { + // A reply this late, with nothing heard meanwhile, means a silently dead connection; probe now, not after the idle watchdog, and keep asking until it settles. + const overdue = hostDedupesResends + ? setInterval(() => client.probeLivenessNow?.(), DECISIVE_PROBE_TIMEOUT_MS) + : undefined // Wait for a reconnect only as long as the host keeps the session; past that, fail and keep the text so far. - dictationAudioChunkSend.request(client, params, { - timeoutMs: MOBILE_DICTATION_RECONNECT_GRACE_MS - }), + return dictationAudioChunkSend + .request(client, params, { timeoutMs: MOBILE_DICTATION_RECONNECT_GRACE_MS }) + .finally(() => clearInterval(overdue)) + }, // A recording cancelled during the outage must not reach the host when it comes back. - queue.pendingAudioBudget.hostDedupesResends ? () => queue.isActive(dictationId) : undefined + hostDedupesResends ? () => queue.isActive(dictationId) : undefined ) .then((reply) => { // Only newer hosts send the text, and only when it changed; any other result still acknowledges the chunk. diff --git a/mobile/src/hooks/mobile-dictation-reconnect.test.tsx b/mobile/src/hooks/mobile-dictation-reconnect.test.tsx index 234f22df15f..73df8e96c1b 100644 --- a/mobile/src/hooks/mobile-dictation-reconnect.test.tsx +++ b/mobile/src/hooks/mobile-dictation-reconnect.test.tsx @@ -183,6 +183,24 @@ describe('dictation through a reconnect', () => { }) }) +async function startWith(rpc: FakeRpcClient, startResult: Record) { + mount(rpc, [], []) + await act(async () => { + const started = dictation().start() + while (rpc.requests.length === 0) { + await Promise.resolve() + } + rpc.requests.splice(0)[0].resolve({ id: 'desktop', ok: true, result: startResult }) + await started + }) +} + +function speak(): void { + for (const handler of seam.chunkHandlers) { + handler({ data: Uint8Array.from([1, 2]), droppedBytes: 0 }) + } +} + describe('audio sent on a connection that died', () => { async function dropMidChunk( startResult: Record, @@ -191,20 +209,7 @@ describe('audio sent on a connection that died', () => { let state: ConnectionState = 'connected' const rpc = createFakeRpcClient({ getState: () => state }) const sent: SentRequest[] = [] - mount(rpc, [], []) - await act(async () => { - const started = dictation().start() - while (rpc.requests.length === 0) { - await Promise.resolve() - } - rpc.requests.splice(0)[0].resolve({ id: 'desktop', ok: true, result: startResult }) - await started - }) - const speak = () => { - for (const handler of seam.chunkHandlers) { - handler({ data: Uint8Array.from([1, 2]), droppedBytes: 0 }) - } - } + await startWith(rpc, startResult) await act(async () => { speak() speak() @@ -248,6 +253,44 @@ describe('audio sent on a connection that died', () => { }) }) +describe('a chunk reply that stops coming', () => { + async function probesAround(startResult: Record, overdueMs: number) { + vi.useFakeTimers() + try { + const rpc = createFakeRpcClient() + let probes = 0 + rpc.probeLivenessNow = () => { + probes += 1 + } + await startWith(rpc, startResult) + await act(async () => { + speak() + vi.advanceTimersByTime(overdueMs - 1) + }) + const early = probes + await act(async () => { + vi.advanceTimersByTime(1) + }) + const overdue = probes + await act(async () => { + vi.advanceTimersByTime(overdueMs) + }) + return [early, overdue, probes] + } finally { + vi.useRealTimers() + } + } + + it('asks for a decisive probe every four seconds the reply is late', async () => { + await expect(probesAround({ dedupesResentChunks: true }, 4000)).resolves.toEqual([0, 1, 2]) + }) + + // A false alarm would end a live connection, and an old host cannot take the lost audio again. + it('leaves an old host to the idle watchdog', async () => { + await expect(probesAround({}, 4000)).resolves.toEqual([0, 0, 0]) + }) +}) + describe('the text so far', () => { it('shows what the host heard while recording and clears it as the final text lands', async () => { const rpc = createFakeRpcClient() diff --git a/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts b/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts index ea5c0a28029..8348ec4dfa5 100644 --- a/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts @@ -9,6 +9,7 @@ import { import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted' import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update' import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge/bridge-haptics-notify' +import { BRIDGE_LIVENESS_PROBE_NOTIFY } from './bridge/bridge-liveness-probe-notify' import { MOBILE_WEB_SHELL_GRANTS } from './page-route-policy' describe('notifications, refusals and the fence', () => { @@ -365,6 +366,23 @@ describe('haptics', () => { }) }) +// A page that stops hearing back cannot probe the socket itself; native owns the connection it rides. +describe('liveness probe', () => { + const probe = clientFrame({ type: 'notify', name: BRIDGE_LIVENESS_PROBE_NOTIFY }) + + it('asks the native client for a decisive probe', () => { + const bridge = harness() + let probes = 0 + bridge.client.probeLivenessNow = () => { + probes += 1 + } + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(probe) + expect(probes).toBe(1) + expect(bridge.diagnostics).toEqual([]) + }) +}) + describe('the page erasing a one-shot route param', () => { // Forward both one-shot key and value to the native route holder; only it can compare before clearing (ruling 34). it('carries a page clear up with the param and the value it named', () => { diff --git a/mobile/src/mobile-web-shell/bridge-host-notify.ts b/mobile/src/mobile-web-shell/bridge-host-notify.ts index 1c636e1d7fa..5fa6f049da2 100644 --- a/mobile/src/mobile-web-shell/bridge-host-notify.ts +++ b/mobile/src/mobile-web-shell/bridge-host-notify.ts @@ -7,6 +7,7 @@ import { type BridgeInitRoute } from './bridge/bridge-envelope' import { BRIDGE_HAPTICS_NOTIFY } from './bridge/bridge-haptics-notify' +import { BRIDGE_LIVENESS_PROBE_NOTIFY } from './bridge/bridge-liveness-probe-notify' import { bridgeNotifyRefusal } from './bridge/bridge-notify-grants' import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge/bridge-page-back' import { BRIDGE_PAGE_LOCATION } from './bridge/bridge-page-location' @@ -94,6 +95,10 @@ export function createBridgeNotifyForwarder(args: { options.onStorageWrite(message.key, message.value) return } + if (message.name === BRIDGE_LIVENESS_PROBE_NOTIFY) { + client.probeLivenessNow?.() + return + } if (message.name === BRIDGE_HAPTICS_NOTIFY) { // Haptics are one-way so each row tap avoids spending a request slot. options.onHaptic(message.kind) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts index d6a66915e67..3a4c038e4c4 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts @@ -7,6 +7,7 @@ import { BRIDGE_PROTOCOL_VERSION } from './bridge-envelope' import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify' +import { BRIDGE_LIVENESS_PROBE_NOTIFY } from './bridge-liveness-probe-notify' import { GRANTS, INIT, createPageClient as createPage } from './bridge-page-client-test-harness' const resources = new Set>() @@ -246,3 +247,15 @@ describe('bridge client haptics', () => { expect(page.frames().at(-1)).toEqual({ v: BRIDGE_PROTOCOL_VERSION, type: 'close' }) }) }) + +// No grant gates it: an older shell refuses the frame with one diagnostic and keeps its idle watchdog. +describe('bridge client liveness probe', () => { + it('posts a probe frame once the session is up', () => { + const page = createPageClient() + page.start() + page.client.probeLivenessNow?.() + expect(page.frames().slice(1)).toEqual([ + { v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_LIVENESS_PROBE_NOTIFY } + ]) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts index 8922f11f925..128fdbb0281 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts @@ -13,6 +13,7 @@ import { type BridgeHapticsKind } from './bridge-haptics-notify' import { captureBridgeError } from './bridge-error-capture' +import { BRIDGE_LIVENESS_PROBE_NOTIFY } from './bridge-liveness-probe-notify' import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' import { BRIDGE_PAGE_LOCATION } from './bridge-page-location' import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' @@ -37,6 +38,7 @@ export type BridgeClientNotifications = { notifyExternalLink: (url: string) => boolean notifyStorageWrite: (key: string, value: string | null) => boolean notifyHaptics: (kind: BridgeHapticsKind) => boolean + notifyLivenessProbe: () => void notifyPageFault: (error: unknown) => boolean notifyPagePainted: () => void notifyBackClaim: (claimed: boolean) => void @@ -109,6 +111,9 @@ export function createBridgeClientNotifications( notifyHaptics: (kind) => deps.hasGrant(BRIDGE_HAPTICS_GRANT) && post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind }), + notifyLivenessProbe: () => { + post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_LIVENESS_PROBE_NOTIFY }) + }, notifyPageFault: (error) => { if (deps.isClosed() || !deps.hasGrant(BRIDGE_FAULT_GRANT)) { return false diff --git a/mobile/src/mobile-web-shell/bridge/bridge-liveness-probe-notify.ts b/mobile/src/mobile-web-shell/bridge/bridge-liveness-probe-notify.ts new file mode 100644 index 00000000000..722e1d4a6eb --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-liveness-probe-notify.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +// The page asks native for a decisive liveness probe when its replies stop coming. Older shells refuse the frame with a diagnostic only and keep the idle watchdog. +export const BRIDGE_LIVENESS_PROBE_NOTIFY = 'liveness-probe' + +export const BRIDGE_LIVENESS_PROBE_NOTIFY_FIELDS = { + type: z.literal('notify'), + name: z.literal(BRIDGE_LIVENESS_PROBE_NOTIFY) +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts index 70a6b454e8c..a55f604260d 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { BridgeErrorCaptureSchema } from './bridge-error-capture' import { BRIDGE_HAPTICS_NOTIFY_FIELDS } from './bridge-haptics-notify' +import { BRIDGE_LIVENESS_PROBE_NOTIFY_FIELDS } from './bridge-liveness-probe-notify' import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' import { BRIDGE_PAGE_LOCATION } from './bridge-page-location' import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' @@ -79,6 +80,7 @@ export const BridgeNotifySchema = z.discriminatedUnion('name', [ error: BridgeErrorCaptureSchema }), z.object({ v: versionSchema, ...BRIDGE_HAPTICS_NOTIFY_FIELDS }), + z.object({ v: versionSchema, ...BRIDGE_LIVENESS_PROBE_NOTIFY_FIELDS }), // Allow only the shell-provided one-shot param; its holder ignores a clear naming an older value (ruling 34). z.object({ v: versionSchema, diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts index ca8eebc0baf..20feb271bdd 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts @@ -5,6 +5,7 @@ import { type BridgeClientMessage } from './bridge-envelope' import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify' +import { BRIDGE_LIVENESS_PROBE_NOTIFY } from './bridge-liveness-probe-notify' import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' import { BRIDGE_PAGE_LOCATION } from './bridge-page-location' import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' @@ -26,7 +27,8 @@ const BRIDGE_NOTIFY_GRANTS: Readonly> = storage: 'storage', [BRIDGE_EXTERNAL_LINK_GRANT]: BRIDGE_EXTERNAL_LINK_GRANT, [BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT, - [BRIDGE_HAPTICS_NOTIFY]: BRIDGE_HAPTICS_GRANT + [BRIDGE_HAPTICS_NOTIFY]: BRIDGE_HAPTICS_GRANT, + [BRIDGE_LIVENESS_PROBE_NOTIFY]: null } export type BridgeNotifyRefusal = 'before-ready' | 'ungranted' diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts index 8051250c344..32b54110d66 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts @@ -319,6 +319,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp // Allow listener registration before init so providers can observe the first state change; it reads no unprimed value. onStateChange: (listener) => cache.onStateChange(listener), notifyForeground: notifications.notifyForeground, + probeLivenessNow: notifications.notifyLivenessProbe, notifyNavigate: notifications.notifyNavigate, notifyNavigateBack: notifications.notifyNavigateBack, notifyExternalLink: notifications.notifyExternalLink, diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts index 1217cdaa282..8241d8f9ebf 100644 --- a/mobile/src/transport/direct-rpc-client.ts +++ b/mobile/src/transport/direct-rpc-client.ts @@ -170,6 +170,10 @@ export class DirectRpcClient implements RpcClient { return this.connectionState.addListener(listener) } + // The watchdog starts before capability negotiation, when a probe cannot be written yet. + readonly probeLivenessNow = (): void => + void (this.getState() === 'connected' && this.liveness.probeCurrentDecisively()) + notifyForeground(_reason?: ForegroundNudgeReason): void { if (this.intentionallyClosed) { return diff --git a/mobile/src/transport/execution-host-scoped-rpc-client.ts b/mobile/src/transport/execution-host-scoped-rpc-client.ts index cd669d26e0d..9c2310cdb19 100644 --- a/mobile/src/transport/execution-host-scoped-rpc-client.ts +++ b/mobile/src/transport/execution-host-scoped-rpc-client.ts @@ -52,6 +52,7 @@ export function routeRpcClientCalls( carriesRpcEnvelope: client.carriesRpcEnvelope && (() => client.carriesRpcEnvelope?.() ?? true), onStateChange: (listener) => client.onStateChange(listener), notifyForeground: (reason) => client.notifyForeground(reason), + probeLivenessNow: client.probeLivenessNow && (() => client.probeLivenessNow?.()), close: () => {} } } diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts index abd0ced5279..f99d064b92f 100644 --- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts @@ -237,6 +237,25 @@ describe('mobile relay RPC session liveness', () => { expect(fakes.close).toHaveBeenCalledOnce() }) + it('probes for a caller that stopped hearing back, even inside the foreground cooldown', async () => { + const session = await authenticateSession() + session.notifyForeground('focus') + const firstProbe = sentRequests()[0]! + fakes.linkOptions!.onText( + JSON.stringify({ id: firstProbe.id, ok: true, result: {}, _meta: { runtimeId: 'r1' } }) + ) + + // Just heard from, so not suspect yet. + session.probeLivenessNow?.() + expect(fakes.sendText).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(4_000) + session.probeLivenessNow?.() + expect(fakes.sendText).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(4_000) + + expect(session.getState()).toBe('disconnected') + }) + it('redials after one 4s miss when a retained relay returns from the background', async () => { const onLog = vi.fn() const session = await authenticateSession(onLog) diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index 9ac1d971763..8a58ba66c38 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -17,7 +17,10 @@ import { isRpcResponse } from './rpc-response-shape' import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-stage' import { RelayPendingRequests } from './relay-pending-requests' import { MobileRelayConnectWaiters } from './mobile-relay-connect-waiters' -import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' +import { + DECISIVE_PROBE_TIMEOUT_MS, + RpcSessionLivenessWatchdog +} from './rpc-session-liveness-watchdog' import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason' import type { RpcClient } from './rpc-client' @@ -26,8 +29,6 @@ import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types' const RELAY_PROBE_TIMEOUT_MS = 4_000 const RELAY_MISSED_PROBE_LIMIT = 2 const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000 -// Use one decisive post-background probe, allowing radio wake/TCP retransmit latency before redial. -const RELAY_RESUME_PROBE_MS = 4_000 let relayRpcSessionSequence = 0 export type MobileRelayRpcSession = RpcClient & @@ -157,7 +158,9 @@ export function connectMobileRelayRpcSession(args: { getResumeConfirmation: () => resumeConfirmation, getFailure: () => failure, probeAfterBackground: () => - state === 'connected' && livenessWatchdog.probeNow(livenessIdentity, RELAY_RESUME_PROBE_MS) + state === 'connected' && + livenessWatchdog.probeNow(livenessIdentity, DECISIVE_PROBE_TIMEOUT_MS), + probeLivenessNow: () => state === 'connected' && livenessWatchdog.probeCurrentDecisively() } const connectWaiters = new MobileRelayConnectWaiters(client) const livenessWatchdog = new RpcSessionLivenessWatchdog({ diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index af3766dc3e1..47e0aa7a01b 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -36,6 +36,8 @@ export type RpcClient = UnvalidatedRpcRequestPort & { carriesRpcEnvelope?: () => boolean onStateChange: (listener: (state: ConnectionState) => void) => () => void notifyForeground: (reason?: ForegroundNudgeReason) => void + // A caller that stopped hearing back asks for one decisive probe, so a silently dead socket ends in seconds. + probeLivenessNow?: () => void // Settle every pending request before returning; only the physical sender knows which reached the wire. // Reject written requests with markRpcDeliveryUnknown: the host may already have executed them. close: () => void diff --git a/mobile/src/transport/rpc-session-liveness-integration.test.ts b/mobile/src/transport/rpc-session-liveness-integration.test.ts index 042978611c8..4c58a358252 100644 --- a/mobile/src/transport/rpc-session-liveness-integration.test.ts +++ b/mobile/src/transport/rpc-session-liveness-integration.test.ts @@ -113,6 +113,49 @@ describe('physical session liveness', () => { } }) + it('ends a silent socket after one 4s miss when a caller stopped hearing back', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = sockets[0]! + socket.authenticate() + + try { + // Just heard from, so not suspect yet. + const probes = () => socket.sent.filter((frame) => frame.includes('mobile-liveness-')) + client.probeLivenessNow?.() + await vi.advanceTimersByTimeAsync(4_000) + expect(probes()).toHaveLength(0) + + client.probeLivenessNow?.() + expect(probes()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(3_999) + expect(socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + expect(socket.close).toHaveBeenCalledOnce() + expect(client.getState()).toBe('reconnecting') + } finally { + client.close() + } + }) + + it('turns a tolerant probe already running into a decisive one', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = sockets[0]! + socket.authenticate() + + try { + // A network switch starts the three-miss probe first. + client.notifyForeground('network-change') + await vi.advanceTimersByTimeAsync(4_000) + client.probeLivenessNow?.() + await vi.advanceTimersByTimeAsync(4_000) + + expect(socket.close).toHaveBeenCalledOnce() + } finally { + client.close() + } + }) + it('counts authenticated terminal binary output as liveness', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = sockets[0]! diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts index d5a8741b560..f8c2b4f2913 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts @@ -3,6 +3,8 @@ import { defaultCancelTimer, defaultScheduleTimer, type ScheduleTimer } from './ export const LIVENESS_IDLE_MS = 20_000 export const LIVENESS_PROBE_TIMEOUT_MS = 8_000 export const MISSED_PROBE_LIMIT = 3 +// One miss ends a session already suspected dead; long enough for radio wake and TCP retransmit before redial. +export const DECISIVE_PROBE_TIMEOUT_MS = 4_000 declare const rpcSessionIdentityBrand: unique symbol @@ -35,6 +37,7 @@ export class RpcSessionLivenessWatchdog { private identity: RpcSessionIdentity | null = null private timer: ReturnType | null = null private probing = false + private probingDecisively = false private missedProbes = 0 private lastInboundAt = 0 private lastVoluntaryProbeAt: number | null = null @@ -106,6 +109,19 @@ export class RpcSessionLivenessWatchdog { this.startProbe(identity, decisiveTimeoutMs) } + // For a caller that stopped hearing back: a session heard from within the window is not suspect, no foreground rate limit applies, and a tolerant probe already running is cut short. + probeCurrentDecisively(): void { + const identity = this.identity + const decisiveRunning = this.probing && this.probingDecisively + if ( + identity && + !decisiveRunning && + this.now() - this.lastInboundAt >= DECISIVE_PROBE_TIMEOUT_MS + ) { + this.startProbe(identity, DECISIVE_PROBE_TIMEOUT_MS) + } + } + stop(identity: RpcSessionIdentity): void { if (this.identity !== identity) { return @@ -143,6 +159,7 @@ export class RpcSessionLivenessWatchdog { } this.clearActiveTimer() this.probing = true + this.probingDecisively = decisiveTimeoutMs !== null const sentAt = this.now() let sent = false try { diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index fab0b476d82..4d11f73718e 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -173,6 +173,11 @@ export function createStableLogicalRpcClient( activeSession.notifyForeground(reason) } }, + probeLivenessNow: () => { + if (!suspended) { + activeSession.probeLivenessNow?.() + } + }, close() { if (closed) { return