diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index e1eb003d7e..6c71647b30 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -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 () => { diff --git a/packages/platform-apple/src/runner/__tests__/runner-contract-request-deadline.test.ts b/packages/platform-apple/src/runner/__tests__/runner-contract-request-deadline.test.ts new file mode 100644 index 0000000000..422547e56c --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-contract-request-deadline.test.ts @@ -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(); +const canceled = new Set(); + +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); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts b/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts index f6fbcc19d7..4b6ab44276 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts @@ -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 @@ -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); }); @@ -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((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'; diff --git a/packages/platform-apple/src/runner/runner-adoption.ts b/packages/platform-apple/src/runner/runner-adoption.ts index 763549dabd..d5f5b985c3 100644 --- a/packages/platform-apple/src/runner/runner-adoption.ts +++ b/packages/platform-apple/src/runner/runner-adoption.ts @@ -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, @@ -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 { @@ -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 = { @@ -306,9 +300,6 @@ function buildAdoptedRunnerSession( state: 'ready', inFlightCommands: 0, hasAbandonedCommands: false, - startupTimeoutMs: normalizeRunnerStartupTimeoutMs( - requireRunnerPhaseRemainingMs(options.budget, 'runner_session_adoption'), - ), lease: buildRunnerLease({ device, sessionId, diff --git a/packages/platform-apple/src/runner/runner-client.ts b/packages/platform-apple/src/runner/runner-client.ts index 76bcc29388..eb93d8f959 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -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, @@ -9,6 +9,7 @@ import { } from './runner-session.ts'; import { assertRunnerRequestActive, + callerDeadlineExpired, resolveRunnerRequestSignal, withRunnerCommandId, type RunnerCommand, @@ -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, diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 86611eca91..8cdc220bee 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -157,6 +157,60 @@ export function resolveRunnerRequestSignal(options: { return AbortSignal.any([registeredSignal, options.signal]); } +type RunnerRequestSignalOptions = { + requestId?: string; + signal?: AbortSignal; +}; + +/** + * Whether an abort reason is a caller's own deadline rather than a cancelled request. 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, never the error text the transport threw on abort. + */ +export function isCallerDeadlineAbortReason(reason: unknown): boolean { + return reason instanceof DOMException && reason.name === 'TimeoutError'; +} + +/** + * Whether the caller's own deadline ended this command, as opposed to the request being cancelled. + * 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. + */ +export function callerDeadlineExpired(options: RunnerRequestSignalOptions): boolean { + if (isRequestCanceled(options.requestId) || getRequestSignal(options.requestId)?.aborted) { + return false; + } + return options.signal?.aborted === true && isCallerDeadlineAbortReason(options.signal.reason); +} + +/** + * The signal a runner start reacts to. A cancelled request (client disconnect) must kill the + * blocking xctestrun build and the runner launch instead of orphaning them, so the registered request + * signal passes through untouched. A caller's own deadline must not: the runner start it interrupts + * is the one the retry needs, and a start that pays itself again on every short-timeout poll never + * finishes on a slow host (#2894). The start keeps going on its own startup budget, and the caller's + * command is still cut off by its unfiltered signal once the runner answers. + */ +export function resolveRunnerStartupSignal( + options: RunnerRequestSignalOptions, +): AbortSignal | undefined { + const registeredSignal = getRequestSignal(options.requestId); + const callerSignal = options.signal; + if (!callerSignal || callerSignal === registeredSignal) return registeredSignal; + // The caller signal is filtered through its own controller, so a deadline never reaches the + // start; the registered signal is composed with `AbortSignal.any`, which detaches its own + // listener once the composed signal settles, so a request that polls many times does not + // accumulate listeners on its long-lived cancellation signal. + const filtered = new AbortController(); + const forward = () => { + if (!isCallerDeadlineAbortReason(callerSignal.reason)) filtered.abort(callerSignal.reason); + }; + if (callerSignal.aborted) forward(); + else callerSignal.addEventListener('abort', forward, { once: true }); + return registeredSignal ? AbortSignal.any([registeredSignal, filtered.signal]) : filtered.signal; +} + /** * The code the XCTest runner answers with when it declines to place a scroll gesture under the * on-screen keyboard (#2500). It is the runner's own vocabulary, so it is declared here beside the diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 5d50e464e9..948945e98f 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -21,6 +21,7 @@ import { } from './runner-session.ts'; import { assertRunnerRequestActive, + callerDeadlineExpired, resolveRunnerRequestSignal, withRunnerCommandId, type RunnerCommand, @@ -311,7 +312,11 @@ export async function executeRunnerCommand( ? session.state === 'starting' : livenessAtEntry !== 'ready'; if (runnerNeverAnswered && isRequestCanceledError(appErr)) { - if (session) { + // A cancelled request leaves no half-started runner behind. A caller whose own deadline ran + // out mid-start leaves it running: the session's launch budget bounds it, the next request + // joins it instead of paying it again, and the reuse check retires it once that budget is + // spent (#2894). + if (session && !callerDeadlineExpired(options)) { await invalidateRunnerSessionBestEffort(session, 'runner_startup_request_canceled'); } throw createRequestCanceledError( diff --git a/packages/platform-apple/src/runner/runner-session-types.ts b/packages/platform-apple/src/runner/runner-session-types.ts index 793a2f2656..f842dfe695 100644 --- a/packages/platform-apple/src/runner/runner-session-types.ts +++ b/packages/platform-apple/src/runner/runner-session-types.ts @@ -1,6 +1,7 @@ import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context'; import type { ExecResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { Deadline } from './host.ts'; import type { RunnerXctestrunArtifact } from './runner-xctestrun.ts'; import type { RunnerLease } from './runner-lease.ts'; import type { IosRunnerDeviceStates } from './runner-error-classification.ts'; @@ -80,7 +81,12 @@ export type RunnerSession = { state: RunnerSessionState; /** Wakes one startup retry when the listener becomes ready or its process exits. */ startupRetryWake?: AbortSignal; - startupTimeoutMs?: number; + /** + * The budget the runner has to answer its first command, opened the moment its process was + * launched. Every request that joins the `starting` session measures readiness from this one + * clock, so a runner that never answers is given up once, not once per joiner (#2894). + */ + launchDeadline?: Deadline; /** * Commands the runner accepted that this process has not seen answered. It comes down only when a * response is decoded: an aborted or dropped exchange leaves the command running on the runner, diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index 5319e7f0e3..b4540b10b7 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -35,7 +35,7 @@ import { decodeRunnerResponseBody, isRunnerResponseOk, readRunnerResponseData, - resolveRunnerRequestSignal, + resolveRunnerStartupSignal, withRunnerCommandId, type RunnerCommand, } from './runner-contract.ts'; @@ -135,17 +135,18 @@ export async function ensureRunnerSession( // Any runner use means the device is active again: a pending idle stop // from a retained-after-close runner no longer applies. cancelIosRunnerIdleStop(device.id); - return await withRunnerSessionLock(device.id, async () => { + const start = withRunnerSessionLock(device.id, async () => { // One budget for the whole startup phase, opened here from the request-level // `startupTimeoutMs`: the reuse check's toolchain probes, adoption and the startup - // itself all spend this one clock. The request's abort signal rides with it, so a + // itself all spend this one clock. The request's cancellation rides with it, so a // client disconnect kills the blocking xctestrun build and runner launch - // (killProcessTree via exec) instead of orphaning them. Request-scoped: only this - // request's device startup reacts, and a signal-less internal caller (shutdown) - // simply gets undefined. + // (killProcessTree via exec) instead of orphaning them; a caller's own deadline does + // not, so the start it interrupts is still there for the retry (#2894). Request-scoped: + // only this request's device startup reacts, and a signal-less internal caller + // (shutdown) simply gets undefined. const startupBudget = createRunnerPhaseBudget( options.startupTimeoutMs, - resolveRunnerRequestSignal(options), + resolveRunnerStartupSignal(options), ); const existing = runnerSessions.get(device.id); if (existing) { @@ -159,6 +160,31 @@ export async function ensureRunnerSession( async () => await startRunnerSessionWithLease(device, options, startupBudget), ); }); + return await raceRunnerStartAgainstCaller(start, options.signal); +} + +/** + * The start runs detached under the session lock; the caller only waits for it as long as its own + * signal allows. A caller whose deadline lands during a cold xctestrun build leaves on time, the + * build keeps going under the lock, and the next request for the device queues behind it and joins + * the session it registers (#2894). Whatever the abort reason, the caller sees the same cancelled + * request it would have seen from any later step; a cancelled request's abort also reaches the + * start through its own startup signal, so nothing here decides whether the start survives. + */ +async function raceRunnerStartAgainstCaller( + start: Promise, + signal: AbortSignal | undefined, +): Promise { + if (!signal) return await start; + return await new Promise((resolve, reject) => { + const abort = () => reject(createRequestCanceledError(undefined, signal.reason)); + if (signal.aborted) { + abort(); + } else { + signal.addEventListener('abort', abort, { once: true }); + } + start.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); } /** How long the device-readiness probe may take, bounded by the startup budget it runs inside. */ @@ -337,7 +363,9 @@ async function startRunnerSessionWithLease( inFlightCommands: 0, hasAbandonedCommands: false, startupRetryWake: runnerProcess.startupRetryWake, - startupTimeoutMs: normalizeRunnerStartupTimeoutMs(startupTimeoutMs), + launchDeadline: Deadline.fromTimeoutMs( + normalizeRunnerStartupTimeoutMs(startupTimeoutMs) ?? RUNNER_STARTUP_TIMEOUT_MS, + ), startupTimings, startupDeviceStates: deviceStates, logicalLeaseContext, @@ -405,6 +433,24 @@ async function isRunnerSessionServing( // A registered session already being taken down or already handed off is not usable, even when // its runner process is still there for a moment while disposal works. if (liveness !== 'starting' && liveness !== 'ready') return false; + if (liveness === 'starting' && existing.launchDeadline?.isExpired()) { + emitDiagnostic({ + level: 'warn', + phase: 'ios_runner_session_invalidated', + data: { + deviceId: device.id, + sessionId: existing.sessionId, + reason: 'runner_launch_budget_exhausted', + }, + }); + await measureRunnerStartupStep({}, 'stop_expired_starting_session', async () => { + await stopRunnerSessionInternal(device.id, existing, { + graceful: false, + waitTimeoutMs: RUNNER_INVALIDATE_WAIT_TIMEOUT_MS, + }); + }); + return false; + } if (isSameRunnerSimulator(existing.device, device)) return true; await measureRunnerStartupStep({}, 'stop_other_simulator_set_session', async () => { await stopRunnerSessionInternal(device.id, existing); @@ -1282,10 +1328,15 @@ function markRunnerPreflightError(error: unknown, details: Record, -): number { - return session.startupTimeoutMs ?? RUNNER_STARTUP_TIMEOUT_MS; +/** + * What a request waiting on a `starting` session may spend on its readiness: the rest of the + * session's launch budget, never a fresh one per joiner. A session with no recorded launch (a + * fixture, or one registered before the deadline existed) falls back to the default budget. + */ +export function readRunnerStartupTimeoutMs(session: Pick): number { + const launchDeadline = session.launchDeadline; + if (!launchDeadline) return RUNNER_STARTUP_TIMEOUT_MS; + return Math.max(0, Math.floor(launchDeadline.remainingMs())); } async function measureRunnerStartupStep( diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index e79e70f92a..8f9458f550 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -174,7 +174,7 @@ function writeProgressEnvelope( res: http.ServerResponse, event: RequestProgressEvent, ): void { - if (res.destroyed) return; + if (res.destroyed || res.writableEnded) return; res.write(serializeDaemonProgressEnvelope(event)); } diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 181e04ba82..8eb2b21d2a 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -121,7 +121,7 @@ "src/daemon/server/http-server.ts#toInstallFromSourceDaemonRequest": "sha256:dff093758b433043816dfc1e2e9658078875e46b887fdc530fb9380158d629f5", "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:bee83566f9c5da4ede12b4844fae0930d09439ba7586123609637107b61f8644", "src/daemon/server/http-server.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", - "src/daemon/server/http-server.ts#writeProgressEnvelope": "sha256:ca661f6dd2de6e517c520dbd1e20b59b24b58d925157ed06123149ed2bc0fa3b", + "src/daemon/server/http-server.ts#writeProgressEnvelope": "sha256:7be778b3902c72d7aa6282c16dc7106fdd64e99c0b5daf9f413ff5dd3fb044f1", "src/daemon/server/http-server.ts#writeRpcResponseEnvelope": "sha256:7a8155e9fcbb53485489728250a85109b5dab0d96cdd4aa8b7e7eb87a4898ddb", "src/daemon/session-tenant-scope.ts#TenantSessionNamespace": "sha256:a6613d22933caa053e479f05b7f016664f6be8d0afdbbf81e578ff497fc1b13d", "src/daemon/session-tenant-scope.ts#isTenantAddressableSessionName": "sha256:cc8ae18094b7f57d4bb694678f84ba767637b1087fd28058ffd6a3c92c79f05d", @@ -300,6 +300,11 @@ "declaration": "packages/kernel/src/contracts.ts#daemonRuntimeSchema", "digest": "sha256:3b99926ce9deb5d66186a55b1168011dc1ae4d010ada5ac5d79c4c7225a1f80f", "rationale": "#2266 broadens runtime-hint validation to accept the additive HarmonyOS platform value; existing protocol-2 runtime hints remain valid and unchanged." + }, + { + "declaration": "src/daemon/server/http-server.ts#writeProgressEnvelope", + "digest": "sha256:7be778b3902c72d7aa6282c16dc7106fdd64e99c0b5daf9f413ff5dd3fb044f1", + "rationale": "#2894 lets an iOS runner start outlive the caller whose deadline gave up on it, so a progress event can arrive after the response ended. The sink now also skips a response whose writable side has ended, next to the destroyed check it already had. No byte written to a live response changes: the envelope framing and payload are untouched, and a released client never sees an event it could not have received before." } ] } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index f17c2983e6..59c29d4a2f 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -444,7 +444,7 @@ agent-device alert dismiss - `wait @ref` resolves the ref to its label/text from that stored snapshot, then polls for that text; it does not track the original node identity. - Because `wait @ref` is text-based after resolution, duplicate labels can match a different element than the original ref target. - `wait` shares the selector/snapshot resolution flow used by `click`, `fill`, `get`, and `is`. -- Wait failures carry a structured `error.details.reason` in `--json` output: `wait_target_absent` proves a positive wait never found a match; `wait_target_present` means strict `wait absent` reached its deadline with valid captures that still contained matches; `predicate_failed` means strict `wait absent` could not prove absence because no valid capture arrived, with the final observation/diagnostic preserved; `wait_capture_stalled` means no readable capture arrived and is retriable; `wait_deadline_exceeded` means a later capture consumed the remaining budget after an earlier readable capture; `wait_readiness_exhausted` means the deadline ended a poll that was still getting the iOS runner to answer its first command or finding the Simulator app, named by `readinessPhase` (`runner-start` or `target-discovery`), so a retry needs a timeout that covers that work; `wait_landmark_identity_mismatch` is a replay destination-guard refusal; and `wait_stable_timeout` means the UI did not settle. Use `readableCaptures`, `waitedMs`, `matches`, and `firstMatch` instead of parsing error text. `firstMatch` carries identity/text evidence only; absence failures do not claim visibility or rect evidence. +- Wait failures carry a structured `error.details.reason` in `--json` output: `wait_target_absent` proves a positive wait never found a match; `wait_target_present` means strict `wait absent` reached its deadline with valid captures that still contained matches; `predicate_failed` means strict `wait absent` could not prove absence because no valid capture arrived, with the final observation/diagnostic preserved; `wait_capture_stalled` means no readable capture arrived and is retriable; `wait_deadline_exceeded` means a later capture consumed the remaining budget after an earlier readable capture; `wait_readiness_exhausted` means the deadline ended a poll that was still getting the iOS runner to answer its first command or finding the Simulator app, named by `readinessPhase` (`runner-start` or `target-discovery`), so a retry needs a timeout that covers that work (an iOS runner start keeps going after the deadline on its own startup budget, so the retry joins it instead of paying it again); `wait_landmark_identity_mismatch` is a replay destination-guard refusal; and `wait_stable_timeout` means the UI did not settle. Use `readableCaptures`, `waitedMs`, `matches`, and `firstMatch` instead of parsing error text. `firstMatch` carries identity/text evidence only; absence failures do not claim visibility or rect evidence. - Polling wait timeouts (`wait `, `wait text`, `wait @ref`, and `wait absent` once a readable capture has been seen) also carry `captures` (every poll attempted), `readableCaptures`, and `polls`, one entry per poll with `startedMs` on the wait's own clock, `durationMs`, and `outcome` (`readable`, `unreadable`, `retriable` for a poll the producer refused with a failure it marked retriable, `deadline`, `runner-restart`, or `readiness`), so a timeout says where its budget went; long waits keep the first five and last twenty-five polls. A replayed selector wait refused for a recorded landmark mismatch (`wait_landmark_identity_mismatch`) carries the same poll evidence next to its mismatch details. A wait that never saw a readable capture reports the cause its polls hit instead of a generic timeout: a content verdict is preserved as its producer wrote it, while a refusal the producer marked retriable keeps its code, message and retry details **and** carries the poll evidence above, so an exhausted budget stays distinguishable from a single immediate refusal. `wait --stable` timeouts and a never-readable strict absence keep their own diagnostics. `logPath` links the full request log. - `alert` inspects or handles system alerts on iOS simulator, macOS desktop, and Android native/runtime permission dialogs. - `alert` without an action is equivalent to `alert get`.