diff --git a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts index 9b4298605c..5ea9401c52 100644 --- a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts +++ b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts @@ -17,7 +17,9 @@ export type FakeRunnerResponse = /** Hangs up on this request and every later one for the command: the entry is never consumed. */ | { kind: 'hangUpAlways' } /** Hangs up and stops listening, as a runner process that died mid-command. */ - | { kind: 'exit' }; + | { kind: 'exit' } + /** Never answers, as a runner still executing the command when its caller gives up. */ + | { kind: 'hold' }; export type FakeRunnerRequest = { command: string; @@ -79,9 +81,10 @@ export async function startFakeRunnerServer( requests, close: () => stopped ?? - new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ), + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), }; } @@ -101,6 +104,7 @@ function writeFakeRunnerResponse( res.end(JSON.stringify({ ok: false, error: { message: 'fake runner script exhausted' } })); return; } + if (next.kind === 'hold') return; if (next.kind === 'hangUp' || next.kind === 'hangUpAlways') { res.destroy(); return; diff --git a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts index e6bf4b125d..bc8107bc75 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts @@ -1,10 +1,25 @@ import assert from 'node:assert/strict'; import type { ExecResult } from '@agent-device/host-kit/command'; -import { afterEach, test } from 'vitest'; +import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors'; +import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; +import { afterEach, test, vi } from 'vitest'; + +const { mockSendRunnerCommandOnce } = vi.hoisted(() => ({ mockSendRunnerCommandOnce: vi.fn() })); + +vi.mock('../runner-transport.ts', async (importOriginal) => { + const actual = await importOriginal(); + mockSendRunnerCommandOnce.mockImplementation(actual.sendRunnerCommandOnce); + return { ...actual, sendRunnerCommandOnce: mockSendRunnerCommandOnce }; +}); + import { executeRunnerExchange } from '../runner-exchange.ts'; import { RunnerCommandAccounting, type RunnerSession } from '../runner-session-types.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; +import { + startFakeRunnerServer, + type FakeRunnerResponse, + type FakeRunnerServer, +} from './fake-runner-server.ts'; let server: FakeRunnerServer | undefined; @@ -184,3 +199,233 @@ test('the exchange awaits owner invalidation before throwing a fatal runner erro } await assert.rejects(exchange); }); + +const LONG_PRESS = { + command: 'longPress', + x: 10, + y: 10, + durationMs: 5_000, + appBundleId: 'com.example.app', +} as const; + +function journalState(lifecycleState: string): FakeRunnerResponse { + return { kind: 'ok', data: { lifecycleState } }; +} + +async function abortOnceReceived( + runner: FakeRunnerServer, + command: string, + controller: AbortController, + reason: unknown = createRequestCanceledError(), +): Promise { + const giveUpAtMs = Date.now() + 2_000; + while (!runner.requests.some((request) => request.command === command)) { + if (Date.now() >= giveUpAtMs) { + throw new Error(`The fake runner never received "${command}"; nothing to abort.`); + } + await new Promise((resolve) => setTimeout(resolve, 1)); + } + controller.abort(reason); +} + +function statusPolls(runner: FakeRunnerServer): unknown[] { + return runner.requests + .filter((request) => request.command === 'status') + .map((request) => request.body.statusCommandId); +} + +function sentCommandId(runner: FakeRunnerServer, command: string): unknown { + return runner.requests.find((request) => request.command === command)?.body.commandId; +} + +test('a canceled exchange whose command reached the runner settles only once its journal entry is terminal', async () => { + server = await startFakeRunnerServer({ + longPress: [{ kind: 'hold' }], + status: [journalState('started'), journalState('completed')], + }); + const runner = server; + const session = sessionFor(runner.port); + const controller = new AbortController(); + let requestsWhenSettled = -1; + + const exchange = executeRunnerExchange( + IOS_SIMULATOR, + session, + LONG_PRESS, + undefined, + 10_000, + async () => {}, + controller.signal, + ).finally(() => { + requestsWhenSettled = runner.requests.length; + }); + await abortOnceReceived(runner, 'longPress', controller); + + await assert.rejects(exchange, isRequestCanceledError); + const commandId = sentCommandId(runner, 'longPress'); + assert.deepEqual(statusPolls(runner), [commandId, commandId]); + assert.equal(requestsWhenSettled, runner.requests.length); + assert.equal(session.commandCharges.hasOutstandingCharges, false); +}); + +test('a request on the same session lock starts only after the canceled command is terminal', async () => { + server = await startFakeRunnerServer({ + longPress: [{ kind: 'hold' }], + status: [journalState('accepted'), journalState('started'), journalState('failed')], + }); + const runner = server; + const session = sessionFor(runner.port); + const locks = new Map>(); + const controller = new AbortController(); + + const canceled = withKeyedLock(locks, 'session:default', async () => { + await executeRunnerExchange( + IOS_SIMULATOR, + session, + LONG_PRESS, + undefined, + 10_000, + async () => {}, + controller.signal, + ); + }); + await abortOnceReceived(runner, 'longPress', controller); + const nextRequestSawPolls = withKeyedLock(locks, 'session:default', async () => + statusPolls(runner), + ); + + await assert.rejects(canceled, isRequestCanceledError); + assert.equal((await nextRequestSawPolls).length, 3); + assert.equal(session.commandCharges.hasOutstandingCharges, false); +}); + +test('a request canceled before its command is sent releases without reading the journal', async () => { + server = await startFakeRunnerServer({}); + const session = sessionFor(server.port); + session.lastHealthyMutation = { atMs: Date.now(), appBundleId: LONG_PRESS.appBundleId }; + const controller = new AbortController(); + controller.abort(createRequestCanceledError()); + + await assert.rejects( + executeRunnerExchange( + IOS_SIMULATOR, + session, + LONG_PRESS, + undefined, + 10_000, + async () => {}, + controller.signal, + ), + isRequestCanceledError, + ); + + assert.deepEqual(server.requests, []); + assert.equal(session.commandCharges.hasOutstandingCharges, false); +}); + +test('a cancellation the transport proves unsent withdraws the charge without reading the journal', async () => { + server = await startFakeRunnerServer({ status: [journalState('started')] }); + const session = sessionFor(server.port); + session.lastHealthyMutation = { atMs: Date.now(), appBundleId: LONG_PRESS.appBundleId }; + const controller = new AbortController(); + mockSendRunnerCommandOnce.mockImplementationOnce(async () => { + controller.abort(createRequestCanceledError()); + throw createRequestCanceledError({ dispatched: 'no' }); + }); + + await assert.rejects( + executeRunnerExchange( + IOS_SIMULATOR, + session, + LONG_PRESS, + undefined, + 10_000, + async () => {}, + controller.signal, + ), + isRequestCanceledError, + ); + + assert.deepEqual(server.requests, []); + assert.equal(session.commandCharges.hasOutstandingCharges, false); +}); + +test('a canceled command the journal never accepted releases after one read and stays abandoned', async () => { + server = await startFakeRunnerServer({ + longPress: [{ kind: 'hold' }], + status: [journalState('notAccepted')], + }); + const session = sessionFor(server.port); + const controller = new AbortController(); + + const exchange = executeRunnerExchange( + IOS_SIMULATOR, + session, + LONG_PRESS, + undefined, + 10_000, + async () => {}, + controller.signal, + ); + await abortOnceReceived(server, 'longPress', controller); + + await assert.rejects(exchange, isRequestCanceledError); + assert.equal(statusPolls(server).length, 1); + assert.equal(session.commandCharges.hasAbandonedCharges, true); +}); + +test('a drain that outlives the command deadline settles and leaves the charge abandoned', async () => { + server = await startFakeRunnerServer({ + longPress: [{ kind: 'hold' }], + status: Array.from({ length: 50 }, () => journalState('started')), + }); + const session = sessionFor(server.port); + const controller = new AbortController(); + const timeoutMs = 300; + const startedAtMs = Date.now(); + + const exchange = executeRunnerExchange( + IOS_SIMULATOR, + session, + LONG_PRESS, + undefined, + timeoutMs, + async () => {}, + controller.signal, + ); + await abortOnceReceived(server, 'longPress', controller); + + await assert.rejects(exchange, isRequestCanceledError); + assert.ok(statusPolls(server).length >= 1); + assert.ok(Date.now() - startedAtMs < timeoutMs + 1_000); + assert.equal(session.commandCharges.hasAbandonedCharges, true); +}); + +test("a caller's own deadline abort does not wait on the runner journal", async () => { + server = await startFakeRunnerServer({ + longPress: [{ kind: 'hold' }], + status: [journalState('started'), journalState('completed')], + }); + const session = sessionFor(server.port); + const controller = new AbortController(); + + const exchange = executeRunnerExchange( + IOS_SIMULATOR, + session, + LONG_PRESS, + undefined, + 10_000, + async () => {}, + controller.signal, + ); + await abortOnceReceived( + server, + 'longPress', + controller, + new DOMException('Wait deadline exceeded', 'TimeoutError'), + ); + + await assert.rejects(exchange); + assert.deepEqual(statusPolls(server), []); + assert.equal(session.commandCharges.hasAbandonedCharges, true); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-types.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-types.test.ts index 886db704f3..7c9ecbacb7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-types.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-types.test.ts @@ -237,6 +237,19 @@ describe('RunnerCommandAccounting abandonment', () => { }); }); +describe('RunnerCommandAccounting unsent sends', () => { + test('withdraws only the charge of the send that wrote nothing', () => { + const charges = new RunnerCommandAccounting(); + charges.charge('cmd-a'); + charges.markAbandoned('cmd-a'); + charges.charge('cmd-b'); + charges.withdrawUnsent('cmd-b'); + + expect(charges.outstandingChargeCount).toBe(1); + expect(charges.hasAbandonedCharges).toBe(true); + }); +}); + describe('RunnerCommandAccounting terminal evidence', () => { test('discharges the abandoned charge it names', () => { const charges = new RunnerCommandAccounting(); diff --git a/packages/platform-apple/src/runner/__tests__/runner-transport.test.ts b/packages/platform-apple/src/runner/__tests__/runner-transport.test.ts index d15a8ef03a..26f0053e16 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-transport.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-transport.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; +import { + AppError, + createRequestCanceledError, + isRequestCanceledError, +} from '@agent-device/kernel/errors'; import { iosDevice, iosSimulator, @@ -221,3 +225,31 @@ test('falls back to the tunnel when usbmux loses the device mid-connect', async assert.equal(response.status, 200); assert.equal(vi.mocked(fetch).mock.calls[0]?.[0], 'http://[fd00::123]:8100/command'); }); + +test('a cancellation during route resolution discloses that the command was never sent', async () => { + mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError()); + stubSuccessfulFetch(); + const controller = new AbortController(); + const lookupTunnelIp = makeTunnelIpLookup('fd00::123'); + mockRunCmd.mockImplementation(async (cmd: string, args: string[]) => { + controller.abort(createRequestCanceledError()); + return await lookupTunnelIp(cmd, args); + }); + + await assert.rejects( + () => + sendRunnerCommandOnce( + iosDevice, + 8100, + { command: 'tap', x: 1, y: 1 }, + 5_000, + controller.signal, + ), + (error: unknown) => { + assert.ok(isRequestCanceledError(error)); + assert.equal((error as AppError).details?.dispatched, 'no'); + return true; + }, + ); + assert.equal(vi.mocked(fetch).mock.calls.length, 0); +}); diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index 8f950100f0..0743272682 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -12,6 +12,10 @@ import { } from './runner-contract.ts'; import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; import { RUNNER_REPLY_LOST_REASON } from './runner-error-classification.ts'; +import { + RUNNER_IN_FLIGHT_LIFECYCLE_STATES, + settleRunnerChargeForTerminalStatus, +} from './runner-session-types.ts'; import type { AppleRunnerCommandOptions } from './runner-provider.ts'; import { executeRunnerCommandWithSession, type RunnerSession } from './runner-session.ts'; @@ -277,38 +281,6 @@ async function tryRecoverRunnerCommandAfterTransportError( ); } -/** - * The runner journal vocabulary, so the charge settlement below and the recovery verdict in - * {@link handleRunnerCommandStatusRecovery} cannot disagree about what a state means. - * `runner-swift-settlement-fixtures.ts` pins these names to `RunnerCommandLifecycleState`, and the - * recovery wiring rows are derived from that same declaration, so a state the runner gains has to be - * ruled here before it can decide a handoff. - * - * `completed` and `failed` close an entry — from the response's `ok` in `finish`, or a thrown error in - * `fail` — so execution ended, and each gets its own recovery verdict below. `accepted` and `started` - * are written as execution opens, so they share one in-flight verdict. `notAccepted` is what `status` - * reports for an id the journal never held, which this daemon cannot read as terminal. - */ -const RUNNER_TERMINAL_LIFECYCLE_STATES: ReadonlySet = new Set(['completed', 'failed']); -const RUNNER_IN_FLIGHT_LIFECYCLE_STATES: ReadonlySet = new Set(['accepted', 'started']); - -/** - * Discharges the abandoned charge terminal status proves landed (#2965). A status reply is served - * inline, so it is no evidence that queued work finished; this is the only place a status answer may - * settle a charge, and only the one its `statusCommandId` names. - * - * @returns whether the evidence paid a debt, or `undefined` when the state is not terminal and no - * settlement was attempted. - */ -function settleRunnerChargeForTerminalStatus( - session: RunnerSession, - command: RunnerCommand, - lifecycleState: string, -): boolean | undefined { - if (!RUNNER_TERMINAL_LIFECYCLE_STATES.has(lifecycleState)) return undefined; - return session.commandCharges.settleTerminalEvidence(command.commandId); -} - function handleRunnerCommandStatusRecovery( status: Record, lifecycleState: string, diff --git a/packages/platform-apple/src/runner/runner-exchange.ts b/packages/platform-apple/src/runner/runner-exchange.ts index be33276077..871bb1f3f9 100644 --- a/packages/platform-apple/src/runner/runner-exchange.ts +++ b/packages/platform-apple/src/runner/runner-exchange.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { Deadline, emitDiagnostic, withDiagnosticTimer } from './host.ts'; import { @@ -6,7 +6,7 @@ import { RUNNER_STARTUP_TIMEOUT_MS, type RunnerConnectionSession, } from './runner-startup-transport.ts'; -import { sendRunnerCommandOnce } from './runner-transport.ts'; +import { sendRunnerCommandOnce, throwIfCanceledBeforeSend } from './runner-transport.ts'; import { buildRunnerResponseError, decodeRunnerResponseBody, @@ -17,6 +17,7 @@ import { } from './runner-contract.ts'; import { resolveRunnerFatalErrorReason, + isRunnerCommandProvablyUnwritten, isRunnerMainThreadOccupiedError, isStructuredRunnerFailure, } from './runner-error-classification.ts'; @@ -31,7 +32,12 @@ import { enrichRunnerFailureFromLog, type RunnerLogAttempt, } from './runner-failure-diagnostics.ts'; -import { advanceRunnerSessionState, type RunnerSession } from './runner-session-types.ts'; +import { + advanceRunnerSessionState, + RUNNER_IN_FLIGHT_LIFECYCLE_STATES, + settleRunnerChargeForTerminalStatus, + type RunnerSession, +} from './runner-session-types.ts'; type RunnerExchangeSession = RunnerConnectionSession & Pick< @@ -41,6 +47,8 @@ type RunnerExchangeSession = RunnerConnectionSession & const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 1_000; const RUNNER_PREFLIGHT_SKIP_FRESHNESS_MS = 5_000; +const CANCELED_COMMAND_STATUS_TIMEOUT_MS = 1_000; +const CANCELED_COMMAND_POLL_INTERVAL_MS = 100; type RunnerReadinessPreflightDecision = | { @@ -252,6 +260,7 @@ async function sendRunnerCommandAfterPreflight(params: { } : { command: runnerCommand.command, commandId: runnerCommand.commandId }; + throwIfCanceledBeforeSend(signal); // From here the runner holds our request, and a shutdown that hands it off would orphan a command // nobody is waiting for any more. A readiness probe is charged no more than the preflight's own // probe is: the runner serves it inline, so its reply says nothing about the queued work a charge @@ -285,11 +294,114 @@ async function sendRunnerCommandAfterPreflight(params: { diagnosticData, ); } catch (error) { - if (charged) session.commandCharges.markAbandoned(runnerCommand.commandId); + if (charged) { + await settleFailedRunnerSend({ device, session, runnerCommand, deadline, signal, error }); + } throw error; } } +/** + * A send that provably wrote nothing withdraws its charge. Otherwise this process stopped waiting on + * a command the runner may hold, so the charge stays as abandoned, and a canceled request also waits + * out the command's execution before it settles (#3383). + */ +async function settleFailedRunnerSend(params: { + device: DeviceInfo; + session: RunnerExchangeSession; + runnerCommand: RunnerCommand; + deadline: Deadline; + signal: AbortSignal | undefined; + error: unknown; +}): Promise { + const { session, runnerCommand, signal, error } = params; + if (isRunnerCommandProvablyUnwritten(error)) { + session.commandCharges.withdrawUnsent(runnerCommand.commandId); + return; + } + session.commandCharges.markAbandoned(runnerCommand.commandId); + if (!isRequestCanceledError(signal?.reason) || session.state !== 'ready') return; + await drainCanceledRunnerCommand(params); +} + +type CanceledRunnerCommandDrainOutcome = + | 'terminal' + | 'not_in_flight' + | 'status_unavailable' + | 'deadline_exceeded'; + +/** + * Holds a canceled exchange until the runner's journal reports its command ended (#3383). The runner + * never sees the disconnect and runs the command to completion, so settling earlier would release the + * request's execution locks while the device is still doing the canceled work. Polls are bounded by the + * command's own remaining deadline rather than the aborted request signal; a drain that runs out of it + * leaves the charge abandoned. + */ +async function drainCanceledRunnerCommand(params: { + device: DeviceInfo; + session: RunnerExchangeSession; + runnerCommand: RunnerCommand; + deadline: Deadline; +}): Promise { + const { device, session, runnerCommand, deadline } = params; + const startedAtMs = Date.now(); + let outcome: CanceledRunnerCommandDrainOutcome = 'deadline_exceeded'; + let lifecycleState: string | undefined; + let polls = 0; + while (deadline.remainingMs() > 0) { + polls += 1; + try { + lifecycleState = await readRunnerCommandLifecycleState( + device, + session, + runnerCommand, + Math.min(CANCELED_COMMAND_STATUS_TIMEOUT_MS, deadline.remainingMs()), + ); + } catch { + outcome = 'status_unavailable'; + break; + } + if (settleRunnerChargeForTerminalStatus(session, runnerCommand, lifecycleState) !== undefined) { + outcome = 'terminal'; + break; + } + if (!RUNNER_IN_FLIGHT_LIFECYCLE_STATES.has(lifecycleState)) { + outcome = 'not_in_flight'; + break; + } + const waitMs = Math.min(CANCELED_COMMAND_POLL_INTERVAL_MS, deadline.remainingMs()); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + emitDiagnostic({ + level: outcome === 'terminal' ? 'debug' : 'warn', + phase: 'ios_runner_canceled_command_drain', + durationMs: Date.now() - startedAtMs, + data: { + command: runnerCommand.command, + commandId: runnerCommand.commandId, + outcome, + lifecycleState, + polls, + }, + }); +} + +async function readRunnerCommandLifecycleState( + device: DeviceInfo, + session: RunnerExchangeSession, + runnerCommand: RunnerCommand, + timeoutMs: number, +): Promise { + const response = await sendRunnerCommandOnce( + device, + session.port, + { command: 'status', statusCommandId: runnerCommand.commandId }, + timeoutMs, + ); + const status = await parseRunnerResponse(response, session); + return typeof status.lifecycleState === 'string' ? status.lifecycleState : ''; +} + async function runRunnerReadinessPreflight(params: { device: DeviceInfo; session: RunnerExchangeSession; diff --git a/packages/platform-apple/src/runner/runner-session-types.ts b/packages/platform-apple/src/runner/runner-session-types.ts index a1b7b7df84..67a604f9aa 100644 --- a/packages/platform-apple/src/runner/runner-session-types.ts +++ b/packages/platform-apple/src/runner/runner-session-types.ts @@ -5,6 +5,7 @@ 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'; +import type { RunnerCommand } from './runner-contract.ts'; /** * Where one runner process stands in the lifecycle of the session that owns it (#2662). The state @@ -259,6 +260,12 @@ export class RunnerCommandAccounting { if (index !== -1) this.charges[index]!.abandoned = true; } + /** Withdraw the charge of a send that provably wrote nothing: the runner holds no such command. */ + withdrawUnsent(commandId: string | undefined): void { + const index = this.findChargeIndex(commandId); + if (index !== -1) this.charges.splice(index, 1); + } + /** * Discharge an answered exchange, then forgive one abandoned charge sent before it — the serial * queue makes this answer evidence that the queued handling ahead of it finished, and any work still @@ -315,6 +322,40 @@ export class RunnerCommandAccounting { } } +/** + * The runner journal vocabulary, so charge settlement, status recovery, and the drain of a canceled + * command cannot disagree about what a state means. `runner-swift-settlement-fixtures.ts` pins these + * names to `RunnerCommandLifecycleState`, and the recovery wiring rows are derived from that same + * declaration, so a state the runner gains has to be ruled here before it can decide a handoff. + * + * `completed` and `failed` close an entry — from the response's `ok` in `finish`, or a thrown error in + * `fail` — so execution ended. `accepted` and `started` are written as execution opens, so they share + * one in-flight verdict. `notAccepted` is what `status` reports for an id the journal never held, + * which this daemon cannot read as terminal. + */ +const RUNNER_TERMINAL_LIFECYCLE_STATES: ReadonlySet = new Set(['completed', 'failed']); +export const RUNNER_IN_FLIGHT_LIFECYCLE_STATES: ReadonlySet = new Set([ + 'accepted', + 'started', +]); + +/** + * Discharges the abandoned charge terminal status proves landed (#2965). A status reply is served + * inline, so it is no evidence that queued work finished; this is the only place a status answer may + * settle a charge, and only the one its `statusCommandId` names. + * + * @returns whether the evidence paid a debt, or `undefined` when the state is not terminal and no + * settlement was attempted. + */ +export function settleRunnerChargeForTerminalStatus( + session: Pick, + command: Pick, + lifecycleState: string, +): boolean | undefined { + if (!RUNNER_TERMINAL_LIFECYCLE_STATES.has(lifecycleState)) return undefined; + return session.commandCharges.settleTerminalEvidence(command.commandId); +} + /** Ids match exactly as sent. */ function normalizeRunnerChargeId(commandId: string | undefined): string { return commandId?.trim() ?? ''; diff --git a/packages/platform-apple/src/runner/runner-transport.ts b/packages/platform-apple/src/runner/runner-transport.ts index 754df7ef74..566c3e5d5a 100644 --- a/packages/platform-apple/src/runner/runner-transport.ts +++ b/packages/platform-apple/src/runner/runner-transport.ts @@ -15,9 +15,7 @@ export async function sendRunnerCommandOnce( timeoutMs: number = RUNNER_COMMAND_TIMEOUT_MS, signal?: AbortSignal, ): Promise { - if (signal?.aborted) { - throw createRequestCanceledError(); - } + throwIfCanceledBeforeSend(signal); const deadline = Deadline.fromTimeoutMs(timeoutMs); const resolver = createRunnerCommandRouteResolver(device, port); let route = await resolver.resolveRoute(deadline.remainingMs()); @@ -30,6 +28,7 @@ export async function sendRunnerCommandOnce( route = await resolver.resolveRoute(deadline.remainingMs()); } } + throwIfCanceledBeforeSend(signal); const remainingMs = deadline.remainingMs(); if (remainingMs <= 0) { throw new AppError('COMMAND_FAILED', 'Runner command deadline exceeded', { timeoutMs }); @@ -53,6 +52,11 @@ export async function sendRunnerCommandOnce( ); } +/** A cancellation that lands before the POST is issued proves the runner received nothing. */ +export function throwIfCanceledBeforeSend(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw createRequestCanceledError({ dispatched: 'no' }); +} + async function postUsbmuxRunnerCommand( device: DeviceInfo, port: number, diff --git a/packages/platform-apple/src/runner/runner-usbmux.ts b/packages/platform-apple/src/runner/runner-usbmux.ts index eb1fb65427..aabbea6ff5 100644 --- a/packages/platform-apple/src/runner/runner-usbmux.ts +++ b/packages/platform-apple/src/runner/runner-usbmux.ts @@ -54,7 +54,7 @@ async function postRunnerHttpCommand( signal?: AbortSignal, ): Promise { requireTimeRemaining(timeoutMs); - if (signal?.aborted) throw createRequestCanceledError(); + if (signal?.aborted) throw createRequestCanceledError({ dispatched: 'no' }); const body = Buffer.from(JSON.stringify(command), 'utf8'); const timeoutSignal = AbortSignal.timeout(timeoutMs); const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 9b4ea68bd6..45e1f22f6c 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -250,7 +250,7 @@ const controller = new AbortController(); await client.interactions.press({ ref: '@e12', signal: controller.signal }); ``` -With the built-in transport, a signal that is already aborted rejects the call without sending anything (`error.details.dispatched: 'no'`). Aborting while the request is in flight closes that request's connection, the daemon marks the request canceled, and the promise rejects with the typed canceled-request error (`error.details.reason: 'request_canceled'`, `error.details.dispatched: 'unknown'`). A custom transport receives the signal on its context and may cancel differently. The daemon and the session stay alive for other requests. An abort is not a timeout: it never triggers the runner cleanup or daemon reset that a timeout does. Cancellation covers only the daemon request, so a response-artifact download already underway is not stopped, and a canceled one-shot replay still runs the existing cleanup that can tear down a daemon this client started. +With the built-in transport, a signal that is already aborted rejects the call without sending anything (`error.details.dispatched: 'no'`). Aborting while the request is in flight closes that request's connection, the daemon marks the request canceled, and the promise rejects with the typed canceled-request error (`error.details.reason: 'request_canceled'`, `error.details.dispatched: 'unknown'`). A custom transport receives the signal on its context and may cancel differently. The daemon and the session stay alive for other requests. On iOS, a command that already reached the device keeps running there, so the daemon holds the session while the device reports it running. Your promise still rejects at once. The hold ends when the device reports the command finished, when the command's own timeout runs out, or when the device cannot confirm the command (it never received it, or the status check fails). Only in the first case is the canceled work known to be over before the session's next request starts. An abort is not a timeout: it never triggers the runner cleanup or daemon reset that a timeout does. Cancellation covers only the daemon request, so a response-artifact download already underway is not stopped, and a canceled one-shot replay still runs the existing cleanup that can tear down a daemon this client started. ```ts await client.command.wait({