fix(ios): keep observation on the bridge while app discovery is pending and no runner is live

#2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback
past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a
relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll
budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no
live runner now stays on the single-flight discovery, one wait slice at a time, until the
discovery's own deadline or the request signal ends it; a runner that is already live still takes
the fallback at once, the cheaper route #2331 chose.
This commit is contained in:
Michał Pierzchała
2026-09-06 19:05:42 +02:00
parent adec882247
commit e729321dcc
2 changed files with 101 additions and 4 deletions
@@ -204,9 +204,10 @@ test('cancelled acquisition does not start a fallback after the request aborts',
expect(fallback).not.toHaveBeenCalled();
});
test('a slow app discovery yields to the XCTest fallback within its wait slice, then serves the bridge', async () => {
test('a slow app discovery yields to a live runner within its wait slice, then serves the bridge', async () => {
// The production resolver over a simctl whose `launchctl list` answers only when released,
// the shape of a loaded CI host: the first capture must not sit on that probe.
// the shape of a loaded CI host: with a runner that can answer at once, the first capture
// must not sit on that probe.
let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
@@ -232,9 +233,11 @@ test('a slow app discovery yields to the XCTest fallback within its wait slice,
producer: 'simulator-ax-bridge' as const,
nodes: [{ index: 0, type: 'Application' }],
}));
const baseHost = platformRuntimeHostFixture();
const route = createAppleSnapshotRoute(
{
...platformRuntimeHostFixture(),
...baseHost,
appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession: async () => true },
snapshot: { captureSurface: vi.fn(), presentIosAcquisition },
},
{ source, resolveTarget: createSimulatorSnapshotTargetResolver() },
@@ -302,3 +305,65 @@ function runnerResult() {
function signal(): AbortSignal {
return new AbortController().signal;
}
test('a slow app discovery keeps observation on the bridge while no runner can answer', async () => {
// #2198: the open no longer awaits the runner, so right after a relaunch the fallback would
// wait for a cold runner start. A capture with no live runner rides the single-flight
// discovery instead, however many wait slices that takes.
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 hasLiveRunnerSession = vi.fn(async () => false);
const baseHost = platformRuntimeHostFixture();
const route = createAppleSnapshotRoute(
{
...baseHost,
appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession },
snapshot: { captureSurface: vi.fn(), presentIosAcquisition },
},
{ source, resolveTarget: createSimulatorSnapshotTargetResolver() },
);
vi.useFakeTimers();
try {
await withAppleToolProvider(
createLocalAppleToolProvider({ simctl: { run }, runCommand }),
async () => {
const capture = route.capture(ios, input, signal(), fallback);
await vi.advanceTimersByTimeAsync(4_500);
expect(fallback).not.toHaveBeenCalled();
expect(hasLiveRunnerSession).toHaveBeenCalled();
release();
await vi.advanceTimersByTimeAsync(0);
const result = await capture;
expect(result.producer).toBe('simulator-ax-bridge');
expect(fallback).not.toHaveBeenCalled();
expect(run.mock.calls.filter(([args]) => args[0] === 'spawn')).toHaveLength(1);
},
);
} finally {
vi.useRealTimers();
}
});
+33 -1
View File
@@ -66,7 +66,7 @@ export function createAppleSnapshotRoute(
if (!isEligible(device, input)) return await fallback(input);
let target: SimulatorSnapshotTarget;
try {
target = await resolveTarget(device, input.options!.appBundleId!, signal);
target = await resolveTargetForObservation(host, resolveTarget, device, input, signal);
} catch (error) {
signal.throwIfAborted();
emitRouteDiagnostic('target-resolution-failed', device, undefined, error);
@@ -143,6 +143,38 @@ export function createAppleSnapshotRoute(
});
}
/**
* A discovery still in flight is not a failure while no runner can answer instead. The XCTest
* fallback would first wait for a runner start, and #2198 keeps observation off that wait, so the
* capture stays on the single-flight discovery: each turn waits one discovery slice, and the
* discovery's own deadline or the request signal ends the loop. A runner that is already live
* answers at once, so there the fallback remains the cheaper route (#2331).
*/
async function resolveTargetForObservation(
host: PlatformRuntimeHost,
resolveTarget: SimulatorSnapshotTargetResolver,
device: DeviceInfo,
input: CaptureSnapshotInput,
signal: AbortSignal,
): Promise<SimulatorSnapshotTarget> {
const appBundleId = input.options!.appBundleId!;
for (;;) {
try {
return await resolveTarget(device, appBundleId, signal);
} catch (error) {
if (!isDiscoveryPending(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' &&