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
20 changes: 20 additions & 0 deletions packages/contracts/src/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
* `wait_capture_stalled` means no readable capture established an observation
* before the deadline and is retriable. `wait_deadline_exceeded` means a later
* capture consumed the remaining budget after at least one readable capture.
* `wait_readiness_exhausted` means the deadline cancelled a poll that was still
* making the target observable; `details.readinessPhase` names the work, and
* `wait_runner_restart_exhausted` is the same verdict for a runner restart.
* `wait_target_absent` is the ordinary timeout reason for a selector that was
* never found. `wait_target_present` is the strict-absence timeout reason when
* valid captures still contain matches. The remaining reasons describe
Expand All @@ -15,6 +18,7 @@ export const WAIT_REASONS = {
captureStalled: 'wait_capture_stalled',
deadlineExceeded: 'wait_deadline_exceeded',
runnerRestartExhausted: 'wait_runner_restart_exhausted',
readinessExhausted: 'wait_readiness_exhausted',
targetAbsent: 'wait_target_absent',
targetPresent: 'wait_target_present',
stableTimeout: 'wait_stable_timeout',
Expand All @@ -23,6 +27,22 @@ export const WAIT_REASONS = {

export type WaitReason = (typeof WAIT_REASONS)[keyof typeof WAIT_REASONS];

/**
* Work a platform does before it can observe the target at all: starting the Apple XCTest runner,
* or discovering the Simulator app process the host AX bridge reads. Only the code doing that work
* at the moment of cancellation names it, as `details.readinessPhase` on the error it throws.
*/
const READINESS_PHASES = ['runner-start', 'target-discovery'] as const;

export type ReadinessPhase = (typeof READINESS_PHASES)[number];

export function readinessPhaseOf(
details: Readonly<Record<string, unknown>> | undefined,
): ReadinessPhase | undefined {
const phase = details?.readinessPhase;
return READINESS_PHASES.find((candidate) => candidate === phase);
}

/**
* Public daemon result for `wait`. The runtime-local result carries a `kind`
* discriminant, but `toDaemonWaitData` intentionally projects the normal daemon
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors';
import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo';
import { IOS_SIMULATOR } from './device-fixtures.ts';
import type { ExecResult } from '@agent-device/host-kit/command';
Expand Down Expand Up @@ -220,6 +222,66 @@ test('a failed restart preserves the invalidated runner evidence', async () => {
}
});

// A runner that has not answered yet is readiness work, not observation: a caller whose deadline
// lands there needs to know the start consumed it, so the cancellation names the phase (#2343).
test('a cancellation during a runner start names the start as the readiness phase', async () => {
const unanswered = await startFakeRunnerServer([]);
await unanswered.close();
const starting = { ...makeRunnerSession(unanswered.port), state: 'starting' as const };
ensureRunnerSessionMock.mockResolvedValue(starting);
const controller = new AbortController();
setTimeout(() => controller.abort(), 50);

await expect(
runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }, { signal: controller.signal }),
).rejects.toMatchObject({
details: { reason: 'request_canceled', readinessPhase: 'runner-start' },
});
expect(invalidateRunnerSessionMock).toHaveBeenCalledWith(
starting,
'runner_startup_request_canceled',
);
});

test('a cancellation before any runner session exists names the start as the readiness phase', async () => {
ensureRunnerSessionMock.mockImplementation(
async (_device: unknown, options: { signal?: AbortSignal }) =>
await new Promise((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(createRequestCanceledError()), {
once: true,
});
}),
);
const controller = new AbortController();
setTimeout(() => controller.abort(), 20);

await expect(
runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }, { signal: controller.signal }),
).rejects.toMatchObject({ details: { readinessPhase: 'runner-start' } });
});

test('a cancellation of a command on a ready runner is not readiness work', async () => {
const silent = http.createServer(() => {});
await new Promise<void>((resolve) => silent.listen(0, '127.0.0.1', resolve));
try {
seedSession((silent.address() as AddressInfo).port);
const controller = new AbortController();
setTimeout(() => controller.abort(), 50);

const command = runAppleRunnerCommand(
IOS_SIMULATOR,
{ command: 'snapshot' },
{ signal: controller.signal },
);

await expect(command).rejects.toMatchObject({ details: { reason: 'request_canceled' } });
await expect(command).rejects.not.toHaveProperty('details.readinessPhase');
} finally {
silent.closeAllConnections();
await new Promise<void>((resolve) => silent.close(() => resolve()));
}
});

test('an exact-session command never dispatches to a replacement runner', async () => {
server = await startFakeRunnerServer({ recordStop: [{ kind: 'ok', data: {} }] });
const replacement = seedSession(server.port);
Expand Down
26 changes: 20 additions & 6 deletions packages/platform-apple/src/runner/runner-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { AppError, asAppError, isRequestCanceledError } from '@agent-device/kernel/errors';
import {
AppError,
asAppError,
createRequestCanceledError,
isRequestCanceledError,
} from '@agent-device/kernel/errors';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { ReadinessPhase } from '@agent-device/contracts/wait';
import { emitDiagnostic } from './host.ts';
import { RUNNER_STARTUP_TIMEOUT_MS } from './runner-startup-transport.ts';
import { RUNNER_COMMAND_TIMEOUT_MS } from './runner-transport.ts';
Expand Down Expand Up @@ -265,15 +271,15 @@ export async function executeRunnerCommand(
const recycleKey = runnerRecycleLedgerKey(options, command);
let session: RunnerSession | undefined;
let recycleBootBegun = false;
const livenessAtEntry = readRunnerSessionLiveness(device.id)?.liveness ?? 'gone';
try {
// A request that already used a runner session and finds no runner process is about to pay
// for a recycle boot (~25s): bound that to the per-request recycle budget so a hostile screen
// fails fast with a preserved session instead of stacking runner boots (#1105).
// `gone` and `stopped` are the two liveness answers that mean no runner is answering now, so
// this command is the one that would start a process (#2662).
const liveness = readRunnerSessionLiveness(device.id)?.liveness ?? 'gone';
if (
(liveness === 'gone' || liveness === 'stopped') &&
(livenessAtEntry === 'gone' || livenessAtEntry === 'stopped') &&
hasRunnerRequestTouchedSession(recycleKey)
) {
if (!tryBeginRunnerRecycle(recycleKey)) {
Expand Down Expand Up @@ -301,9 +307,17 @@ export async function executeRunnerCommand(
} catch (error) {
if (options.expectedRunnerSessionId !== undefined) throw error;
const appErr = asAppError(error, 'COMMAND_FAILED');
if (session && session.state === 'starting' && isRequestCanceledError(appErr)) {
await invalidateRunnerSessionBestEffort(session, 'runner_startup_request_canceled');
throw error;
const runnerNeverAnswered = session
? session.state === 'starting'
: livenessAtEntry !== 'ready';
if (runnerNeverAnswered && isRequestCanceledError(appErr)) {
if (session) {
await invalidateRunnerSessionBestEffort(session, 'runner_startup_request_canceled');
}
throw createRequestCanceledError(
{ ...appErr.details, readinessPhase: 'runner-start' satisfies ReadinessPhase },
appErr,
);
}
if (shouldRestartRunnerBeforeCommandSend(appErr) && session) {
assertRunnerRequestActive(options.requestId);
Expand Down
128 changes: 128 additions & 0 deletions packages/platform-apple/src/snapshot-route.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test, vi } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createRequestCanceledError } from '@agent-device/kernel/errors';

// Keep the route tests hermetic: the default system-surface presence probe shells out to `ps`, which
// never resolves under the fake timers these tests drive. Tests that exercise the bypass inject
Expand Down Expand Up @@ -783,3 +784,130 @@ test('a slow app discovery keeps observation on the bridge while no runner can a
vi.useRealTimers();
}
});

test.for(['rejects', 'exits'] as const)(
'a deadline during the cached target re-check is not readiness work (ps %s)',
async (psOnAbort) => {
// A known target is re-checked with one `ps` per capture; no discovery runs. A deadline that
// lands there must stay a plain cancellation, or a wait would report readiness exhaustion over
// evidence its earlier polls already gathered (#2343 review).
const run = vi.fn(async (args: readonly string[]) => ({
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,
}));
let recheckStarted!: () => void;
const recheck = new Promise<void>((resolve) => {
recheckStarted = resolve;
});
let psCalls = 0;
const runCommand = vi.fn(
async (cmd: string, args: readonly string[], options?: { signal?: AbortSignal }) => {
if (cmd !== 'ps' || ++psCalls === 1) return { stdout: 'start-a', stderr: '', exitCode: 0 };
recheckStarted();
return await new Promise<{ stdout: string; stderr: string; exitCode: number }>(
(resolve, reject) => {
options?.signal?.addEventListener(
'abort',
() =>
psOnAbort === 'rejects'
? reject(createRequestCanceledError({ cmd, args }))
: resolve({ stdout: '', stderr: '', exitCode: 1 }),
{ once: true },
);
},
);
},
);
const fallback = vi.fn(async () => runnerResult());
const baseHost = platformRuntimeHostFixture();
const route = createAppleSnapshotRoute(
{
...baseHost,
appleApplications: {
...baseHost.appleApplications,
hasLiveRunnerSession: async () => false,
},
snapshot: {
captureSurface: vi.fn(),
presentIosAcquisition: vi.fn(async () => ({
backend: 'xctest' as const,
producer: 'simulator-ax-bridge' as const,
nodes: [{ index: 0, type: 'Application' }],
})),
},
},
{
source: sourceReturning(bridgeAcquisition()),
resolveTarget: createSimulatorSnapshotTargetResolver(),
},
);
await withAppleToolProvider(
createLocalAppleToolProvider({ simctl: { run }, runCommand }),
async () => {
await route.capture(ios, input, signal(), fallback);
const deadline = new AbortController();
const capture = route.capture(ios, input, deadline.signal, fallback);
await recheck;
deadline.abort(new DOMException('Wait deadline exceeded', 'TimeoutError'));

await expect(capture).rejects.not.toHaveProperty('details.readinessPhase');
expect(run.mock.calls.filter(([args]) => args[0] === 'spawn')).toHaveLength(1);
expect(fallback).not.toHaveBeenCalled();
},
);
},
);

test('a deadline during a slow app discovery names the discovery as the readiness phase', async () => {
// The capture never reached the bridge or the runner: its whole cost was finding the target, so
// the cancellation says so instead of reading as a capture that produced nothing (#2343).
const run = vi.fn(async (args: readonly string[]) => {
if (args[0] === 'spawn') await new Promise<never>(() => {});
return {
stdout: 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 baseHost = platformRuntimeHostFixture();
const route = createAppleSnapshotRoute(
{
...baseHost,
appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession: async () => false },
},
{
source: sourceReturning(bridgeAcquisition()),
resolveTarget: createSimulatorSnapshotTargetResolver(),
},
);
const deadline = new AbortController();
vi.useFakeTimers();
try {
await withAppleToolProvider(
createLocalAppleToolProvider({ simctl: { run }, runCommand }),
async () => {
const capture = route.capture(ios, input, deadline.signal, fallback);
const settled = expect(capture).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: { reason: 'request_canceled', readinessPhase: 'target-discovery' },
});
await vi.advanceTimersByTimeAsync(3_000);
deadline.abort(new DOMException('Wait deadline exceeded', 'TimeoutError'));
await settled;
expect(fallback).not.toHaveBeenCalled();
},
);
} finally {
vi.useRealTimers();
}
});
12 changes: 10 additions & 2 deletions packages/platform-apple/src/snapshot-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
deriveIosCaptureHint,
} from '@agent-device/capture-kit/ios-snapshot-planning';
import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics';
import { AppError } from '@agent-device/kernel/errors';
import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors';
import type { DeviceInfo } from '@agent-device/kernel/device';
import {
createSimulatorSnapshotSource,
Expand Down Expand Up @@ -140,7 +140,7 @@ export function createAppleSnapshotRoute(
try {
target = await resolveTargetForObservation(host, resolveTarget, device, input, signal);
} catch (error) {
signal.throwIfAborted();
rethrowIfResolutionCancelled(signal, error);
emitRouteDiagnostic('target-resolution-failed', device, undefined, error);
return await runFallback(
device.id,
Expand Down Expand Up @@ -247,6 +247,14 @@ async function resolveTargetForObservation(
}
}

/**
* A cancelled target resolution rethrows the resolver's own cancellation, which names the readiness
* phase only when the resolver was waiting on a running discovery.
*/
function rethrowIfResolutionCancelled(signal: AbortSignal, error: unknown): void {
if (signal.aborted) throw isRequestCanceledError(error) ? error : signal.reason;
}

function isEligible(device: DeviceInfo, input: CaptureSnapshotInput): boolean {
return (
device.platform === 'apple' &&
Expand Down
28 changes: 26 additions & 2 deletions packages/platform-apple/src/snapshot-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,39 @@ test('a cancelled caller leaves discovery running for the next capture', async (
await withAppleToolProvider(fixture.provider, async () => {
const controller = new AbortController();
const cancelled = fixture.resolve(ios, app, controller.signal);
controller.abort(new Error('request-ended'));
await expect(cancelled).rejects.toThrow('request-ended');
const reason = new Error('request-ended');
controller.abort(reason);
// The caller spent its time waiting on discovery, so its cancellation names that readiness
// work (#2343) and keeps the caller's own reason as the cause.
await expect(cancelled).rejects.toMatchObject({
details: { reason: 'request_canceled', readinessPhase: 'target-discovery' },
cause: reason,
});

release();
expect(await fixture.resolve(ios, app, signal())).toMatchObject({ pid: 42 });
expect(fixture.discoveryCount()).toBe(1);
});
});

test('a cancelled re-check of a known target keeps it and names no readiness work', async () => {
const fixture = targetFixture();
await withAppleToolProvider(fixture.provider, async () => {
await fixture.resolve(ios, app, signal());
const controller = new AbortController();
const reason = new Error('wait-deadline');
fixture.runCommand.mockImplementationOnce(async () => {
controller.abort(reason);
return { stdout: '', stderr: '', exitCode: 1 };
});

await expect(fixture.resolve(ios, app, controller.signal)).rejects.toBe(reason);

expect(await fixture.resolve(ios, app, signal())).toMatchObject({ pid: 42 });
expect(fixture.discoveryCount()).toBe(1);
});
});

test('one discovery shares a single deadline across its simctl probes and the identity read', async () => {
const fixture = targetFixture();
const release = deferredSpawn(fixture);
Expand Down
Loading
Loading