diff --git a/packages/platform-apple/src/snapshot-observability.test.ts b/packages/platform-apple/src/snapshot-observability.test.ts index 9854396e7a..3993ec6a42 100644 --- a/packages/platform-apple/src/snapshot-observability.test.ts +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -1,7 +1,12 @@ 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'; +import type { SimulatorSnapshotTarget } from './snapshot-target.ts'; const simulator = { platform: 'apple', @@ -42,16 +47,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 +146,54 @@ 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('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 }], ])('%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..94d75274f5 100644 --- a/packages/platform-apple/src/snapshot-observability.ts +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -2,10 +2,14 @@ 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'; -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 +55,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 +67,23 @@ 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. + // 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(); 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'); } 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',