Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
7 changes: 4 additions & 3 deletions packages/platform-apple/src/open-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlatformRuntimeHost, 'clock'>,
Expand Down
27 changes: 27 additions & 0 deletions packages/platform-apple/src/snapshot-observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }],
Expand Down
33 changes: 28 additions & 5 deletions packages/platform-apple/src/snapshot-observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<SimulatorSnapshotTarget | undefined> {
for (;;) {
try {
return await resolveTarget(device, appBundleId, signal);
} catch (error) {
signal.throwIfAborted();
if (!isSimulatorTargetDiscoveryPending(error)) return undefined;
}
}
}
68 changes: 68 additions & 0 deletions packages/platform-apple/src/snapshot-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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',
Expand Down
9 changes: 2 additions & 7 deletions packages/platform-apple/src/snapshot-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from './snapshot-observability.ts';
import {
createSimulatorSnapshotTargetResolver,
isSimulatorTargetDiscoveryPending,
type SimulatorSnapshotTarget,
type SimulatorSnapshotTargetResolver,
} from './snapshot-target.ts';
Expand Down Expand Up @@ -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' &&
Expand Down
12 changes: 11 additions & 1 deletion packages/platform-apple/src/snapshot-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading