Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -544,24 +544,24 @@ test('read-only commands retry when completed status has no retained response',
});
});

test('read-only startup commands use the session startup timeout override', async () => {
test('read-only startup commands measure readiness from the session launch deadline', async () => {
vi.useFakeTimers({ now: 1_000 });
const session = makeRunnerSession({
port: 8100,
state: 'starting',
startupTimeoutMs: 240_000,
launchDeadline: Deadline.fromTimeoutMs(240_000),
});
mockEnsureRunnerSession.mockImplementationOnce(async () => {
vi.setSystemTime(41_000);
return session;
});

mockEnsureRunnerSession.mockResolvedValue(session);
mockExecuteRunnerCommandWithSession.mockResolvedValue({ currentUptimeMs: 42 });

const result = await runAppleRunnerCommand(
IOS_SIMULATOR,
{ command: 'uptime' },
{ startupTimeoutMs: 240_000 },
);
const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'uptime' });
vi.useRealTimers();

assert.deepEqual(result, { currentUptimeMs: 42 });
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 240_000);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 200_000);
});

test('read-only commands retry when status shows in-flight work', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { beforeEach, test } from 'vitest';
import { createRequestCanceledError } from '@agent-device/kernel/errors';
import { appleRunnerTestHost } from '../test-host.ts';
import {
callerDeadlineExpired,
isCallerDeadlineAbortReason,
resolveRunnerStartupSignal,
} from '../runner-contract.ts';

const registered = new Map<string, AbortController>();
const canceled = new Set<string>();

function deadlineReason(): DOMException {
return new DOMException('Wait deadline exceeded', 'TimeoutError');
}

beforeEach(() => {
registered.clear();
canceled.clear();
appleRunnerTestHost.update({
getRequestSignal: (requestId) => (requestId ? registered.get(requestId)?.signal : undefined),
isRequestCanceled: (requestId) => requestId !== undefined && canceled.has(requestId),
});
});

test('a caller deadline is the typed TimeoutError reason, not its text', () => {
assert.equal(isCallerDeadlineAbortReason(deadlineReason()), true);
assert.equal(isCallerDeadlineAbortReason(new Error('Wait deadline exceeded')), false);
assert.equal(isCallerDeadlineAbortReason(createRequestCanceledError()), false);
});

test('the startup signal ignores a caller deadline and forwards every other abort', () => {
const deadline = new AbortController();
const startup = resolveRunnerStartupSignal({ signal: deadline.signal });
assert.ok(startup);
deadline.abort(deadlineReason());
assert.equal(startup.aborted, false);

const disconnect = new AbortController();
const killed = resolveRunnerStartupSignal({ signal: disconnect.signal });
assert.ok(killed);
const reason = new Error('client disconnected');
disconnect.abort(reason);
assert.equal(killed.aborted, true);
assert.equal(killed.reason, reason);
});

test('a caller signal already aborted by its deadline does not abort the startup signal', () => {
const expired = new AbortController();
expired.abort(deadlineReason());
const startup = resolveRunnerStartupSignal({ signal: expired.signal });
assert.equal(startup?.aborted, false);
});

test('the registered request signal kills a start even when it rides with a caller deadline', () => {
const request = new AbortController();
registered.set('req-1', request);
const deadline = new AbortController();
const startup = resolveRunnerStartupSignal({ requestId: 'req-1', signal: deadline.signal });
assert.ok(startup);
deadline.abort(deadlineReason());
assert.equal(startup.aborted, false);
request.abort(createRequestCanceledError());
assert.equal(startup.aborted, true);
});

test('without a caller signal the registered request signal is the startup signal itself', () => {
const request = new AbortController();
registered.set('req-2', request);
assert.equal(resolveRunnerStartupSignal({ requestId: 'req-2' }), request.signal);
assert.equal(resolveRunnerStartupSignal({}), undefined);
});

test('callerDeadlineExpired reads the deadline reason and yields to a cancelled request', () => {
const deadline = new AbortController();
deadline.abort(deadlineReason());
assert.equal(callerDeadlineExpired({ signal: deadline.signal }), true);
canceled.add('req-3');
assert.equal(callerDeadlineExpired({ requestId: 'req-3', signal: deadline.signal }), false);
const plain = new AbortController();
plain.abort(new Error('client disconnected'));
assert.equal(callerDeadlineExpired({ signal: plain.signal }), false);
assert.equal(callerDeadlineExpired({}), false);
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,23 @@ vi.mock('../runner-xctestrun.ts', async () => {

import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors';
import { abortAllIosRunnerSessions, readRunnerSessionLiveness } from '../runner-session.ts';
import { RUNNER_STARTUP_TIMEOUT_MS } from '../runner-startup-transport.ts';
import type { RunnerLease } from '../runner-lease.ts';
import { executeRunnerCommand, prepareLocalIosRunner } from '../runner-lifecycle.ts';
import { captureDiagnostics } from './runner-session-fixtures.ts';

const SNAPSHOT = { command: 'snapshot', appBundleId: 'com.example.demo' } as const;

function callerDeadline(): DOMException {
return new DOMException('Wait deadline exceeded', 'TimeoutError');
}

function readinessCanceled(error: unknown): boolean {
return (
isRequestCanceledError(error) &&
(error as { details?: { readinessPhase?: string } }).details?.readinessPhase === 'runner-start'
);
}

// Root-registry writers (`request/cancel.ts`) are not visible to the package;
// this reproduces the same canceled-set/AbortController-map model locally and
Expand Down Expand Up @@ -161,8 +176,9 @@ test('direct command cancellation reaches runner launch without a registered req
const controller = new AbortController();
const device = { ...IOS_SIMULATOR, id: 'runner-direct-signal-sim' };
mockRunCmdBackground.mockImplementationOnce((_cmd, _args, options) => {
assert.equal(options?.signal, controller.signal);
controller.abort(new Error('wait deadline exceeded'));
assert.equal(options?.signal?.aborted, false);
controller.abort(new Error('client disconnected'));
assert.equal(options?.signal?.aborted, true);
return makeBackgroundRunner(4141);
});

Expand All @@ -178,6 +194,145 @@ test('direct command cancellation reaches runner launch without a registered req
assert.equal(readRunnerSessionLiveness(device.id), null);
});

/**
* A `wait` poll bounds each attempt with a `TimeoutError` deadline. When that deadline lands while the
* runner is still starting, the start it interrupts is the one the retry needs: the launch must not be
* killed and the session must stay registered as starting, so the next request joins it and spends
* what is left of the launch budget, not a fresh one (#2894).
*/
test('a caller deadline during runner start leaves the starting runner for the next request', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
const controller = new AbortController();
const device = { ...IOS_SIMULATOR, id: 'runner-caller-deadline-sim' };
let launchSignal: AbortSignal | undefined;
mockRunCmdBackground.mockImplementationOnce((_cmd, _args, options) => {
launchSignal = options?.signal;
return makeBackgroundRunner(4545);
});
mockWaitForRunner.mockImplementationOnce(async () => {
controller.abort(callerDeadline());
throw createRequestCanceledError();
});

await assert.rejects(
executeRunnerCommand(device, SNAPSHOT, {
signal: controller.signal,
logPath: '/tmp/runner.log',
}),
readinessCanceled,
);
assert.equal(launchSignal?.aborted, false, 'the launch outlives the caller deadline');
assert.equal(readRunnerSessionLiveness(device.id)?.liveness, 'starting');
assert.deepEqual(readRetainedLeaseDeviceIds(), [device.id]);

vi.setSystemTime(Date.now() + 10_000);
await executeRunnerCommand(device, SNAPSHOT, { logPath: '/tmp/runner.log' });

assert.equal(mockRunCmdBackground.mock.calls.length, 1, 'the retry did not launch again');
assert.equal(
mockWaitForRunner.mock.calls[1]?.[4],
RUNNER_STARTUP_TIMEOUT_MS - 10_000,
'the joiner measures readiness from the launch, not from its own arrival',
);
assert.equal(readRunnerSessionLiveness(device.id)?.liveness, 'ready');
} finally {
vi.useRealTimers();
}
});

/**
* A runner whose process stays up but never answers must not be joined forever: once the launch
* budget recorded on the session is spent, the next request retires it and launches again, however
* the earlier joiners' waits ended (#2894).
*/
test('a request joining a start past its launch budget retires the runner and launches again', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
const first = new AbortController();
const device = { ...IOS_SIMULATOR, id: 'runner-launch-budget-sim' };
mockRunCmdBackground
.mockReturnValueOnce(makeBackgroundRunner(4646))
.mockReturnValueOnce(makeBackgroundRunner(4747));
mockWaitForRunner.mockImplementationOnce(async () => {
first.abort(callerDeadline());
throw createRequestCanceledError();
});

await assert.rejects(
executeRunnerCommand(device, SNAPSHOT, { signal: first.signal, logPath: '/tmp/runner.log' }),
readinessCanceled,
);
const hung = readRunnerSessionLiveness(device.id);
assert.equal(hung?.liveness, 'starting');

vi.setSystemTime(Date.now() + RUNNER_STARTUP_TIMEOUT_MS + 1);
const second = new AbortController();
const diagnostics = await captureDiagnostics(async () => {
await executeRunnerCommand(device, SNAPSHOT, {
signal: second.signal,
logPath: '/tmp/runner.log',
});
});

assert.match(diagnostics, /runner_launch_budget_exhausted/);
assert.equal(mockRunCmdBackground.mock.calls.length, 2, 'the hung runner was replaced');
const relaunched = readRunnerSessionLiveness(device.id);
assert.equal(relaunched?.liveness, 'ready');
assert.notEqual(relaunched?.sessionId, hung?.sessionId);
assert.deepEqual(readRetainedLeaseDeviceIds(), [device.id]);
} finally {
vi.useRealTimers();
}
});

/**
* The start runs detached under the session lock. A caller whose deadline lands during the cold
* xctestrun build leaves on time with the readiness verdict, the build is neither killed nor
* repeated, and the next request queues behind it and joins the session it registers (#2894).
*/
test('a caller deadline during the xctestrun build leaves on time and the next request joins that start', async () => {
const controller = new AbortController();
const device = { ...IOS_SIMULATOR, id: 'runner-build-deadline-sim' };
let releaseBuild: () => void = () => {};
const buildReleased = new Promise<void>((resolve) => {
releaseBuild = resolve;
});
let buildSignal: AbortSignal | undefined;
mockEnsureXctestrunArtifact.mockImplementationOnce(async (_device, options) => {
buildSignal = options.budget?.signal;
controller.abort(callerDeadline());
await buildReleased;
return {
xctestrunPath: '/tmp/base-runner.xctestrun',
derived: '/tmp/derived',
cache: 'miss',
artifact: 'rebuilt',
buildMs: 12,
xctestrunPathSource: 'build',
};
});

await assert.rejects(
executeRunnerCommand(device, SNAPSHOT, {
signal: controller.signal,
logPath: '/tmp/runner.log',
}),
readinessCanceled,
);
assert.equal(mockRunCmdBackground.mock.calls.length, 0, 'the caller left before the launch');
assert.equal(buildSignal?.aborted, false, 'the build outlives the caller deadline');
assert.equal(readRunnerSessionLiveness(device.id), null);

const joined = executeRunnerCommand(device, SNAPSHOT, { logPath: '/tmp/runner.log' });
releaseBuild();
await joined;

assert.equal(mockEnsureXctestrunArtifact.mock.calls.length, 1, 'the joiner did not build again');
assert.equal(mockRunCmdBackground.mock.calls.length, 1, 'the joiner did not launch again');
assert.equal(readRunnerSessionLiveness(device.id)?.liveness, 'ready');
});

test('prepare cancellation stops only its runner and preserves unrelated prep', async () => {
const survivorRequestId = 'prepare-runner-survivor-B';
const canceledRequestId = 'prepare-runner-canceled-A';
Expand Down
13 changes: 2 additions & 11 deletions packages/platform-apple/src/runner/runner-adoption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,12 @@ import {
type RunnerLeaseAdoptionRefusal,
} from './runner-lease.ts';
import {
requireRunnerPhaseRemainingMs,
resolveExpectedRunnerCacheMetadata,
resolveRunnerDerivedPath,
type RunnerPhaseBudget,
type RunnerXctestrunArtifact,
} from './runner-xctestrun.ts';
import {
normalizeRunnerStartupTimeoutMs,
type RunnerProcessHandle,
type RunnerSession,
} from './runner-session-types.ts';
import type { RunnerProcessHandle, RunnerSession } from './runner-session-types.ts';

// A healthy localhost runner answers uptime in tens of milliseconds and a dead
// port refuses immediately; the timeout only bounds the wedged-runner case,
Expand Down Expand Up @@ -132,7 +127,7 @@ export async function tryAdoptRunnerSessionFromLease(
return skip('runner_pid_recycled', lease);
}

const session = buildAdoptedRunnerSession(device, lease, runnerPid, expectedDerived, options);
const session = buildAdoptedRunnerSession(device, lease, runnerPid, expectedDerived);
try {
writeRunnerLease(session.lease);
} catch {
Expand Down Expand Up @@ -276,7 +271,6 @@ function buildAdoptedRunnerSession(
lease: RunnerLease,
runnerPid: number,
expectedDerived: string,
options: { budget?: RunnerPhaseBudget },
): RunnerSession & { lease: RunnerLease } {
const sessionId = lease.sessionId;
const artifact: RunnerXctestrunArtifact = {
Expand Down Expand Up @@ -306,9 +300,6 @@ function buildAdoptedRunnerSession(
state: 'ready',
inFlightCommands: 0,
hasAbandonedCommands: false,
startupTimeoutMs: normalizeRunnerStartupTimeoutMs(
requireRunnerPhaseRemainingMs(options.budget, 'runner_session_adoption'),
),
lease: buildRunnerLease({
device,
sessionId,
Expand Down
18 changes: 2 additions & 16 deletions packages/platform-apple/src/runner/runner-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { retryWithPolicy, emitDiagnostic, getRequestSignal, isRequestCanceled } from './host.ts';
import { retryWithPolicy, emitDiagnostic } from './host.ts';
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
import {
ensureRunnerSession,
Expand All @@ -9,6 +9,7 @@ import {
} from './runner-session.ts';
import {
assertRunnerRequestActive,
callerDeadlineExpired,
resolveRunnerRequestSignal,
withRunnerCommandId,
type RunnerCommand,
Expand Down Expand Up @@ -57,21 +58,6 @@ function readOnlyResendBudget(error: unknown): number {
return isRetryableRunnerError(error) ? TRANSPORT_RESEND_ATTEMPTS : 1;
}

/**
* Whether the caller's own deadline ended this command, as opposed to the request being cancelled.
* A `wait` bounds each poll with an abort signal whose reason is a `TimeoutError`
* (`runWithinWaitDeadline`); a cancelled request aborts through the registered request signal or
* the cancellation registry. The typed reason decides, so a deadline that lands mid-fetch (surfacing
* as whatever the transport threw on abort) is read the same way as one that wakes a delay.
*/
function callerDeadlineExpired(options: AppleRunnerCommandOptions): boolean {
if (isRequestCanceled(options.requestId) || getRequestSignal(options.requestId)?.aborted) {
return false;
}
const reason: unknown = options.signal?.aborted ? options.signal.reason : undefined;
return reason instanceof DOMException && reason.name === 'TimeoutError';
}

export async function runAppleRunnerCommand(
device: DeviceInfo,
command: RunnerCommand,
Expand Down
Loading
Loading