From ae20958a33a7e784cd38d7ff48650200ec0ebabc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 8 Sep 2026 08:06:24 +0200 Subject: [PATCH 1/3] perf(apple): gate the launch-observation probe on the snapshot circuit breaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Simulator AX-bridge launch-observation probe was re-exported straight through `createAppleSnapshotRoute`, so it never consulted the generation circuit the capture route opens on a typed bridge failure. Re-opening an already-running app therefore re-polled a generation the circuit had already given up on: `application-server-unavailable` and `application-element-missing` carry a 5 s transition window at a 150 ms poll, so ~33 `source.acquire` round trips per `open`, each reconnecting through `connectUntilReady`. Route and probe now share one predicate that rebaselines the generation and reports whether the bridge is disabled for it, so a disabled generation is answered without a bridge round trip while a relaunch — which carries a new generation — clears the circuit and observes as usual. Refs #2198, #2199. --- .../src/snapshot-observability.test.ts | 23 +++++++- .../src/snapshot-observability.ts | 10 +++- .../platform-apple/src/snapshot-route.test.ts | 58 +++++++++++++++++++ packages/platform-apple/src/snapshot-route.ts | 21 +++++-- 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/packages/platform-apple/src/snapshot-observability.test.ts b/packages/platform-apple/src/snapshot-observability.test.ts index 9854396e7a..fa43acb9ad 100644 --- a/packages/platform-apple/src/snapshot-observability.test.ts +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -2,6 +2,7 @@ import { expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createLaunchObservationProbe } from './snapshot-observability.ts'; import type { SnapshotSourceFailure, SnapshotSourceOutcome } from './snapshot-source-facade.ts'; +import type { SimulatorSnapshotTarget } from './snapshot-target.ts'; const simulator = { platform: 'apple', @@ -42,16 +43,19 @@ const acquired = (): SnapshotSourceOutcome => ({ function probe( outcomes: readonly SnapshotSourceOutcome[], clock: { now(): number; sleep(ms: number): Promise }, + isBridgeDisabled: (probed: SimulatorSnapshotTarget) => boolean = () => false, ) { let index = 0; const acquire = vi.fn(async () => outcomes[Math.min(index++, outcomes.length - 1)]!); const sleep = vi.fn(clock.sleep); + const gate = vi.fn(isBridgeDisabled); const observe = createLaunchObservationProbe({ source: { acquire, close: async () => {} }, resolveTarget: async () => target, clock: { now: clock.now, sleep }, + isBridgeDisabled: gate, }); - return { observe, acquire, sleep }; + return { observe, acquire, sleep, gate }; } test('a launched app is observable as soon as the bridge publishes it', async () => { @@ -138,15 +142,30 @@ test('a failure outside the launch transition ends the wait at once', async () = expect(sleep).not.toHaveBeenCalled(); }); +test('a generation whose bridge circuit is open is unobservable without a bridge round trip', async () => { + const { observe, acquire, sleep, gate } = probe( + [acquired()], + { now: () => 0, sleep: async () => {} }, + () => true, + ); + await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe( + 'unobservable', + ); + expect(gate).toHaveBeenCalledWith(target); + expect(acquire).not.toHaveBeenCalled(); + expect(sleep).not.toHaveBeenCalled(); +}); + test.each([ ['a physical iOS device', { ...simulator, kind: 'device' as const }], ['a tvOS Simulator', { ...simulator, appleOs: 'tvos' as const, target: 'tv' as const }], ])('%s has no bridge and is not eligible', async (_name, device) => { - const { observe, acquire } = probe([acquired()], { now: () => 0, sleep: async () => {} }); + const { observe, acquire, gate } = probe([acquired()], { now: () => 0, sleep: async () => {} }); await expect(observe.awaitObservable(device, 'com.example.app', signal())).resolves.toBe( 'not-eligible', ); expect(acquire).not.toHaveBeenCalled(); + expect(gate).not.toHaveBeenCalled(); }); function signal(): AbortSignal { diff --git a/packages/platform-apple/src/snapshot-observability.ts b/packages/platform-apple/src/snapshot-observability.ts index c782a9fca7..d2fe9e2455 100644 --- a/packages/platform-apple/src/snapshot-observability.ts +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -5,7 +5,10 @@ import { import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SimulatorSnapshotSource } from './snapshot-source-facade.ts'; -import type { SimulatorSnapshotTargetResolver } from './snapshot-target.ts'; +import type { + SimulatorSnapshotTarget, + SimulatorSnapshotTargetResolver, +} from './snapshot-target.ts'; /** * What an `open` learned about the app it just launched on a local Simulator: `observable` means @@ -51,6 +54,7 @@ export function createLaunchObservationProbe( source: SimulatorSnapshotSource; resolveTarget: SimulatorSnapshotTargetResolver; clock: PlatformRuntimeHost['clock']; + isBridgeDisabled: (target: SimulatorSnapshotTarget) => boolean; }>, ): LaunchObservationPort { const hint = deriveIosCaptureHint(createIosSnapshotRequest({ depth: 1, interactiveOnly: true })); @@ -62,6 +66,10 @@ export function createLaunchObservationProbe( const target = await deps.resolveTarget(device, appBundleId, signal).catch(() => undefined); signal.throwIfAborted(); if (!target) return 'unobservable'; + // A generation whose bridge already failed a capture fails this probe the same way, and + // the codes it fails with are the ones this loop re-reads for seconds. Ask the circuit + // first; a relaunch carries a new generation, which rebaselines and observes as usual. + if (deps.isBridgeDisabled(target)) return 'unobservable'; const outcome = await deps.source.acquire({ target, hint, signal }); if (outcome.stage !== 'failed') return 'observable'; signal.throwIfAborted(); diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index 6c4e8280f8..89cebb51fc 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -269,6 +269,64 @@ test('a slow app discovery yields to a live runner within its wait slice, then s } }); +test('an open whose generation already failed the bridge skips the launch-observation poll', async () => { + // #2199: `application-server-unavailable` is a launch-transition code, so an ungated probe would + // re-read the bridge every 150 ms for its whole 5 s window on a generation the circuit already + // gave up on — ~33 acquisitions per `open`, each a fresh connect. + const source = sourceReturning({ + stage: 'failed', + failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, + }); + const route = createAppleSnapshotRoute( + { ...platformRuntimeHostFixture(), clock: steppingClock() }, + { source, resolveTarget: vi.fn(async () => target) }, + ); + + await route.capture(ios, input, signal(), async () => runnerResult()); + expect(source.acquire).toHaveBeenCalledOnce(); + + await expect(route.awaitObservable(ios, input.options.appBundleId, signal())).resolves.toBe( + 'unobservable', + ); + expect(source.acquire).toHaveBeenCalledOnce(); +}); + +test('a relaunched generation rebaselines the circuit and observes the launch', async () => { + const outcomes: SnapshotSourceOutcome[] = [ + { stage: 'failed', failure: { kind: 'transport-failure', code: 'bridge-disconnected' } }, + bridgeAcquisition(), + ]; + let acquisitions = 0; + const source = { + acquire: vi.fn(async () => outcomes[Math.min(acquisitions++, outcomes.length - 1)]!), + close: vi.fn(async () => {}), + }; + const relaunched = { ...target, pid: 84, generation: '84:launch-b' }; + const resolveTarget = vi.fn().mockResolvedValueOnce(target).mockResolvedValue(relaunched); + const route = createAppleSnapshotRoute( + { ...platformRuntimeHostFixture(), clock: steppingClock() }, + { source, resolveTarget }, + ); + + await route.capture(ios, input, signal(), async () => runnerResult()); + + await expect(route.awaitObservable(ios, input.options.appBundleId, signal())).resolves.toBe( + 'observable', + ); + expect(source.acquire).toHaveBeenCalledTimes(2); +}); + +/** A clock the launch-observation loop can run to its deadline instead of spinning forever. */ +function steppingClock() { + let now = 0; + return { + now: () => now, + sleep: async (ms: number) => { + now += ms; + }, + }; +} + function bridgeAcquisition(): Extract { return { stage: 'acquired', diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index cb4df8560e..1123b3f476 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -57,7 +57,22 @@ export function createAppleSnapshotRoute( const resolveTarget = options.resolveTarget ?? createSimulatorSnapshotTargetResolver(); const disabledGenerations = new Set(); const latestGeneration = new Map(); - const observation = createLaunchObservationProbe({ source, resolveTarget, clock: host.clock }); + /** + * Records `target` as the newest generation of its app — which clears the circuit an earlier + * generation opened — and reports whether the bridge is disabled for it. Both a capture and the + * launch-observation probe ask before they spend a bridge round trip, so one generation's + * failure is paid once rather than once per route (#2198, #2199). + */ + const isBridgeDisabled = (target: SimulatorSnapshotTarget): boolean => { + rebaselineGeneration(target, latestGeneration, disabledGenerations); + return disabledGenerations.has(generationKey(target)); + }; + const observation = createLaunchObservationProbe({ + source, + resolveTarget, + clock: host.clock, + isBridgeDisabled, + }); return Object.freeze({ awaitObservable: observation.awaitObservable, @@ -79,9 +94,7 @@ export function createAppleSnapshotRoute( [unknownGenerationResidue()], ); } - rebaselineGeneration(target, latestGeneration, disabledGenerations); - const circuitKey = generationKey(target); - if (disabledGenerations.has(circuitKey)) { + if (isBridgeDisabled(target)) { return await runFallback(input, fallback, target, requestFor(input), 'circuit-disabled'); } From ea79a2d8ef57b42e601700e4a4f8a6292e0ce4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 10:53:52 +0200 Subject: [PATCH 2/3] perf(apple): report the launch-observation skip so a live run can prove it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skipped probe and a target that never resolved both end the wait at `unobservable` after zero bridge acquisitions, so on a live device the two are indistinguishable — a live receipt could not show that the circuit gate, rather than a resolution failure, is what stopped the poll. Emit `ios_launch_observation_skipped` with the generation the circuit refused. --- .../src/snapshot-observability.test.ts | 28 +++++++++++++++++++ .../src/snapshot-observability.ts | 16 ++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/platform-apple/src/snapshot-observability.test.ts b/packages/platform-apple/src/snapshot-observability.test.ts index fa43acb9ad..3993ec6a42 100644 --- a/packages/platform-apple/src/snapshot-observability.test.ts +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -1,4 +1,8 @@ import { expect, test, vi } from 'vitest'; +import { + countDiagnosticEventsByPhase, + withDiagnosticsScope, +} from '@agent-device/host-kit/diagnostics'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createLaunchObservationProbe } from './snapshot-observability.ts'; import type { SnapshotSourceFailure, SnapshotSourceOutcome } from './snapshot-source-facade.ts'; @@ -156,6 +160,30 @@ test('a generation whose bridge circuit is open is unobservable without a bridge expect(sleep).not.toHaveBeenCalled(); }); +test('the skip is reported, so a live run can tell it from an unresolvable target', async () => { + // Both verdicts are `unobservable` with zero acquisitions; only the diagnostic separates a + // circuit skip from a target that never resolved. + await withDiagnosticsScope({ command: 'open' }, async () => { + const skipped = probe([acquired()], { now: () => 0, sleep: async () => {} }, () => true); + await skipped.observe.awaitObservable(simulator, 'com.example.app', signal()); + expect(countDiagnosticEventsByPhase(['ios_launch_observation_skipped'])).toBe(1); + }); + await withDiagnosticsScope({ command: 'open' }, async () => { + const unresolvable = createLaunchObservationProbe({ + source: { acquire: vi.fn(), close: async () => {} }, + resolveTarget: async () => { + throw new Error('no target'); + }, + clock: { now: () => 0, sleep: async () => {} }, + isBridgeDisabled: () => true, + }); + await expect( + unresolvable.awaitObservable(simulator, 'com.example.app', signal()), + ).resolves.toBe('unobservable'); + expect(countDiagnosticEventsByPhase(['ios_launch_observation_skipped'])).toBe(0); + }); +}); + test.each([ ['a physical iOS device', { ...simulator, kind: 'device' as const }], ['a tvOS Simulator', { ...simulator, appleOs: 'tvos' as const, target: 'tv' as const }], diff --git a/packages/platform-apple/src/snapshot-observability.ts b/packages/platform-apple/src/snapshot-observability.ts index d2fe9e2455..94d75274f5 100644 --- a/packages/platform-apple/src/snapshot-observability.ts +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -2,6 +2,7 @@ import { createIosSnapshotRequest, deriveIosCaptureHint, } from '@agent-device/capture-kit/ios-snapshot-planning'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SimulatorSnapshotSource } from './snapshot-source-facade.ts'; @@ -69,7 +70,20 @@ export function createLaunchObservationProbe( // A generation whose bridge already failed a capture fails this probe the same way, and // the codes it fails with are the ones this loop re-reads for seconds. Ask the circuit // first; a relaunch carries a new generation, which rebaselines and observes as usual. - if (deps.isBridgeDisabled(target)) return 'unobservable'; + // A skip is reported, because an unresolvable target reaches the same verdict by a + // different route and only the diagnostic tells the two apart on a live device. + if (deps.isBridgeDisabled(target)) { + emitDiagnostic({ + level: 'debug', + phase: 'ios_launch_observation_skipped', + data: { + reason: 'circuit-disabled', + deviceId: device.id, + generation: target.generation, + }, + }); + return 'unobservable'; + } const outcome = await deps.source.acquire({ target, hint, signal }); if (outcome.stage !== 'failed') return 'observable'; signal.throwIfAborted(); From 8b170ede11ebbd80087cd2a72142e2d915a422fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 10:53:52 +0200 Subject: [PATCH 3/3] test(daemon): assert the prepare budget is wired, not that zero time passed `prepareAppleRunner` spends one budget across the boot wait and the runner, so what reaches the runner is `--timeout` minus whatever readiness already used. The assertion demanded exactly 240000, which holds only when both `Date.now()` reads land in the same millisecond; CI caught it at 239999. Assert each budget carries the unspent remainder and never exceeds the request. Both bounds are live: forcing the remainder to 1 fails the lower bound, and re-spending the full budget fails the upper. --- .../__tests__/session-open-url-prewarm.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts index 8b296734d4..8be53b738c 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts @@ -604,8 +604,6 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', expect.objectContaining({ platform: 'apple', id: 'sim-1' }), expect.objectContaining({ cleanStaleBundles: true, - buildTimeoutMs: 240000, - healthTimeoutMs: 240000, logPath: expect.stringMatching(/runner\.log$/), prepareDeadline: expect.objectContaining({ elapsedMs: expect.any(Function), @@ -613,9 +611,21 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', remainingMs: expect.any(Function), }), requestId: 'prepare-request', - startupTimeoutMs: 240000, }), ); + // `prepareAppleRunner` spends one budget across the boot wait and the runner, so what reaches + // the runner is `--timeout` minus whatever readiness already used. Asserting the exact request + // asserts that zero wall-clock time passed, which is an accident of scheduling rather than a + // property of the system; the guarantee is that each budget is wired and never re-spent. + const [, prepareOptions] = mockPrepareIosRunner.mock.calls[0]!; + for (const field of ['buildTimeoutMs', 'healthTimeoutMs', 'startupTimeoutMs'] as const) { + expect + .soft(prepareOptions[field], `${field} carries the unspent remainder of --timeout`) + .toBeGreaterThan(239_000); + expect + .soft(prepareOptions[field], `${field} never exceeds --timeout`) + .toBeLessThanOrEqual(240_000); + } if (response.ok) { expect(response.data).toMatchObject({ action: 'ios-runner',