From e003dea9d05f933022eb7b0ee2ccf4c9c9ad6450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 19:28:27 +0200 Subject: [PATCH 1/4] fix(apple): name an Xcode too old for its host macOS as a runner startup failure An Xcode older than the macOS it sits on cannot load its own DTServiceHub: AMFI refuses its version-restriction entitlement, so every `xcodebuild test-without-building` writes "DTServiceHubClient failed to bless service hub" into runner.log and the runner never serves a command. Callers saw only `Runner did not accept connection`, ordered by a classifier that had no name for this cause, so the request restarted the session and rebuilt a binary this macOS refuses to load (#3449). Classify the bless refusal from the launch output into the typed reason `simulator_service_hub_unavailable`, terminal on every recovery axis, with a hint to select a supporting Xcode on the daemon host (`xcode-select -s` or DEVELOPER_DIR). Both connect-stage failures now read the launch log through one classifier, so the message, reason, hint and logPath cannot disagree. --- .../runner-error-classification.test.ts | 33 ++++ .../runner-startup-failure-fixtures.ts | 20 +- ...nner-startup-transport-service-hub.test.ts | 172 ++++++++++++++++++ ...-xctestrun-startup-failure-reasons.test.ts | 1 + .../src/runner/runner-error-classification.ts | 56 ++++++ .../src/runner/runner-startup-transport.ts | 126 +++++++++---- src/commands/schema/cli-help.ts | 2 +- website/docs/docs/installation.md | 3 +- 8 files changed, 370 insertions(+), 43 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts 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 f6ceb35127..b51ebee934 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 @@ -250,6 +250,39 @@ test('only a runner that never accepted a connection indicts the cached artifact assert.equal(shouldRebuildCachedRunnerArtifact(commandFailed('fetch failed')), false); }); +test('a simulator service this macOS refuses to load is terminal on every recovery axis', () => { + // The reason the launch paths publish is the whole fact: this Xcode fails every launch on this + // macOS, so no restart, resend, replay, or clean rebuild may be ordered for it (#3449). + const blessRefusal = commandFailed('Runner did not accept connection', { + reason: 'simulator_service_hub_unavailable', + runnerConnectFailureReason: 'runner_connect_refused', + dispatched: 'no', + }); + assert.equal(shouldRetryRunnerConnectError(blessRefusal), false); + assert.equal(isRetryableRunnerError(blessRefusal), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(blessRefusal, SNAPSHOT), false); + assert.equal(shouldRebuildCachedRunnerArtifact(blessRefusal), false); + // The preflight's restart marker cannot outvote it either: the preflight would boot the same + // runner that just failed to load its service hub. + assert.equal( + shouldRestartRunnerAfterReadinessPreflight( + commandFailed('Runner readiness refused', { + reason: 'simulator_service_hub_unavailable', + runnerReadinessPreflightFailed: true, + }), + ), + false, + ); +}); + +test('a refused connection still restarts and rebuilds without the service-hub reason', () => { + // The closest negative: the same connect-shaped failure a host with a supported Xcode produces. + const refused = unwrittenConnectRefusal(); + assert.equal(shouldRetryRunnerConnectError(refused), true); + assert.equal(shouldRestartRunnerBeforeCommandSend(refused, TAP), true); + assert.equal(shouldRebuildCachedRunnerArtifact(refused), true); +}); + test('a device that is busy connecting is a terminal connect verdict', () => { assert.equal( shouldRetryRunnerConnectError(commandFailed('Device is busy (Connecting to Simulator)')), diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts index 8c520eceba..1ca3bd7228 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts @@ -15,6 +15,10 @@ import { IOS_SIMULATOR } from './device-fixtures.ts'; * * - `captured` — `output` was pasted from a run, and `command` plus `xcodeVersion` (from * `xcodebuild -version`) were recorded with it by `.device-evidence/CHECKLIST-runner-failures.md`. + * - `reported-log-excerpt` — `output` was pasted from a `runner.log` someone else ran, and the + * versions in `xcodeVersion` are the ones that reporter stated. No `command` and no checklist row: + * the host that produced it is not reachable from here, which is also why no run on this machine + * can promote such an entry to `captured`. * - `shipped-sniff-trigger` — the substrings a rule matches are the ones shipped in * `resolveSigningFailureHint` before #2680, which is evidence xcodebuild can emit them. The * sentence around them is ours, so `command` and `xcodeVersion` stay unrecorded. @@ -44,6 +48,7 @@ import { IOS_SIMULATOR } from './device-fixtures.ts'; export type RunnerStartupFailureSite = | 'build-for-testing' + | 'launch-test-without-building' | 'host-dev-tools-security' | 'device-readiness'; @@ -109,7 +114,7 @@ export type RunnerStartupFailureFixture = Readonly<{ command?: string; /** `xcodebuild -version` recorded from that run, or `unobserved`. */ xcodeVersion: string; - provenance: 'captured' | 'shipped-sniff-trigger' | 'invented-shape'; + provenance: 'captured' | 'reported-log-excerpt' | 'shipped-sniff-trigger' | 'invented-shape'; /** The tool's own stdout/stderr. */ output: string; /** The argv the exec reported, which is never evidence of a cause (#2680). */ @@ -390,6 +395,19 @@ export const RUNNER_STARTUP_FAILURE_FIXTURES: readonly RunnerStartupFailureFixtu deviceReport: { developerMode: 'enabled', developerDiskImage: 'unavailable' }, note: 'The decisive pairing, and the one #2682 used to answer with Developer Mode advice: the toggle is on and only the image is down. It is NOT a pre-build refusal (#2683 review): iOS 17+ mounts the image on demand during build and launch, so this report has to survive to a failure — which is what `unclassified-build-on-device-with-image-down` records. The enabled half is captured on some devices; a device waiting on device support has not been captured.', }, + { + id: 'simulator-service-hub-bless-refused', + reason: 'simulator_service_hub_unavailable', + site: 'launch-test-without-building', + xcodeVersion: 'Xcode 26.6 / Build version 17F113', + provenance: 'reported-log-excerpt', + output: + '2026-10-15 10:23:41.112 xcodebuild[40217:166902] [MT] iOSSimulator: unable to connect to "com.apple.instruments.deviceservice.lockdown"\n' + + '(Error Domain=DVTiPhoneSimulatorErrorDomain Code=-1 "DTServiceHubClient failed to bless service hub for simulator iPhone (abcdef0123456789abcdef0123456789abcdef01)" UserInfo={NSLocalizedDescription=DTServiceHubClient failed to bless service hub for simulator iPhone (abcdef0123456789abcdef0123456789abcdef01), NSUnderlyingError=0x1234 {Error Domain=DTServiceHubClient Code=1 "\'localDeviceConnectionWithError:\' failed but didn\'t provide an error."}})\n' + + '** TEST EXECUTE FAILED **\n' + + '** BUILD INTERRUPTED **\n', + note: 'Reported on #3449 from two hosts (macOS 27.2 with Xcode 26.6, macOS 27.2 with Xcode 26.5), pasted from `runner.log`. The issue quotes the bless line and the `DTServiceHubClient` line with their middles elided; the device description, the pids and pointers, and the repeated `NSLocalizedDescription` are filled in with placeholders in the shape Xcode writes, and `** BUILD INTERRUPTED **` is the line the report says follows. The row keys on the two phrases it quotes verbatim. Not inducible here: it needs an Xcode older than the host macOS supports, and the AMFI refusal behind it is in the kernel log (`com.apple.DTServiceHub disallowed without correct com.apple.private.amfi.version-restriction entitlement version`).', + }, { id: 'devtools-security-disabled', reason: 'devtools_security_developer_mode_disabled', diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts new file mode 100644 index 0000000000..cc1319dc3b --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, test, vi } from 'vitest'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; +import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; +import { buildRunnerEarlyExitError, waitForRunner } from '../runner-startup-transport.ts'; +import { readRunnerLogTail } from '../runner-io.ts'; +import { RunnerCommandAccounting, type RunnerSession } from '../runner-session-types.ts'; +import { + isRetryableRunnerError, + shouldRebuildCachedRunnerArtifact, + shouldRestartRunnerAfterReadinessPreflight, + shouldRestartRunnerBeforeCommandSend, + shouldRetryRunnerConnectError, +} from '../runner-error-classification.ts'; +import { RUNNER_ENDPOINT_FIXTURE, iosSimulator } from './runner-transport.fixtures.ts'; +import { buildFixtureById } from './runner-startup-failure-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +beforeEach(resetAllProcessMemosForTests); + +/** + * An Xcode older than the macOS it sits on cannot load its own `DTServiceHub`: AMFI refuses its + * version-restriction entitlement (#3449). Every `xcodebuild test-without-building` then writes the + * bless refusal into `runner.log` while the runner never serves a command, and the caller only ever + * saw `Runner did not accept connection` on a runner the request had just restarted. The cause is + * readable nowhere but that log, so both connect-stage failures have to read it, name it, and refuse + * the restart and resend they would otherwise order. These cases drive the real producers. + */ + +const BLESS_LOG = buildFixtureById('simulator-service-hub-bless-refused').output; + +function sessionWithLaunchLog(log: string, exitCode: number | null): RunnerSession { + const runnerLogPath = path.join(mkdtempForTestSync('runner-service-hub-'), 'runner.log'); + fs.writeFileSync(runnerLogPath, log); + return { + sessionId: 'service-hub-session', + device: iosSimulator, + deviceId: iosSimulator.id, + endpoint: RUNNER_ENDPOINT_FIXTURE, + xctestrunPath: '/tmp/runner.xctestrun', + jsonPath: '/tmp/runner.json', + runnerLogPath, + readLogTail: (maxBytes) => + readRunnerLogTail({ logPath: runnerLogPath, startOffset: 0 }, maxBytes), + testPromise: + exitCode === null + ? new Promise(() => {}) + : Promise.resolve({ exitCode, stdout: '', stderr: '' }), + child: { pid: 4242, exitCode } as ExecBackgroundResult['child'], + state: 'starting', + commandCharges: new RunnerCommandAccounting(), + }; +} + +/** The device states a simulator startup carries; the bless cause must outrank them (#2683). */ +const SIMULATOR_WITH_IMAGE_DOWN = { + developerMode: 'enabled' as const, + developerDiskImage: 'unavailable' as const, + developerDiskImageHint: 'Unlock the iPhone so it can mount the developer disk image.', +}; + +test('a launch that cannot bless the simulator service names the Xcode cause and its fix', async () => { + const session = { + ...sessionWithLaunchLog(BLESS_LOG, 65), + startupDeviceStates: SIMULATOR_WITH_IMAGE_DOWN, + }; + + const error = await buildRunnerEarlyExitError({ session, port: 8100 }); + const envelope = normalizeError(error, { diagnosticId: 'diag-service-hub' }); + + assert.equal(envelope.code, 'COMMAND_FAILED'); + assert.equal( + envelope.message, + 'Runner did not accept connection (xcodebuild exited early): this Xcode cannot start its simulator service on this macOS', + ); + assert.equal(envelope.details?.reason, 'simulator_service_hub_unavailable'); + // The fix is the daemon host's Xcode selection, so the hint has to name both levers for it. + assert.match(String(envelope.hint), /xcode-select -s/); + assert.match(String(envelope.hint), /DEVELOPER_DIR/); + // The bless line is in the runner's log, and that is the file the caller is sent to. + assert.equal(envelope.logPath, session.runnerLogPath); + assert.equal(envelope.diagnosticId, 'diag-service-hub'); + // A cause this log proved outranks a device state read before the launch; the state still travels. + assert.equal(envelope.details?.developerDiskImage, 'unavailable'); +}); + +test('a still-alive xcodebuild whose log carries the bless refusal fails the connect stage by name and stays terminal', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { + code: 'ECONNREFUSED', + }); + }), + ); + const session = sessionWithLaunchLog(BLESS_LOG, null); + + const error = await waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'tap', x: 200, y: 300 }, + undefined, + 300, + session, + ).catch((error: unknown) => error as AppError); + + assert.ok(error instanceof AppError); + assert.equal( + error.message, + 'Runner did not accept connection: this Xcode cannot start its simulator service on this macOS', + ); + assert.equal(error.details?.reason, 'simulator_service_hub_unavailable'); + assert.match(String(error.details?.hint), /xcode-select -s/); + assert.equal(error.details?.logPath, session.runnerLogPath); + // Nothing was written before the loop gave up, so the disclosure the refusal earns still holds. + assert.equal(error.details?.dispatched, 'no'); + // Before the cause had a name, this same failure ordered a session restart, a resend, and a clean + // rebuild of the very binary this macOS refuses to load (#3449). None of that may survive. + assert.equal(shouldRetryRunnerConnectError(error), false); + assert.equal(isRetryableRunnerError(error), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(error, { command: 'tap' }), false); + assert.equal(shouldRebuildCachedRunnerArtifact(error), false); +}); + +test('a still-alive xcodebuild whose log names no service-hub cause keeps the ordinary connect failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { + code: 'ECONNREFUSED', + }); + }), + ); + + await assert.rejects( + () => + waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + 300, + sessionWithLaunchLog( + '2026-10-15 10:23:41.112 xcodebuild[9:1] [MT] iOSSimulator: waiting for the simulator to boot\n', + null, + ), + ), + (error: unknown) => { + const appError = error as AppError; + assert.equal(appError.message, 'Runner did not accept connection'); + assert.equal(appError.details?.reason, 'IOS_RUNNER_CONNECT_TIMEOUT'); + return true; + }, + ); +}); + +test('bless wording without the typed reason cannot stop the connect retry or the resend', () => { + // The recovery rows key on the reason the launch paths publish, so foreign text carrying Xcode's + // own sentence — in a message or a captured log — cannot by itself mark a failure terminal. + const untyped = new AppError( + 'COMMAND_FAILED', + 'Runner did not accept connection: DTServiceHubClient failed to bless service hub for simulator iPhone (UDID)', + { stderr: BLESS_LOG }, + ); + + // The connect loop keeps waiting on it, exactly as it does on any unclassified connect failure. + assert.equal(shouldRetryRunnerConnectError(untyped), true); + assert.equal(shouldRestartRunnerAfterReadinessPreflight(untyped), false); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun-startup-failure-reasons.test.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun-startup-failure-reasons.test.ts index d2aa865755..980b3b42af 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun-startup-failure-reasons.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun-startup-failure-reasons.test.ts @@ -66,6 +66,7 @@ const HINT_FOR_REASON: Record = { signing_provisioning_profile_missing: 'AGENT_DEVICE_IOS_PROVISIONING_PROFILE', signing_unspecified: 'Automatic Signing', devtools_security_developer_mode_disabled: 'DevToolsSecurity -enable', + simulator_service_hub_unavailable: 'xcode-select -s', simulator_set_destination_not_found: '-DVTSimulatorSetLocation', // Both device remedies are owned by `core/devicectl.ts` and travel on the device report, so this // table quotes them instead of restating them; `runner-device-readiness.test.ts` is where the diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 025310c0a9..7f14c4b2e8 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -108,6 +108,13 @@ const hasRunnerConnectFailureReason = (reason: RunnerConnectFailureReason): RunnerErrorDetailsMatch => (details) => details.runnerConnectFailureReason === reason; +/** + * The terminal verdict of a runner launch whose simulator service this macOS refused to load. It keys + * on the reason the launch paths publish, never on the bless sentence they quote, so foreign text + * carrying Xcode's own wording cannot, on its own, mark a failure terminal. + */ +const hasSimulatorServiceHubFailure: RunnerErrorDetailsMatch = (details) => + details.reason === SIMULATOR_SERVICE_HUB_UNAVAILABLE_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 @@ -179,6 +186,7 @@ export const RUNNER_STARTUP_FAILURE_REASONS = [ 'signing_provisioning_profile_missing', 'signing_unspecified', 'devtools_security_developer_mode_disabled', + 'simulator_service_hub_unavailable', 'simulator_set_destination_not_found', ...RUNNER_DEVICE_READINESS_FAILURE_REASONS, 'build_failed_unclassified', @@ -190,6 +198,25 @@ export type RunnerStartupFailureReason = (typeof RUNNER_STARTUP_FAILURE_REASONS) export type RunnerDeviceReadinessFailureReason = (typeof RUNNER_DEVICE_READINESS_FAILURE_REASONS)[number]; +/** + * The reason for a simulator runner launch whose service hub the host macOS refused to load. macOS + * gates `DTServiceHub` behind a version-restriction entitlement, so an Xcode older than the macOS it + * runs on cannot bless the simulator service at all: every launch fails the same way until the host + * selects an Xcode that supports its macOS. AMFI's refusal is visible only as the bless line in the + * launch output, which is why the row keys on that phrase and why no host-side preflight exists. + */ +export const SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON: RunnerStartupFailureReason = + 'simulator_service_hub_unavailable'; + +/** + * Names the cause in the message beside the connect-shaped failure the bless refusal produced. The + * reason and hint come from {@link classifyRunnerStartupFailure}; this only folds the cause into the + * sentence the caller reads first. + */ +export function simulatorServiceHubFailureMessage(message: string): string { + return `${message}: this Xcode cannot start its simulator service on this macOS`; +} + /** * The reason a startup failure carries when no rule proves a cause. Its hint is deliberately the * cache-recovery advice rather than anything about signing: an unclassified build is not evidence of @@ -284,6 +311,19 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ }, verdicts: { retryable: false, connectRetry: false, artifactSuspect: false }, }, + { + // A boot whose simulator service the host refused is not cured by wiping derived data, and no + // restart or resend can make AMFI load a binary this macOS rejects. + reason: 'simulator_service_hub_unavailable', + match: { code: 'COMMAND_FAILED', details: hasSimulatorServiceHubFailure }, + verdicts: { + retryable: false, + connectRetry: false, + restartBeforeSend: false, + restartAfterReadinessPreflight: 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. @@ -393,6 +433,22 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ hint: 'Check that the simulator still exists in the --ios-simulator-device-set this error names (`xcrun simctl --set list devices`). If it does, the Xcode this error names no longer honors -DVTSimulatorSetLocation, so use a simulator in the default set.', }, }, + { + // macOS validates Xcode's `DTServiceHub` against a version-restriction entitlement and refuses a + // build older than itself (AMFI: "disallowed without correct + // com.apple.private.amfi.version-restriction entitlement version"), so no launch of this Xcode's + // simulator service can succeed on this host. Both phrases are Xcode's own sentence and meet on + // the same line of the launch output; the domain names the client, the complaint names the bless. + reason: 'simulator_service_hub_bless_refused', + match: { + toolTextLineIncludesAll: ['dtservicehubclient', 'failed to bless service hub'], + }, + verdicts: {}, + buildFailure: { + reason: SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON, + hint: 'This Xcode cannot start its simulator service on this macOS: macOS refuses to load its DTServiceHub. Select an Xcode that supports this macOS on the daemon host (`xcode-select -s /Applications/Xcode.app`, or DEVELOPER_DIR=/Applications/Xcode.app for the daemon process), then retry. Updating Xcode is the fix; restarting the runner or clearing derived data cannot change it.', + }, + }, { reason: 'bundle_identifier_registration_failed', match: { toolTextIncludesAll: ['failed registering bundle identifier'] }, diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index 436cedae03..108d7d6fa2 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -21,7 +21,9 @@ import { RUNNER_CACHE_RECOVERY_HINT, runnerConnectFailureDetails, shouldRetryRunnerConnectError, - type IosRunnerDeviceStates, + SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON, + simulatorServiceHubFailureMessage, + type RunnerStartupFailureReason, } from './runner-error-classification.ts'; import { type RunnerCommand, @@ -149,11 +151,12 @@ export async function waitForRunner( } throw withRunnerWriteEvidence( buildRunnerConnectError({ + device, port, url: route.url, logPath, lastError, - deviceStates: session?.startupDeviceStates, + session, }), commandMayHaveBeenWritten, ); @@ -395,31 +398,85 @@ export function resolveRunnerEarlyExitHint( return `${bootFailureHint(classified)} ${RUNNER_CACHE_RECOVERY_HINT}`; } +/** The cause a runner launch log names, in the shape both connect-stage failures publish. */ +type RunnerLaunchLogCause = Readonly<{ + message: string; + reason: RunnerStartupFailureReason; + hint: string; +}>; + +/** + * The cause readable from the launch output when the runner never served a command: a scoped-set + * destination this Xcode cannot resolve, or a simulator service this macOS refuses to load. One read + * of the text the failure is about to quote produces the reason, the message and the hint beside it, + * so the three cannot disagree about which cause this was (#3449). No cause is claimed for anything + * else the log holds: the boot-failure classification owns those, and a line nobody named stays a + * connect timeout. + */ +function resolveRunnerLaunchLogCause(params: { + message: string; + output: string; + device: DeviceInfo; +}): RunnerLaunchLogCause | undefined { + const simulatorSet = runnerSimulatorSetFailureDetails(params.device); + const classified = classifyRunnerStartupFailure( + new AppError('COMMAND_FAILED', params.message, { stderr: params.output, ...simulatorSet }), + ); + if (classified.reason === 'simulator_set_destination_not_found') { + return { + message: simulatorSetDestinationNotFoundMessage(params.message, params.device, simulatorSet), + reason: classified.reason, + hint: classified.hint, + }; + } + if (classified.reason === SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON) { + return { + message: simulatorServiceHubFailureMessage(params.message), + reason: classified.reason, + hint: classified.hint, + }; + } + return undefined; +} + function buildRunnerConnectError(params: { + device: DeviceInfo; port: number; url: string | null; logPath?: string; lastError: unknown; - deviceStates?: IosRunnerDeviceStates; + session?: RunnerConnectionSession; }): AppError { - const { port, url, logPath, lastError, deviceStates } = params; + const { device, port, url, logPath, lastError, session } = params; const message = 'Runner did not accept connection'; - const error = new AppError('COMMAND_FAILED', message, { + const launchLogCause = resolveRunnerLaunchLogCause({ + message, + output: session?.readLogTail?.(RUNNER_EARLY_EXIT_LOG_TAIL_BYTES) ?? '', + device, + }); + const error = new AppError('COMMAND_FAILED', launchLogCause?.message ?? message, { port, url, - logPath, + // A cause read from the launch log has to name the file the reader can check it against; the + // request's own log is not necessarily that file (#2681). + logPath: launchLogCause ? (session?.runnerLogPath ?? logPath) : logPath, lastError: lastError ? String(lastError) : undefined, - reason: classifyBootFailure({ - error: lastError, - message, - context: { platform: 'ios', phase: 'connect' }, - }), - hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'), + reason: + launchLogCause?.reason ?? + classifyBootFailure({ + error: lastError, + message, + context: { platform: 'ios', phase: 'connect' }, + }), + hint: launchLogCause?.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). - return enrichRunnerStartupFailureWithDeviceStates(error, deviceStates) as AppError; + return enrichRunnerStartupFailureWithDeviceStates( + error, + session?.startupDeviceStates, + ) as AppError; } export async function buildRunnerEarlyExitError(params: { @@ -440,37 +497,26 @@ export async function buildRunnerEarlyExitError(params: { context: { platform: 'ios', phase: 'connect' }, }); const simulatorSet = runnerSimulatorSetFailureDetails(session.device); - const setDestination = classifyRunnerStartupFailure( - new AppError('COMMAND_FAILED', message, { stderr: output, ...simulatorSet }), - ); - const setDestinationMissing = setDestination.reason === 'simulator_set_destination_not_found'; + const launchLogCause = resolveRunnerLaunchLogCause({ message, output, device: session.device }); // exec-guard-allow: xcodebuild can exit 0 and still count as an early exit; // the trio is nested tool context under `xcodebuild`, classified into // `reason`/`hint` above — not a process-exit wrap. - const error = new AppError( - 'COMMAND_FAILED', - setDestinationMissing - ? simulatorSetDestinationNotFoundMessage(message, session.device, simulatorSet) - : message, - { - port, - // The quote always comes from the runner's own file, so that is the file the error has to name; - // pointing at the request's log would advertise a file that does not contain what is quoted (#2681). - logPath: session.runnerLogPath ?? logPath, - xcodebuild: { - exitCode: result.exitCode, - // One merged file since #2681: the tail is reported under `stderr`, which is where readers - // already look, next to the file it came from. - stderr: output, - }, - reason: setDestinationMissing ? setDestination.reason : reason, - hint: setDestinationMissing - ? setDestination.hint - : resolveRunnerEarlyExitHint(message, output, output, reason), - ...simulatorSet, - ...runnerConnectFailureDetails('xcodebuild_exited_early'), + const error = new AppError('COMMAND_FAILED', launchLogCause?.message ?? message, { + port, + // The quote always comes from the runner's own file, so that is the file the error has to name; + // pointing at the request's log would advertise a file that does not contain what is quoted (#2681). + logPath: session.runnerLogPath ?? logPath, + xcodebuild: { + exitCode: result.exitCode, + // One merged file since #2681: the tail is reported under `stderr`, which is where readers + // already look, next to the file it came from. + stderr: output, }, - ); + reason: launchLogCause?.reason ?? reason, + hint: launchLogCause?.hint ?? resolveRunnerEarlyExitHint(message, output, output, reason), + ...simulatorSet, + ...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 // catch and the disk-image state read before the build would be dropped. Same enrichment, applied diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 62fec6a4a6..bcdf459321 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -496,7 +496,7 @@ iOS physical-device prerequisites: If Xcode cannot choose a profile, set AGENT_DEVICE_IOS_PROVISIONING_PROFILE to the profile name/specifier, not a file path. AGENT_DEVICE_IOS_SIGNING_IDENTITY is optional; omit it unless xcodebuild asks for a specific identity. The profile/team must allow AGENT_DEVICE_IOS_BUNDLE_ID and .uitests. - A runner startup failure names its class in error details.reason rather than only in prose: signing_no_development_team, signing_provisioning_profile_missing, bundle_identifier_already_registered, signing_unspecified, devtools_security_developer_mode_disabled (the Mac's DevToolsSecurity setting, which says nothing about the device's Developer Mode toggle), device_developer_mode_disabled (read from an iPhone's own report before the runner builds, and the only device state that stops a run up front), device_developer_disk_image_unavailable (also read from the device, and published on a startup failure that named no cause of its own, since iOS 17+ mounts the developer disk image on demand during build and launch rather than gating the build), or build_failed_unclassified when nothing proved a cause. The two device reasons are never inferred from tool output or from each other. Branch on details.reason and follow hint; the message is for humans. + A runner startup failure names its class in error details.reason rather than only in prose: signing_no_development_team, signing_provisioning_profile_missing, bundle_identifier_already_registered, signing_unspecified, devtools_security_developer_mode_disabled (the Mac's DevToolsSecurity setting, which says nothing about the device's Developer Mode toggle), simulator_service_hub_unavailable (an Xcode too old for the host macOS: macOS refuses to load its DTServiceHub, so every runner launch fails; select an Xcode that supports this macOS on the daemon host), device_developer_mode_disabled (read from an iPhone's own report before the runner builds, and the only device state that stops a run up front), device_developer_disk_image_unavailable (also read from the device, and published on a startup failure that named no cause of its own, since iOS 17+ mounts the developer disk image on demand during build and launch rather than gating the build), or build_failed_unclassified when nothing proved a cause. The two device reasons are never inferred from tool output or from each other. Branch on details.reason and follow hint; the message is for humans. First-run XCTest setup/build can take longer than normal commands; keep the device connected and use --debug to inspect signing/build diagnostics if setup times out. Android physical-device prerequisites: diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index c76c2e3fcc..ceea9564d3 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -112,7 +112,8 @@ Run `agent-device help physical-device` for the same setup guidance from the CLI - Set `AGENT_DEVICE_IOS_SIGNING_IDENTITY` only when `xcodebuild` asks for a specific identity. - The profile and team must allow both `AGENT_DEVICE_IOS_BUNDLE_ID` and `.uitests`. - Free Apple Developer (Personal Team) accounts can fail with "bundle identifier is not available" for generic IDs. Set `AGENT_DEVICE_IOS_BUNDLE_ID` to a unique reverse-DNS value (for example `com.yourname.agentdevice.runner`). -- When the runner fails to start, `error.details.reason` is one of `signing_no_development_team`, `signing_provisioning_profile_missing`, `bundle_identifier_already_registered`, `signing_unspecified`, `devtools_security_developer_mode_disabled` (the Mac's `DevToolsSecurity` setting, which says nothing about the device's Developer Mode toggle), `device_developer_mode_disabled`, `device_developer_disk_image_unavailable`, or `build_failed_unclassified` when the cause is unknown. Branch on `details.reason` and follow `hint`; the error code is `COMMAND_FAILED` for every reason. +- When the runner fails to start, `error.details.reason` is one of `signing_no_development_team`, `signing_provisioning_profile_missing`, `bundle_identifier_already_registered`, `signing_unspecified`, `devtools_security_developer_mode_disabled` (the Mac's `DevToolsSecurity` setting, which says nothing about the device's Developer Mode toggle), `simulator_service_hub_unavailable`, `device_developer_mode_disabled`, `device_developer_disk_image_unavailable`, or `build_failed_unclassified` when the cause is unknown. Branch on `details.reason` and follow `hint`; the error code is `COMMAND_FAILED` for every reason. +- `simulator_service_hub_unavailable` means the selected Xcode is too old for the host macOS: macOS refuses to load that Xcode's `DTServiceHub`, so every iOS Simulator runner start fails. Commands the runner doesn't serve keep working — `open`, and a `snapshot` or `screenshot` the simulator bridge answers — while a capture the runner has to serve (`snapshot --actions`, `snapshot --observe-only`, or one the bridge can't answer) fails the same way. The `DTServiceHub` refusal is visible in `runner.log`. Fix it on the machine running the daemon: `xcode-select -s /Applications/Xcode.app`, or start the daemon with `DEVELOPER_DIR=/Applications/Xcode.app`, pointing at an Xcode that supports this macOS. The failure is not retried and the runner cache is not rebuilt, because neither can change which Xcode the host can load. - The two `device_*` reasons come from the device itself (`xcrun devicectl device info details`), checked before the runner builds. `device_developer_mode_disabled` means the Developer Mode toggle in Settings is off; turn it on. `device_developer_disk_image_unavailable` means Developer Mode is on but the developer disk image isn't available yet, usually because device support is still installing; wait and retry. - The first run builds the Apple runner and takes longer than later commands. Keep the device connected; if setup times out, retry with `--debug` to see signing and build diagnostics. - If you set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` together with `AGENT_DEVICE_IOS_CLEAN_DERIVED`, keep the path in a subdirectory of the project's `.tmp/` directory. For any other path, the command fails instead of cleaning it, and the error hint names both fixes. From d91886f7685bf99dfe8d405c0d5b3315358b5fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 19:52:58 +0200 Subject: [PATCH 2/4] fix(apple): fail a bless-refused runner launch at the connect attempt The refusal names its cause only in runner.log, and the classifier read that log after the connect loop had spent its whole deadline, so the reporter's ~90 seconds of futile retries survived the first fix. Read the launch tail at each attempt while the session has yet to answer and stop there; the tail is bounded to what an error would quote, read from this generation's offset, and costs no subprocess. Also completes the documented reason vocabulary, which was missing simulator_set_destination_not_found at both prose surfaces, and guards it: the union in runner-error-classification.ts is now checked against both by test/repo-guards/runner-startup-reason-docs.test.ts. Refs #3449, #3739 review round 1. Co-Authored-By: Claude --- .../runner-startup-failure-fixtures.ts | 8 +- ...nner-startup-transport-service-hub.test.ts | 104 +++++++++++++++++- .../src/runner/runner-error-classification.ts | 26 ++++- .../src/runner/runner-startup-transport.ts | 27 ++++- src/commands/schema/cli-help.ts | 2 +- .../runner-startup-reason-docs.test.ts | 88 +++++++++++++++ website/docs/docs/installation.md | 4 +- 7 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 test/repo-guards/runner-startup-reason-docs.test.ts diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts index 1ca3bd7228..2ceec53fd0 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts @@ -25,9 +25,11 @@ import { IOS_SIMULATOR } from './device-fixtures.ts'; * - `invented-shape` — no shipped trigger and no capture. The entry exists to exercise a rule and * makes no claim about wording xcodebuild prints. * - * Until Phase B captures the real runs, every entry is `unobserved` for `xcodeVersion` and carries - * no `command`: an invocation we did not run is not provenance. Nothing in the classifier reads - * these fields; they exist so a reason can be traced to an observation instead of to a guess. + * The two kinds with no observation behind the invocation — `shipped-sniff-trigger` and + * `invented-shape` — are `unobserved` for `xcodeVersion` and carry no `command`: an invocation we did + * not run is not provenance. `captured` records both, and `reported-log-excerpt` records the version + * its reporter stated and no command. Nothing in the classifier reads these fields; they exist so a + * reason can be traced to an observation instead of to a guess. * * Blocked on the host, not unexamined. One Phase B run on `thymikee-iphone` / Xcode 26.2 settled the * shape question these rows were held on — `No profiles for '' were found` arrives as one long diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts index cc1319dc3b..0f146b5c51 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts @@ -15,7 +15,11 @@ import { shouldRestartRunnerBeforeCommandSend, shouldRetryRunnerConnectError, } from '../runner-error-classification.ts'; -import { RUNNER_ENDPOINT_FIXTURE, iosSimulator } from './runner-transport.fixtures.ts'; +import { + RUNNER_ENDPOINT_FIXTURE, + iosSimulator, + runnerTransportResponse, +} from './runner-transport.fixtures.ts'; import { buildFixtureById } from './runner-startup-failure-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; @@ -125,6 +129,104 @@ test('a still-alive xcodebuild whose log carries the bless refusal fails the con assert.equal(shouldRebuildCachedRunnerArtifact(error), false); }); +/** A connect budget far longer than the bless refusal needs, so its arrival is a claim about timing. */ +const CONNECT_BUDGET_MS = 5_000; + +test('a bless refusal already in the launch log ends the connect loop on its first attempt', async () => { + // The reporters' 90 seconds came from spending the whole connect budget on a boot that had already + // said why it would never serve a command (#3449). The signature appears in `runner.log` within the + // first seconds of the launch, so the loop has to read it and stop. + const fetchSpy = vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { code: 'ECONNREFUSED' }); + }); + vi.stubGlobal('fetch', fetchSpy); + const session = sessionWithLaunchLog(BLESS_LOG, null); + const startedAt = Date.now(); + + const error = await waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + CONNECT_BUDGET_MS, + session, + ).catch((error: unknown) => error as AppError); + + const elapsedMs = Date.now() - startedAt; + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'simulator_service_hub_unavailable'); + // No connect attempt is dialed once the boot is known terminal, and the failure arrives with most + // of the budget unspent rather than at its deadline. + assert.equal(fetchSpy.mock.calls.length, 0); + assert.ok( + elapsedMs < CONNECT_BUDGET_MS / 2, + `expected the failure well before its ${CONNECT_BUDGET_MS}ms deadline, took ${elapsedMs}ms`, + ); +}); + +test('a bless refusal in an older runner generation does not end this launch early', async () => { + // The log is append-only across launches, so a bless refusal the previous launch wrote is not + // evidence about this one (#2681). The session's tail reader starts at this generation's offset. + const runnerLogPath = path.join(mkdtempForTestSync('runner-service-hub-stale-'), 'runner.log'); + fs.writeFileSync(runnerLogPath, BLESS_LOG); + const generationStartOffset = BLESS_LOG.length; + fs.appendFileSync( + runnerLogPath, + '2026-10-15 10:24:41.112 xcodebuild[9:1] [MT] iOSSimulator: waiting for the simulator to boot\n', + ); + const session = { + ...sessionWithLaunchLog('', null), + runnerLogPath, + readLogTail: (maxBytes: number) => + readRunnerLogTail({ logPath: runnerLogPath, startOffset: generationStartOffset }, maxBytes), + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { + code: 'ECONNREFUSED', + }); + }), + ); + + await assert.rejects( + () => + waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + 300, + session, + ), + (error: unknown) => { + // The ordinary connect failure, on the ordinary deadline: nothing named this boot. + assert.equal((error as AppError).details?.reason, 'IOS_RUNNER_CONNECT_TIMEOUT'); + return true; + }, + ); +}); + +test('a runner that already answered is not re-judged by its launch log', async () => { + // A bless line written during a boot that did serve commands (an Xcode fixed mid-session, say) must + // not refuse a later command on the strength of it. + const fetchSpy = vi.fn(async () => runnerTransportResponse()); + vi.stubGlobal('fetch', fetchSpy); + const session = { ...sessionWithLaunchLog(BLESS_LOG, null), state: 'ready' as const }; + + const response = await waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + 300, + session, + ); + + assert.equal(response.status, 200); + assert.equal(fetchSpy.mock.calls.length, 1); +}); + test('a still-alive xcodebuild whose log names no service-hub cause keeps the ordinary connect failure', async () => { vi.stubGlobal( 'fetch', diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 7f14c4b2e8..d54821bd67 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -208,6 +208,30 @@ export type RunnerDeviceReadinessFailureReason = export const SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON: RunnerStartupFailureReason = 'simulator_service_hub_unavailable'; +/** + * The bless refusal, in the two phrases Xcode writes on one line: the client that failed and the + * bless it failed to perform. Declared once because the classifier row reads it and the connect loop + * checks the launch tail for it; the two cannot disagree about what a terminal boot looks like. The + * loop's check must stay no broader than the row's, or it would order a verdict the row refuses to + * prove. + */ +const SIMULATOR_SERVICE_HUB_BLESS_MATCH = [ + 'dtservicehubclient', + 'failed to bless service hub', +] as const; + +/** + * Whether runner launch output carries the bless refusal. The connect loop asks this before it pays + * for a full startup classification, which resolves a simulator-set cause through a toolchain probe. + * Same one-line rule the row applies, over the same text. + */ +export function isSimulatorServiceHubLaunchOutput(output: string): boolean { + const normalized = output.toLowerCase(); + return normalized + .split('\n') + .some((line) => SIMULATOR_SERVICE_HUB_BLESS_MATCH.every((part) => line.includes(part))); +} + /** * Names the cause in the message beside the connect-shaped failure the bless refusal produced. The * reason and hint come from {@link classifyRunnerStartupFailure}; this only folds the cause into the @@ -441,7 +465,7 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ // the same line of the launch output; the domain names the client, the complaint names the bless. reason: 'simulator_service_hub_bless_refused', match: { - toolTextLineIncludesAll: ['dtservicehubclient', 'failed to bless service hub'], + toolTextLineIncludesAll: SIMULATOR_SERVICE_HUB_BLESS_MATCH, }, verdicts: {}, buildFailure: { diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index 108d7d6fa2..2950e44067 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -21,6 +21,7 @@ import { RUNNER_CACHE_RECOVERY_HINT, runnerConnectFailureDetails, shouldRetryRunnerConnectError, + isSimulatorServiceHubLaunchOutput, SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON, simulatorServiceHubFailureMessage, type RunnerStartupFailureReason, @@ -41,6 +42,7 @@ export type RunnerConnectionSession = Pick< | 'readLogTail' | 'device' | 'runnerLogPath' + | 'state' >; import { runnerSimulatorSetFailureDetails, @@ -220,6 +222,7 @@ async function attemptRunnerConnection(params: { async function ensureRunnerAttemptCanStart(params: { endpoint: RunnerEndpoint; + route: RunnerCommandRoute; timeoutMs: number; logPath?: string; session?: RunnerConnectionSession; @@ -238,6 +241,25 @@ async function ensureRunnerAttemptCanStart(params: { logPath: params.logPath, }); } + // A launch whose simulator service this macOS refused never serves a command, and its child stays + // alive to the deadline, so without this the request waits out the whole connect budget before + // reaching the classifier that names the cause (#3449). Checked on each attempt while the session + // has yet to answer: once a runner has answered, its log is no longer evidence about a boot that + // demonstrably succeeded. Bounded to the tail an error would quote, read from this generation's + // offset, and no child process is spawned for it. + const session = params.session; + if (session?.state !== 'starting') return; + const launchLogOutput = session.readLogTail?.(RUNNER_EARLY_EXIT_LOG_TAIL_BYTES) ?? ''; + if (!isSimulatorServiceHubLaunchOutput(launchLogOutput)) return; + throw buildRunnerConnectError({ + device: session.device, + port: params.endpoint.port, + url: params.route.url, + logPath: params.logPath, + lastError: null, + session, + launchLogOutput, + }); } async function tryPrimaryRunnerRoute(params: { @@ -446,12 +468,15 @@ function buildRunnerConnectError(params: { logPath?: string; lastError: unknown; session?: RunnerConnectionSession; + /** The launch tail already read for this decision; read here when it is absent. */ + launchLogOutput?: string; }): AppError { const { device, port, url, logPath, lastError, session } = params; const message = 'Runner did not accept connection'; const launchLogCause = resolveRunnerLaunchLogCause({ message, - output: session?.readLogTail?.(RUNNER_EARLY_EXIT_LOG_TAIL_BYTES) ?? '', + output: + params.launchLogOutput ?? session?.readLogTail?.(RUNNER_EARLY_EXIT_LOG_TAIL_BYTES) ?? '', device, }); const error = new AppError('COMMAND_FAILED', launchLogCause?.message ?? message, { diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index bcdf459321..9db8680ddb 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -496,7 +496,7 @@ iOS physical-device prerequisites: If Xcode cannot choose a profile, set AGENT_DEVICE_IOS_PROVISIONING_PROFILE to the profile name/specifier, not a file path. AGENT_DEVICE_IOS_SIGNING_IDENTITY is optional; omit it unless xcodebuild asks for a specific identity. The profile/team must allow AGENT_DEVICE_IOS_BUNDLE_ID and .uitests. - A runner startup failure names its class in error details.reason rather than only in prose: signing_no_development_team, signing_provisioning_profile_missing, bundle_identifier_already_registered, signing_unspecified, devtools_security_developer_mode_disabled (the Mac's DevToolsSecurity setting, which says nothing about the device's Developer Mode toggle), simulator_service_hub_unavailable (an Xcode too old for the host macOS: macOS refuses to load its DTServiceHub, so every runner launch fails; select an Xcode that supports this macOS on the daemon host), device_developer_mode_disabled (read from an iPhone's own report before the runner builds, and the only device state that stops a run up front), device_developer_disk_image_unavailable (also read from the device, and published on a startup failure that named no cause of its own, since iOS 17+ mounts the developer disk image on demand during build and launch rather than gating the build), or build_failed_unclassified when nothing proved a cause. The two device reasons are never inferred from tool output or from each other. Branch on details.reason and follow hint; the message is for humans. + A runner startup failure names its class in error details.reason rather than only in prose: signing_no_development_team, signing_provisioning_profile_missing, bundle_identifier_already_registered, signing_unspecified, devtools_security_developer_mode_disabled (the Mac's DevToolsSecurity setting, which says nothing about the device's Developer Mode toggle), simulator_service_hub_unavailable (an Xcode too old for the host macOS: macOS refuses to load its DTServiceHub, so every runner launch fails; the command stops as soon as the refusal reaches runner.log, and selecting an Xcode that supports this macOS on the daemon host is the fix), simulator_set_destination_not_found (a --ios-simulator-device-set simulator the selected Xcode cannot resolve; names the set), device_developer_mode_disabled (read from an iPhone's own report before the runner builds, and the only device state that stops a run up front), device_developer_disk_image_unavailable (also read from the device, and published on a startup failure that named no cause of its own, since iOS 17+ mounts the developer disk image on demand during build and launch rather than gating the build), or build_failed_unclassified when nothing proved a cause. The two device reasons are never inferred from tool output or from each other. Branch on details.reason and follow hint; the message is for humans. First-run XCTest setup/build can take longer than normal commands; keep the device connected and use --debug to inspect signing/build diagnostics if setup times out. Android physical-device prerequisites: diff --git a/test/repo-guards/runner-startup-reason-docs.test.ts b/test/repo-guards/runner-startup-reason-docs.test.ts new file mode 100644 index 0000000000..4195c89d65 --- /dev/null +++ b/test/repo-guards/runner-startup-reason-docs.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'vitest'; + +/** + * `error.details.reason` is a published vocabulary: a client that branches on it treats an + * undocumented member as an unknown value. Nothing but this file connects the union to the two + * surfaces that enumerate it, so a new reason added to the classifier has to be documented in the + * same change. The reason union is the source of truth; the docs are read as text because they carry + * the prose a reader follows, not a machine-readable list. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const classificationPath = path.join( + repoRoot, + 'packages/platform-apple/src/runner/runner-error-classification.ts', +); +const installationDocPath = path.join(repoRoot, 'website/docs/docs/installation.md'); +const cliHelpPath = path.join(repoRoot, 'src/commands/schema/cli-help.ts'); + +/** The two reasons the device reports about itself, spread into the startup union. */ +const DEVICE_READINESS_MEMBERS = 'RUNNER_DEVICE_READINESS_FAILURE_REASONS'; + +test('the runner startup reason union is documented in full at both prose surfaces', () => { + const reasons = startupFailureReasons(); + + for (const { surface, line } of [ + { surface: 'installation.md', line: startupReasonSentence(readFile(installationDocPath)) }, + { surface: 'cli-help.ts', line: startupReasonSentence(readFile(cliHelpPath)) }, + ]) { + for (const reason of reasons) { + assert.ok( + line.includes(reason), + `${surface} must document the ${reason} reason: the union in ` + + `${path.relative(repoRoot, classificationPath)} publishes it and a client cannot branch on ` + + 'a value it was never told exists.', + ); + } + } +}); + +/** + * Every member of `RUNNER_STARTUP_FAILURE_REASONS`, including the ones spread in from the + * device-readiness list. Anything the two lists no longer read as fails rather than silently + * shrinking the set the guard checks. + */ +function startupFailureReasons(): string[] { + const source = readFile(classificationPath); + const reasons: string[] = []; + for (const member of literalMembers(source, 'RUNNER_STARTUP_FAILURE_REASONS')) { + if (!member.startsWith('...')) { + reasons.push(member); + continue; + } + assert.equal( + member, + `...${DEVICE_READINESS_MEMBERS}`, + `unknown spread in RUNNER_STARTUP_FAILURE_REASONS: ${member}`, + ); + reasons.push(...literalMembers(source, DEVICE_READINESS_MEMBERS)); + } + assert.ok(reasons.length > 0, 'the startup reason union must not read as empty'); + return reasons; +} + +/** The members of one `export const X = [...] as const;` literal, in source order. */ +function literalMembers(source: string, name: string): string[] { + const declaration = new RegExp(`export const ${name} = \\[([\\s\\S]*?)\\] as const`).exec(source); + assert.ok(declaration, `${name} no longer reads as a string-literal union`); + return [...declaration[1]!.matchAll(/'([a-z_]+)'|\.\.\.([A-Z_]+)/g)].map((match) => + match[1] === undefined ? `...${match[2]}` : match[1]!, + ); +} + +/** The sentence that enumerates the startup reasons, found on the same line at both surfaces. */ +function startupReasonSentence(text: string): string { + const line = text + .split('\n') + .find((candidate) => /error details\.reason|`error\.details\.reason`/.test(candidate)); + assert.ok(line, 'no sentence enumerating error.details.reason was found'); + return line; +} + +function readFile(absolutePath: string): string { + return fs.readFileSync(absolutePath, 'utf8'); +} diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index ceea9564d3..5c474502a1 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -112,8 +112,8 @@ Run `agent-device help physical-device` for the same setup guidance from the CLI - Set `AGENT_DEVICE_IOS_SIGNING_IDENTITY` only when `xcodebuild` asks for a specific identity. - The profile and team must allow both `AGENT_DEVICE_IOS_BUNDLE_ID` and `.uitests`. - Free Apple Developer (Personal Team) accounts can fail with "bundle identifier is not available" for generic IDs. Set `AGENT_DEVICE_IOS_BUNDLE_ID` to a unique reverse-DNS value (for example `com.yourname.agentdevice.runner`). -- When the runner fails to start, `error.details.reason` is one of `signing_no_development_team`, `signing_provisioning_profile_missing`, `bundle_identifier_already_registered`, `signing_unspecified`, `devtools_security_developer_mode_disabled` (the Mac's `DevToolsSecurity` setting, which says nothing about the device's Developer Mode toggle), `simulator_service_hub_unavailable`, `device_developer_mode_disabled`, `device_developer_disk_image_unavailable`, or `build_failed_unclassified` when the cause is unknown. Branch on `details.reason` and follow `hint`; the error code is `COMMAND_FAILED` for every reason. -- `simulator_service_hub_unavailable` means the selected Xcode is too old for the host macOS: macOS refuses to load that Xcode's `DTServiceHub`, so every iOS Simulator runner start fails. Commands the runner doesn't serve keep working — `open`, and a `snapshot` or `screenshot` the simulator bridge answers — while a capture the runner has to serve (`snapshot --actions`, `snapshot --observe-only`, or one the bridge can't answer) fails the same way. The `DTServiceHub` refusal is visible in `runner.log`. Fix it on the machine running the daemon: `xcode-select -s /Applications/Xcode.app`, or start the daemon with `DEVELOPER_DIR=/Applications/Xcode.app`, pointing at an Xcode that supports this macOS. The failure is not retried and the runner cache is not rebuilt, because neither can change which Xcode the host can load. +- When the runner fails to start, `error.details.reason` is one of `signing_no_development_team`, `signing_provisioning_profile_missing`, `bundle_identifier_already_registered`, `signing_unspecified`, `devtools_security_developer_mode_disabled` (the Mac's `DevToolsSecurity` setting, which says nothing about the device's Developer Mode toggle), `simulator_service_hub_unavailable`, `simulator_set_destination_not_found` (a `--ios-simulator-device-set` simulator the selected Xcode cannot resolve, which names the set), `device_developer_mode_disabled`, `device_developer_disk_image_unavailable`, or `build_failed_unclassified` when the cause is unknown. Branch on `details.reason` and follow `hint`; the error code is `COMMAND_FAILED` for every reason. +- `simulator_service_hub_unavailable` means the selected Xcode is too old for the host macOS: macOS refuses to load that Xcode's `DTServiceHub`, so every iOS Simulator runner start fails. Commands the runner doesn't serve keep working — `open`, and a `snapshot` or `screenshot` the simulator bridge answers — while a capture the runner has to serve (`snapshot --actions`, `snapshot --observe-only`, or one the bridge can't answer) fails the same way. The `DTServiceHub` refusal is visible in `runner.log`. Fix it on the machine running the daemon: `xcode-select -s /Applications/Xcode.app`, or start the daemon with `DEVELOPER_DIR=/Applications/Xcode.app`, pointing at an Xcode that supports this macOS. The command fails as soon as the refusal reaches `runner.log` instead of waiting out the connect timeout, and the failure is not retried or rebuilt, because neither can change which Xcode the host can load. - The two `device_*` reasons come from the device itself (`xcrun devicectl device info details`), checked before the runner builds. `device_developer_mode_disabled` means the Developer Mode toggle in Settings is off; turn it on. `device_developer_disk_image_unavailable` means Developer Mode is on but the developer disk image isn't available yet, usually because device support is still installing; wait and retry. - The first run builds the Apple runner and takes longer than later commands. Keep the device connected; if setup times out, retry with `--debug` to see signing and build diagnostics. - If you set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` together with `AGENT_DEVICE_IOS_CLEAN_DERIVED`, keep the path in a subdirectory of the project's `.tmp/` directory. For any other path, the command fails instead of cleaning it, and the error hint names both fixes. From bd0cae4ce049b0fc5ffd95ab465745abac043ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 21:55:16 +0200 Subject: [PATCH 3/4] refactor(apple): let the classifier own the launch-log verdict The connect loop decided a boot was terminal with its own bless-signature check, a copy of the row's line matching that ignored row order. On a scoped-set launch whose tail held both signatures the loop stopped while the built error carried `simulator_set_destination_not_found`, whose verdict was retryable, so each remaining attempt re-threw without dialing until the deadline. Now the verdict is whatever `classifyRunnerStartupFailure` returns over the tail: a row declares the cause it names in the message, the classifier returns it, and the loop throws the built error only when `shouldRetryRunnerConnectError` says a retry cannot help. The scoped-set destination cause gets the same fast path, the transport knows no hub specifics, and the two message helpers and the signature predicate are gone. Refs #3449, #3739 review round 2. Co-Authored-By: Claude --- .../runner-error-classification.test.ts | 35 ++++++++ ...nner-startup-transport-service-hub.test.ts | 52 ++++++++++- .../src/runner/runner-artifact.ts | 42 +++++---- .../src/runner/runner-device-set.ts | 17 ++-- .../src/runner/runner-error-classification.ts | 89 +++++++++++-------- .../src/runner/runner-startup-transport.ts | 62 ++++++------- 6 files changed, 196 insertions(+), 101 deletions(-) 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 b51ebee934..e4e715600e 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 @@ -283,6 +283,41 @@ test('a refused connection still restarts and rebuilds without the service-hub r assert.equal(shouldRebuildCachedRunnerArtifact(refused), true); }); +test('a scoped-set destination this Xcode cannot resolve is terminal on every recovery axis', () => { + // The other cause a launch log names. The set is on the command, so a retry, a restart, or a fresh + // build resolves nothing, and the connect loop has to stop on the attempt that reads the log — the + // same terminal reading the service-hub cause gets. + const destinationFailure = commandFailed('Runner did not accept connection', { + reason: 'simulator_set_destination_not_found', + runnerConnectFailureReason: 'runner_connect_refused', + dispatched: 'no', + simulatorSetPath: '/Users/dev/Library/Developer/CoreSimulator/Sets/foo.json', + }); + assert.equal(shouldRetryRunnerConnectError(destinationFailure), false); + assert.equal(isRetryableRunnerError(destinationFailure), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(destinationFailure, SNAPSHOT), false); + assert.equal(shouldRebuildCachedRunnerArtifact(destinationFailure), false); + assert.equal( + shouldRestartRunnerAfterReadinessPreflight( + commandFailed('Runner readiness refused', { + reason: 'simulator_set_destination_not_found', + runnerReadinessPreflightFailed: true, + }), + ), + false, + ); +}); + +test('the reason in foreign text marks nothing terminal', () => { + // The verdict keys on the reason the launch paths publish. A runner response or tool transcript that + // merely repeats the words cannot order a failure non-retryable. + const echoed = commandFailed( + 'Runner did not accept connection: simulator_set_destination_not_found', + { stderr: 'DTServiceHubClient failed to bless service hub for simulator' }, + ); + assert.equal(shouldRetryRunnerConnectError(echoed), true); +}); + test('a device that is busy connecting is a terminal connect verdict', () => { assert.equal( shouldRetryRunnerConnectError(commandFailed('Device is busy (Connecting to Simulator)')), diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts index 0f146b5c51..8ed8d1e5e7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts @@ -20,7 +20,11 @@ import { iosSimulator, runnerTransportResponse, } from './runner-transport.fixtures.ts'; -import { buildFixtureById } from './runner-startup-failure-fixtures.ts'; +import { + CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, + CAPTURED_SCOPED_SIMULATOR, + buildFixtureById, +} from './runner-startup-failure-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; beforeEach(resetAllProcessMemosForTests); @@ -164,6 +168,52 @@ test('a bless refusal already in the launch log ends the connect loop on its fir ); }); +test("one tail holding two launch-log causes gets the classifier's own winner, terminal", async () => { + // The row order decides, not the loop. A scoped-set launch whose log holds both the bless refusal and + // the missing destination reads as `simulator_set_destination_not_found`, the row that comes first. + // A pre-check of the loop's own would see the bless, order the stop, and hand back an error whose + // reason says otherwise — and the first fix did exactly that, so each remaining attempt re-threw + // without dialing until the deadline. One classifier over the tail cannot produce that pair. + const fetchSpy = vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { code: 'ECONNREFUSED' }); + }); + vi.stubGlobal('fetch', fetchSpy); + const scopedSimulator = { + ...iosSimulator, + id: CAPTURED_SCOPED_SIMULATOR.udid, + simulatorSetPath: CAPTURED_SCOPED_SIMULATOR.setWithoutUdid, + }; + const session = { + ...sessionWithLaunchLog(`${CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT}\n${BLESS_LOG}`, null), + device: scopedSimulator, + deviceId: scopedSimulator.id, + }; + const startedAt = Date.now(); + + const error = await waitForRunner( + scopedSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + CONNECT_BUDGET_MS, + session, + ).catch((error: unknown) => error as AppError); + + const elapsedMs = Date.now() - startedAt; + assert.ok(error instanceof AppError); + // The reason and the message agree because one row produced both, and the verdict beside them is the + // terminal one that class shares, so the loop stopped rather than re-throwing at the deadline. + assert.equal(error.details?.reason, 'simulator_set_destination_not_found'); + assert.ok(error.message.includes(CAPTURED_SCOPED_SIMULATOR.setWithoutUdid), error.message); + assert.doesNotMatch(error.message, /simulator service/); + assert.equal(shouldRetryRunnerConnectError(error), false); + assert.equal(fetchSpy.mock.calls.length, 0); + assert.ok( + elapsedMs < CONNECT_BUDGET_MS / 2, + `expected the failure well before its ${CONNECT_BUDGET_MS}ms deadline, took ${elapsedMs}ms`, + ); +}); + test('a bless refusal in an older runner generation does not end this launch early', async () => { // The log is append-only across launches, so a bless refusal the previous launch wrote is not // evidence about this one (#2681). The session's tail reader starts at this generation's offset. diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index 2db0996401..b15275d240 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -21,8 +21,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { classifyRunnerStartupFailure } from './runner-error-classification.ts'; import { logChunk } from './runner-io.ts'; import { + runnerLaunchLogClassificationDetails, runnerSimulatorSetFailureDetails, - simulatorSetDestinationNotFoundMessage, xcodebuildDestinationArgs, } from './runner-device-set.ts'; import { @@ -925,11 +925,15 @@ async function buildRunnerXctestrun( if (isRequestCanceledError(error)) throw error; const appErr = error instanceof AppError ? error : new AppError('COMMAND_FAILED', String(error)); - const simulatorSet = runnerSimulatorSetFailureDetails(device); - // The reason and the hint beside it come from one classifier (#2680), so the reason a caller - // switches on can never disagree with the advice it is handed. - const { reason, hint, matched } = classifyRunnerStartupFailure( - new AppError(appErr.code, appErr.message, { ...appErr.details, ...simulatorSet }), + const publishedSimulatorSet = runnerSimulatorSetFailureDetails(device); + // The reason, the hint, and any cause named in the message all come from one classifier (#2680), so + // the reason a caller switches on can never disagree with the advice or the sentence beside it. + const classificationDetails = runnerLaunchLogClassificationDetails(device); + const { reason, hint, messageSuffix, matched } = classifyRunnerStartupFailure( + new AppError(appErr.code, appErr.message, { + ...appErr.details, + ...classificationDetails, + }), ); const hostDeadlineHit = isCommandTimeoutError(appErr); // `startupRuleMatched` travels with the verdict: this wrapper buries the tool's text a level too @@ -937,21 +941,15 @@ async function buildRunnerXctestrun( // alone (#2690 review). The device's own state is attached further out, by the startup catch that // can see this build and the launch after it. const message = 'xcodebuild build-for-testing failed'; - throw new AppError( - 'COMMAND_FAILED', - reason === 'simulator_set_destination_not_found' - ? simulatorSetDestinationNotFoundMessage(message, device, simulatorSet) - : message, - { - reason, - error: appErr.message, - details: appErr.details, - logPath: options.logPath, - hint, - startupRuleMatched: matched, - startupHostDeadlineHit: hostDeadlineHit, - ...simulatorSet, - }, - ); + throw new AppError('COMMAND_FAILED', messageSuffix ? `${message}: ${messageSuffix}` : message, { + reason, + error: appErr.message, + details: appErr.details, + logPath: options.logPath, + hint, + startupRuleMatched: matched, + startupHostDeadlineHit: hostDeadlineHit, + ...publishedSimulatorSet, + }); } } diff --git a/packages/platform-apple/src/runner/runner-device-set.ts b/packages/platform-apple/src/runner/runner-device-set.ts index 1a86aa4954..db826f06ec 100644 --- a/packages/platform-apple/src/runner/runner-device-set.ts +++ b/packages/platform-apple/src/runner/runner-device-set.ts @@ -43,13 +43,16 @@ export function runnerSimulatorSetFailureDetails( } /** - * Names the simulator and the scoped set behind a `simulator_set_destination_not_found`, and the Xcode - * when this process has read its version. + * The classification inputs one device's launch hands `classifyRunnerStartupFailure`. Besides the + * scoped set and the Xcode this process already read, it carries the simulator the launch was built + * for, which the row that reports a missing destination names in its own sentence. + * + * An input, not a published shape: callers spread it into the error they classify and publish only + * {@link runnerSimulatorSetFailureDetails} beside their failure. The simulator is in the sentence that + * sentence builds, so the row is the only reader of `simulatorUdid`. */ -export function simulatorSetDestinationNotFoundMessage( - message: string, +export function runnerLaunchLogClassificationDetails( device: DeviceInfo, - details: RunnerSimulatorSetFailureDetails, -): string { - return `${message}: xcodebuild found no simulator ${device.id} in simulator set ${details.simulatorSetPath}${details.xcodeVersion === undefined ? '' : ` with Xcode ${details.xcodeVersion}`}`; +): RunnerSimulatorSetFailureDetails & { simulatorUdid: string } { + return { ...runnerSimulatorSetFailureDetails(device), simulatorUdid: device.id }; } diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index d54821bd67..8a34cf3473 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -109,12 +109,14 @@ const hasRunnerConnectFailureReason = (details) => details.runnerConnectFailureReason === reason; /** - * The terminal verdict of a runner launch whose simulator service this macOS refused to load. It keys - * on the reason the launch paths publish, never on the bless sentence they quote, so foreign text - * carrying Xcode's own wording cannot, on its own, mark a failure terminal. + * The terminal verdict of a runner launch whose cause the launch output itself names — the simulator + * service this macOS refused to load, or a scoped-set destination this Xcode cannot resolve. It keys on + * the reason the launch paths publish, never on the tool sentence they quote, so foreign text carrying + * Xcode's own wording cannot, on its own, mark a failure terminal. */ -const hasSimulatorServiceHubFailure: RunnerErrorDetailsMatch = (details) => - details.reason === SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON; +const hasLaunchLogCauseFailure: RunnerErrorDetailsMatch = (details) => + details.reason === SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON || + details.reason === SIMULATOR_SET_DESTINATION_NOT_FOUND_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 @@ -205,42 +207,25 @@ export type RunnerDeviceReadinessFailureReason = * selects an Xcode that supports its macOS. AMFI's refusal is visible only as the bless line in the * launch output, which is why the row keys on that phrase and why no host-side preflight exists. */ -export const SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON: RunnerStartupFailureReason = +const SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON: RunnerStartupFailureReason = 'simulator_service_hub_unavailable'; +/** + * The reason for a scoped-set launch whose simulator this Xcode cannot resolve. The set is named on the + * command, so a launch that finds no matching destination fails identically on every retry. + */ +const SIMULATOR_SET_DESTINATION_NOT_FOUND_REASON: RunnerStartupFailureReason = + 'simulator_set_destination_not_found'; + /** * The bless refusal, in the two phrases Xcode writes on one line: the client that failed and the - * bless it failed to perform. Declared once because the classifier row reads it and the connect loop - * checks the launch tail for it; the two cannot disagree about what a terminal boot looks like. The - * loop's check must stay no broader than the row's, or it would order a verdict the row refuses to - * prove. + * bless it failed to perform. */ const SIMULATOR_SERVICE_HUB_BLESS_MATCH = [ 'dtservicehubclient', 'failed to bless service hub', ] as const; -/** - * Whether runner launch output carries the bless refusal. The connect loop asks this before it pays - * for a full startup classification, which resolves a simulator-set cause through a toolchain probe. - * Same one-line rule the row applies, over the same text. - */ -export function isSimulatorServiceHubLaunchOutput(output: string): boolean { - const normalized = output.toLowerCase(); - return normalized - .split('\n') - .some((line) => SIMULATOR_SERVICE_HUB_BLESS_MATCH.every((part) => line.includes(part))); -} - -/** - * Names the cause in the message beside the connect-shaped failure the bless refusal produced. The - * reason and hint come from {@link classifyRunnerStartupFailure}; this only folds the cause into the - * sentence the caller reads first. - */ -export function simulatorServiceHubFailureMessage(message: string): string { - return `${message}: this Xcode cannot start its simulator service on this macOS`; -} - /** * The reason a startup failure carries when no rule proves a cause. Its hint is deliberately the * cache-recovery advice rather than anything about signing: an unclassified build is not evidence of @@ -264,6 +249,18 @@ type RunnerErrorRule = { buildFailure?: { reason: RunnerStartupFailureReason; hint: string; + /** + * The cause this row names inside the sentence the caller publishes, in the words the tool itself + * used. A row that carries it is a cause the startup paths can name from the launch output alone, + * so this module stays the only text matcher: a failure site deciding which causes deserve their own + * sentence would be a second classifier. Rows without it travel as a reason beside the caller's + * sentence. + * + * `details` is what the caller handed the classifier, so a row naming a per-launch fact reads it from + * there ({@link runnerLaunchLogClassificationDetails}). Returning undefined when a fact is missing + * leaves the caller's sentence standing. + */ + messageSuffix?: (details: AppErrorDetails) => string | undefined; }; }; @@ -336,10 +333,14 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ verdicts: { retryable: false, connectRetry: false, artifactSuspect: false }, }, { - // A boot whose simulator service the host refused is not cured by wiping derived data, and no - // restart or resend can make AMFI load a binary this macOS rejects. - reason: 'simulator_service_hub_unavailable', - match: { code: 'COMMAND_FAILED', details: hasSimulatorServiceHubFailure }, + // The terminal reading of every cause a launch log can name: a simulator service this macOS refuses + // to load, or a scoped-set destination this Xcode cannot resolve. Neither is cured by a connect + // retry, a session restart, or a rebuilt artifact, and this row has to precede the generic + // `runner_connect_refused` row to say so on a connect-shaped failure carrying one of them. It keys + // on the reason the launch paths publish, never on the tool sentence they quote, so foreign text + // wearing Xcode's own wording cannot mark a failure terminal on its own. + reason: 'runner_launch_log_cause', + match: { code: 'COMMAND_FAILED', details: hasLaunchLogCauseFailure }, verdicts: { retryable: false, connectRetry: false, @@ -455,6 +456,10 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ buildFailure: { reason: 'simulator_set_destination_not_found', hint: 'Check that the simulator still exists in the --ios-simulator-device-set this error names (`xcrun simctl --set list devices`). If it does, the Xcode this error names no longer honors -DVTSimulatorSetLocation, so use a simulator in the default set.', + messageSuffix: (details) => + typeof details.simulatorSetPath === 'string' && typeof details.simulatorUdid === 'string' + ? `xcodebuild found no simulator ${details.simulatorUdid} in simulator set ${details.simulatorSetPath}${typeof details.xcodeVersion === 'string' ? ` with Xcode ${details.xcodeVersion}` : ''}` + : undefined, }, }, { @@ -471,6 +476,7 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ buildFailure: { reason: SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON, hint: 'This Xcode cannot start its simulator service on this macOS: macOS refuses to load its DTServiceHub. Select an Xcode that supports this macOS on the daemon host (`xcode-select -s /Applications/Xcode.app`, or DEVELOPER_DIR=/Applications/Xcode.app for the daemon process), then retry. Updating Xcode is the fix; restarting the runner or clearing derived data cannot change it.', + messageSuffix: () => 'this Xcode cannot start its simulator service on this macOS', }, }, { @@ -802,7 +808,12 @@ export function classifyRunnerStartupFailure(error: unknown): RunnerStartupClass const buildFailure = rule.buildFailure; if (!buildFailure) continue; if (matchesRunnerErrorRule(error, rule.match)) { - return { reason: buildFailure.reason, hint: buildFailure.hint, matched: true }; + return { + reason: buildFailure.reason, + hint: buildFailure.hint, + messageSuffix: buildFailure.messageSuffix?.(error.details ?? {}), + matched: true, + }; } } } @@ -822,6 +833,12 @@ export function classifyRunnerStartupFailure(error: unknown): RunnerStartupClass export type RunnerStartupClassification = Readonly<{ reason: RunnerStartupFailureReason; hint: string; + /** + * The cause in the tool's own words, when the matched row declares one ({@link RunnerErrorRule}). A + * caller publishing a sentence about this failure appends it, so the words naming the cause come from + * the same row that named the reason — never from a failure site choosing its own phrasing. + */ + messageSuffix?: string; /** * False only when no rule row matched at all. The catch that publishes this verdict carries it on * `details.startupRuleMatched`, which is the half a caller cannot recover from the reason alone. diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index 2950e44067..8d0fa08775 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -21,9 +21,6 @@ import { RUNNER_CACHE_RECOVERY_HINT, runnerConnectFailureDetails, shouldRetryRunnerConnectError, - isSimulatorServiceHubLaunchOutput, - SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON, - simulatorServiceHubFailureMessage, type RunnerStartupFailureReason, } from './runner-error-classification.ts'; import { @@ -45,8 +42,8 @@ export type RunnerConnectionSession = Pick< | 'state' >; import { + runnerLaunchLogClassificationDetails, runnerSimulatorSetFailureDetails, - simulatorSetDestinationNotFoundMessage, } from './runner-device-set.ts'; import { canFallBackFromUsbmux, postRunnerCommandOverRoute } from './runner-transport.ts'; @@ -241,17 +238,19 @@ async function ensureRunnerAttemptCanStart(params: { logPath: params.logPath, }); } - // A launch whose simulator service this macOS refused never serves a command, and its child stays - // alive to the deadline, so without this the request waits out the whole connect budget before - // reaching the classifier that names the cause (#3449). Checked on each attempt while the session - // has yet to answer: once a runner has answered, its log is no longer evidence about a boot that - // demonstrably succeeded. Bounded to the tail an error would quote, read from this generation's - // offset, and no child process is spawned for it. + // A launch whose log already names a cause the classifier calls terminal will never serve a + // command, and its child may stay alive to the deadline, so without this the request waits out the + // whole connect budget before reaching the failure that names the cause (#3449). The verdict is the + // classifier's over the tail — this adds no matching of its own — and it is acted on only when the + // table says a retry cannot help, which is the same predicate the retry loop applies to errors. + // Checked while the session has yet to answer: once a runner has answered, its log is no longer + // evidence about a boot that demonstrably succeeded. Bounded to the tail an error would quote, read + // from this generation's offset, and no child process is spawned for it. const session = params.session; if (session?.state !== 'starting') return; const launchLogOutput = session.readLogTail?.(RUNNER_EARLY_EXIT_LOG_TAIL_BYTES) ?? ''; - if (!isSimulatorServiceHubLaunchOutput(launchLogOutput)) return; - throw buildRunnerConnectError({ + if (!launchLogOutput) return; + const terminalError = buildRunnerConnectError({ device: session.device, port: params.endpoint.port, url: params.route.url, @@ -260,6 +259,7 @@ async function ensureRunnerAttemptCanStart(params: { session, launchLogOutput, }); + if (!shouldRetryRunnerConnectError(terminalError)) throw terminalError; } async function tryPrimaryRunnerRoute(params: { @@ -428,37 +428,29 @@ type RunnerLaunchLogCause = Readonly<{ }>; /** - * The cause readable from the launch output when the runner never served a command: a scoped-set - * destination this Xcode cannot resolve, or a simulator service this macOS refuses to load. One read - * of the text the failure is about to quote produces the reason, the message and the hint beside it, - * so the three cannot disagree about which cause this was (#3449). No cause is claimed for anything - * else the log holds: the boot-failure classification owns those, and a line nobody named stays a - * connect timeout. + * The cause readable from the launch output when the runner never served a command, if any. Which + * outputs carry a cause, what it is called, and what the sentence beside it says are all decided by the + * classifier's rows: this reads the tail's verdict and folds the row's own words into the message, so + * reason, message and hint cannot disagree about which cause this was (#3449). No cause is claimed for + * anything no row named: the boot-failure classification owns those. */ function resolveRunnerLaunchLogCause(params: { message: string; output: string; device: DeviceInfo; }): RunnerLaunchLogCause | undefined { - const simulatorSet = runnerSimulatorSetFailureDetails(params.device); const classified = classifyRunnerStartupFailure( - new AppError('COMMAND_FAILED', params.message, { stderr: params.output, ...simulatorSet }), + new AppError('COMMAND_FAILED', params.message, { + stderr: params.output, + ...runnerLaunchLogClassificationDetails(params.device), + }), ); - if (classified.reason === 'simulator_set_destination_not_found') { - return { - message: simulatorSetDestinationNotFoundMessage(params.message, params.device, simulatorSet), - reason: classified.reason, - hint: classified.hint, - }; - } - if (classified.reason === SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON) { - return { - message: simulatorServiceHubFailureMessage(params.message), - reason: classified.reason, - hint: classified.hint, - }; - } - return undefined; + if (!classified.messageSuffix) return undefined; + return { + message: `${params.message}: ${classified.messageSuffix}`, + reason: classified.reason, + hint: classified.hint, + }; } function buildRunnerConnectError(params: { From cf83c868d8abdd1bfd3b0e627b20ecebe9ee6f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:16:34 +0200 Subject: [PATCH 4/4] docs(apple): state the classifier-input helper plainly in runner-device-set Refs #3739 review round 2 (Cubic r4298841230). Co-Authored-By: Claude --- .../platform-apple/src/runner/runner-device-set.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/platform-apple/src/runner/runner-device-set.ts b/packages/platform-apple/src/runner/runner-device-set.ts index db826f06ec..d08d5327fb 100644 --- a/packages/platform-apple/src/runner/runner-device-set.ts +++ b/packages/platform-apple/src/runner/runner-device-set.ts @@ -43,13 +43,12 @@ export function runnerSimulatorSetFailureDetails( } /** - * The classification inputs one device's launch hands `classifyRunnerStartupFailure`. Besides the - * scoped set and the Xcode this process already read, it carries the simulator the launch was built - * for, which the row that reports a missing destination names in its own sentence. + * Supplies the classification details for one device's launch to `classifyRunnerStartupFailure`. + * Besides the scoped set and the Xcode version this process already read, it carries the simulator ID + * needed to describe a missing destination. * - * An input, not a published shape: callers spread it into the error they classify and publish only - * {@link runnerSimulatorSetFailureDetails} beside their failure. The simulator is in the sentence that - * sentence builds, so the row is the only reader of `simulatorUdid`. + * This is an input shape, not a published shape: callers spread it into the error they classify and + * publish only {@link runnerSimulatorSetFailureDetails} beside their failure. */ export function runnerLaunchLogClassificationDetails( device: DeviceInfo,