From 84c47cca787c3595e992f081ca9ae85f7ac7d447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 18:23:46 +0200 Subject: [PATCH] fix(ios): let a Simulator open wait out a slow app discovery before observing the launch The launch-observation probe read a target discovery that outlasted one 1.5 s wait slice as an unobservable app. On a loaded host simctl launchctl list takes longer than that, so open --relaunch returned before the discovery, the AX-bridge preparation or the first bridge connection, and the first wait after it paid all three in one poll behind a runner findText. On iOS smoke CI that poll consumed the whole 10 s budget (wait_capture_stalled, captures: 1) at step 7 of smoke:automation-input. The probe now keeps joining the running discovery, bounded by the discovery's own deadline; any other resolution failure still ends it at once. The discovery-pending reason and its predicate move to the resolver that owns them, shared by the capture route. --- CHANGELOG.md | 8 +++ packages/platform-apple/src/open-policy.ts | 7 +- .../src/snapshot-observability.test.ts | 27 ++++++++ .../src/snapshot-observability.ts | 33 +++++++-- .../platform-apple/src/snapshot-route.test.ts | 68 +++++++++++++++++++ packages/platform-apple/src/snapshot-route.ts | 9 +-- .../platform-apple/src/snapshot-target.ts | 12 +++- 7 files changed, 148 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e9850d969..9bf19bbaba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Fixed (ios): `open` on a local Simulator now waits for the launched app's discovery before it + decides whether the app is observable. On a loaded host `simctl spawn launchctl list` outlasts one + 1.5 s discovery wait slice, and the launch observation read that slice as an unobservable app, so + the open returned before the discovery, the AX-bridge preparation or the first bridge connection. + The first `wait` after the open then paid all three in one poll, behind a runner `findText`, and on + CI it spent its whole 10 s budget there (`wait_capture_stalled` with `captures: 1`). The probe now + joins the running discovery, bounded by the discovery's own deadline, and then observes the launch + as before. A resolution failure other than a pending discovery still ends the probe at once. - Fixed (ios): a local Simulator snapshot taken through the host AX bridge once again publishes the geometric `hittable` fact, so `is hittable` and a `hittable:` selector resolve the same controls on the bridge and the XCTest runner. The snapshot capability table has declared `hittable = diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index df7608e794..67f0ebe24a 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -69,9 +69,10 @@ export function releaseSpeculativeRunner( /** * Lets the opened app become observable before the open returns. A local Simulator asks its AX - * bridge, bounded by the launch-transition windows the bridge itself defines, so the first - * observation never pays the launch and never falls back to a runner start for it. Any other - * device, or a Simulator whose bridge cannot answer, keeps the fixed settle. + * bridge once the app's discovery settles, bounded by the discovery's own deadline and the + * launch-transition windows the bridge itself defines, so the first observation never pays the + * launch and never falls back to a runner start for it. Any other device, or a Simulator whose + * bridge cannot answer, keeps the fixed settle. */ export async function settleAppleOpen( host: Pick, diff --git a/packages/platform-apple/src/snapshot-observability.test.ts b/packages/platform-apple/src/snapshot-observability.test.ts index 00e4d58c94..d107e33ce5 100644 --- a/packages/platform-apple/src/snapshot-observability.test.ts +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -4,6 +4,7 @@ import { withDiagnosticsScope, } from '@agent-device/host-kit/diagnostics'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { createLaunchObservationProbe } from './snapshot-observability.ts'; import type { SnapshotSourceFailure, SnapshotSourceOutcome } from './snapshot-source-facade.ts'; import type { SimulatorSnapshotTarget } from './snapshot-target.ts'; @@ -187,6 +188,32 @@ test('the skip is reported, so a live run can tell it from an unresolvable targe }); }); +test.each([ + ['a discovery still running is joined until it answers', 'simulator-target-discovery-pending', 3], + ['any other resolution failure is unobservable at once', 'simulator-target-unavailable', 1], +])('%s', async (_name, reason, expectedResolutions) => { + let resolutions = 0; + const acquire = vi.fn(async () => acquired()); + const observe = createLaunchObservationProbe({ + source: { acquire, close: async () => {} }, + resolveTarget: async () => { + resolutions += 1; + if (resolutions < 3) { + throw new AppError('COMMAND_FAILED', 'Unable to resolve the running iOS Simulator app.', { + reason, + }); + } + return target; + }, + clock: { now: () => 0, sleep: async () => {} }, + isBridgeDisabled: () => false, + }); + await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe( + expectedResolutions === 3 ? 'observable' : 'unobservable', + ); + expect(resolutions).toBe(expectedResolutions); +}); + 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 94d75274f5..91ff821b3d 100644 --- a/packages/platform-apple/src/snapshot-observability.ts +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -6,9 +6,10 @@ 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 { - SimulatorSnapshotTarget, - SimulatorSnapshotTargetResolver, +import { + isSimulatorTargetDiscoveryPending, + type SimulatorSnapshotTarget, + type SimulatorSnapshotTargetResolver, } from './snapshot-target.ts'; /** @@ -64,8 +65,7 @@ export function createLaunchObservationProbe( if (!hasSimulatorBridge(device)) return 'not-eligible'; let deadline: number | undefined; for (;;) { - const target = await deps.resolveTarget(device, appBundleId, signal).catch(() => undefined); - signal.throwIfAborted(); + const target = await resolveLaunchedTarget(deps.resolveTarget, device, appBundleId, signal); 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 @@ -97,3 +97,26 @@ export function createLaunchObservationProbe( }, }); } + +/** + * The launched app's bridge target, or `undefined` when it cannot be resolved. A discovery that is + * still running has not answered yet, so the probe keeps joining it one wait slice at a time until + * the discovery's own deadline settles it. Returning early would hand the discovery, the bridge + * preparation and the first bridge connection to the first observation after the open, which pays + * them inside its own budget. + */ +async function resolveLaunchedTarget( + resolveTarget: SimulatorSnapshotTargetResolver, + device: DeviceInfo, + appBundleId: string, + signal: AbortSignal, +): Promise { + for (;;) { + try { + return await resolveTarget(device, appBundleId, signal); + } catch (error) { + signal.throwIfAborted(); + if (!isSimulatorTargetDiscoveryPending(error)) return undefined; + } + } +} diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index 4d9c0775b9..2f5a9b6647 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -546,6 +546,74 @@ test('a slow app discovery yields to a live runner within its wait slice, then s } }); +test('an open waits out a slow app discovery, so the first capture after it starts warm', async () => { + // iOS smoke `wait for Agent Device Tester` right after `open --relaunch`: `launchctl list` + // outlasted one discovery slice on CI, the open read that as an unobservable app and returned, + // and the wait's first poll paid the discovery, the bridge preparation and the first bridge + // connection behind a runner findText until its 10 s budget ran out. + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const run = vi.fn(async (args: string[]) => { + if (args[0] === 'spawn') await released; + return { + stdout: + args[0] === 'spawn' + ? `42\t0\tUIKitApplication:${input.options.appBundleId}[launch-a][rb-legacy]` + : JSON.stringify({ + devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: ios.id }] }, + }), + stderr: '', + exitCode: 0, + }; + }); + const runCommand = vi.fn(async () => ({ stdout: 'start-a', stderr: '', exitCode: 0 })); + const fallback = vi.fn(async () => runnerResult()); + const source = sourceReturning(bridgeAcquisition()); + const presentIosAcquisition = vi.fn(async () => ({ + backend: 'xctest' as const, + producer: 'simulator-ax-bridge' as const, + nodes: [{ index: 0, type: 'Application' }], + })); + const baseHost = platformRuntimeHostFixture(); + const route = createAppleSnapshotRoute( + { + ...baseHost, + appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession: async () => true }, + snapshot: { captureSurface: vi.fn(), presentIosAcquisition }, + }, + { source, resolveTarget: createSimulatorSnapshotTargetResolver() }, + ); + vi.useFakeTimers(); + try { + await withAppleToolProvider( + createLocalAppleToolProvider({ simctl: { run }, runCommand }), + async () => { + let verdict: string | undefined; + const observed = route + .awaitObservable(ios, input.options.appBundleId, signal()) + .then((value) => (verdict = value)); + await vi.advanceTimersByTimeAsync(4_500); + expect(verdict).toBeUndefined(); + expect(source.acquire).not.toHaveBeenCalled(); + + release(); + await vi.advanceTimersByTimeAsync(0); + await expect(observed).resolves.toBe('observable'); + expect(source.acquire).toHaveBeenCalledOnce(); + + const first = await route.capture(ios, input, signal(), fallback); + expect(first.producer).toBe('simulator-ax-bridge'); + expect(fallback).not.toHaveBeenCalled(); + expect(run.mock.calls.filter(([args]) => args[0] === 'spawn')).toHaveLength(1); + }, + ); + } finally { + vi.useRealTimers(); + } +}); + test.each([ 'application-server-unavailable', 'continuation-budget-exhausted', diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 339f305088..1961abdca5 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -29,6 +29,7 @@ import { } from './snapshot-observability.ts'; import { createSimulatorSnapshotTargetResolver, + isSimulatorTargetDiscoveryPending, type SimulatorSnapshotTarget, type SimulatorSnapshotTargetResolver, } from './snapshot-target.ts'; @@ -239,19 +240,13 @@ async function resolveTargetForObservation( try { return await resolveTarget(device, appBundleId, signal); } catch (error) { - if (!isDiscoveryPending(error)) throw error; + if (!isSimulatorTargetDiscoveryPending(error)) throw error; const execution = { requestId: input.execution?.requestId }; if (await host.appleApplications.hasLiveRunnerSession(device, execution)) throw error; } } } -function isDiscoveryPending(error: unknown): boolean { - return ( - error instanceof AppError && error.details?.reason === 'simulator-target-discovery-pending' - ); -} - function isEligible(device: DeviceInfo, input: CaptureSnapshotInput): boolean { return ( device.platform === 'apple' && diff --git a/packages/platform-apple/src/snapshot-target.ts b/packages/platform-apple/src/snapshot-target.ts index 544dad5e21..9978f55796 100644 --- a/packages/platform-apple/src/snapshot-target.ts +++ b/packages/platform-apple/src/snapshot-target.ts @@ -15,6 +15,8 @@ const TARGET_IDENTITY_TIMEOUT_MS = 3_000; const TARGET_DISCOVERY_WAIT_MS = 1_500; /** Overall deadline of one discovery (both simctl probes and the `ps` identity read). */ const TARGET_DISCOVERY_TIMEOUT_MS = 15_000; +/** A caller's wait slice ran out while the discovery it joined is still running. */ +const TARGET_DISCOVERY_PENDING = 'simulator-target-discovery-pending'; export type SimulatorSnapshotTarget = Readonly<{ udid: string; @@ -61,11 +63,19 @@ export function createSimulatorSnapshotTargetResolver(): SimulatorSnapshotTarget }, wait: (waitMs, stop) => waitForDetachedAttempt({ waitMs, signal, stop, cancelled: () => signal.reason }), - pending: () => targetError('simulator-target-discovery-pending', device, appBundleId), + pending: () => targetError(TARGET_DISCOVERY_PENDING, device, appBundleId), }); }; } +/** + * Whether a resolver failure only says the discovery is still running. The discovery keeps going + * under its own deadline, so asking again joins it rather than starting another. + */ +export function isSimulatorTargetDiscoveryPending(error: unknown): boolean { + return error instanceof AppError && error.details?.reason === TARGET_DISCOVERY_PENDING; +} + async function resolveSimulatorSnapshotTarget( device: DeviceInfo, appBundleId: string,