From 8a47040e0f1c21211ff8a809670c7870ef8f8e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 12:16:06 +0200 Subject: [PATCH 1/3] test(apple-runner): pin exchange accounting through real HTTP --- .../runner/__tests__/runner-exchange.test.ts | 85 +++++++++++++++++++ .../src/runner/runner-exchange.ts | 28 ++++-- .../src/runner/runner-startup-transport.ts | 22 +++-- 3 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts diff --git a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts new file mode 100644 index 0000000000..47963b77ce --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import type { ExecResult } from '@agent-device/host-kit/command'; +import { afterEach, test } from 'vitest'; +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'; + +let server: FakeRunnerServer | undefined; + +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +function sessionFor(port: number): RunnerSession { + return { + sessionId: `exchange:${port}`, + device: IOS_SIMULATOR, + deviceId: IOS_SIMULATOR.id, + port, + xctestrunPath: '/tmp/fake.xctestrun', + jsonPath: '/tmp/fake.json', + testPromise: new Promise(() => {}), + child: { pid: process.pid, exitCode: null }, + state: 'ready', + commandCharges: new RunnerCommandAccounting(), + }; +} + +test('the exchange preflights a mutation and settles only its answer', async () => { + server = await startFakeRunnerServer({ + uptime: [{ kind: 'ok', data: { uptimeMs: 5 } }], + tap: [{ kind: 'ok', data: { tapped: true } }], + }); + const session = sessionFor(server.port); + const invalidations: string[] = []; + + const result = await executeRunnerExchange( + IOS_SIMULATOR, + session, + { command: 'tap', x: 10, y: 10, appBundleId: 'com.example.app' }, + undefined, + 10_000, + async (reason) => { + invalidations.push(reason); + }, + ); + + assert.deepEqual(result, { tapped: true }); + assert.deepEqual( + server.requests.map(({ command }) => command), + ['uptime', 'tap'], + ); + assert.equal(session.commandCharges.hasOutstandingCharges, false); + assert.equal(session.lastHealthyMutation?.appBundleId, 'com.example.app'); + assert.deepEqual(invalidations, []); +}); + +test('the exchange awaits owner invalidation before returning a fatal answer', async () => { + server = await startFakeRunnerServer({ + snapshot: [{ kind: 'ok', data: { runnerFatal: true, runnerFatalReason: 'ax_failed' } }], + }); + const session = sessionFor(server.port); + session.lastHealthyMutation = { atMs: Date.now(), appBundleId: 'com.example.app' }; + const order: string[] = []; + + const result = await executeRunnerExchange( + IOS_SIMULATOR, + session, + { command: 'snapshot', appBundleId: 'com.example.app' }, + undefined, + 10_000, + async (reason) => { + await Promise.resolve(); + order.push(reason); + }, + ); + order.push('returned'); + + assert.equal(result.runnerFatal, true); + assert.deepEqual(order, ['ax_failed', 'returned']); + assert.equal(session.lastHealthyMutation, undefined); + assert.equal(session.commandCharges.hasOutstandingCharges, false); +}); diff --git a/packages/platform-apple/src/runner/runner-exchange.ts b/packages/platform-apple/src/runner/runner-exchange.ts index 9ffffe2a2b..5dbd7d9d36 100644 --- a/packages/platform-apple/src/runner/runner-exchange.ts +++ b/packages/platform-apple/src/runner/runner-exchange.ts @@ -1,7 +1,11 @@ import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { Deadline, emitDiagnostic, withDiagnosticTimer } from './host.ts'; -import { waitForRunner, RUNNER_STARTUP_TIMEOUT_MS } from './runner-startup-transport.ts'; +import { + waitForRunner, + RUNNER_STARTUP_TIMEOUT_MS, + type RunnerConnectionSession, +} from './runner-startup-transport.ts'; import { sendRunnerCommandOnce } from './runner-transport.ts'; import { buildRunnerResponseError, @@ -29,6 +33,12 @@ import { } from './runner-failure-diagnostics.ts'; import { advanceRunnerSessionState, type RunnerSession } from './runner-session-types.ts'; +type RunnerExchangeSession = RunnerConnectionSession & + Pick< + RunnerSession, + 'port' | 'commandCharges' | 'lastHealthyMutation' | 'runnerMainThreadBusy' | 'launchDeadline' + >; + const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 1_000; const RUNNER_PREFLIGHT_SKIP_FRESHNESS_MS = 5_000; @@ -63,7 +73,7 @@ type RunnerReadinessPreflightDecision = */ export async function executeRunnerExchange( device: DeviceInfo, - session: RunnerSession, + session: RunnerExchangeSession, command: RunnerCommand, logPath: string | undefined, timeoutMs: number, @@ -137,7 +147,7 @@ export async function executeRunnerExchange( * runner's occupancy report, and applies what the payload says about the session. */ async function settleRunnerAnsweredExchange( - session: RunnerSession, + session: RunnerExchangeSession, runnerCommand: RunnerCommand, data: Record, invalidateFatalSession: (reason: string) => Promise, @@ -169,7 +179,7 @@ async function settleRunnerAnsweredExchange( * runner answered it. */ function recordUnansweredRunnerExchange( - session: RunnerSession, + session: RunnerExchangeSession, runnerCommand: RunnerCommand, error: unknown, ): boolean { @@ -199,7 +209,7 @@ function readRunnerMainThreadBusy(data: Record): boolean | unde function markSkippedPreflightTransportError( error: unknown, - session: RunnerSession, + session: RunnerExchangeSession, preflightDecision: RunnerReadinessPreflightDecision, ): unknown { if ( @@ -218,7 +228,7 @@ function markSkippedPreflightTransportError( async function sendRunnerCommandAfterPreflight(params: { device: DeviceInfo; - session: RunnerSession; + session: RunnerExchangeSession; runnerCommand: RunnerCommand; logPath: string | undefined; deadline: Deadline; @@ -282,7 +292,7 @@ async function sendRunnerCommandAfterPreflight(params: { async function runRunnerReadinessPreflight(params: { device: DeviceInfo; - session: RunnerSession; + session: RunnerExchangeSession; runnerCommand: RunnerCommand; logAttempt: RunnerLogAttempt | undefined; deadline: Deadline; @@ -325,7 +335,7 @@ async function runRunnerReadinessPreflight(params: { function emitRunnerReadinessPreflightSkipped( runnerCommand: RunnerCommand, - session: RunnerSession, + session: RunnerExchangeSession, decision: Extract, ): void { emitDiagnostic({ @@ -392,7 +402,7 @@ function resolveRunnerFatalReason(data: Record): string | undef } function resolveRunnerReadinessPreflightDecision( - session: RunnerSession, + session: RunnerExchangeSession, command: RunnerCommand, ): RunnerReadinessPreflightDecision { const readOnlyCommand = isReadOnlyRunnerCommand(command); diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index d816c1b3c7..425846d9ab 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -30,6 +30,18 @@ import { } from './runner-error-classification.ts'; import type { RunnerCommand } from './runner-contract.ts'; import type { RunnerSession } from './runner-session-types.ts'; + +export type RunnerConnectionSession = Pick< + RunnerSession, + | 'startupRetryWake' + | 'child' + | 'startupDeviceStates' + | 'state' + | 'testPromise' + | 'readLogTail' + | 'device' + | 'runnerLogPath' +>; import { runnerSimulatorSetFailureDetails, simulatorSetDestinationNotFoundMessage, @@ -53,7 +65,7 @@ export async function waitForRunner( command: RunnerCommand, logPath?: string, timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS, - session?: RunnerSession, + session?: RunnerConnectionSession, signal?: AbortSignal, ): Promise { const deadline = Deadline.fromTimeoutMs(timeoutMs); @@ -148,7 +160,7 @@ async function attemptRunnerConnection(params: { command: RunnerCommand; timeoutMs: number; logPath?: string; - session?: RunnerSession; + session?: RunnerConnectionSession; route: RunnerCommandRoute; resolveRoute: RunnerRouteResolver; markUsbmuxUnattached: () => void; @@ -172,7 +184,7 @@ async function ensureRunnerAttemptCanStart(params: { port: number; timeoutMs: number; logPath?: string; - session?: RunnerSession; + session?: RunnerConnectionSession; attemptDeadline?: Deadline; }): Promise { if (params.attemptDeadline?.isExpired()) { @@ -246,7 +258,7 @@ async function tryReadySimulatorEndpoint(params: { device: DeviceInfo; port: number; command: RunnerCommand; - session?: RunnerSession; + session?: RunnerConnectionSession; signal?: AbortSignal; attemptDeadline?: Deadline; setLastError: (error: unknown) => void; @@ -516,7 +528,7 @@ function buildRunnerConnectError(params: { } export async function buildRunnerEarlyExitError(params: { - session: RunnerSession; + session: RunnerConnectionSession; port: number; logPath?: string; }): Promise { From 7b2b1c5c64a3e5b959cf40ae92092f94aed1505b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 15:49:00 +0200 Subject: [PATCH 2/3] fix(apple): preserve runner preflight state and await invalidation --- .../runner/__tests__/runner-exchange.test.ts | 115 ++++++++++++++++-- .../src/runner/runner-exchange.ts | 15 ++- 2 files changed, 121 insertions(+), 9 deletions(-) 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 47963b77ce..26a19fb13a 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts @@ -57,29 +57,130 @@ test('the exchange preflights a mutation and settles only its answer', async () assert.deepEqual(invalidations, []); }); +test('the readiness probe preserves its main-thread busy report when the command omits it', async () => { + server = await startFakeRunnerServer({ + uptime: [{ kind: 'ok', data: { runnerMainThreadBusy: true } }], + tap: [{ kind: 'ok', data: { tapped: true } }], + }); + const session = sessionFor(server.port); + + await executeRunnerExchange( + IOS_SIMULATOR, + session, + { command: 'tap', x: 10, y: 10, appBundleId: 'com.example.app' }, + undefined, + 10_000, + async () => {}, + ); + + assert.equal(session.runnerMainThreadBusy, true); +}); + +test('a preflight-exempt command still waits for a starting runner', async () => { + server = await startFakeRunnerServer({ + uptime: [{ kind: 'ok', data: { uptimeMs: 5 } }], + terminate: [{ kind: 'ok', data: { terminated: true } }], + }); + const session = sessionFor(server.port); + session.state = 'starting'; + + await executeRunnerExchange( + IOS_SIMULATOR, + session, + { command: 'terminate', appBundleId: 'com.example.app' }, + undefined, + 10_000, + async () => {}, + ); + + assert.deepEqual( + server.requests.map(({ command }) => command), + ['uptime', 'terminate'], + ); +}); + test('the exchange awaits owner invalidation before returning a fatal answer', async () => { server = await startFakeRunnerServer({ snapshot: [{ kind: 'ok', data: { runnerFatal: true, runnerFatalReason: 'ax_failed' } }], }); const session = sessionFor(server.port); session.lastHealthyMutation = { atMs: Date.now(), appBundleId: 'com.example.app' }; - const order: string[] = []; + let signalInvalidationStarted!: () => void; + const invalidationStarted = new Promise((resolve) => { + signalInvalidationStarted = resolve; + }); + let releaseInvalidation!: () => void; + const invalidationGate = new Promise((resolve) => { + releaseInvalidation = resolve; + }); + let settled = false; - const result = await executeRunnerExchange( + const exchange = executeRunnerExchange( IOS_SIMULATOR, session, { command: 'snapshot', appBundleId: 'com.example.app' }, undefined, 10_000, async (reason) => { - await Promise.resolve(); - order.push(reason); + assert.equal(reason, 'ax_failed'); + signalInvalidationStarted(); + await invalidationGate; }, - ); - order.push('returned'); + ).finally(() => { + settled = true; + }); + + try { + await invalidationStarted; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false); + } finally { + releaseInvalidation(); + } + const result = await exchange; assert.equal(result.runnerFatal, true); - assert.deepEqual(order, ['ax_failed', 'returned']); assert.equal(session.lastHealthyMutation, undefined); assert.equal(session.commandCharges.hasOutstandingCharges, false); }); + +test('the exchange awaits owner invalidation before throwing a fatal runner error', async () => { + server = await startFakeRunnerServer({ + snapshot: [{ kind: 'runnerError', code: 'RUNNER_WEDGED', message: 'runner wedged' }], + }); + const session = sessionFor(server.port); + let signalInvalidationStarted!: () => void; + const invalidationStarted = new Promise((resolve) => { + signalInvalidationStarted = resolve; + }); + let releaseInvalidation!: () => void; + const invalidationGate = new Promise((resolve) => { + releaseInvalidation = resolve; + }); + let settled = false; + + const exchange = executeRunnerExchange( + IOS_SIMULATOR, + session, + { command: 'snapshot', appBundleId: 'com.example.app' }, + undefined, + 10_000, + async (reason) => { + assert.equal(reason, 'runner_main_thread_wedged'); + signalInvalidationStarted(); + await invalidationGate; + }, + ).finally(() => { + settled = true; + }); + void exchange.catch(() => {}); + + try { + await invalidationStarted; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false); + } finally { + releaseInvalidation(); + } + await assert.rejects(exchange); +}); diff --git a/packages/platform-apple/src/runner/runner-exchange.ts b/packages/platform-apple/src/runner/runner-exchange.ts index 5dbd7d9d36..daf0fba8d6 100644 --- a/packages/platform-apple/src/runner/runner-exchange.ts +++ b/packages/platform-apple/src/runner/runner-exchange.ts @@ -327,7 +327,11 @@ async function runRunnerReadinessPreflight(params: { timeoutMs: readinessTimeoutMs, }, ); - await parseRunnerResponse(readinessResponse, session, logAttempt); + const readinessData = await parseRunnerResponse(readinessResponse, session, logAttempt); + const stampedMainThreadBusy = readRunnerMainThreadBusy(readinessData); + if (stampedMainThreadBusy !== undefined) { + session.runnerMainThreadBusy = stampedMainThreadBusy; + } } catch (error) { throw markRunnerReadinessPreflightError(error); } @@ -406,7 +410,7 @@ function resolveRunnerReadinessPreflightDecision( command: RunnerCommand, ): RunnerReadinessPreflightDecision { const readOnlyCommand = isReadOnlyRunnerCommand(command); - if (isRunnerReadinessPreflightExempt(command)) { + if (canSkipReadySessionPreflightForExemptCommand(session, command)) { return { action: 'skip', reason: 'preflight_exempt_command' }; } if (session.state !== 'ready') { @@ -464,6 +468,13 @@ function resolveRunnerReadinessPreflightDecision( }; } +function canSkipReadySessionPreflightForExemptCommand( + session: RunnerExchangeSession, + command: RunnerCommand, +): boolean { + return session.state === 'ready' && isRunnerReadinessPreflightExempt(command); +} + function markRunnerReadinessPreflightError(error: unknown): AppError { return markRunnerPreflightError(error, { runnerReadinessPreflightFailed: true, From 5894447eee7a34fcc928f3d34de1eefeccc6d48e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 19:56:53 +0200 Subject: [PATCH 3/3] fix(apple-runner): preserve exempt startup command order --- .../runner/__tests__/runner-exchange.test.ts | 44 +++++++++---------- .../src/runner/runner-exchange.ts | 9 +--- 2 files changed, 23 insertions(+), 30 deletions(-) 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 26a19fb13a..e6bf4b125d 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts @@ -76,28 +76,28 @@ test('the readiness probe preserves its main-thread busy report when the command assert.equal(session.runnerMainThreadBusy, true); }); -test('a preflight-exempt command still waits for a starting runner', async () => { - server = await startFakeRunnerServer({ - uptime: [{ kind: 'ok', data: { uptimeMs: 5 } }], - terminate: [{ kind: 'ok', data: { terminated: true } }], - }); - const session = sessionFor(server.port); - session.state = 'starting'; - - await executeRunnerExchange( - IOS_SIMULATOR, - session, - { command: 'terminate', appBundleId: 'com.example.app' }, - undefined, - 10_000, - async () => {}, - ); - - assert.deepEqual( - server.requests.map(({ command }) => command), - ['uptime', 'terminate'], - ); -}); +test.each(['activate', 'terminate', 'targetReset'] as const)( + 'a starting runner sends exempt %s without a readiness preflight', + async (command) => { + server = await startFakeRunnerServer([{ kind: 'ok', data: {} }]); + const session = sessionFor(server.port); + session.state = 'starting'; + + await executeRunnerExchange( + IOS_SIMULATOR, + session, + { command, appBundleId: 'com.example.app' }, + undefined, + 10_000, + async () => {}, + ); + + assert.deepEqual( + server.requests.map((request) => request.command), + [command], + ); + }, +); test('the exchange awaits owner invalidation before returning a fatal answer', async () => { server = await startFakeRunnerServer({ diff --git a/packages/platform-apple/src/runner/runner-exchange.ts b/packages/platform-apple/src/runner/runner-exchange.ts index daf0fba8d6..be33276077 100644 --- a/packages/platform-apple/src/runner/runner-exchange.ts +++ b/packages/platform-apple/src/runner/runner-exchange.ts @@ -410,7 +410,7 @@ function resolveRunnerReadinessPreflightDecision( command: RunnerCommand, ): RunnerReadinessPreflightDecision { const readOnlyCommand = isReadOnlyRunnerCommand(command); - if (canSkipReadySessionPreflightForExemptCommand(session, command)) { + if (isRunnerReadinessPreflightExempt(command)) { return { action: 'skip', reason: 'preflight_exempt_command' }; } if (session.state !== 'ready') { @@ -468,13 +468,6 @@ function resolveRunnerReadinessPreflightDecision( }; } -function canSkipReadySessionPreflightForExemptCommand( - session: RunnerExchangeSession, - command: RunnerCommand, -): boolean { - return session.state === 'ready' && isRunnerReadinessPreflightExempt(command); -} - function markRunnerReadinessPreflightError(error: unknown): AppError { return markRunnerPreflightError(error, { runnerReadinessPreflightFailed: true,