diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2732d0c5c15..659d1cf1b05 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3546,8 +3546,8 @@ "motivatingLinks": [ "https://linear.app/stably/issue/STA-4153/mobile-app-disconnects-frequently" ], - "invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions probe after 15s of authenticated silence, emit no demand-driven probes, use rate-limited foreground probes, and terminate after two fair misses; a tolerant miss does not count while authenticated frames arrived during the probe window, or while a sent request still awaits its reply and the link has been silent for less than the request timeout. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death. Relay recovery must notify presentation independently of transport-state changes, must not inherit a failed direct path's Tailscale hint, and must retain long-outage escalation.", - "oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay idle probes only after 15s without authenticated frames, no demand-driven liveness traffic, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay. Start and clear Relay recovery without changing transport state and require observers to rerender. Project five failed Tailscale attempts into a neutral Relay-specific verdict, then require twelve attempts to produce a Relay-specific unreachable verdict with no Tailscale hint.", + "invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions probe after 15s of authenticated silence, retry at once after a fair miss, probe on demand only decisively while a caller's reply is overdue after 4s of authenticated silence, use rate-limited foreground probes, and terminate after two fair misses; a tolerant miss does not count while authenticated frames arrived during the probe window, or while a sent request still awaits its reply and the link has been silent for less than the request timeout. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death. Relay recovery must notify presentation independently of transport-state changes, must not inherit a failed direct path's Tailscale hint, and must retain long-outage escalation.", + "oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay idle probes only after 15s without authenticated frames, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay. Start and clear Relay recovery without changing transport state and require observers to rerender. Project five failed Tailscale attempts into a neutral Relay-specific verdict, then require twelve attempts to produce a Relay-specific unreachable verdict with no Tailscale hint.", "commands": [ "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts" ], @@ -3686,7 +3686,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Each wanted clientless host owns at most one open and one retry timer with a 60-second capped tail. Direct sessions own one idle or probe timer. Relay sessions own one idle or probe timer and send at most one idle probe per 15 seconds without authenticated frames, about four small round trips per idle foreground minute, and no demand-driven liveness traffic; foreground sequences start at most once per 10 seconds. Each Relay idle probe costs one relay forward and one desktop status.get probe dispatch; no subprocess or wire opcode is introduced." + "evidence": "Each wanted clientless host owns at most one open and one retry timer with a 60-second capped tail. Direct sessions own one idle or probe timer. Relay sessions own one idle or probe timer and start an idle probe 15 seconds after the last authenticated frame, about four small round trips per healthy idle foreground minute. A fair miss on a link that isn't busy sends one retry as soon as its 4-second timeout fires, and a second fair miss closes the session. Demand-driven probes are decisive and run only while a caller's reply is overdue after 4 seconds of authenticated silence, at most one per 4 seconds; foreground sequences start at most once per 10 seconds. Each Relay probe costs one relay forward and one desktop status.get probe dispatch; no subprocess or wire opcode is introduced." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history.", @@ -3696,6 +3696,7 @@ ], "knownGaps": [ "No physical iOS or Android device was exercised.", + "No test in the commands covers the decisive on-demand Relay probe that runs while a dictation reply is overdue (rpc-liveness-probe-while-waiting.ts).", "No affected-device log yet proves the old one-window watchdog caused the reported live-session drop.", "Carrier NAT, production Tailscale, production relay cells, and real scheduler suspension remain unmeasured.", "Persistent pre-client failure metadata is not yet exposed in Home UI.", @@ -3713,7 +3714,7 @@ "Audit1351 refreshes Audit1339 removals/narrowing of mobile/src/transport/mobile-relay-e2ee-link.test.ts. Exact retired scenarios are intentionally lost; surviving assertions are limited to unchanged retained authored cases. No equivalent replacement, physical phone or deployed mixed-host claim. Fresh validation is required for narrowed commands.", "Retired during audit1352 selector cleanup; original complete execution receipt (unchanged): {\"date\":\"2026-11-04\",\"runner\":\"local\",\"platform\":\"linux\",\"result\":\"passed\",\"command\":\"cd mobile && ORCA_BACKGROUND_LAUNCH=1 node /home/neil/orca/workspaces/orca/comprehensive-test-audit-10/build/audits/audit1345-combined-low-value-tests/validation/mobile/node_modules/vitest/vitest.mjs run --config vitest.config.ts src/transport/client-context.test.ts src/transport/host-open-recovery.test.tsx src/transport/host-open-retry-scheduler.test.ts src/transport/mobile-endpoint-nudge-router.test.ts src/transport/mobile-relay-e2ee-link.test.ts src/transport/mobile-relay-rpc-session-liveness.test.ts src/transport/mobile-relay-rpc-session.test.ts src/transport/rpc-session-liveness-integration.test.ts src/transport/rpc-session-liveness-watchdog.test.ts src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2\",\"currentCommand\":\"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts\",\"durationSeconds\":3.07,\"summary\":\"Test Files 10 passed (10); Tests 83 passed (83). Focused Linux unit execution; no p95, physical-provider or cross-platform claim. Source head de234fc7b4a73eda58dd0084bc9e064b5d1191e0.\"}" ], - "demotionRule": "Keep experimental or demote if a wanted host can park without retry, stale work can publish or notify, disconnect or forget resurrects a host, direct closes before three fair misses, Relay polls while idle or on demand, survives two fair foreground misses, authenticated traffic fails to restore health, timers leak across identity replacement, mutations replay after ambiguous delivery, or physical-device radio cost exceeds the measured budget." + "demotionRule": "Keep experimental or demote if a wanted host can park without retry, stale work can publish or notify, disconnect or forget resurrects a host, direct closes before three fair misses, Relay idle-probes within 15s of authenticated traffic, probes on demand while replies arrive on time, survives two fair misses, authenticated traffic fails to restore health, timers leak across identity replacement, mutations replay after ambiguous delivery, or physical-device radio cost exceeds the measured budget." }, { "id": "desktop-relay.assignment-backpressure", diff --git a/mobile/src/transport/host-path-memory.ts b/mobile/src/transport/host-path-memory.ts index 85cde9bc0bd..aee36aa0906 100644 --- a/mobile/src/transport/host-path-memory.ts +++ b/mobile/src/transport/host-path-memory.ts @@ -46,13 +46,17 @@ async function readNetworkFingerprint(): Promise { type NetworkReading = { type: string; local: string | null } | null -// Every paired host's supervisor reads on the same nudge; share one native read across them. +// Paired hosts read in one synchronous nudge fan-out, so share that tick's read; a later caller reads again +// because a read started before its event can report the network the phone just left. let networkRead: Promise | null = null function readNetwork(): Promise { - networkRead ??= readNetworkNow().finally(() => { - networkRead = null - }) + if (!networkRead) { + networkRead = readNetworkNow() + queueMicrotask(() => { + networkRead = null + }) + } return networkRead } diff --git a/mobile/src/transport/mobile-endpoint-nudge-router.test.ts b/mobile/src/transport/mobile-endpoint-nudge-router.test.ts index bc04661aff8..26fdca794db 100644 --- a/mobile/src/transport/mobile-endpoint-nudge-router.test.ts +++ b/mobile/src/transport/mobile-endpoint-nudge-router.test.ts @@ -3,9 +3,13 @@ import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router' import type { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' -function routerFixture() { +function routerFixture(firstRead?: Promise) { let foreground = false let localNetwork: string | null = null + const readLocalNetwork = vi.fn(async () => localNetwork) + if (firstRead) { + readLocalNetwork.mockReturnValueOnce(firstRead) + } const logical = { getActivePath: vi.fn(() => 'relay'), getState: vi.fn(() => 'connected'), @@ -25,7 +29,7 @@ function routerFixture() { isForeground: () => foreground, setForeground, scheduleDirectProbe, - readLocalNetwork: async () => localNetwork, + readLocalNetwork, onLocalNetworkArrival }) const join = (network: string | null) => { @@ -36,12 +40,23 @@ function routerFixture() { join, logical, onLocalNetworkArrival, + readLocalNetwork, router, scheduleDirectProbe, setForeground } } +function deferredRead() { + let settle: (network: string | null) => void = () => {} + const promise = new Promise((resolve) => { + settle = resolve + }) + return { promise, settle } +} + +const settleReads = () => new Promise((resolve) => setTimeout(resolve, 0)) + describe('MobileEndpointNudgeRouter', () => { it('processes a focus nudge after restoring lagging foreground state', () => { const fixture = routerFixture() @@ -81,4 +96,52 @@ describe('MobileEndpointNudgeRouter', () => { await vi.waitFor(() => expect(fixture.onLocalNetworkArrival).toHaveBeenCalledTimes(2)) expect(fixture.handleActiveNudge).toHaveBeenCalledTimes(6) }) + + it('counts a join seen while the startup read is still pending as an arrival', async () => { + const startupRead = deferredRead() + const fixture = routerFixture(startupRead.promise) + fixture.readLocalNetwork.mockReturnValueOnce(startupRead.promise) + fixture.router.nudge('focus') + + fixture.router.nudge('network-change') + startupRead.settle('WIFI:192.168.1') + + await vi.waitFor(() => expect(fixture.onLocalNetworkArrival).toHaveBeenCalledOnce()) + }) + + it('keeps the last network across a failed read', async () => { + const fixture = routerFixture() + fixture.router.nudge('focus') + fixture.join('WIFI:192.168.1') + fixture.router.nudge('network-change') + await settleReads() + + fixture.readLocalNetwork.mockRejectedValueOnce(new Error('network read failed')) + fixture.router.nudge('network-change') + await settleReads() + fixture.router.nudge('network-change') + await settleReads() + + expect(fixture.onLocalNetworkArrival).toHaveBeenCalledOnce() + }) + + it('ignores a read that settles after a newer one', async () => { + const fixture = routerFixture() + fixture.router.nudge('focus') + const older = deferredRead() + const newer = deferredRead() + fixture.readLocalNetwork.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise) + fixture.router.nudge('network-change') + fixture.router.nudge('network-change') + + newer.settle('WIFI:192.168.1') + await settleReads() + older.settle(null) + await settleReads() + fixture.join('WIFI:192.168.1') + fixture.router.nudge('network-change') + await settleReads() + + expect(fixture.onLocalNetworkArrival).toHaveBeenCalledOnce() + }) }) diff --git a/mobile/src/transport/mobile-endpoint-nudge-router.ts b/mobile/src/transport/mobile-endpoint-nudge-router.ts index fd5eaf675f6..14eef48a9c9 100644 --- a/mobile/src/transport/mobile-endpoint-nudge-router.ts +++ b/mobile/src/transport/mobile-endpoint-nudge-router.ts @@ -5,6 +5,9 @@ import type { ForegroundNudgeReason } from './types' export class MobileEndpointNudgeRouter { // Undefined until read, so a join before the first read still counts as a change. private lastLocalNetwork: string | null | undefined + // Reads can settle out of order; a result older than the last applied one is stale. + private readsStarted = 0 + private lastAppliedRead = 0 constructor( private readonly args: { @@ -20,7 +23,8 @@ export class MobileEndpointNudgeRouter { ) { void args.readLocalNetwork().then( (network) => { - if (this.lastLocalNetwork === undefined) { + // Once a nudge reads, this result may predate its network, so it can't be the baseline. + if (this.readsStarted === 0) { this.lastLocalNetwork = network } }, @@ -52,8 +56,22 @@ export class MobileEndpointNudgeRouter { // iOS repeats callbacks for an unchanged network; only a move onto a new Wi-Fi network is an arrival. private async routeNetworkChange(): Promise { - const network = await this.args.readLocalNetwork().catch(() => null) - if (network === this.lastLocalNetwork || this.args.isStopped()) { + const read = ++this.readsStarted + let network: string | null + try { + network = await this.args.readLocalNetwork() + } catch { + // Keep the baseline: a failed read isn't a move. + if (!this.args.isStopped()) { + this.args.scheduleDirectProbe() + } + return + } + if (read < this.lastAppliedRead || this.args.isStopped()) { + return + } + this.lastAppliedRead = read + if (network === this.lastLocalNetwork) { return } this.lastLocalNetwork = network