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