diff --git a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts index d351529a3b..1e2252e5eb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts @@ -30,10 +30,6 @@ vi.mock('../runner-macos-products.ts', async () => { import type { DeviceInfo } from '@agent-device/kernel/device'; import { RUNNER_COMMAND_TRAITS, isReadOnlyRunnerCommand } from '../runner-command-traits.ts'; -import { - isRetryableRunnerError, - shouldRetryRunnerConnectError, -} from '../runner-error-classification.ts'; import { withRunnerCommandId, type RunnerCommand } from '../runner-contract.ts'; import { resolveRunnerBuildDestination, @@ -412,32 +408,6 @@ test('assertSafeDerivedCleanup allows cleaning override path under project .tmp' }); }); -test('shouldRetryRunnerConnectError does not retry xcodebuild early-exit errors', () => { - const err = new AppError( - 'COMMAND_FAILED', - 'Runner did not accept connection (xcodebuild exited early)', - ); - assert.equal(shouldRetryRunnerConnectError(err), false); -}); - -test('shouldRetryRunnerConnectError retries transient connect errors', () => { - const err = new AppError('COMMAND_FAILED', 'Runner endpoint probe failed'); - assert.equal(shouldRetryRunnerConnectError(err), true); -}); - -test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => { - const err = new AppError( - 'COMMAND_FAILED', - 'Runner did not accept connection (xcodebuild exited early)', - ); - assert.equal(isRetryableRunnerError(err), false); -}); - -test('isRetryableRunnerError does not retry busy-connecting errors', () => { - const err = new AppError('COMMAND_FAILED', 'Device is busy (Connecting to iPhone)'); - assert.equal(isRetryableRunnerError(err), false); -}); - test('xctestrunReferencesProjectRoot rejects stale worktree artifacts', async () => { const tmpDir = await makeTmpDir(); const xctestrunPath = path.join(tmpDir, 'AgentDeviceRunner.xctestrun'); 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 56019ce0ea..e1eb003d7e 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 @@ -1,7 +1,7 @@ import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { createTestRequestCancellation } from './runner-session-fixtures.ts'; +import { createTestRequestCancellation, runnerConnectFailure } from './runner-session-fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import { Deadline } from '../host.ts'; import { appleRunnerTestHost } from '../test-host.ts'; @@ -71,7 +71,7 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea .mockResolvedValueOnce(fixtures.restoredSession) .mockResolvedValueOnce(fixtures.rebuiltSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ uptimeMs: 42 }); const result = await prepareIosRunner(IOS_SIMULATOR, { @@ -110,7 +110,7 @@ test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery heal .mockResolvedValueOnce(restoredSession) .mockResolvedValueOnce(rebuiltSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner endpoint probe failed')) + .mockRejectedValueOnce(runnerConnectFailure('runner_endpoint_probe_exhausted')) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner health timed out')); await assert.rejects( @@ -144,7 +144,7 @@ test('prepareIosRunner retries a fresh launch session when the health check cann .mockResolvedValueOnce(stuckSession) .mockResolvedValueOnce(relaunchedSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ uptimeMs: 42 }); const result = await prepareIosRunner(IOS_SIMULATOR, { @@ -232,8 +232,8 @@ test('prepareIosRunner does not force a rebuild when the relaunched fresh sessio .mockResolvedValueOnce(stuckSession) .mockResolvedValueOnce(relaunchedSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')); + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')); await assert.rejects( () => @@ -261,7 +261,7 @@ test('prepareIosRunner does not relaunch after non-retryable runner startup fail mockEnsureRunnerSession.mockResolvedValueOnce(failedSession); mockExecuteRunnerCommandWithSession.mockRejectedValueOnce( - new AppError('COMMAND_FAILED', 'xcodebuild exited early'), + runnerConnectFailure('xcodebuild_exited_early'), ); await assert.rejects( @@ -281,7 +281,7 @@ test('prepareIosRunner does not relaunch after request cancellation', async () = mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession); mockExecuteRunnerCommandWithSession.mockImplementationOnce(() => { markRequestCanceled(requestId); - throw new AppError('COMMAND_FAILED', 'Runner did not accept connection'); + throw runnerConnectFailure('runner_connect_refused'); }); try { @@ -303,7 +303,7 @@ test('mutating commands restart stale ready sessions when the preflight probe ne mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -326,7 +326,7 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async mockEnsureRunnerSession.mockResolvedValueOnce(startupSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -811,7 +811,7 @@ test('mutating commands invalidate the retry session without replaying again', a mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockResolvedValueOnce({ lifecycleState: 'notAccepted' }); @@ -1153,7 +1153,7 @@ test('a failed replacement boot does not consume the request recycle budget', as const requestId = 'req-recycle-transient-boot-failure'; mockEnsureRunnerSession .mockResolvedValueOnce(makeRunnerSession({ port: 8100, state: 'ready' })) - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce(makeRunnerSession({ port: 8101, state: 'ready' })); mockExecuteRunnerCommandWithSession .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) @@ -1204,7 +1204,7 @@ test('a later command in the same request cannot pay for a second recycle boot', mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ message: 'tapped' }); // First command consumes the request's only recycle via restart-and-replay. diff --git a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts index 2ff3beb550..f1936ad802 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts @@ -14,6 +14,7 @@ import { shouldRestartRunnerBeforeCommandSend, shouldRetryRunnerConnectError, } from '../runner-error-classification.ts'; +import { runnerConnectFailure } from './runner-session-fixtures.ts'; function commandFailed(message: string, details?: Record): AppError { return new AppError('COMMAND_FAILED', message, details); @@ -27,12 +28,16 @@ test('every rule carries a unique reason', () => { // --- retryable axis (isRetryableRunnerError) --- test('transport-shaped failures are retryable', () => { - for (const message of [ - 'Runner did not accept connection on port 8100', - 'fetch failed', - 'connect ECONNREFUSED 127.0.0.1:8100', - 'socket hang up', - ]) { + assert.equal( + isRetryableRunnerError( + runnerConnectFailure( + 'runner_connect_refused', + 'Runner did not accept connection on port 8100', + ), + ), + true, + ); + for (const message of ['fetch failed', 'connect ECONNREFUSED 127.0.0.1:8100', 'socket hang up']) { assert.equal(isRetryableRunnerError(commandFailed(message)), true, message); } }); @@ -40,7 +45,10 @@ test('transport-shaped failures are retryable', () => { test('boot-shaped failures are not retryable', () => { assert.equal( isRetryableRunnerError( - commandFailed('Runner did not accept connection (xcodebuild exited early)'), + runnerConnectFailure( + 'xcodebuild_exited_early', + 'Runner did not accept connection (xcodebuild exited early)', + ), ), false, ); @@ -51,7 +59,9 @@ test('boot-shaped failures are not retryable', () => { }); test('an explicitly retriable flag wins over any message denial', () => { - const flagged = commandFailed('xcodebuild exited early', { retriable: true }); + const flagged = runnerConnectFailure('xcodebuild_exited_early', 'xcodebuild exited early', { + retriable: true, + }); assert.equal(isRetryableRunnerError(flagged), true); }); @@ -64,7 +74,9 @@ test('retryable requires an AppError with COMMAND_FAILED', () => { test('connect loop keeps waiting by default, including for unknown errors', () => { assert.equal( - shouldRetryRunnerConnectError(commandFailed('Runner did not accept connection')), + shouldRetryRunnerConnectError( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ), true, ); assert.equal(shouldRetryRunnerConnectError(new Error('anything')), true); @@ -72,7 +84,12 @@ test('connect loop keeps waiting by default, including for unknown errors', () = }); test('connect loop stops for terminal verdicts', () => { - assert.equal(shouldRetryRunnerConnectError(commandFailed('xcodebuild exited early')), false); + assert.equal( + shouldRetryRunnerConnectError( + runnerConnectFailure('xcodebuild_exited_early', 'xcodebuild exited early'), + ), + false, + ); const unattached = new AppError('DEVICE_NOT_FOUND', 'device not attached', { usbmuxDeviceAttached: false, }); @@ -140,27 +157,37 @@ test('a deadline on its own earns no recovery verdict', () => { test('only a runner that never accepted a connection indicts the cached artifact', () => { assert.equal( - shouldRebuildCachedRunnerArtifact(commandFailed('Runner endpoint probe failed')), + shouldRebuildCachedRunnerArtifact( + runnerConnectFailure('runner_endpoint_probe_exhausted', 'Runner endpoint probe failed'), + ), true, ); assert.equal( - shouldRebuildCachedRunnerArtifact(commandFailed('Runner did not accept connection')), + shouldRebuildCachedRunnerArtifact( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ), true, ); assert.equal( shouldRebuildCachedRunnerArtifact( - commandFailed('Runner did not accept connection (simctl spawn)', { port: 8100 }), + runnerConnectFailure( + 'runner_connect_refused', + 'Runner did not accept connection (simctl spawn)', + { + port: 8100, + }, + ), ), true, ); - // Wiping derived data cannot fix a boot that refuses to compile, and its message - // otherwise reads as a refused connection. + // Wiping derived data cannot fix a boot that refuses to compile. assert.equal( shouldRebuildCachedRunnerArtifact( - commandFailed('Runner did not accept connection (xcodebuild exited early)', { - port: 8100, - logPath: '/tmp/runner.log', - }), + runnerConnectFailure( + 'xcodebuild_exited_early', + 'Runner did not accept connection (xcodebuild exited early)', + { port: 8100, logPath: '/tmp/runner.log' }, + ), ), false, ); @@ -198,23 +225,67 @@ test('ordinary errors are never session-fatal', () => { // --- restart-before-send axis (shouldRestartRunnerBeforeCommandSend) --- -test('a refused connection before send restarts the session, case-insensitively', () => { +test('a refused connection before send restarts the session', () => { assert.equal( - shouldRestartRunnerBeforeCommandSend(commandFailed('Runner did not accept connection')), - true, - ); - assert.equal( - shouldRestartRunnerBeforeCommandSend(commandFailed('runner did not accept connection')), + shouldRestartRunnerBeforeCommandSend( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ), true, ); }); -test('a terminal connect verdict refuses the restart even when the message matches', () => { - const both = commandFailed('xcodebuild exited early: runner did not accept connection'); - assert.equal(shouldRestartRunnerBeforeCommandSend(both), false); +test('an early exit or a foreign transport failure earns no restart before send', () => { + const earlyExit = runnerConnectFailure( + 'xcodebuild_exited_early', + 'xcodebuild exited early: runner did not accept connection', + ); + assert.equal(shouldRestartRunnerBeforeCommandSend(earlyExit), false); assert.equal(shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up')), false); }); +// --- typed connect-failure reasons (agent-device's own connect path) --- + +test('xcodebuild_exited_early is decided by the typed reason, not the message', () => { + for (const message of ['Runner did not accept connection (xcodebuild exited early)', 'boom']) { + const error = runnerConnectFailure('xcodebuild_exited_early', message); + assert.equal(isRetryableRunnerError(error), false, message); + assert.equal(shouldRetryRunnerConnectError(error), false, message); + assert.equal(shouldRebuildCachedRunnerArtifact(error), false, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message); + } + // The same words without the reason earn no terminal verdict. + const untyped = commandFailed('Runner did not accept connection (xcodebuild exited early)'); + assert.equal(shouldRetryRunnerConnectError(untyped), true); +}); + +test('runner_connect_refused is decided by the typed reason, not the message', () => { + for (const message of ['Runner did not accept connection', 'boom']) { + const error = runnerConnectFailure('runner_connect_refused', message); + assert.equal(isRetryableRunnerError(error), true, message); + assert.equal(shouldRetryRunnerConnectError(error), true, message); + assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error), true, message); + } + const untyped = commandFailed('Runner did not accept connection'); + assert.equal(isRetryableRunnerError(untyped), false); + assert.equal(shouldRebuildCachedRunnerArtifact(untyped), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(untyped), false); +}); + +test('runner_endpoint_probe_exhausted is decided by the typed reason, not the message', () => { + for (const message of ['Runner endpoint probe failed', 'boom']) { + const error = runnerConnectFailure('runner_endpoint_probe_exhausted', message); + assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message); + assert.equal(isRetryableRunnerError(error), false, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message); + assert.equal(shouldRetryRunnerConnectError(error), true, message); + } + assert.equal( + shouldRebuildCachedRunnerArtifact(commandFailed('Runner endpoint probe failed')), + false, + ); +}); + // The literals are what the Swift runner encodes, so they are the contract and not the constant // names: a rename on one side has to fail here rather than silently split the pair (#2728). test('a refused screen capture keeps the runner reason and stays off the wire code', () => { diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts index dd28f4152a..efe279f753 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts @@ -8,7 +8,11 @@ import { import { appleRunnerTestHost } from '../test-host.ts'; import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { createTestRequestCancellation, makeRunnerSession } from './runner-session-fixtures.ts'; +import { + createTestRequestCancellation, + makeRunnerSession, + runnerConnectFailure, +} from './runner-session-fixtures.ts'; const { mockEnsureRunnerSession, @@ -216,7 +220,10 @@ test('a boot that exited early does not wipe a restored runner artifact', async mockEnsureRunnerSession.mockResolvedValueOnce(restoredSession); mockExecuteRunnerCommandWithSession.mockRejectedValueOnce( - new AppError('COMMAND_FAILED', 'Runner did not accept connection (xcodebuild exited early)'), + runnerConnectFailure( + 'xcodebuild_exited_early', + 'Runner did not accept connection (xcodebuild exited early)', + ), ); await assert.rejects( diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts index 2fa54f1694..0aa6a8f605 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts @@ -2,11 +2,16 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { EventEmitter } from 'node:events'; import { vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import { runnerOwnerStartTime, type RunnerLease } from '../runner-lease.ts'; import type { RunnerSession } from '../runner-session-types.ts'; import type { XcodebuildSimulatorSetRedirectHandle } from '../runner-device-set.ts'; +import { + runnerConnectFailureDetails, + type RunnerConnectFailureReason, +} from '../runner-error-classification.ts'; // Fabricated runner sessions, leases, background children, and transport // payloads shared by the runner-session tests. The child pids here are made up @@ -71,6 +76,25 @@ export function runnerError(error: { code: string; message: string }): Response return new Response(JSON.stringify({ ok: false, error })); } +/** Each reason's canonical connect-path message, reused as `runnerConnectFailure`'s default. */ +const RUNNER_CONNECT_FAILURE_MESSAGES: Record = { + runner_connect_refused: 'Runner did not accept connection', + runner_endpoint_probe_exhausted: 'Runner endpoint probe failed', + xcodebuild_exited_early: 'xcodebuild exited early', +}; + +/** A failure in the shape the runner connect path throws: its message plus its typed reason. */ +export function runnerConnectFailure( + reason: RunnerConnectFailureReason, + message: string = RUNNER_CONNECT_FAILURE_MESSAGES[reason], + details?: Record, +): AppError { + return new AppError('COMMAND_FAILED', message, { + ...details, + ...runnerConnectFailureDetails(reason), + }); +} + // Records everything the runner package emits through host.emitDiagnostic / // host.withDiagnosticTimer during `callback` and renders it back as the same // newline-delimited-JSON shape a flushed diagnostics session file holds, so diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts index 38ae2a55f2..a335e95414 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts @@ -142,6 +142,22 @@ test('waitForRunner uses simulator fallback within the attempt for ready session ]); }); +test('waitForRunner types a failed simulator fallback as a refused connection', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + mockRunCmd.mockResolvedValue({ exitCode: 7, stdout: '', stderr: 'curl: (7) Failed to connect' }); + + await assert.rejects( + () => waitForRunner(iosSimulator, 8100, { command: 'uptime' }, undefined, 100), + (error: unknown) => { + const appError = error as AppError; + assert.equal(appError.message, 'Runner did not accept connection (simctl spawn)'); + assert.equal(appError.details?.runnerConnectFailureReason, 'runner_connect_refused'); + return true; + }, + ); + assert.equal(mockRunCmd.mock.calls.length, 1); +}); + test('waitForRunner wakes a simulator startup retry when the listener reports ready', async () => { vi.useFakeTimers(); const readiness = new AbortController(); @@ -249,6 +265,7 @@ test('waitForRunner preserves xcodebuild diagnostics when the runner exits durin (error: unknown) => { const appError = error as AppError; assert.equal(appError.message, 'Runner did not accept connection (xcodebuild exited early)'); + assert.equal(appError.details?.runnerConnectFailureReason, 'xcodebuild_exited_early'); assert.equal( (appError.details?.xcodebuild as { exitCode?: number } | undefined)?.exitCode, 65, @@ -294,6 +311,7 @@ test('waitForRunner carries the disk-image state when the runner is still alive (error: unknown) => { const appError = error as AppError; assert.equal(appError.message, 'Runner did not accept connection'); + assert.equal(appError.details?.runnerConnectFailureReason, 'runner_connect_refused'); assert.equal(appError.details?.developerDiskImage, 'unavailable'); assert.equal(appError.details?.reason, 'IOS_RUNNER_CONNECT_TIMEOUT'); assert.doesNotMatch(String(appError.details?.hint), /Unlock the iPhone/); diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 882dc73eaf..32514622aa 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -21,11 +21,32 @@ export const RUNNER_CACHE_RECOVERY_HINT = */ type RunnerErrorDetailsMatch = (details: AppErrorDetails) => boolean; +/** + * Why agent-device's own runner connect path gave up, published in + * `details.runnerConnectFailureReason` by the error that path throws. It sits beside + * `details.reason` rather than in it: on the refused-connection and early-exit failures + * `reason` already carries the `BootFailureReason` the caller's hint answers. + */ +export type RunnerConnectFailureReason = + | 'xcodebuild_exited_early' + | 'runner_connect_refused' + | 'runner_endpoint_probe_exhausted'; + +export function runnerConnectFailureDetails(reason: RunnerConnectFailureReason): { + runnerConnectFailureReason: RunnerConnectFailureReason; +} { + return { runnerConnectFailureReason: reason }; +} + type RunnerErrorMatch = { /** Required `AppError.code`; absent = any AppError. */ code?: AppErrorCode; - /** Every entry must appear in the lowercased message. */ - messageIncludesAll?: readonly string[]; + /** + * Every entry must appear in the lowercased message. Only for text a foreign runtime or tool + * wrote, which `asAppError` copies into the message verbatim; a failure agent-device produces + * publishes a typed detail instead, and its rule keys on that. + */ + foreignMessageIncludesAll?: readonly string[]; /** * Every entry must appear in the lowercased {@link runnerToolText}: our message plus the tool's * own `stdout`/`stderr`. Nothing else in `details` is read, so the argv we were asked to run and @@ -54,6 +75,10 @@ const hasDevToolsSecurityStatus: RunnerErrorDetailsMatch = (details) => typeof details.devToolsSecurityStatus === 'string'; const hasUsbmuxDeviceUnattached: RunnerErrorDetailsMatch = (details) => details.usbmuxDeviceAttached === false; +const hasRunnerConnectFailureReason = + (reason: RunnerConnectFailureReason): RunnerErrorDetailsMatch => + (details) => + details.runnerConnectFailureReason === reason; /** * The preflight marks whatever it was waiting on when it stopped, and one of the things it waits on * is a caller that stopped waiting. A canceled request is not a wedged runner: the restart this @@ -188,17 +213,21 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ verdicts: { retryable: true, connectRetry: true }, }, { - // Says `artifactSuspect: false` on purpose: its message also reads as a refused - // connection, and a boot that cannot compile is not cured by wiping derived data. + // Says `artifactSuspect: false` on purpose: a boot that cannot compile is not cured by wiping + // derived data. reason: 'xcodebuild_exited_early', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['xcodebuild exited early'] }, + match: { + code: 'COMMAND_FAILED', + details: hasRunnerConnectFailureReason('xcodebuild_exited_early'), + }, verdicts: { retryable: false, connectRetry: false, artifactSuspect: false }, }, { // A device still mid-attachment is not a runner we can talk to yet, and waiting on // it inside this request is what the caller's own retry is for. reason: 'device_busy_connecting', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['device is busy', 'connecting'] }, + // Xcode/CoreDevice text: "Device is busy (Connecting to )". + match: { code: 'COMMAND_FAILED', foreignMessageIncludesAll: ['device is busy', 'connecting'] }, verdicts: { retryable: false, connectRetry: false }, }, { @@ -212,7 +241,10 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ }, { reason: 'runner_connect_refused', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['runner did not accept connection'] }, + match: { + code: 'COMMAND_FAILED', + details: hasRunnerConnectFailureReason('runner_connect_refused'), + }, verdicts: { retryable: true, connectRetry: true, @@ -224,22 +256,28 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ // Every endpoint answered and none of them had a runner: with a restored artifact // in hand, that artifact is the common cause. reason: 'runner_endpoint_probe_exhausted', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['runner endpoint probe failed'] }, + match: { + code: 'COMMAND_FAILED', + details: hasRunnerConnectFailureReason('runner_endpoint_probe_exhausted'), + }, verdicts: { artifactSuspect: true }, }, { reason: 'fetch_failed', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['fetch failed'] }, + // Node's fetch (undici) rejects a failed request with TypeError "fetch failed". + match: { code: 'COMMAND_FAILED', foreignMessageIncludesAll: ['fetch failed'] }, verdicts: { retryable: true, connectRetry: true }, }, { reason: 'econnrefused', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['econnrefused'] }, + // Node's net socket: "connect ECONNREFUSED
". + match: { code: 'COMMAND_FAILED', foreignMessageIncludesAll: ['econnrefused'] }, verdicts: { retryable: true, connectRetry: true }, }, { reason: 'socket_hang_up', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['socket hang up'] }, + // Node's http client: "socket hang up" when the peer closes before responding. + match: { code: 'COMMAND_FAILED', foreignMessageIncludesAll: ['socket hang up'] }, verdicts: { retryable: true, connectRetry: true }, }, { @@ -266,7 +304,8 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ // caller and no recovery verdicts, because there is no session to invalidate and nothing was sent // to resend. Specific rows precede generic ones: the classifier takes the first match. // - // Why these rows are text matchers while the rows above key on a code or a typed field: + // Why these rows are text matchers while the rows above key on a code, a typed field, or a + // foreign runtime's message: // `runnerToolText` reads xcodebuild's own prose because that prose is the only publication these // failures have — there is no code and no typed field to key on. Its haystack is deliberately // narrow: our message plus the tool's stdout/stderr, never the whole details bag, which also @@ -390,7 +429,7 @@ function matchesRunnerErrorRule(error: AppError, match: RunnerErrorMatch): boole if (!matchesRunnerErrorDetails(error, match.details)) return false; if (!matchesRunnerToolText(error, match.toolTextIncludesAll)) return false; if (!matchesRunnerToolTextLine(error, match.toolTextLineIncludesAll)) return false; - return matchesRunnerErrorMessage(error, match.messageIncludesAll); + return matchesRunnerErrorMessage(error, match.foreignMessageIncludesAll); } function matchesRunnerErrorDetails(error: AppError, details: RunnerErrorMatch['details']): boolean { @@ -522,17 +561,10 @@ export function resolveRunnerFatalErrorReason(error: unknown): string | undefine /** * A connect-shaped failure that surfaced before the command was sent: restart * the runner session and replay the command, rather than probing a runner - * that never accepted the connection. Composed with the connect-retry axis so - * a terminal connect verdict (cable unattached, xcodebuild exited early) - * still refuses the restart. Matching is table-driven and therefore - * case-insensitive, unlike the raw-message check it replaced; the message is - * our own transport literal, so no real error changes class. + * that never accepted the connection. */ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { - return ( - (runnerErrorVerdict(error, 'restartBeforeSend') ?? false) && - shouldRetryRunnerConnectError(error) - ); + return runnerErrorVerdict(error, 'restartBeforeSend') ?? false; } /** diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index df48f0863b..4eb7ce8488 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -23,6 +23,7 @@ import { enrichRunnerStartupFailureWithDeviceStates, isUsbmuxDeviceUnattachedError, RUNNER_CACHE_RECOVERY_HINT, + runnerConnectFailureDetails, shouldRetryRunnerConnectError, type IosRunnerDeviceStates, } from './runner-error-classification.ts'; @@ -296,6 +297,7 @@ function buildRunnerEndpointProbeError(params: { port: params.port, endpoints: params.endpoints, lastError: params.lastError ? String(params.lastError) : undefined, + ...runnerConnectFailureDetails('runner_endpoint_probe_exhausted'), }); } @@ -451,6 +453,7 @@ async function postCommandViaSimulator( port, reason, hint: bootFailureHint(reason), + ...runnerConnectFailureDetails('runner_connect_refused'), }; }, ); @@ -500,6 +503,7 @@ function buildRunnerConnectError(params: { context: { platform: 'ios', phase: 'connect' }, }), hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'), + ...runnerConnectFailureDetails('runner_connect_refused'), }); // The other way the connect stage gives up: `xcodebuild` is still alive at the deadline. It gets // the same enrichment as the early exit below (#2683). @@ -539,6 +543,7 @@ export async function buildRunnerEarlyExitError(params: { }, reason, hint: resolveRunnerEarlyExitHint(message, output, output, reason), + ...runnerConnectFailureDetails('xcodebuild_exited_early'), }); // The build catch is not the only way a runner stops before serving a command. A locked phone lets // the build finish and kills `xcodebuild test-without-building` instead, so nothing reaches that