From 0a3739a0d0a61a170345f58982876eb5eaf3fd8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 27 Oct 2026 17:11:44 +0100 Subject: [PATCH 1/2] fix(apple): read the runner cache toolchain key from Xcode's own files Perf Nightly (run 40441065511) and an Apple smoke run built the runner, then failed writing its cache metadata: `xcrun --show-sdk-version` hung 30 s, its retry hung again, and the 45 s fingerprint budget ran out. The stall is a hosted-runner condition no budget can size. The fingerprint now reads version.plist, SDKSettings.json, and the SDK's SystemVersion.plist, the files those tools print from. Nothing execs, so the cold-probe budget, retry, memo, the selection records that kept the memo honest, and the runner host's sync exec are gone. A failure report reads the selected Xcode directly instead of waiting for a cache decision to have probed it. A selection link whose Xcode is gone fails as unreadable rather than falling through to another Xcode. The apple-runner test project selects a fixture Xcode by default, so no suite reads the host's toolchain. Co-Authored-By: Claude Opus 5.5 --- .../platform-apple/src/core/runner-host.ts | 4 - .../src/foldable/fold-helper-cache.ts | 6 +- .../src/native-build/toolchain-identity.ts | 11 +- .../__tests__/apple-toolchain-fixtures.ts | 163 ++-- .../src/runner/__tests__/host-setup.ts | 4 + .../runner/__tests__/runner-adoption.test.ts | 21 +- .../runner-artifact-build-location.test.ts | 2 - .../runner-artifact-phase-budget.test.ts | 91 +-- .../__tests__/runner-artifact-reuse.test.ts | 2 - .../runner-artifact-start-admission.test.ts | 2 - .../__tests__/runner-cache-metadata.test.ts | 582 ++------------ .../__tests__/runner-cache-probe.test.ts | 16 +- .../__tests__/runner-cache-retention.test.ts | 2 - .../__tests__/runner-cache-trim.test.ts | 2 - .../runner/__tests__/runner-cache.fixtures.ts | 5 - .../runner-client-recovery-wiring.test.ts | 16 - .../runner/__tests__/runner-client.test.ts | 2 - .../__tests__/runner-device-set.test.ts | 22 +- .../runner-session-close-prep-fence.test.ts | 3 - ...unner-startup-transport-early-exit.test.ts | 11 +- .../runner-toolchain-identity.test.ts | 205 +++++ .../__tests__/runner-toolchain-probe.test.ts | 710 ------------------ ...-xctestrun-startup-failure-reasons.test.ts | 5 +- .../runner/__tests__/runner-xctestrun.test.ts | 82 +- .../src/runner/apple-runner-platform.ts | 31 +- packages/platform-apple/src/runner/host.ts | 5 - .../src/runner/runner-adoption.ts | 4 +- .../src/runner/runner-artifact.ts | 5 +- .../src/runner/runner-cache-metadata.ts | 7 +- .../src/runner/runner-client.ts | 4 +- .../src/runner/runner-device-set.ts | 8 +- .../src/runner/runner-error-classification.ts | 4 +- .../src/runner/runner-phase-budget.ts | 9 +- .../src/runner/runner-session.ts | 4 +- .../src/runner/runner-toolchain-identity.ts | 208 +++++ .../src/runner/runner-toolchain-probe.ts | 509 ------------- .../src/snapshot-source/preparation.ts | 2 +- 37 files changed, 622 insertions(+), 2147 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-toolchain-identity.test.ts delete mode 100644 packages/platform-apple/src/runner/__tests__/runner-toolchain-probe.test.ts create mode 100644 packages/platform-apple/src/runner/runner-toolchain-identity.ts delete mode 100644 packages/platform-apple/src/runner/runner-toolchain-probe.ts diff --git a/packages/platform-apple/src/core/runner-host.ts b/packages/platform-apple/src/core/runner-host.ts index 4f94713b87..faefd60aba 100644 --- a/packages/platform-apple/src/core/runner-host.ts +++ b/packages/platform-apple/src/core/runner-host.ts @@ -7,7 +7,6 @@ import { requireExecSuccess, runCmdBackground, runCmdStreaming, - runCmdSync, } from '@agent-device/host-kit/command'; import { shellQuote } from '@agent-device/kernel/device-shell'; import { AppError } from '@agent-device/kernel/errors'; @@ -31,7 +30,6 @@ import { findProjectRoot, readVersion } from '@agent-device/host-kit/version'; import { isRecord } from '@agent-device/kernel/record'; import { parseBooleanLiteral } from '@agent-device/kernel/source-value'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; -import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; import { getRequestSignal, @@ -70,7 +68,6 @@ import { */ export const appleRunnerHost: AppleRunnerHost = { runCmdStreaming, - runCmdSync, runCmdBackground, requireExecSuccess, isCommandTimeoutError, @@ -104,7 +101,6 @@ export const appleRunnerHost: AppleRunnerHost = { publishFileSync, classifyOwnerLiveness, readHostCpuArchSync, - createTtlMemo, parseBooleanLiteral, isRecord, emitRequestProgress, diff --git a/packages/platform-apple/src/foldable/fold-helper-cache.ts b/packages/platform-apple/src/foldable/fold-helper-cache.ts index cb5f966d39..b16db329f6 100644 --- a/packages/platform-apple/src/foldable/fold-helper-cache.ts +++ b/packages/platform-apple/src/foldable/fold-helper-cache.ts @@ -4,11 +4,13 @@ import { execFailureDetails } from '@agent-device/host-kit/command'; import { hostHomeDirectory } from '@agent-device/host-kit/host-file'; import { findProjectRoot } from '@agent-device/host-kit/version'; import { runAppleToolCommand } from '../core/tool-provider.ts'; -import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; import { createNativeBuildDeadline, type NativeBuildDeadline } from '../native-build/deadline.ts'; import { NativeBuildError } from '../native-build/errors.ts'; import { createNativeBuildHost, type NativeBuildHost } from '../native-build/host.ts'; -import { readHostToolchainIdentity } from '../native-build/toolchain-identity.ts'; +import { + COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, + readHostToolchainIdentity, +} from '../native-build/toolchain-identity.ts'; import { ensureNativeBuildCacheEntry, execNativeBuildClang, diff --git a/packages/platform-apple/src/native-build/toolchain-identity.ts b/packages/platform-apple/src/native-build/toolchain-identity.ts index ea47a33b15..980ea82c4a 100644 --- a/packages/platform-apple/src/native-build/toolchain-identity.ts +++ b/packages/platform-apple/src/native-build/toolchain-identity.ts @@ -1,10 +1,19 @@ import { isCommandTimeoutError, type ExecResult } from '@agent-device/host-kit/command'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; -import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; import { nativeBuildError, NativeBuildError } from './errors.ts'; import { remainingNativeBuildMs, type NativeBuildDeadline } from './deadline.ts'; import type { NativeBuildHost } from './host.ts'; +/** + * Ceiling on one toolchain probe attempt (`xcodebuild -version`, run through the Apple tool + * provider). On a fresh macOS host Apple's syspolicyd signature scan blocks the first + * `xcodebuild`/`xcrun` exec after boot for roughly 18 to 19 seconds at 0% CPU, and the next exec of + * the same tool is instant; a budget sized for a warm toolchain (the old 10 s / 5 s split) trips on + * that stall and reports a toolchain timeout that says nothing about the toolchain (#2422). The + * build deadlines around this probe add it for the same reason. + */ +export const COLD_TOOLCHAIN_PROBE_TIMEOUT_MS = 30_000; + /** * The host's active toolchain, independent of any simulator runtime: which Xcode `xcrun` resolves * against, the macOS build it runs on, and its architecture. Shared by every runtime clang build in diff --git a/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts b/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts index d5e9ff6953..3cceb6930b 100644 --- a/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts @@ -1,9 +1,8 @@ import { beforeEach, vi } from 'vitest'; import fs from 'node:fs'; -import { AppError } from '@agent-device/kernel/errors'; -import { appleRunnerTestHost } from '../test-host.ts'; -import { XCODE_SELECT_LINK_PATH } from '../apple-runner-platform.ts'; -import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; +import path from 'node:path'; +import { XCODE_SELECT_LINK_PATH } from '../runner-toolchain-identity.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; export const STUBBED_APPLE_TOOLCHAIN = { xcodeVersion: '26.2', @@ -12,120 +11,92 @@ export const STUBBED_APPLE_TOOLCHAIN = { sdkBuildVersion: '23C53', } as const; -const STUBBED_SELECTED_DEVELOPER_DIR = '/Applications/Xcode-Stub.app/Contents/Developer'; +type StubbedToolchain = { [Key in keyof typeof STUBBED_APPLE_TOOLCHAIN]: string }; -export function selectedDeveloperDirResult(developerDir: string): ExecResult { - return { exitCode: 0, stdout: `${developerDir}\n`, stderr: '' }; -} +const SDK_DIRECTORY_NAMES = [ + 'iPhoneSimulator', + 'iPhoneOS', + 'AppleTVSimulator', + 'AppleTVOS', + 'MacOSX', + 'XRSimulator', + 'XROS', +]; -export function appleToolchainProbeResult(command: string, args: readonly string[]): ExecResult { - if (command === 'xcode-select' && args[0] === '-p') { - return selectedDeveloperDirResult(STUBBED_SELECTED_DEVELOPER_DIR); - } - if (command === 'xcodebuild' && args[0] === '-version') { - return { - exitCode: 0, - stdout: `Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}\nBuild version ${STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion}\n`, - stderr: '', - }; - } - if (command === 'xcrun' && args.includes('--show-sdk-build-version')) { - return { exitCode: 0, stdout: `${STUBBED_APPLE_TOOLCHAIN.sdkBuildVersion}\n`, stderr: '' }; - } - if (command === 'xcrun' && args.includes('--show-sdk-version')) { - return { exitCode: 0, stdout: `${STUBBED_APPLE_TOOLCHAIN.sdkVersion}\n`, stderr: '' }; - } - throw new Error(`Unexpected Apple toolchain probe: ${command} ${args.join(' ')}`); +function plist(entries: Record): string { + const body = Object.entries(entries) + .map(([key, value]) => `\t${key}\n\t${value}`) + .join('\n'); + return `\n\n\n${body}\n\n\n`; } /** - * Answers the runner cache's toolchain probes from a fixed toolchain, so cases that key the cache - * neither read the host's Xcode nor depend on one existing. No client chooses a `DEVELOPER_DIR`, so - * the selection a key probe resolves is the one the case scripts. Returns the mock so a case can - * make a probe fail. + * An `Xcode.app` under `root` carrying the files the toolchain identity reads, for every SDK the + * runner keys on. Returns its developer dir. */ -export function stubAppleToolchainProbes(): ReturnType { - stubAppleSelectionLink(); - const runCmdSync = vi.fn(appleToolchainProbeResult); - beforeEach(() => { - runCmdSync.mockImplementation(appleToolchainProbeResult); - appleRunnerTestHost.update({ - runCmdSync, - commandDeveloperDir: () => undefined, - }); - }); - return runCmdSync; +export function writeXcodeFixture( + root: string, + toolchain: StubbedToolchain = STUBBED_APPLE_TOOLCHAIN, +): string { + const contents = path.join(root, 'Xcode.app', 'Contents'); + const developerDir = path.join(contents, 'Developer'); + fs.mkdirSync(developerDir, { recursive: true }); + fs.writeFileSync( + path.join(contents, 'version.plist'), + plist({ + CFBundleShortVersionString: toolchain.xcodeVersion, + ProductBuildVersion: toolchain.xcodeBuildVersion, + }), + ); + for (const name of SDK_DIRECTORY_NAMES) { + const sdk = path.join(developerDir, 'Platforms', `${name}.platform/Developer/SDKs/${name}.sdk`); + fs.mkdirSync(path.join(sdk, 'System/Library/CoreServices'), { recursive: true }); + fs.writeFileSync( + path.join(sdk, 'SDKSettings.json'), + JSON.stringify({ Version: toolchain.sdkVersion }), + ); + fs.writeFileSync( + path.join(sdk, 'System/Library/CoreServices/SystemVersion.plist'), + plist({ ProductBuildVersion: toolchain.sdkBuildVersion }), + ); + } + return developerDir; +} + +let stubbedDeveloperDir: string | undefined; + +/** One {@link STUBBED_APPLE_TOOLCHAIN} Xcode per worker, so a case never reads the host's Xcode. */ +export function stubbedAppleDeveloperDir(): string { + stubbedDeveloperDir ??= writeXcodeFixture(mkdtempForTestSync('apple-toolchain-')); + return stubbedDeveloperDir; } /** - * The host's default selection link as the Apple toolchain probes see it. Undefined keeps the link - * unreadable, which sends every read down the `xcode-select -p` fallback a case already scripts; a - * case that owns a link points it at a path it created. + * The host's default selection link as the toolchain identity sees it. Undefined keeps the link + * unreadable; a case that owns a link points it at a path it created. */ export const appleSelectionLink: { target: string | undefined } = { target: undefined }; /** * Hides the selection link of the machine running the suite, so a case decides the host's selection - * rather than inheriting it. The real host has one, and where it points is not this suite's business. + * rather than inheriting it. */ export function stubAppleSelectionLink(): void { const realRealpathSync = fs.realpathSync; + const realReadlinkSync = fs.readlinkSync; + const absent = () => + Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); beforeEach(() => { appleSelectionLink.target = undefined; + vi.spyOn(fs, 'readlinkSync').mockImplementation(((target: fs.PathLike) => { + if (String(target) !== XCODE_SELECT_LINK_PATH) return realReadlinkSync(target); + if (appleSelectionLink.target === undefined) throw absent(); + return appleSelectionLink.target; + }) as typeof fs.readlinkSync); vi.spyOn(fs, 'realpathSync').mockImplementation((target: fs.PathLike) => { if (String(target) !== XCODE_SELECT_LINK_PATH) return realRealpathSync(target); - if (appleSelectionLink.target === undefined) { - throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); - } + if (appleSelectionLink.target === undefined) throw absent(); return realRealpathSync(appleSelectionLink.target); }); }); } - -/** - * The fingerprint budget's clock, advanced only by an exec that blocks for the whole timeout it was - * handed. Without it a mock that throws immediately proves nothing about a deadline: no time passes, - * so every budget looks untouched however many attempts run. - */ -export function installToolchainProbeClock(): { nowMs: number } { - const clock = { nowMs: 0 }; - appleRunnerTestHost.update({ - deadlineFromTimeoutMs: (timeoutMs: number) => { - const startedAtMs = clock.nowMs; - const expiresAtMs = startedAtMs + Math.max(0, timeoutMs); - return { - remainingMs: () => Math.max(0, expiresAtMs - clock.nowMs), - elapsedMs: () => Math.max(0, clock.nowMs - startedAtMs), - isExpired: () => expiresAtMs - clock.nowMs <= 0, - }; - }, - }); - return clock; -} - -/** An exec that blocked for its whole timeout and was then killed, as the exec layer reports it. */ -export function blockForWholeTimeout( - clock: { nowMs: number }, - command: string, - args: readonly string[], - options: ExecOptions, -): AppError { - const timeoutMs = options.timeoutMs ?? 0; - clock.nowMs += timeoutMs; - return new AppError('COMMAND_FAILED', `${command} timed out after ${timeoutMs}ms`, { - cmd: command, - args, - timeoutMs, - }); -} - -/** - * The execs that read the toolchain itself, as opposed to the `xcode-select -p` a read runs to - * decide which fingerprint entry it belongs to. A case that says a cache hit must not re-read the - * toolchain says nothing about that one exec, so it asserts through this. - */ -export function valueProbeCommands(calls: readonly (readonly unknown[])[]): string[] { - return calls - .filter(([command]) => command !== 'xcode-select') - .map(([command]) => String(command)); -} diff --git a/packages/platform-apple/src/runner/__tests__/host-setup.ts b/packages/platform-apple/src/runner/__tests__/host-setup.ts index c37952bc0e..5e65cee878 100644 --- a/packages/platform-apple/src/runner/__tests__/host-setup.ts +++ b/packages/platform-apple/src/runner/__tests__/host-setup.ts @@ -6,6 +6,7 @@ import { loadAppleRunnerHost, } from '../test-host.ts'; import { setAppleRunnerHostEnvironment } from '../../core/runner-owner-state.ts'; +import { stubbedAppleDeveloperDir } from './apple-toolchain-fixtures.ts'; // The apple-runner vitest project runs the package's own suites, which cannot // import root utilities directly (R11). This setup installs the real root host @@ -19,6 +20,9 @@ bindTestCommandHost(); beforeEach(() => { appleRunnerTestHost.reset(); + // The runner cache key reads the selected Xcode's files. A suite reads a stubbed Xcode rather than + // the host's, which CI hosts outside macOS do not have; a case that selects one overrides this. + appleRunnerTestHost.update({ commandDeveloperDir: stubbedAppleDeveloperDir }); // Runner configuration reads take the runtime's host (#3794). A package suite runs outside a // composed runtime, so it binds the live process host the command host above carries. setAppleRunnerHostEnvironment({ env: process.env, cwd: process.cwd() }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts b/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts index 03bccffd03..0261940ca4 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts @@ -3,7 +3,6 @@ import path from 'node:path'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors'; -import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { buildDetachedRunnerLease, buildRunnerLease, @@ -20,7 +19,6 @@ import { import { resolveRunnerCacheKey } from '../runner-cache-metadata.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import type { DiagnosticEventInput } from '@agent-device/host-kit/diagnostics'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; vi.mock('../runner-transport.ts', async (importOriginal) => { @@ -269,9 +267,8 @@ test('the adopted runner keeps the log file its predecessor opened for it', asyn expect(restamped.runnerLogPath).toBe(runnerLogPath); }); -test('a request canceled during the fingerprint probe fails adoption instead of skipping it, cold or with the fingerprint cache warm', async () => { - // The fingerprint check runs the same blocking toolchain probes a fresh - // startup would, and it is handed the request's signal. A cancellation from +test('a request canceled before the fingerprint check fails adoption instead of skipping it', async () => { + // The fingerprint check is handed the request's signal. A cancellation from // there is not an unresolvable derived path: swallowing it would let startup // walk on past a client that is already gone (#2422). const lease = writeStaleLease(); @@ -292,18 +289,12 @@ test('a request canceled during the fingerprint probe fails adoption instead of expect(mockSendRunnerCommandOnce).not.toHaveBeenCalled(); // The mock above only proves adoption propagates whatever the fingerprint - // call throws. Exercise the real production code path too: warm the actual - // toolchain fingerprint memo (the module-level cache in - // runner-cache-metadata.ts is a singleton the mock above never touches), - // then adopt again with an already-aborted signal. Before the fix, - // requireRunnerToolchainFingerprint returned the memoized value without - // checking the signal, so a cache hit let adoption go on to probe uptime - // and write the lease for an already-canceled request (#2422 round 4). - resetAllProcessMemosForTests(); + // call throws. Exercise the real production code path too: with an + // already-aborted signal, the real fingerprint read cancels before it reads + // anything, so adoption never probes uptime or writes the lease (#2422). const { resolveExpectedRunnerCacheMetadata: actualResolveExpectedRunnerCacheMetadata } = await vi.importActual('../runner-xctestrun.ts'); - appleRunnerTestHost.update({ runCmdSync: vi.fn(appleToolchainProbeResult) }); - actualResolveExpectedRunnerCacheMetadata(simulator); + appleRunnerTestHost.update({}); const warmRequest = new AbortController(); warmRequest.abort(); diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-build-location.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-build-location.test.ts index 0683ba524f..ecdf9734e0 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-build-location.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-build-location.test.ts @@ -6,7 +6,6 @@ import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { appleRunnerTestHost } from '../test-host.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; import { ensureXctestrunArtifact } from '../runner-xctestrun.ts'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; @@ -31,7 +30,6 @@ beforeEach(() => { process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derived; runCmdStreaming.mockReset().mockImplementation(xcodebuildHonoringCustomBuildLocation); appleRunnerTestHost.update({ - runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), runCmdStreaming, findProjectRoot: () => projectRoot, readVersion: () => '0.0.0-test', diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-phase-budget.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-phase-budget.test.ts index 1f26407745..3cfc787b96 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-phase-budget.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-phase-budget.test.ts @@ -3,34 +3,23 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, beforeEach, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { appleRunnerTestHost } from '../test-host.ts'; -import type { ExecOptions, ExecResult, ExecStreamOptions } from '@agent-device/host-kit/command'; +import type { ExecResult, ExecStreamOptions } from '@agent-device/host-kit/command'; import { createRunnerPhaseBudget, ensureXctestrunArtifact } from '../runner-xctestrun.ts'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; import { MACOS_DEVICE } from './device-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; /** - * The build phase spends one budget, not two (#2422). - * - * `ensureXctestrunArtifact` runs the cache decision's blocking toolchain probes - * before it starts `xcodebuild`. Those probes used to be handed - * `buildTimeoutMs` and the build was then handed the same number again, so a - * cold-start probe stall added its 30 to 45 seconds on top of the phase budget - * instead of coming out of it. These cases pin the shared deadline: the clock - * moves only when a probe actually blocks for the timeout it was handed, so a - * case that claims the budget was spent had to spend it. + * The build phase spends one budget (#2422). The cache decision reads the toolchain from files + * before `xcodebuild` starts, so it spends none of that budget. */ +/** The phase clock stands still, so the build is handed exactly the budget the phase left it. */ const clock = { nowMs: 0 }; -const runCmdSync = vi.fn(); const runCmdStreaming = vi.fn(); let projectRoot: string; beforeEach(() => { - resetAllProcessMemosForTests(); - clock.nowMs = 0; projectRoot = mkdtempForTestSync('agent-device-runner-phase-root-'); // `buildXctestrunArtifact` refuses to start a build without the runner project. fs.mkdirSync( @@ -40,12 +29,10 @@ beforeEach(() => { process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = mkdtempForTestSync( 'agent-device-runner-phase-derived-', ); - runCmdSync.mockReset().mockImplementation(appleToolchainProbeResult); runCmdStreaming .mockReset() .mockImplementation(async (): Promise => ({ exitCode: 0, stdout: '', stderr: '' })); appleRunnerTestHost.update({ - runCmdSync, runCmdStreaming, findProjectRoot: () => projectRoot, readVersion: () => '0.0.0-test', @@ -65,65 +52,13 @@ afterEach(() => { delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; }); -test('a warm toolchain leaves the build the whole phase budget', async () => { +test('the cache decision leaves the build the whole phase budget', async () => { await assert.rejects( ensureXctestrunArtifact(MACOS_DEVICE, { budget: createRunnerPhaseBudget(120_000, undefined) }), - missingXctestrun, - ); - - assert.equal(clock.nowMs, 0); - assert.equal(buildTimeoutMsGiven(), 120_000); -}); - -test('a cold-start probe stall comes out of the build budget instead of being added to it', async () => { - let xcodebuildProbes = 0; - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - // The syspolicyd stall: the first `xcodebuild` exec blocks for the whole - // timeout it was handed, the immediate next one answers at once. - xcodebuildProbes += command === 'xcodebuild' ? 1 : 0; - if (xcodebuildProbes === 1 && command === 'xcodebuild') { - throw blockForWholeTimeout(command, options); - } - return appleToolchainProbeResult(command, args); - }); - - await assert.rejects( - ensureXctestrunArtifact(MACOS_DEVICE, { budget: createRunnerPhaseBudget(120_000, undefined) }), - missingXctestrun, - ); - - // 30 s of stall, absorbed by the retry -- and charged to the phase, so the - // build is given 90 s rather than a second full 120 s. - assert.equal(clock.nowMs, 30_000); - assert.equal(buildTimeoutMsGiven(), 90_000); -}); - -test('a probe that spends the whole phase fails before xcodebuild is spawned', async () => { - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - // The last of the three probes answers, but only after blocking for - // everything the phase had left. - if (args.includes('--show-sdk-build-version')) clock.nowMs += options.timeoutMs ?? 0; - return appleToolchainProbeResult(command, args); - }); - - await assert.rejects( - ensureXctestrunArtifact(MACOS_DEVICE, { budget: createRunnerPhaseBudget(30_000, undefined) }), (error: unknown) => - error instanceof AppError && - error.details?.reason === 'runner_phase_budget_exhausted' && - error.details?.phase === 'runner_xctestrun_build', + error instanceof AppError && error.message === 'Failed to locate .xctestrun after build', ); - assert.equal(clock.nowMs, 30_000); - assert.equal(runCmdStreaming.mock.calls.length, 0); -}); - -/** The fake build writes no `.xctestrun`; the budget it was handed is what these cases read. */ -function missingXctestrun(error: unknown): boolean { - return error instanceof AppError && error.message === 'Failed to locate .xctestrun after build'; -} - -function buildTimeoutMsGiven(): number | undefined { assert.equal(runCmdStreaming.mock.calls.length, 1); const [command, , options] = runCmdStreaming.mock.calls[0] as [ string, @@ -131,15 +66,5 @@ function buildTimeoutMsGiven(): number | undefined { ExecStreamOptions, ]; assert.equal(command, 'xcodebuild'); - return options.timeoutMs; -} - -/** A probe that blocked for its whole timeout and was then killed, as the exec layer reports it. */ -function blockForWholeTimeout(command: string, options: ExecOptions): AppError { - const timeoutMs = options.timeoutMs ?? 0; - clock.nowMs += timeoutMs; - return new AppError('COMMAND_FAILED', `${command} timed out after ${timeoutMs}ms`, { - cmd: command, - timeoutMs, - }); -} + assert.equal(options.timeoutMs, 120_000); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts index 9bf4dcf410..bccf5ad296 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts @@ -16,7 +16,6 @@ import { markRunnerXctestrunArtifactBadForRun, resolveExpectedRunnerCacheMetadata, } from '../runner-xctestrun.ts'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; import { digestFile } from './digest-file.ts'; import { REPO_ROOT_FOR_TEST as repoRoot, @@ -87,7 +86,6 @@ beforeEach(() => { vi.resetAllMocks(); appleRunnerTestHost.update({ runCmdStreaming: mockRunCmdStreaming, - runCmdSync: appleToolchainProbeResult, readProcessStartTime: () => 'test-process-start', }); mockRunCmdStreaming.mockResolvedValue(undefined); diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-start-admission.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-start-admission.test.ts index 98048ef08f..69ff718b85 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-start-admission.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-start-admission.test.ts @@ -13,7 +13,6 @@ import { openRunnerStartAdmission, runnerStartAdmitsPreparation, } from '../runner-xctestrun.ts'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; @@ -37,7 +36,6 @@ beforeEach(() => { process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derived; runCmdStreaming.mockReset(); appleRunnerTestHost.update({ - runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), runCmdStreaming, findProjectRoot: () => projectRoot, readVersion: () => '0.0.0-test', diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts index d53b59b26d..121ddb09b8 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts @@ -1,10 +1,8 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; -import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { IOS_DEVICE, IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; -import type { ExecOptions } from '@agent-device/host-kit/command'; import fs from 'node:fs'; import path from 'node:path'; import { @@ -19,23 +17,11 @@ import { resolveRunnerDerivedPath, } from '../runner-cache-metadata.ts'; import { createRunnerPhaseBudget } from '../runner-phase-budget.ts'; -import { memoizedRunnerXcodeVersion } from '../runner-toolchain-probe.ts'; -import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../apple-runner-platform.ts'; -import { - appleToolchainProbeResult, - blockForWholeTimeout, - installToolchainProbeClock, - selectedDeveloperDirResult, - STUBBED_APPLE_TOOLCHAIN, - stubAppleToolchainProbes, - valueProbeCommands, -} from './apple-toolchain-fixtures.ts'; +import { STUBBED_APPLE_TOOLCHAIN, writeXcodeFixture } from './apple-toolchain-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; import { xcodebuildLogWithBuildArguments } from './runner-build-log.fixtures.ts'; import { withoutRunnerDerivedPathEnv } from './runner-xctestrun.fixtures.ts'; -const runCmdSync = stubAppleToolchainProbes(); - test('resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators', () => { assert.equal( resolveRunnerMaxConcurrentDestinationsFlag(IOS_SIMULATOR), @@ -238,429 +224,6 @@ test('metadata diff elides an over-long value in the middle so both ends stay co assert.ok(difference?.actual.endsWith('=two"]')); }); -function unavailableProbes(): { probe: string; reason: string }[] { - try { - resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - return []; - } catch (error) { - assert.ok(error instanceof AppError); - assert.equal(error.details?.reason, 'apple_toolchain_probe_unavailable'); - const probes = error.details?.probes as { probe: string; reason: string }[]; - return probes.map(({ probe, reason }) => ({ probe, reason })); - } -} - -test('a timed-out probe leaves the toolchain unavailable instead of a comparable value', () => { - runCmdSync.mockImplementation((command: string, args: readonly string[]) => { - if (command === 'xcodebuild') { - throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', { - timeoutMs: 5_000, - }); - } - return appleToolchainProbeResult(command, args); - }); - - assert.deepEqual(unavailableProbes(), [{ probe: 'xcodebuild -version', reason: 'probe_error' }]); -}); - -// Apple's syspolicyd signature scan blocks the first xcodebuild/xcrun exec -// after a fresh macOS host boots for roughly 18 to 19 seconds; the immediate -// next exec of the same tool is instant (#2422). These cases exercise the -// resulting one-retry policy, and the budget that bounds it, without waiting -// on a real cold-start stall: the fake clock only moves when a probe actually -// blocks for the timeout it was given, so a case that claims the budget was -// spent had to spend it. -describe('toolchain probe budget', () => { - // Failures are never memoized, but the recovery case below succeeds; each - // case starts from an empty toolchain fingerprint cache so none of them - // reads another's answer. - beforeEach(resetAllProcessMemosForTests); - - test('a cold-start probe recovers on retry, and the stall it survived is charged to the budget', () => { - const clock = installToolchainProbeClock(); - const xcodebuildTimeouts: number[] = []; - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - if (command !== 'xcodebuild') return appleToolchainProbeResult(command, args); - xcodebuildTimeouts.push(options.timeoutMs ?? 0); - if (xcodebuildTimeouts.length > 1) return appleToolchainProbeResult(command, args); - throw blockForWholeTimeout(clock, command, args, options); - }); - runCmdSync.mockClear(); - - const metadata = resolveExpectedRunnerCacheMetadata(IOS_DEVICE); - - assert.equal(metadata.xcodeVersion, '26.2'); - assert.equal(metadata.xcodeBuildVersion, '17C52'); - // The retry runs on what the shared budget has left, not on a fresh - // per-call ceiling: 45 s total minus the 30 s the first attempt burned. - assert.deepEqual(xcodebuildTimeouts, [COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, 15_000]); - }); - - test('an owning phase with 4 s left gets one 4 s attempt and no retry', () => { - const clock = installToolchainProbeClock(); - const phaseBudget = createRunnerPhaseBudget(4_000, undefined); - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - if (command === 'xcode-select') return appleToolchainProbeResult(command, args); - throw blockForWholeTimeout(clock, command, args, options); - }); - runCmdSync.mockClear(); - - assert.throws( - () => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR, undefined, phaseBudget), - (error: unknown) => expectRunnerPhaseBudgetExhausted(error), - ); - assert.equal(valueProbeCommands(runCmdSync.mock.calls).length, 1); - // The one attempt was capped by the phase, not by the 30 s per-call ceiling. - assert.equal(runCmdSync.mock.calls.at(-1)?.[2]?.timeoutMs, 4_000); - assert.equal(clock.nowMs, 4_000); - }); - - test('a probe that failed on its own and merely says "timed out" in its message is not retried', () => { - installToolchainProbeClock(); - runCmdSync.mockImplementation((command: string, args: string[]) => { - if (command !== 'xcodebuild') return appleToolchainProbeResult(command, args); - // No `timeoutMs` detail: this is the tool reporting its own failure, not - // the exec layer killing it at a timeout we asked for. - throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 10ms', { - cmd: command, - args, - }); - }); - runCmdSync.mockClear(); - - assert.throws( - () => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR), - (error: unknown) => { - assert.ok(error instanceof AppError); - expect(error.message).toContain('xcodebuild timed out after 10ms'); - return true; - }, - ); - expect(runCmdSync.mock.calls.filter(([command]) => command === 'xcodebuild')).toHaveLength(1); - }); - - /** - * One row per way a fingerprint read can be interrupted: when the owning request aborts, - * what the probe that abort lands on does, and what the phase must then surface. - * `spawnSync` cannot be interrupted once it has started, so a cancellation is only ever - * observed between attempts -- the exec count is what pins which attempt each row stopped. - */ - type ToolchainProbeCancellationCase = { - label: string; - /** Which execs refuse to answer, and how they end; every other exec answers. */ - failing?: { at: 'first' | 'final' | 'every'; as: 'exec-timeout' | 'command-failure' }; - /** - * When the owning request aborts: never, before the phase runs a probe at all, while - * the failing exec is still blocked, or as that exec's timeout unwinds. - */ - aborts: 'never' | 'before-any-probe' | 'while-it-blocks' | 'as-it-unwinds'; - /** Whether the process-wide fingerprint memo already holds an answer. */ - fingerprintCache?: 'warm'; - expected: 'request-canceled' | 'budget-exhausted' | 'fingerprint'; - /** Execs the phase is allowed to have run by the time it settles. */ - execs: number; - /** Wall clock the phase spent: only an exec that blocks for its whole timeout moves it. */ - clockMs: number; - }; - - const CANCELLATION_CASES: ToolchainProbeCancellationCase[] = [ - { - label: 'aborted before the first probe, cold cache', - aborts: 'before-any-probe', - expected: 'request-canceled', - execs: 0, - clockMs: 0, - }, - { - // A cache hit must not answer a request that is already gone (#2422 round 4). - label: 'aborted before the first probe, fingerprint cache warm', - aborts: 'before-any-probe', - fingerprintCache: 'warm', - expected: 'request-canceled', - execs: 0, - clockMs: 0, - }, - { - label: 'aborted while the first probe blocks, and it then times out', - failing: { at: 'first', as: 'exec-timeout' }, - aborts: 'while-it-blocks', - expected: 'request-canceled', - execs: 1, - clockMs: COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, - }, - { - label: 'aborted while the first probe fails with a non-timeout error', - failing: { at: 'first', as: 'command-failure' }, - aborts: 'while-it-blocks', - expected: 'request-canceled', - execs: 1, - clockMs: 0, - }, - { - // The budget still had 15 s: only the cancellation stops the retry. - label: "aborted as the first attempt's timeout unwinds, before its retry", - failing: { at: 'first', as: 'exec-timeout' }, - aborts: 'as-it-unwinds', - expected: 'request-canceled', - execs: 1, - clockMs: COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, - }, - { - // Nothing is left to catch the cancellation on a later attempt, so the failing - // probe's own catch must: an unavailable toolchain would be the wrong verdict. - label: 'aborted while the final probe fails with a non-timeout error', - failing: { at: 'final', as: 'command-failure' }, - aborts: 'while-it-blocks', - expected: 'request-canceled', - execs: 3, - clockMs: 0, - }, - { - label: 'aborted while the final probe blocks, and it then times out', - failing: { at: 'final', as: 'exec-timeout' }, - aborts: 'while-it-blocks', - expected: 'request-canceled', - execs: 3, - clockMs: COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, - }, - { - // 30 s plus a 15 s retry spends the whole 45 s ceiling on the first probe. The two - // xcrun probes never ran, so the verdict is the budget, not an unreadable toolchain: - // an `xcode-select` hint here would point at the wrong thing. - label: 'never aborted, the first probe and its retry spend the whole budget', - failing: { at: 'every', as: 'exec-timeout' }, - aborts: 'never', - expected: 'budget-exhausted', - execs: 2, - clockMs: 45_000, - }, - { - label: 'never aborted, the first probe times out and its retry recovers', - failing: { at: 'first', as: 'exec-timeout' }, - aborts: 'never', - expected: 'fingerprint', - execs: 4, - clockMs: COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, - }, - ]; - - test.each(CANCELLATION_CASES)('cancellation matrix: $label', (testCase) => { - const clock = installToolchainProbeClock(); - const request = new AbortController(); - if (testCase.fingerprintCache === 'warm') { - runCmdSync.mockImplementation(appleToolchainProbeResult); - resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - } - if (testCase.aborts === 'before-any-probe') request.abort(); - - let execs = 0; - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - // The cache-key resolution picks which entry the read belongs to; it is never one of the - // three probes this matrix interrupts. - if (command === 'xcode-select') return appleToolchainProbeResult(command, args); - execs += 1; - if (!failsOnExec(testCase, execs)) return appleToolchainProbeResult(command, args); - if (testCase.aborts === 'while-it-blocks') request.abort(); - const failure = - testCase.failing?.as === 'exec-timeout' - ? blockForWholeTimeout(clock, command, args, options) - : // No `timeoutMs` detail: the tool failed on its own, so nothing retries it. - new AppError('COMMAND_FAILED', `${command}: unexpected error`, { cmd: command, args }); - if (testCase.aborts === 'as-it-unwinds') request.abort(); - throw failure; - }); - - const readFingerprint = () => - resolveExpectedRunnerCacheMetadata( - IOS_SIMULATOR, - undefined, - createRunnerPhaseBudget(undefined, request.signal), - ); - - if (testCase.expected === 'fingerprint') { - const metadata = readFingerprint(); - assert.equal(metadata.xcodeVersion, '26.2', testCase.label); - assert.equal(metadata.xcodeBuildVersion, '17C52', testCase.label); - } else { - assert.throws(readFingerprint, (error: unknown) => { - assert.ok( - testCase.expected === 'request-canceled' - ? isRequestCanceledError(error) - : expectRunnerPhaseBudgetExhausted(error), - `${testCase.label}: expected ${testCase.expected}, got ${String(error)}`, - ); - return true; - }); - } - assert.equal(execs, testCase.execs, `${testCase.label}: exec count`); - assert.equal(clock.nowMs, testCase.clockMs, `${testCase.label}: wall clock spent`); - }); - - /** Which exec a row's failing probe is: the first, the last of the three, or all of them. */ - function failsOnExec(testCase: ToolchainProbeCancellationCase, exec: number): boolean { - switch (testCase.failing?.at) { - case 'first': - return exec === 1; - case 'final': - return exec === 3; - case 'every': - return true; - default: - return false; - } - } -}); - -/** The error a runner phase raises when a step is reached with nothing left to spend. */ -function expectRunnerPhaseBudgetExhausted(error: unknown): boolean { - assert.ok(error instanceof AppError); - assert.equal(error.code, 'COMMAND_FAILED'); - assert.equal(error.details?.reason, 'runner_phase_budget_exhausted'); - assert.equal(error.details?.phase, 'apple_toolchain_probe'); - assert.equal(error.details?.retriable, true); - expect(error.message).toContain('budget ran out'); - return true; -} - -test('a failing probe reports its exit status rather than a fabricated SDK version', () => { - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcrun' - ? { - exitCode: 70, - stdout: '', - stderr: 'xcrun: error: SDK cannot be located\n', - } - : appleToolchainProbeResult(command, args), - ); - - assert.deepEqual(unavailableProbes(), [ - { - probe: 'xcrun --sdk iphonesimulator --show-sdk-version', - reason: 'nonzero_exit', - }, - { - probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', - reason: 'nonzero_exit', - }, - ]); -}); - -test('unrecognized xcodebuild output is unavailable, not a partially parsed fingerprint', () => { - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcodebuild' - ? { - exitCode: 0, - stdout: 'xcode-select: error: tool not configured\n', - stderr: '', - } - : appleToolchainProbeResult(command, args), - ); - - assert.deepEqual(unavailableProbes(), [ - { probe: 'xcodebuild -version', reason: 'unparsable_output' }, - ]); -}); - -test('an empty probe answer is unavailable rather than an empty cache key field', () => { - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcrun' && args.includes('--show-sdk-build-version') - ? { exitCode: 0, stdout: '\n', stderr: '' } - : appleToolchainProbeResult(command, args), - ); - - assert.deepEqual(unavailableProbes(), [ - { - probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', - reason: 'empty_output', - }, - ]); -}); - -test('an unavailable toolchain fails the cache decision with a retriable typed error', () => { - runCmdSync.mockImplementation(() => { - throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', {}); - }); - - try { - resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - assert.fail('expected an unavailable toolchain to fail the cache decision'); - } catch (error) { - assert.ok(error instanceof AppError); - assert.equal(error.code, 'COMMAND_FAILED'); - assert.equal(error.details?.retriable, true); - expect(error.message).toContain('xcodebuild -version'); - expect(String(error.details?.hint)).toContain('xcode-select'); - } -}); - -test('an unavailable probe never reaches cache metadata, and is not memoized as one', () => { - runCmdSync.mockImplementation(() => { - throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', {}); - }); - expect(() => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR)).toThrow( - /Could not read the Xcode toolchain versions/, - ); - - runCmdSync.mockImplementation(appleToolchainProbeResult); - const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - - assert.equal(metadata.xcodeVersion, '26.2'); - assert.equal(metadata.xcodeBuildVersion, '17C52'); - assert.equal(metadata.sdkVersion, '26.2'); - assert.equal(metadata.sdkBuildVersion, '23C53'); -}); - -test('a malformed xcodebuild answer is not memoized: the next request re-probes and recovers', () => { - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcodebuild' - ? { - exitCode: 0, - stdout: 'xcode-select: error: tool not configured\n', - stderr: '', - } - : appleToolchainProbeResult(command, args), - ); - assert.deepEqual(unavailableProbes(), [ - { probe: 'xcodebuild -version', reason: 'unparsable_output' }, - ]); - - runCmdSync.mockImplementation(appleToolchainProbeResult); - runCmdSync.mockClear(); - const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - - assert.equal(metadata.xcodeVersion, '26.2'); - assert.equal(metadata.xcodeBuildVersion, '17C52'); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual(['xcodebuild', 'xcrun', 'xcrun']); -}); - -test('only a complete, parsed toolchain fingerprint is memoized', () => { - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcrun' && args.includes('--show-sdk-build-version') - ? { exitCode: 0, stdout: '\n', stderr: '' } - : appleToolchainProbeResult(command, args), - ); - assert.deepEqual(unavailableProbes(), [ - { - probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', - reason: 'empty_output', - }, - ]); - - runCmdSync.mockImplementation(appleToolchainProbeResult); - runCmdSync.mockClear(); - const first = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - // The healthy xcodebuild answer from the failed round was not kept either: all three re-run. - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual(['xcodebuild', 'xcrun', 'xcrun']); - - runCmdSync.mockClear(); - const second = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual([]); - assert.deepEqual( - [second.xcodeVersion, second.xcodeBuildVersion, second.sdkVersion, second.sdkBuildVersion], - [first.xcodeVersion, first.xcodeBuildVersion, first.sdkVersion, first.sdkBuildVersion], - ); -}); - function recordedRunnerBuildArguments(): string[] { const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); return [ @@ -724,26 +287,64 @@ test('an unreadable or setting-less build log fails the check', () => { ); }); +test('the toolchain half of the key is read from the selected Xcode', () => { + const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + assert.deepEqual( + { + xcodeVersion: metadata.xcodeVersion, + xcodeBuildVersion: metadata.xcodeBuildVersion, + sdkName: metadata.sdkName, + sdkVersion: metadata.sdkVersion, + sdkBuildVersion: metadata.sdkBuildVersion, + }, + { ...STUBBED_APPLE_TOOLCHAIN, sdkName: 'iphonesimulator' }, + ); +}); + +test('an unreadable toolchain fails the cache decision with a typed error naming the file', () => { + const developerDir = writeXcodeFixture(mkdtempForTestSync('xcode-without-sdk-')); + fs.rmSync(path.join(developerDir, 'Platforms/iPhoneSimulator.platform'), { recursive: true }); + appleRunnerTestHost.update({ commandDeveloperDir: () => developerDir }); + + assert.throws( + () => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR), + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + String(error.details?.file).endsWith('iPhoneSimulator.sdk/SDKSettings.json') && + error.details?.detail === 'ENOENT', + ); +}); + +test('an aborted owner is canceled before the toolchain is read', () => { + const request = new AbortController(); + request.abort(); + assert.throws( + () => + resolveExpectedRunnerCacheMetadata( + IOS_SIMULATOR, + undefined, + createRunnerPhaseBudget(10_000, request.signal), + ), + (error: unknown) => isRequestCanceledError(error), + ); +}); + test('a toolchain read under one DEVELOPER_DIR does not answer for another, so its runner is stale', () => { // An AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH override pins one path for every toolchain. withoutRunnerDerivedPathEnv(); - let developerDir: string | undefined = '/Applications/Xcode-A.app/Contents/Developer'; + const xcodeA = writeXcodeFixture(mkdtempForTestSync('xcode-a-')); + const xcodeB = writeXcodeFixture(mkdtempForTestSync('xcode-b-'), { + ...STUBBED_APPLE_TOOLCHAIN, + xcodeVersion: '27.0', + xcodeBuildVersion: '18A100', + }); + let developerDir = xcodeA; appleRunnerTestHost.update({ commandDeveloperDir: () => developerDir }); - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - developerDir === '/Applications/Xcode-B.app/Contents/Developer' && command === 'xcodebuild' - ? { exitCode: 0, stdout: 'Xcode 27.0\nBuild version 18A100\n', stderr: '' } - : appleToolchainProbeResult(command, args), - ); const underA = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - developerDir = '/Applications/Xcode-B.app/Contents/Developer'; - runCmdSync.mockClear(); + developerDir = xcodeB; const underB = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual([ - 'xcodebuild', - 'xcrun', - 'xcrun', - ]); assert.equal(underA.xcodeBuildVersion, STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); assert.equal(underB.xcodeBuildVersion, '18A100'); // The derived path is what a retained runner is reused by, so a differing one makes it stale. @@ -751,75 +352,16 @@ test('a toolchain read under one DEVELOPER_DIR does not answer for another, so i resolveRunnerDerivedPath(IOS_SIMULATOR, underA), resolveRunnerDerivedPath(IOS_SIMULATOR, underB), ); - - developerDir = '/Applications/Xcode-A.app/Contents/Developer'; - runCmdSync.mockClear(); - assert.equal( - resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR).xcodeBuildVersion, - STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion, - ); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual([]); }); -test('a default selection the host moved does not answer from the fingerprint it left behind', () => { - // Same consequence as the DEVELOPER_DIR case above, reached through `xcode-select` instead: - // `sudo xcode-select -s` changes what a read without a client dir is answering for (#3300). - withoutRunnerDerivedPathEnv(); - let selectedDir = '/Applications/Xcode-A.app/Contents/Developer'; - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcode-select' - ? selectedDeveloperDirResult(selectedDir) - : command === 'xcodebuild' && selectedDir === '/Applications/Xcode-B.app/Contents/Developer' - ? { exitCode: 0, stdout: 'Xcode 27.0\nBuild version 18A100\n', stderr: '' } - : appleToolchainProbeResult(command, args), - ); - - const onA = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - selectedDir = '/Applications/Xcode-B.app/Contents/Developer'; - runCmdSync.mockClear(); - const onB = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual([ - 'xcode-select', - 'xcodebuild', - 'xcrun', - 'xcrun', - ]); - assert.equal(onA.xcodeBuildVersion, STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - assert.equal(onB.xcodeBuildVersion, '18A100'); - // The derived path is what a retained runner is reused by, so a differing one makes it stale. - assert.notEqual( - resolveRunnerDerivedPath(IOS_SIMULATOR, onA), - resolveRunnerDerivedPath(IOS_SIMULATOR, onB), - ); -}); +test('an Xcode updated in place is read again rather than answered from before the update', () => { + const developerDir = writeXcodeFixture(mkdtempForTestSync('xcode-in-place-')); + appleRunnerTestHost.update({ commandDeveloperDir: () => developerDir }); + assert.equal(resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR).xcodeVersion, '26.2'); -test('a toolchain fingerprint is renewed by each hit and expires once nothing uses it', () => { - vi.useFakeTimers(); - try { - appleRunnerTestHost.update({ - commandDeveloperDir: () => '/Applications/Xcode-A.app/Contents/Developer', - }); - resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - runCmdSync.mockClear(); - // A hit is an entry keyed on the resolved dir AND the identity of the bundle standing in it, so - // the toolchain behind that key is still the one in place and the entry renews itself (#3300 - // asked to keep this). A bundle replaced in place moves the identity, not just the dir; see - // `a bundle replaced in place at the same path moves the key`. What an idle entry cannot - // outlast is the TTL, measured from the hit that last renewed it. - vi.advanceTimersByTime(9 * 60_000); - resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - expect(runCmdSync).not.toHaveBeenCalled(); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - - vi.advanceTimersByTime(10 * 60_001); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBeUndefined(); - resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual([ - 'xcodebuild', - 'xcrun', - 'xcrun', - ]); - } finally { - vi.useRealTimers(); - } + writeXcodeFixture(path.resolve(developerDir, '../../..'), { + ...STUBBED_APPLE_TOOLCHAIN, + xcodeVersion: '26.3', + }); + assert.equal(resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR).xcodeVersion, '26.3'); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-probe.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-probe.test.ts index 1aeff9854c..f2da45c768 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-probe.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-probe.test.ts @@ -1,15 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test } from 'vitest'; import { mkdtempForTestSync } from './tmp-dir.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { appleRunnerTestHost } from '../test-host.ts'; import { hasCachedAppleRunnerArtifact } from '../runner-xctestrun.ts'; -const mockRunCmdSync = vi.fn(); - beforeEach(() => { - appleRunnerTestHost.update({ runCmdSync: mockRunCmdSync }); + appleRunnerTestHost.update({}); }); const simulator: DeviceInfo = { @@ -24,16 +22,6 @@ const simulator: DeviceInfo = { let derivedDir: string; beforeEach(() => { - mockRunCmdSync.mockImplementation((command: string, args: string[]) => { - if (command === 'xcodebuild') { - return { exitCode: 0, stdout: 'Xcode 26.2\nBuild version 17C52\n', stderr: '' }; - } - return { - exitCode: 0, - stdout: args.includes('--show-sdk-build-version') ? '23C53\n' : '26.2\n', - stderr: '', - }; - }); derivedDir = mkdtempForTestSync('agent-device-cache-probe-'); process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derivedDir; }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-retention.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-retention.test.ts index a9813b7019..1cb38df628 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-retention.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-retention.test.ts @@ -15,7 +15,6 @@ import { writeRunnerLease, type RunnerLease, } from '../runner-lease.ts'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; @@ -306,7 +305,6 @@ test('building a new runner cache key sweeps the keys beside it', async () => { return { exitCode: 0, stdout: '', stderr: '' }; }); appleRunnerTestHost.update({ - runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), runCmdStreaming, findProjectRoot: () => projectRoot, readVersion: () => '0.0.0-test', diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts index e15f797632..3650eab7b7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts @@ -7,7 +7,6 @@ import { appleRunnerTestHost } from '../test-host.ts'; import { resolveRunnerCacheMetadataPath } from '../runner-cache.ts'; import { trimRunnerBuildScratch } from '../runner-cache-trim.ts'; import { ensureXctestrunArtifact } from '../runner-xctestrun.ts'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; @@ -198,7 +197,6 @@ function mockRunnerBuild() { return { exitCode: 0, stdout: '', stderr: '' }; }); appleRunnerTestHost.update({ - runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), runCmdStreaming, findProjectRoot: () => projectRoot, readVersion: () => '0.0.0-test', diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts index 3ed5c010e9..413ea934f8 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts @@ -5,14 +5,9 @@ import { onTestFinished } from 'vitest'; import { writeRunnerCacheMetadataForArtifacts } from '../runner-cache.ts'; import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import type { ExistingXctestrunState, RunnerXctestrunCacheMetadata } from '../runner-cache.ts'; -import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; -// The identity half reads the host toolchain, so the fixture that builds a manifest answers those -// probes itself: a consumer must not have to know it is importing an Xcode-touching helper. -stubAppleToolchainProbes(); - /** * A cache root and a content manifest for the tests that decide what a manifest certifies: the * bytes must exist before a digest can name them, and the layout must be the one a build leaves. diff --git a/packages/platform-apple/src/runner/__tests__/runner-client-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client-recovery-wiring.test.ts index 7dda00ea7e..ba3b8aff43 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client-recovery-wiring.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; -import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; import type { RunnerSession } from '../runner-session.ts'; @@ -11,7 +10,6 @@ import { appleRunnerTestHost } from '../test-host.ts'; import { withAppleRunnerProvider } from '../runner-provider.ts'; import { classifyRunnerReportedError, type RunnerCommand } from '../runner-contract.ts'; import { RUNNER_REPLY_LOST_REASON } from '../runner-error-classification.ts'; -import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import { createRunnerPhaseBudget, requireRunnerPhaseRemainingMs } from '../runner-phase-budget.ts'; import { captureDiagnostics } from './runner-session-fixtures.ts'; import { @@ -755,20 +753,6 @@ test.each([ return { run: readSnapshot, sends: sessionStarts }; }, }, - { - producer: 'an unreadable toolchain', - reason: 'apple_toolchain_probe_unavailable', - arrange: () => { - resetAllProcessMemosForTests(); - appleRunnerTestHost.update({ - runCmdSync: () => ({ exitCode: 1, stdout: '', stderr: 'xcode-select: error' }), - }); - ensureRunnerSessionMock.mockImplementation(async () => - resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR), - ); - return { run: readSnapshot, sends: sessionStarts }; - }, - }, { producer: 'an external runner provider', reason: 'provider_transport_unavailable', 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 5722bb1517..4939398efd 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts @@ -18,7 +18,6 @@ import { resolveExpectedRunnerCacheMetadata, resolveRunnerDerivedPath, } from '../runner-xctestrun.ts'; -import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; import { REPO_ROOT_FOR_TEST, makeScratchDir, @@ -73,7 +72,6 @@ const repoRoot = REPO_ROOT_FOR_TEST; // These cases key the cache without building it, so they answer the cache's toolchain probes // from a fixed Xcode instead of the host's. -stubAppleToolchainProbes(); test('resolveRunnerDestination uses simulator destination for simulators', () => { assert.equal(resolveRunnerDestination(iosSimulator), 'platform=iOS Simulator,id=sim-1'); diff --git a/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts b/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts index d06500e68e..fe78125d9b 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts @@ -2,15 +2,15 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, test, vi } from 'vitest'; import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { IOS_DEVICE, IOS_SIMULATOR, TVOS_SIMULATOR } from './device-fixtures.ts'; -import { STUBBED_APPLE_TOOLCHAIN, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; +import { STUBBED_APPLE_TOOLCHAIN } from './apple-toolchain-fixtures.ts'; import { isSameRunnerSimulator, runnerSimulatorSetFailureDetails, xcodebuildDestinationArgs, } from '../runner-device-set.ts'; -import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; -const toolchainProbe = stubAppleToolchainProbes(); beforeEach(resetAllProcessMemosForTests); afterEach(() => { vi.restoreAllMocks(); @@ -49,23 +49,19 @@ test('the default set, a blank set path and a physical device leave the destinat }); test('a failure reports the scoped set and the selected Xcode only for a scoped-set simulator', () => { - const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulators' }; - resolveExpectedRunnerCacheMetadata(scoped); - - assert.deepEqual(runnerSimulatorSetFailureDetails(scoped), { - simulatorSetPath: '/tmp/tenant-a/simulators', - xcodeVersion: STUBBED_APPLE_TOOLCHAIN.xcodeVersion, - }); + assert.deepEqual( + runnerSimulatorSetFailureDetails({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/sims' }), + { simulatorSetPath: '/tmp/tenant-a/sims', xcodeVersion: STUBBED_APPLE_TOOLCHAIN.xcodeVersion }, + ); assert.deepEqual(runnerSimulatorSetFailureDetails(IOS_SIMULATOR), {}); }); -test('a failure before any cache decision read the Xcode names the scoped set, never probing', () => { - toolchainProbe.mockClear(); +test('a failure under an unreadable Xcode names the scoped set alone', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => mkdtempForTestSync('no-xcode-') }); assert.deepEqual( runnerSimulatorSetFailureDetails({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/sims' }), { simulatorSetPath: '/tmp/tenant-a/sims' }, ); - assert.equal(toolchainProbe.mock.calls.length, 0); }); test('one udid in two simulator sets names two simulators', () => { diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-close-prep-fence.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-close-prep-fence.test.ts index 0587ee314d..d3ab3686f7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-close-prep-fence.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-close-prep-fence.test.ts @@ -12,7 +12,6 @@ import { makeBackgroundRunner, runnerResponse, } from './runner-session-fixtures.ts'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; @@ -136,7 +135,6 @@ beforeEach(async () => { builds = []; appleRunnerTestHost.update({ runCmdStreaming: mockBuildForTesting, - runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), runCmdBackground: mockRunCmdBackground, findProjectRoot: () => projectRoot, readVersion: () => '0.0.0-test', @@ -158,7 +156,6 @@ beforeEach(async () => { vi.resetAllMocks(); appleRunnerTestHost.update({ runCmdStreaming: mockBuildForTesting, - runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), findProjectRoot: () => projectRoot, readVersion: () => '0.0.0-test', }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport-early-exit.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-early-exit.test.ts index b27033e215..d7acbb7652 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-transport-early-exit.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-early-exit.test.ts @@ -7,16 +7,15 @@ import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import { buildRunnerEarlyExitError } from '../runner-startup-transport.ts'; import { readRunnerLogTail } from '../runner-io.ts'; -import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import { RunnerCommandAccounting, type RunnerSession } from '../runner-session-types.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; -import { STUBBED_APPLE_TOOLCHAIN, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { STUBBED_APPLE_TOOLCHAIN } from './apple-toolchain-fixtures.ts'; import { CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, CAPTURED_SCOPED_SIMULATOR, } from './runner-startup-failure-fixtures.ts'; -const toolchainProbe = stubAppleToolchainProbes(); beforeEach(resetAllProcessMemosForTests); // Verbatim xcodebuild output from an iPhone that was not in the signing account. @@ -208,7 +207,6 @@ test('a scoped-set simulator whose destination is missing names its set and the CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, SET_WITHOUT_UDID, ); - resolveExpectedRunnerCacheMetadata(session.device); const error = (await buildRunnerEarlyExitError({ session, port: 8100 })) as AppError; @@ -222,8 +220,8 @@ test('a scoped-set simulator whose destination is missing names its set and the ); }); -test('a scoped-set destination error before any cache decision read the Xcode still names the set', async () => { - toolchainProbe.mockClear(); +test('a scoped-set destination error under an unreadable Xcode still names the set', async () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => mkdtempForTestSync('no-xcode-') }); const error = (await buildRunnerEarlyExitError({ session: simulatorSessionFailingWith( CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, @@ -239,7 +237,6 @@ test('a scoped-set destination error before any cache decision read the Xcode st error.message, `Runner did not accept connection (xcodebuild exited early): xcodebuild found no simulator ${CAPTURED_SCOPED_SIMULATOR.udid} in simulator set ${SET_WITHOUT_UDID}`, ); - assert.equal(toolchainProbe.mock.calls.length, 0); }); test('a default-set simulator early exit keeps its boot-failure reason', async () => { diff --git a/packages/platform-apple/src/runner/__tests__/runner-toolchain-identity.test.ts b/packages/platform-apple/src/runner/__tests__/runner-toolchain-identity.test.ts new file mode 100644 index 0000000000..df97a5a23f --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-toolchain-identity.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { + appleSelectionLink, + STUBBED_APPLE_TOOLCHAIN, + stubAppleSelectionLink, + writeXcodeFixture, +} from './apple-toolchain-fixtures.ts'; +import { + readRunnerXcodeVersion, + requireRunnerToolchainFingerprint, + XCODE_SELECT_LINK_PATH, + type AppleSdkName, +} from '../runner-toolchain-identity.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +stubAppleSelectionLink(); + +/** Every field differs from the stubbed toolchain, so a half read from the wrong Xcode shows. */ +const XCODE_B_TOOLCHAIN = { + xcodeVersion: '27.0', + xcodeBuildVersion: '18A100', + sdkVersion: '27.0', + sdkBuildVersion: '24A100', +}; +const FINGERPRINT_A = { ...STUBBED_APPLE_TOOLCHAIN, sdkName: 'iphonesimulator' }; +const FINGERPRINT_B = { ...XCODE_B_TOOLCHAIN, sdkName: 'iphonesimulator' }; + +let xcodeA: string; +let xcodeB: string; + +beforeEach(() => { + xcodeA = writeXcodeFixture(mkdtempForTestSync('xcode-a-')); + xcodeB = writeXcodeFixture(mkdtempForTestSync('xcode-b-'), XCODE_B_TOOLCHAIN); +}); + +function readFingerprint(sdkName: AppleSdkName = 'iphonesimulator') { + return requireRunnerToolchainFingerprint(sdkName, undefined); +} + +test('a client DEVELOPER_DIR names the Xcode the fingerprint is read from', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => xcodeB }); + assert.deepEqual(readFingerprint(), FINGERPRINT_B); +}); + +test('a DEVELOPER_DIR carrying a trailing newline names the dir alone', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => `${xcodeB}\n` }); + assert.deepEqual(readFingerprint(), FINGERPRINT_B); +}); + +test('a DEVELOPER_DIR naming the Xcode.app bundle reads its Contents/Developer', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => path.resolve(xcodeB, '../..') }); + assert.deepEqual(readFingerprint(), FINGERPRINT_B); +}); + +test('without DEVELOPER_DIR the host selection link names the Xcode, and a moved link moves it', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + appleSelectionLink.target = xcodeA; + assert.deepEqual(readFingerprint(), FINGERPRINT_A); + appleSelectionLink.target = xcodeB; + assert.deepEqual(readFingerprint(), FINGERPRINT_B); +}); + +test('a selection link whose Xcode is gone fails instead of reading another Xcode', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + appleSelectionLink.target = path.join(mkdtempForTestSync('deleted-xcode-'), 'Xcode.app'); + assert.throws( + readFingerprint, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + error.details?.file === XCODE_SELECT_LINK_PATH && + error.details?.detail === 'dangling selection link', + ); +}); + +test('a selection link this process cannot read fails instead of reading the default Xcode', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + vi.spyOn(fs, 'readlinkSync').mockImplementation(() => { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + }); + assert.throws( + readFingerprint, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + error.details?.file === XCODE_SELECT_LINK_PATH && + error.details?.detail === 'EACCES', + ); +}); + +test('a bundle swapped behind a stable selection path is read from the bundle now standing there', () => { + const stable = path.join(mkdtempForTestSync('xcode-stable-'), 'Xcode.app'); + fs.symlinkSync(path.resolve(xcodeA, '../..'), stable); + appleRunnerTestHost.update({ + commandDeveloperDir: () => path.join(stable, 'Contents/Developer'), + }); + assert.deepEqual(readFingerprint(), FINGERPRINT_A); + + fs.rmSync(stable); + fs.symlinkSync(path.resolve(xcodeB, '../..'), stable); + assert.deepEqual(readFingerprint(), FINGERPRINT_B); +}); + +test('a developer dir without an Xcode bundle fails with the file it could not read', () => { + const commandLineTools = mkdtempForTestSync('command-line-tools-'); + appleRunnerTestHost.update({ commandDeveloperDir: () => commandLineTools }); + assert.throws( + readFingerprint, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + error.details?.file === path.join(fs.realpathSync(commandLineTools), '..', 'version.plist'), + ); + assert.equal(readRunnerXcodeVersion(), undefined); +}); + +test('a failure report reads the selected Xcode without waiting for a cache decision', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => xcodeB }); + assert.equal(readRunnerXcodeVersion(), '27.0'); +}); + +test('the Xcode version is the bundle short version and product build, as xcodebuild prints', () => { + fs.writeFileSync( + path.join(xcodeB, '..', 'version.plist'), + ` + + + + BuildVersion + 2 + CFBundleShortVersionString + 27.1 + CFBundleVersion + 24563 + ProductBuildVersion + 27A9275 + + +`, + ); + appleRunnerTestHost.update({ commandDeveloperDir: () => xcodeB }); + assert.deepEqual(readFingerprint(), { + ...FINGERPRINT_B, + xcodeVersion: '27.1', + xcodeBuildVersion: '27A9275', + }); +}); + +test('each runner SDK reads its version and build from its own platform directory', () => { + const sdkDirectories: Record = { + iphonesimulator: 'iPhoneSimulator', + iphoneos: 'iPhoneOS', + appletvsimulator: 'AppleTVSimulator', + appletvos: 'AppleTVOS', + macosx: 'MacOSX', + xrsimulator: 'XRSimulator', + xros: 'XROS', + }; + const expected = Object.entries(sdkDirectories).map(([sdkName, directory], index) => { + const sdk = path.join( + xcodeB, + `Platforms/${directory}.platform/Developer/SDKs/${directory}.sdk`, + ); + fs.writeFileSync( + path.join(sdk, 'SDKSettings.json'), + JSON.stringify({ Version: `30.${index}` }), + ); + fs.writeFileSync( + path.join(sdk, 'System/Library/CoreServices/SystemVersion.plist'), + `ProductBuildVersionB${index}`, + ); + return { + ...FINGERPRINT_B, + sdkName: sdkName as AppleSdkName, + sdkVersion: `30.${index}`, + sdkBuildVersion: `B${index}`, + }; + }); + appleRunnerTestHost.update({ commandDeveloperDir: () => xcodeB }); + for (const fingerprint of expected) { + assert.deepEqual(readFingerprint(fingerprint.sdkName), fingerprint); + } +}); + +test('a toolchain file without the key it must carry is a typed failure, not an empty value', () => { + const settings = path.join( + fs.realpathSync(xcodeB), + 'Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/SDKSettings.json', + ); + appleRunnerTestHost.update({ commandDeveloperDir: () => xcodeB }); + const unreadable = (detail: string) => (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + error.details?.file === settings && + error.details?.detail === detail; + fs.writeFileSync(settings, JSON.stringify({ Version: ' ' })); + assert.throws(readFingerprint, unreadable('no Version')); + fs.writeFileSync(settings, '{'); + assert.throws(readFingerprint, unreadable('not JSON')); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-toolchain-probe.test.ts b/packages/platform-apple/src/runner/__tests__/runner-toolchain-probe.test.ts deleted file mode 100644 index 7df14c9580..0000000000 --- a/packages/platform-apple/src/runner/__tests__/runner-toolchain-probe.test.ts +++ /dev/null @@ -1,710 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { isRequestCanceledError } from '@agent-device/kernel/errors'; -import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; -import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; -import type { DiagnosticEventInput } from '@agent-device/host-kit/diagnostics'; -import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { appleRunnerTestHost } from '../test-host.ts'; -import { - appleToolchainProbeResult, - appleSelectionLink, - blockForWholeTimeout, - installToolchainProbeClock, - selectedDeveloperDirResult, - STUBBED_APPLE_TOOLCHAIN, - stubAppleSelectionLink, - valueProbeCommands, -} from './apple-toolchain-fixtures.ts'; -import { - memoizedRunnerXcodeVersion, - requireRunnerToolchainFingerprint, -} from '../runner-toolchain-probe.ts'; -import { createRunnerPhaseBudget, type RunnerPhaseBudget } from '../runner-phase-budget.ts'; -import { mkdtempForTestSync } from './tmp-dir.ts'; -import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../apple-runner-platform.ts'; - -// The fingerprint memo is process-wide and the selection it keys on is host state, so these cases -// script both: `xcode-select -p` answers `selectedDeveloperDir`, and `xcodebuild` names the Xcode -// belonging to that selection. -const XCODE_A = '/Applications/Xcode-A.app/Contents/Developer'; -const XCODE_B = '/Applications/Xcode-B.app/Contents/Developer'; -const IPHONESIMULATOR_SDK = 'iphonesimulator'; -const XCODE_B_BUILD_VERSION = '18A100'; -const FINGERPRINT_TTL_MS = 10 * 60_000; -/** The ceiling one `xcode-select -p` attempt gets, below the 30 s a value probe gets. */ -const XCODE_SELECT_TIMEOUT_MS = 2_000; - -const runCmdSync = vi.fn(); -let selectedDeveloperDir = XCODE_A; -let nowMs = 1_700_000_000_000; - -stubAppleSelectionLink(); - -/** A scratch root the case's fake developer dirs and selection links live under. */ -let tempRoot: string; - -beforeEach(() => { - resetAllProcessMemosForTests(); - selectedDeveloperDir = XCODE_A; - tempRoot = mkdtempForTestSync('runner-selection-'); - runCmdSync.mockReset().mockImplementation(appleToolchainProbeResult); - appleRunnerTestHost.update({ - runCmdSync, - commandDeveloperDir: () => undefined, - }); - vi.spyOn(Date, 'now').mockImplementation(() => nowMs); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -/** - * A developer dir the host can resolve, standing in for an installed Xcode at `dir`. Cases below - * compare keys through the memo, and a dir that does not exist cannot be resolved to its real path, - * so its key would fall back to the name rather than the resolved dir the case means. - */ -function realSelectionDir(dir: string): string { - const created = path.join(tempRoot, dir); - fs.mkdirSync(created, { recursive: true }); - fs.writeFileSync(path.join(created, '..', 'version.plist'), 'build 1\n'); - return created; -} - -/** Two Xcodes and the `sudo xcode-select -s` that moves the host's default selection between them. */ -function scriptSelectableXcodes(): void { - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcode-select' - ? selectedDeveloperDirResult(selectedDeveloperDir) - : command === 'xcodebuild' && selectedDeveloperDir === XCODE_B - ? { - exitCode: 0, - stdout: `Xcode 27.0\nBuild version ${XCODE_B_BUILD_VERSION}\n`, - stderr: '', - } - : appleToolchainProbeResult(command, args), - ); -} - -function readFingerprint( - budget?: RunnerPhaseBudget, -): ReturnType { - return requireRunnerToolchainFingerprint(IPHONESIMULATOR_SDK, budget); -} - -function attemptedTimeoutMs(command: string): number | undefined { - const call = runCmdSync.mock.calls.find(([executed]) => executed === command); - return (call?.[2] as ExecOptions | undefined)?.timeoutMs; -} - -function blockResolutionForWholeTimeout(clock: { nowMs: number }): void { - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - if (command !== 'xcode-select') return appleToolchainProbeResult(command, args); - throw blockForWholeTimeout(clock, command, args, options); - }); -} - -test('an exhausted fingerprint retains the failed probe and retry in diagnostics', () => { - const clock = installToolchainProbeClock(); - vi.spyOn(Date, 'now').mockImplementation(() => clock.nowMs); - const events: DiagnosticEventInput[] = []; - appleRunnerTestHost.update({ emitDiagnostic: (event) => events.push(event) }); - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - if (command === 'xcode-select') return selectedDeveloperDirResult(XCODE_A); - throw blockForWholeTimeout(clock, command, args, options); - }); - - expect(readFingerprint).toThrow('The Apple runner budget ran out before this step began'); - - expect( - events - .filter(({ data }) => data?.cmd) - .map(({ phase, data }) => [phase, data?.event, data?.cmd, data?.timeoutMs]), - ).toEqual([ - ['apple_toolchain_probe', 'started', 'xcodebuild', 30_000], - ['apple_toolchain_probe', 'failed', 'xcodebuild', 30_000], - ['apple_toolchain_probe', 'started', 'xcodebuild', 15_000], - ['apple_toolchain_probe', 'failed', 'xcodebuild', 15_000], - ]); - for (const event of events.filter(({ data }) => data?.event === 'failed')) { - expect(event).toMatchObject({ - durationMs: event.data?.timeoutMs, - data: { - args: ['-version'], - error: { code: 'COMMAND_FAILED', details: { timeoutMs: event.data?.timeoutMs } }, - }, - }); - } - expect(clock.nowMs).toBe(45_000); -}); - -test.each([ - { - label: 'nonzero exit', - output: { exitCode: 70, stdout: '', stderr: 'SDK unavailable' }, - reason: 'nonzero_exit', - detail: 'exit 70: SDK unavailable', - }, - { - label: 'empty output', - output: { exitCode: 0, stdout: ' \n', stderr: '' }, - reason: 'empty_output', - detail: 'no output', - }, - { - label: 'unparsable output', - output: { exitCode: 0, stdout: 'unexpected version format', stderr: '' }, - reason: 'unparsable_output', - detail: 'unrecognized output: unexpected version format', - }, - { - label: 'exec failure', - output: new Error('version probe failed'), - reason: 'probe_error', - detail: 'version probe failed', - }, -])( - 'the trace records the classified $label before a later probe exhausts the budget', - ({ output, reason, detail }) => { - const clock = installToolchainProbeClock(); - const events: DiagnosticEventInput[] = []; - appleRunnerTestHost.update({ emitDiagnostic: (event) => events.push(event) }); - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - if (command === 'xcode-select') return selectedDeveloperDirResult(XCODE_A); - if (command === 'xcodebuild') { - if (output instanceof Error) throw output; - return output; - } - throw blockForWholeTimeout(clock, command, args, options); - }); - - expect(readFingerprint).toThrow('The Apple runner budget ran out before this step began'); - expect( - events.filter( - ({ data }) => data?.event === 'rejected' && data?.probe === 'xcodebuild -version', - ), - ).toEqual([ - { - level: 'error', - phase: 'apple_toolchain_probe', - data: { event: 'rejected', probe: 'xcodebuild -version', reason, detail }, - }, - ]); - }, -); - -test('the trace does not reject a readable toolchain fingerprint', () => { - const events: DiagnosticEventInput[] = []; - appleRunnerTestHost.update({ emitDiagnostic: (event) => events.push(event) }); - - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(events.filter(({ data }) => data?.event === 'rejected')).toEqual([]); -}); - -describe('the developer dir a toolchain fingerprint answers for', () => { - test('a default selection that moved re-reads instead of answering the Xcode it replaced', () => { - scriptSelectableXcodes(); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - - selectedDeveloperDir = XCODE_B; - runCmdSync.mockClear(); - expect(readFingerprint().xcodeBuildVersion).toBe(XCODE_B_BUILD_VERSION); - - // The switch costs one resolution and one full re-read. The entry keyed on the old dir cannot - // answer for the new one, which is what let a daemon serve the Xcode the host had left (#3300). - expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual([ - 'xcode-select', - 'xcodebuild', - 'xcrun', - 'xcrun', - ]); - }); - - test('a selection that comes back to a dir answers from that dir, not from the one it left', () => { - scriptSelectableXcodes(); - readFingerprint(); - selectedDeveloperDir = XCODE_B; - readFingerprint(); - - selectedDeveloperDir = XCODE_A; - runCmdSync.mockClear(); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual([]); - }); - - test('each read resolves the selection it keys on, and one resolution is enough', () => { - readFingerprint(); - runCmdSync.mockClear(); - - readFingerprint(); - - expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual(['xcode-select']); - }); - - test('a failure report names the Xcode of the selection this process resolved last', () => { - scriptSelectableXcodes(); - readFingerprint(); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - - selectedDeveloperDir = XCODE_B; - readFingerprint(); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe('27.0'); - - // Naming a selection is not worth waiting on one: only a fingerprint read resolves it. - runCmdSync.mockClear(); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe('27.0'); - expect(runCmdSync.mock.calls).toEqual([]); - }); - - test('a selection that comes back to a dir answers its report from that dir as well', () => { - scriptSelectableXcodes(); - readFingerprint(); - selectedDeveloperDir = XCODE_B; - readFingerprint(); - - selectedDeveloperDir = XCODE_A; - // The read itself is a hit on A's entry, so it stores nothing; the report pointer still has to - // follow the selection this read resolved, or it would blame B for an A-backed runner (#3300). - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - }); - - test('a client that chooses DEVELOPER_DIR keys its read without resolving the default', () => { - scriptSelectableXcodes(); - appleRunnerTestHost.update({ commandDeveloperDir: () => XCODE_A }); - - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - runCmdSync.mockClear(); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - - expect(runCmdSync.mock.calls).toEqual([]); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - }); - - test('a read that fails on its probes leaves a report naming the selection that stored one', () => { - scriptSelectableXcodes(); - readFingerprint(); - - selectedDeveloperDir = XCODE_B; - runCmdSync.mockImplementation((command: string) => - command === 'xcode-select' - ? selectedDeveloperDirResult(XCODE_B) - : { exitCode: 1, stdout: '', stderr: 'xcodebuild: no such tool\n' }, - ); - expect(() => readFingerprint()).toThrow(/Could not read the Xcode toolchain versions/); - - // XCODE_B stored nothing, so the only report still owed an answer is the one made under XCODE_A. - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - }); - - test('a selection that will not resolve reads a fingerprint and caches nothing for it', () => { - const unresolvable = (): ExecResult => ({ - exitCode: 72, - stdout: '', - stderr: 'xcode-select: error: no developer directory\n', - }); - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcode-select' ? unresolvable() : appleToolchainProbeResult(command, args), - ); - - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - runCmdSync.mockClear(); - // Nothing is cached under an unknown dir: the next read cannot tell which Xcode the entry would - // answer for, so it pays the probes again rather than risking another selection's answer. - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual([ - 'xcode-select', - 'xcodebuild', - 'xcrun', - 'xcrun', - ]); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBeUndefined(); - }); - - test('a warm read resolves its key from the selection link without spawning anything', () => { - appleSelectionLink.target = realSelectionDir(XCODE_A); - readFingerprint(); - runCmdSync.mockClear(); - - readFingerprint(); - - // The link is what a warm read reads, so the memo costs no process at all. `xcode-select -p` is - // the fallback, not the per-read cost, which is what made the key change expensive before. - expect(runCmdSync.mock.calls).toEqual([]); - }); - - test('a selection link naming a toolchain answers from that toolchain, not from xcode-select', () => { - appleSelectionLink.target = realSelectionDir(XCODE_A); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - - // The two sources disagree, which is what a host whose selection moved without its link keeping - // up looks like. The link is what `xcode-select -s` writes, so it is the one that names it. - scriptSelectableXcodes(); - selectedDeveloperDir = XCODE_B; - runCmdSync.mockClear(); - - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(runCmdSync.mock.calls).toEqual([]); - }); - - test('a selection link moved to another developer dir moves the key with it', () => { - appleSelectionLink.target = realSelectionDir(XCODE_A); - readFingerprint(); - - appleSelectionLink.target = realSelectionDir(XCODE_B); - runCmdSync.mockClear(); - - // The entry under the dir the host left cannot answer for the dir it moved to. - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual(['xcodebuild', 'xcrun', 'xcrun']); - expect(runCmdSync.mock.calls.some(([command]) => command === 'xcode-select')).toBe(false); - }); - - test('a bundle swapped behind a stable selection path moves the key', () => { - // What a tool like xcodes does: it replaces the Xcode.app bundle, so the selection keeps naming - // the same path while the toolchain behind it changes. Keying on the link's raw target would - // keep answering for the Xcode that path used to reach, so the key follows the real dir. - const stable = path.join(tempRoot, 'Xcode.app'); - const first = path.join(tempRoot, 'Xcode-1.app'); - const second = path.join(tempRoot, 'Xcode-2.app'); - for (const bundle of [first, second]) { - fs.mkdirSync(path.join(bundle, 'Contents', 'Developer'), { recursive: true }); - } - fs.symlinkSync(path.basename(first), stable); - appleSelectionLink.target = stable; - - readFingerprint(); - fs.unlinkSync(stable); - fs.symlinkSync(path.basename(second), stable); - - runCmdSync.mockClear(); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual(['xcodebuild', 'xcrun', 'xcrun']); - }); - - test('a selection reported with its trailing newline keys on the dir alone', () => { - // `xcode-select -p` answers with a newline. A key that kept it would never match the dir a - // client's DEVELOPER_DIR names, so one Xcode would look like two toolchains. - const reported = realSelectionDir(XCODE_A); - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcode-select' - ? { exitCode: 0, stdout: `${reported}\n`, stderr: '' } - : appleToolchainProbeResult(command, args), - ); - - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - runCmdSync.mockClear(); - - appleRunnerTestHost.update({ commandDeveloperDir: () => reported }); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - - // The client-named dir hit the entry the newline-built key stored, so nothing re-read. - expect(runCmdSync.mock.calls).toEqual([]); - }); - - test('a bundle replaced in place at the same path moves the key', () => { - // An App Store or installer update replaces the bundle without moving it, and the selection keeps - // naming the same dir. Renewal on a hit means a key blind to that would keep serving the - // fingerprint the update replaced for as long as the daemon stays busy, which is #3300 again. - const selected = realSelectionDir(XCODE_A); - appleSelectionLink.target = selected; - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - - // The installer writes a new bundle: same path, new inode for the version file. - const versionFile = path.join(selected, '..', 'version.plist'); - const replacedAt = fs.statSync(versionFile).mtimeMs + 1_000; - fs.unlinkSync(versionFile); - fs.writeFileSync(versionFile, 'build 2\n'); - // Not left to the filesystem to make the two halves of the identity differ. On ext4 or tmpfs a - // freed inode comes straight back out, and the two writes can land in one truncated millisecond, - // which would leave the key untouched and this case red for a reason the fix has nothing to do - // with. A replacement bundle is written after the one it replaces, so one second later says that - // wherever the temp dir lives. - fs.utimesSync(versionFile, new Date(replacedAt), new Date(replacedAt)); - - runCmdSync.mockClear(); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual(['xcodebuild', 'xcrun', 'xcrun']); - }); - - test('a report reads only the memo, never the filesystem', () => { - // A report runs after a failure, with no budget of its own to spend and no deadline to answer - // to. Everything it needs is the key a real read already built, so a slow or vanished bundle - // cannot make reporting wait, on either of its two paths. - const selected = realSelectionDir(XCODE_A); - appleSelectionLink.target = selected; - readFingerprint(); - appleRunnerTestHost.update({ commandDeveloperDir: () => selected }); - readFingerprint(); - - // The selection-link stub already spies realpathSync, so its history from the reads above is - // cleared before the report is asked to answer. - const statSync = vi.spyOn(fs, 'statSync').mockClear(); - const realpathSync = vi.spyOn(fs, 'realpathSync').mockClear(); - - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - - expect(statSync.mock.calls).toEqual([]); - expect(realpathSync.mock.calls).toEqual([]); - }); - - test('each client-named selection keeps its own report across another client read', () => { - // A daemon serves clients with different request-scoped DEVELOPER_DIRs at once. The second - // client's read must not erase what the first one needs to report on, even though both of their - // fingerprints are still cached. - const dirA = realSelectionDir(XCODE_A); - const dirB = realSelectionDir(XCODE_B); - let chosen: string | undefined = dirA; - appleRunnerTestHost.update({ commandDeveloperDir: () => chosen }); - runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === 'xcodebuild' && chosen === dirA - ? { exitCode: 0, stdout: 'Xcode 15.1\nBuild version 1A111\n', stderr: '' } - : appleToolchainProbeResult(command, args), - ); - - expect(readFingerprint().xcodeBuildVersion).toBe('1A111'); - chosen = dirB; - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - - chosen = dirA; - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe('15.1'); - chosen = dirB; - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - }); - - test('a report for a client dir this process never read answers with nothing', () => { - appleSelectionLink.target = realSelectionDir(XCODE_A); - readFingerprint(); - - const otherDir = realSelectionDir(XCODE_B); - appleRunnerTestHost.update({ commandDeveloperDir: () => otherDir }); - runCmdSync.mockClear(); - - // That dir has an entry only after a read resolves it, and looking one up would mean going and - // stat-ing a bundle a report must not wait on. - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBeUndefined(); - expect(runCmdSync.mock.calls).toEqual([]); - }); - - test('a selection that answers nothing, or dies on exec, is no dir rather than a bad one', () => { - for (const unanswered of [ - { exitCode: 0, stdout: ' \n', stderr: '' } satisfies ExecResult, - null, - ]) { - runCmdSync.mockImplementation((command: string, args: readonly string[]) => { - if (command !== 'xcode-select') return appleToolchainProbeResult(command, args); - if (!unanswered) throw new Error('ENOENT: xcode-select not found'); - return unanswered; - }); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - } - - expect(runCmdSync.mock.calls.filter(([command]) => command === 'xcode-select')).toHaveLength(2); - }); - - test('a default-selection read keys on what the resolution answers, not on a dir it saw before', () => { - scriptSelectableXcodes(); - appleRunnerTestHost.update({ commandDeveloperDir: () => XCODE_A }); - readFingerprint(); - - selectedDeveloperDir = XCODE_B; - appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); - runCmdSync.mockClear(); - expect(readFingerprint().xcodeBuildVersion).toBe(XCODE_B_BUILD_VERSION); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual(['xcodebuild', 'xcrun', 'xcrun']); - - // The resolution is what puts this read back on the client-dir entry: without believing its - // answer, a selection that came home would re-probe a toolchain this process already knows. - selectedDeveloperDir = XCODE_A; - runCmdSync.mockClear(); - expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual([]); - }); -}); - -describe('the budget and cancellation a default-selection resolution answers to', () => { - test('a resolution is capped below the value probe whose entry it decides', () => { - readFingerprint(); - - expect(attemptedTimeoutMs('xcode-select')).toBe(XCODE_SELECT_TIMEOUT_MS); - expect(attemptedTimeoutMs('xcodebuild')).toBe(COLD_TOOLCHAIN_PROBE_TIMEOUT_MS); - }); - - test('an owning phase leaves the resolution half of what it has and no more', () => { - installToolchainProbeClock(); - - readFingerprint(createRunnerPhaseBudget(1_200, undefined)); - - expect(attemptedTimeoutMs('xcode-select')).toBe(600); - }); - - test('a stalled resolution spends its own ceiling and never the fingerprint behind it', () => { - const clock = installToolchainProbeClock(); - blockResolutionForWholeTimeout(clock); - - const fingerprint = readFingerprint(); - - // A stalled `xcode-select` is a host not saying which Xcode it means, so the read continues - // with no dir and pays for the whole fingerprint: the optional probe cannot cost the answer. - expect(fingerprint.xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(clock.nowMs).toBe(XCODE_SELECT_TIMEOUT_MS); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual(['xcodebuild', 'xcrun', 'xcrun']); - }); - - test('a stalled resolution costs a cold-start retry its headroom, and only that', () => { - const clock = installToolchainProbeClock(); - const xcodebuildTimeouts: number[] = []; - runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { - if (command === 'xcode-select') throw blockForWholeTimeout(clock, command, args, options); - if (command !== 'xcodebuild') return appleToolchainProbeResult(command, args); - xcodebuildTimeouts.push(options.timeoutMs ?? 0); - if (xcodebuildTimeouts.length > 1) return appleToolchainProbeResult(command, args); - throw blockForWholeTimeout(clock, command, args, options); - }); - - const fingerprint = readFingerprint(); - - // The same cold-start stall the budget is sized around, on a read that also lost 2 s deciding - // its key: the retry runs on what is left, so one ceiling still bounds the whole fingerprint. - expect(fingerprint.xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); - expect(xcodebuildTimeouts).toEqual([ - COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, - 45_000 - COLD_TOOLCHAIN_PROBE_TIMEOUT_MS - XCODE_SELECT_TIMEOUT_MS, - ]); - expect(clock.nowMs).toBe(COLD_TOOLCHAIN_PROBE_TIMEOUT_MS + XCODE_SELECT_TIMEOUT_MS); - }); - - test('a request aborted during the key reads is canceled even on a warm cache', () => { - const request = new AbortController(); - appleSelectionLink.target = realSelectionDir(XCODE_A); - readFingerprint(createRunnerPhaseBudget(undefined, request.signal)); - - // The key's filesystem reads cannot be interrupted, so cancellation is observed after them. On a - // warm cache the answer is already in hand, which is exactly when a hit could otherwise hide the - // abort from the owner that asked for this request to stop. - const statTheBundle = fs.statSync; - vi.spyOn(fs, 'statSync').mockImplementation(((...args: Parameters) => { - request.abort(); - return statTheBundle(...args); - }) as typeof fs.statSync); - runCmdSync.mockClear(); - - try { - readFingerprint(createRunnerPhaseBudget(undefined, request.signal)); - assert.fail('expected the aborted request to be canceled at the key reads'); - } catch (error) { - assert.ok(isRequestCanceledError(error), `got ${String(error)}`); - } - assert.deepEqual(valueProbeCommands(runCmdSync.mock.calls), []); - }); - - for (const cache of ['cold', 'warm'] as const) { - test(`a request aborted while the resolution blocks is canceled with a ${cache} cache`, () => { - const request = new AbortController(); - if (cache === 'warm') { - readFingerprint(); - runCmdSync.mockClear(); - } - runCmdSync.mockImplementation((command: string) => { - if (command === 'xcode-select') { - request.abort(); - throw new Error('spawnSync was killed'); - } - throw new Error(`no value probe may run once the resolution was aborted: ${command}`); - }); - - try { - readFingerprint(createRunnerPhaseBudget(undefined, request.signal)); - assert.fail('expected the aborted request to be canceled at the resolution'); - } catch (error) { - assert.ok(isRequestCanceledError(error), `got ${String(error)}`); - } - assert.deepEqual( - runCmdSync.mock.calls.filter(([command]) => command !== 'xcode-select').flat(), - [], - ); - }); - } -}); - -describe('the lifetime of a memoized toolchain fingerprint', () => { - function advance(ms: number): void { - nowMs += ms; - } - - test('a hit renews the entry it answered from, because its key is still the selection', () => { - readFingerprint(); - - // The key names the dir this host selects, so a hit is itself evidence the entry is still the - // answer for that host. #3300 asked to keep this renewal; the stale answer it could once serve - // now costs a key change instead of a TTL. - advance(FINGERPRINT_TTL_MS / 2); - runCmdSync.mockClear(); - readFingerprint(); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual([]); - - advance(FINGERPRINT_TTL_MS / 2); - runCmdSync.mockClear(); - readFingerprint(); - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual([]); - }); - - test('an entry no read takes expires at the TTL its storing read started', () => { - readFingerprint(); - - advance(FINGERPRINT_TTL_MS); - runCmdSync.mockClear(); - readFingerprint(); - - // Renewal is for entries in use. One that nothing has asked for goes away, which is also the - // ceiling on how long a failure report can name a selection no request is using. - expect(valueProbeCommands(runCmdSync.mock.calls)).toEqual(['xcodebuild', 'xcrun', 'xcrun']); - }); - - test('a hit well inside the TTL answers the read without touching the toolchain', () => { - readFingerprint(); - runCmdSync.mockClear(); - - advance(FINGERPRINT_TTL_MS / 4); - readFingerprint(); - - expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual(['xcode-select']); - }); - - test('a report names no Xcode once the entry its selection recorded has expired', () => { - scriptSelectableXcodes(); - readFingerprint(); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - - // The selection stays recorded past expiry; what goes away is the entry behind it, and a report - // answers with nothing rather than probing or naming another selection's Xcode. - advance(FINGERPRINT_TTL_MS); - runCmdSync.mockClear(); - - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBeUndefined(); - expect(runCmdSync.mock.calls).toEqual([]); - }); - - test('a fingerprint expired under one selection is read again under the current one', () => { - scriptSelectableXcodes(); - readFingerprint(); - selectedDeveloperDir = XCODE_B; - - advance(FINGERPRINT_TTL_MS); - runCmdSync.mockClear(); - readFingerprint(); - - expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual([ - 'xcode-select', - 'xcodebuild', - 'xcrun', - 'xcrun', - ]); - expect(memoizedRunnerXcodeVersion(IOS_SIMULATOR)).toBe('27.0'); - }); -}); 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 980b3b42af..5930ea8659 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 @@ -22,7 +22,7 @@ import { type RunnerStartupFailureReason, } from '../runner-error-classification.ts'; import { assertDevToolsSecurityForIosRunner } from '../runner-dev-tools-security.ts'; -import { appleToolchainProbeResult, STUBBED_APPLE_TOOLCHAIN } from './apple-toolchain-fixtures.ts'; +import { STUBBED_APPLE_TOOLCHAIN } from './apple-toolchain-fixtures.ts'; import { IOS_DEVICE } from './device-fixtures.ts'; import { CAPTURED_SCOPED_SIMULATOR, @@ -76,7 +76,6 @@ const HINT_FOR_REASON: Record = { build_failed_unclassified: 'clean:xcuitest', }; -const runCmdSync = vi.fn(); const runCmdStreaming = vi.fn(); const runAppleToolCommand = vi.fn(); const DIAGNOSTIC_ID = 'diag-build-failure-1'; @@ -95,7 +94,6 @@ beforeEach(() => { derivedPath = mkdtempForTestSync('agent-device-startup-failure-derived-'); logPath = path.join(derivedPath, 'runner.log'); process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derivedPath; - runCmdSync.mockReset().mockImplementation(appleToolchainProbeResult); runCmdStreaming.mockReset().mockImplementation(async (): Promise => ({ exitCode: 0, stdout: '', @@ -103,7 +101,6 @@ beforeEach(() => { })); runAppleToolCommand.mockReset().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); appleRunnerTestHost.update({ - runCmdSync, runCmdStreaming, runAppleToolCommand, findProjectRoot: () => projectRoot, diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts index cd5ecc0b6a..89e94e6783 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts @@ -1,4 +1,4 @@ -import { test, vi, beforeEach } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; @@ -14,21 +14,9 @@ import { import { xcodebuildLogWithBuildArguments } from './runner-build-log.fixtures.ts'; import { writeXctestrunFixture } from './runner-xctestrun.fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; +import { stubbedAppleDeveloperDir } from './apple-toolchain-fixtures.ts'; import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; -// The toolchain probes reach the exec layer through `execFileSync`, which the package's host -// seam does not wrap; the module mock keeps a test from shelling out to a real Xcode. -const { mockExecFileSync } = vi.hoisted(() => ({ - mockExecFileSync: vi.fn(), -})); - -vi.mock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process'); - return { ...actual, execFileSync: mockExecFileSync }; -}); - -const mockRunCmdSync = vi.fn(); - import type { DeviceInfo } from '@agent-device/kernel/device'; import { findXctestrun, scoreXctestrunCandidate } from '../runner-artifact.ts'; import { evaluateExistingXctestrun } from '../runner-cache.ts'; @@ -63,28 +51,6 @@ const iosDevice: DeviceInfo = { const runnerPortEnv = { AGENT_DEVICE_RUNNER_PORT: '12345' }; -function appleToolFingerprintOutput(command: string, args: readonly string[]): string { - if (command === 'xcodebuild' && args[0] === '-version') { - return 'Xcode 26.2\nBuild version 17C52\n'; - } - if (command === 'xcrun' && args.includes('--show-sdk-version')) return '26.2\n'; - if (command === 'xcrun' && args.includes('--show-sdk-build-version')) return '23C53\n'; - throw new Error(`Unexpected Apple fingerprint command: ${command} ${args.join(' ')}`); -} - -mockExecFileSync.mockImplementation((command: string, args: readonly string[]) => - appleToolFingerprintOutput(command, args), -); -mockRunCmdSync.mockImplementation((command: string, args: string[]) => ({ - exitCode: 0, - stdout: appleToolFingerprintOutput(command, args), - stderr: '', -})); - -beforeEach(() => { - appleRunnerTestHost.update({ runCmdSync: mockRunCmdSync }); -}); - /** * `prepareXctestrunWithEnv` reads/writes the xctestrun plist via * `runAppleToolCommand('plutil', ...)`; fake just those calls (via a host override) the way @@ -230,7 +196,6 @@ test('the build script and the metadata writer publish one iOS simulator identit project.derivedPath, project.destination, project.buildLogPath, - project.bin, ]); assert.equal(written.status, 0, written.stderr); @@ -314,7 +279,6 @@ test('the metadata writer refuses a build log whose recipe it did not record', a project.derivedPath, project.destination, project.buildLogPath, - project.bin, ]); assert.notEqual(written.status, 0); assert.match(written.stderr, /did not use the settings its cache identity records/); @@ -327,7 +291,6 @@ test('the metadata writer refuses a build log whose recipe it did not record', a type RunnerBuildFixture = { root: string; - bin: string; derivedPath: string; buildLogPath: string; destination: string; @@ -397,7 +360,6 @@ function seedRunnerBuildFixture(root: string): RunnerBuildFixture { fs.mkdirSync(path.dirname(buildLogPath), { recursive: true }); return { root: projectRoot, - bin: fakeAppleToolchainBin(root), derivedPath, buildLogPath, destination: 'generic/platform=iOS Simulator', @@ -407,44 +369,11 @@ function seedRunnerBuildFixture(root: string): RunnerBuildFixture { }; } -/** - * The three tools the scripts exec: `xcodebuild -version` and two `xcrun --sdk` probes, answering - * exactly what this suite's fingerprint stub reports so the child and parent identities agree. - */ -function fakeAppleToolchainBin(root: string): string { - const bin = path.join(root, 'bin'); - fs.mkdirSync(bin, { recursive: true }); - writeFakeTool( - bin, - 'xcodebuild', - String.raw`{ printf 'Xcode 26.2 -'; printf 'Build version 17C52 -'; }`, - ); - writeFakeTool( - bin, - 'xcrun', - String.raw`{ for arg in "$@"; do - if [ "$arg" = "--show-sdk-build-version" ]; then printf "23C53 -"; exit 0; fi -done -printf "26.2 -"; }`, - ); - return bin; -} - -function writeFakeTool(bin: string, name: string, body: string): void { - fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`); - fs.chmodSync(path.join(bin, name), 0o755); -} - /** The recipe the emitter hands `xcodebuild`, read the way the build script reads it. */ function runBuildSettings(project: RunnerBuildFixture): string[] { const emitted = runScript(project.root, 'xcuitest-build-settings.ts', [ 'ios', project.destination, - project.bin, ]); assert.equal(emitted.status, 0, emitted.stderr); return emitted.stdout.split('\n').filter((line: string) => line !== ''); @@ -460,15 +389,14 @@ function runScript( stdout: string; stderr: string; } { - const fakeBin = args.at(-1)!; - const rest = args.slice(0, -1); const result = spawnSync( process.execPath, - ['--experimental-strip-types', path.join(repoRoot, 'scripts', script), ...rest], + ['--experimental-strip-types', path.join(repoRoot, 'scripts', script), ...args], { cwd, encoding: 'utf8', - env: { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}` }, + // The script keys its cache on the stubbed Xcode, as the parent suite does. + env: { ...process.env, DEVELOPER_DIR: stubbedAppleDeveloperDir() }, }, ); return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr }; diff --git a/packages/platform-apple/src/runner/apple-runner-platform.ts b/packages/platform-apple/src/runner/apple-runner-platform.ts index 42d8c4c6ab..f74c31c53d 100644 --- a/packages/platform-apple/src/runner/apple-runner-platform.ts +++ b/packages/platform-apple/src/runner/apple-runner-platform.ts @@ -8,43 +8,22 @@ import { type DeviceInfo, } from '@agent-device/kernel/device'; import { readHostCpuArchSync } from './host.ts'; - -/** - * Ceiling on one Apple toolchain identity probe attempt (`xcodebuild -version`, `xcrun - * --sdk --show-sdk-version`). On a fresh macOS host Apple's syspolicyd signature - * scan blocks the first `xcodebuild`/`xcrun` exec after boot for roughly 18 to 19 seconds - * at 0% CPU, and the next exec of the same tool is instant; a budget sized for a warm - * toolchain (the old 10 s / 5 s split) trips on that stall and reports a toolchain - * timeout that says nothing about the toolchain (#2422). - * - * It sits beside the SDK names the probes run against so both Apple toolchain probers - * read one value without either owning it. - */ -export const COLD_TOOLCHAIN_PROBE_TIMEOUT_MS = 30_000; +import type { AppleSdkName } from './runner-toolchain-identity.ts'; /** * The snapshot bridge's shared full-screen rule, named once. The bridge's runtime includes it and * the runner's UITests target compiles against it through HEADER_SEARCH_PATHS, so its bytes change - * both compiled binaries. Beside the probe ceiling above so the constant sits with the constants - * this package shares: the bridge cache identity and the runner fingerprint both read it without - * either subtree entering the other's graph. + * both compiled binaries. It sits with the constants this package shares: the bridge cache identity + * and the runner fingerprint both read it without either subtree entering the other's graph. */ export const SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME = 'ForegroundApplicationGeometry.h'; -/** - * The symlink `xcode-select -s` writes and `xcode-select -p` reads back. Resolving it answers which - * developer dir the host selects, without a subprocess, so the toolchain memo can key on that answer - * on a hit as well as on a miss. Beside the probe ceiling above so the constant sits with the - * toolchain constants this package shares rather than inside the one module that reads it. - */ -export const XCODE_SELECT_LINK_PATH = '/var/db/xcode_select_link'; - export type RunnerApplePlatformName = 'iOS' | 'tvOS' | 'macOS' | 'visionOS'; type RunnerPlatformDeviceKind = 'simulator' | 'device'; type RunnerPlatformProfile = { - sdkName: Record; + sdkName: Record; derivedBaseName: Record; xctestrunHints: Record; }; @@ -316,7 +295,7 @@ export function resolveRunnerHandoffTarget(device: DeviceInfo): RunnerHandoffTar export function resolveRunnerSdkName( platformName: RunnerApplePlatformName, deviceKind: DeviceInfo['kind'], -): string { +): AppleSdkName { return RUNNER_PLATFORM_PROFILES[platformName].sdkName[runnerPlatformDeviceKind(deviceKind)]; } diff --git a/packages/platform-apple/src/runner/host.ts b/packages/platform-apple/src/runner/host.ts index d6ffdd770e..687f46e7f7 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -10,7 +10,6 @@ import type * as KernelDeviceShell from '@agent-device/kernel/device-shell'; import type * as KernelKeyedLock from '@agent-device/kernel/keyed-lock'; import type * as KernelRecord from '@agent-device/kernel/record'; import type * as KernelSourceValue from '@agent-device/kernel/source-value'; -import type * as KernelTtlMemo from '@agent-device/kernel/ttl-memo'; import type * as BootDiagnostics from '@agent-device/provision-kit/boot-diagnostics'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { IosPhysicalDeviceRunnerControl } from '../core/physical-device-routing.ts'; @@ -54,7 +53,6 @@ import type * as AppleToolProvider from '../core/tool-provider.ts'; export type AppleRunnerHost = Pick< typeof HostCommand, | 'runCmdStreaming' - | 'runCmdSync' | 'runCmdBackground' | 'requireExecSuccess' | 'isCommandTimeoutError' @@ -77,7 +75,6 @@ export type AppleRunnerHost = Pick< Pick & Pick & Pick & - Pick & Pick & Pick & Pick & @@ -164,7 +161,6 @@ function delegate(name: Name): AppleRunnerHo } export const runCmdStreaming = delegate('runCmdStreaming'); -export const runCmdSync = delegate('runCmdSync'); export const runCmdBackground = delegate('runCmdBackground'); export const requireExecSuccess = delegate('requireExecSuccess'); export const isCommandTimeoutError = delegate('isCommandTimeoutError'); @@ -193,7 +189,6 @@ export const emitRequestProgress = delegate('emitRequestProgress'); export const getRequestSignal = delegate('getRequestSignal'); export const isRequestCanceled = delegate('isRequestCanceled'); export const withKeyedLock = delegate('withKeyedLock'); -export const createTtlMemo = delegate('createTtlMemo'); export const isRecord = delegate('isRecord'); export const parseBooleanLiteral = delegate('parseBooleanLiteral'); export const classifyBootFailure = delegate('classifyBootFailure'); diff --git a/packages/platform-apple/src/runner/runner-adoption.ts b/packages/platform-apple/src/runner/runner-adoption.ts index 1270405ddc..a333660b34 100644 --- a/packages/platform-apple/src/runner/runner-adoption.ts +++ b/packages/platform-apple/src/runner/runner-adoption.ts @@ -197,8 +197,8 @@ export async function tryAdoptRunnerSessionFromLease( device: DeviceInfo, options: { /** - * The startup phase's one budget: the fingerprint check below spends from it, its - * cancellation reaches those probes, and the adopted session inherits the rest (#2422). + * The startup phase's one budget: its cancellation stops the fingerprint check below, and the + * adopted session inherits the rest (#2422). */ budget?: RunnerPhaseBudget; expectedRunnerSessionId?: string; diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index af226cc51e..05bec97de6 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -479,9 +479,8 @@ type RunnerXctestrunBuildOptions = { */ startAdmission?: RunnerStartAdmission; /** - * The build phase's one budget, opened by whoever owns the build: the cache decision's - * blocking toolchain probes and `xcodebuild` spend the same clock, and the owning - * request cancels both (#2422). + * The build phase's one budget, opened by whoever owns the build: `xcodebuild` spends its + * clock, and the owning request cancels the cache decision and the build (#2422). */ budget?: RunnerPhaseBudget; }; diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index e0b93300bc..06e1d6d833 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -16,7 +16,7 @@ import { import { requireRunnerToolchainFingerprint, type RunnerToolchainFingerprint, -} from './runner-toolchain-probe.ts'; +} from './runner-toolchain-identity.ts'; import type { RunnerPhaseBudget } from './runner-phase-budget.ts'; import { computeRunnerSourceFingerprint } from './runner-source.ts'; @@ -138,7 +138,10 @@ export function resolveExpectedRunnerCacheMetadata( schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(projectRoot), runnerSourceFingerprint: computeRunnerSourceFingerprint(projectRoot), - ...requireRunnerToolchainFingerprint(resolveRunnerSdkName(platformName, device.kind), budget), + ...requireRunnerToolchainFingerprint( + resolveRunnerSdkName(platformName, device.kind), + budget?.signal, + ), platformName, deviceKind: device.kind, target: device.target ?? 'mobile', diff --git a/packages/platform-apple/src/runner/runner-client.ts b/packages/platform-apple/src/runner/runner-client.ts index c5a1a425cc..8e5506bddd 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -140,8 +140,8 @@ export function prewarmAppleRunnerCache( options, failurePhase: 'ios_runner_cache_prewarm_failed', task: async (runnerOptions) => { - // A cache prewarm owns the build phase it starts: one budget for the cache - // decision's toolchain probes and the `xcodebuild` that may follow them. + // A cache prewarm owns the build phase it starts: one budget for the + // `xcodebuild` the cache decision may call for. await ensureXctestrunArtifact(device, { ...runnerOptions, budget: createRunnerPhaseBudget(runnerOptions.buildTimeoutMs, runnerOptions.signal), diff --git a/packages/platform-apple/src/runner/runner-device-set.ts b/packages/platform-apple/src/runner/runner-device-set.ts index d08d5327fb..edea2ee1db 100644 --- a/packages/platform-apple/src/runner/runner-device-set.ts +++ b/packages/platform-apple/src/runner/runner-device-set.ts @@ -1,6 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { simulatorAddressFor } from './host.ts'; -import { memoizedRunnerXcodeVersion } from './runner-toolchain-probe.ts'; +import { readRunnerXcodeVersion } from './runner-toolchain-identity.ts'; /** The scoped simulator set that holds this runner's simulator, or undefined for the default set. */ export function runnerSimulatorSetPath(device: DeviceInfo): string | undefined { @@ -30,15 +30,15 @@ export function xcodebuildDestinationArgs(device: DeviceInfo, destination: strin type RunnerSimulatorSetFailureDetails = { simulatorSetPath?: string; xcodeVersion?: string }; /** - * The scoped set a runner xcodebuild phase resolved its destination in, with the selected Xcode when - * the runner cache decision already read it; empty for the default set. + * The scoped set a runner xcodebuild phase resolved its destination in, with the selected Xcode's + * version when it is readable; empty for the default set. */ export function runnerSimulatorSetFailureDetails( device: DeviceInfo, ): RunnerSimulatorSetFailureDetails { const simulatorSetPath = runnerSimulatorSetPath(device); if (simulatorSetPath === undefined) return {}; - const xcodeVersion = memoizedRunnerXcodeVersion(device); + const xcodeVersion = readRunnerXcodeVersion(); return xcodeVersion === undefined ? { simulatorSetPath } : { simulatorSetPath, xcodeVersion }; } diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 8e1e66010c..e734429286 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -87,8 +87,8 @@ const hasRunnerTokenUnsupportedReason: RunnerErrorDetailsMatch = (details) => /** * The runner refused the command before running it while abandoned main-thread work drains (#1105). * A resend keys on this code, never on `details.retriable`: that flag tells a caller's poll to try - * again, and its other producers (a not-running app, a spent startup budget, an unavailable - * toolchain probe, an external provider) must not be resent inside one request. + * again, and its other producers (a not-running app, a spent startup budget, an external + * provider) must not be resent inside one request. */ const hasRunnerBusyCode: RunnerErrorDetailsMatch = (details) => details.runnerErrorCode === RUNNER_BUSY_RUNNER_CODE; diff --git a/packages/platform-apple/src/runner/runner-phase-budget.ts b/packages/platform-apple/src/runner/runner-phase-budget.ts index b6e6bf9d67..73854c3153 100644 --- a/packages/platform-apple/src/runner/runner-phase-budget.ts +++ b/packages/platform-apple/src/runner/runner-phase-budget.ts @@ -5,7 +5,7 @@ import { Deadline } from './host.ts'; * Everything one runner phase may spend: the single clock every step of the phase reads, * and the owning request's cancellation. Created once, where the phase begins, and handed * on as this object — no step below receives a timeout number it could open a second phase - * with, which is how a cold probe stall and the build each spent the same budget (#2422). + * with, which is how a toolchain probe stall and the build once each spent the same budget (#2422). */ export type RunnerPhaseBudget = Readonly<{ /** The phase's clock; absent when its owner carries no budget at all. */ @@ -45,15 +45,10 @@ export function requireRunnerPhaseRemainingMs( } /** Says the phase budget ran out, not that the step it would have run is broken. */ -export function runnerPhaseBudgetExhaustedError(phase: string): AppError { +function runnerPhaseBudgetExhaustedError(phase: string): AppError { return new AppError('COMMAND_FAILED', 'The Apple runner budget ran out before this step began', { phase, reason: 'runner_phase_budget_exhausted', retriable: true, }); } - -/** The step that threw named its own phase; the caller only needs to know it was the budget. */ -export function isRunnerPhaseBudgetExhaustedError(error: unknown): boolean { - return error instanceof AppError && error.details?.reason === 'runner_phase_budget_exhausted'; -} diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index f313c9c860..85788d33a7 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -118,8 +118,8 @@ export async function ensureRunnerSession( readmitRunnerStartAdmission(startAdmission); assertRunnerStartAdmitsPreparation(device.id, startAdmission); // One budget for the whole start, opened once the lock is held so a start queued behind - // another does not spend its clock waiting: the reuse check's toolchain probes, adoption and - // the startup itself all read it. The request's cancellation rides with it, so a client + // another does not spend its clock waiting: the reuse check, adoption and the startup itself + // all read it. The request's cancellation rides with it, so a client // disconnect kills the blocking xctestrun build and runner launch (killProcessTree via exec) // instead of orphaning them; a caller's own deadline does not, so the start it interrupts is // still there for the retry (#2894). A start that outlives its caller is still bounded: once diff --git a/packages/platform-apple/src/runner/runner-toolchain-identity.ts b/packages/platform-apple/src/runner/runner-toolchain-identity.ts new file mode 100644 index 0000000000..3aad2fb18a --- /dev/null +++ b/packages/platform-apple/src/runner/runner-toolchain-identity.ts @@ -0,0 +1,208 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import { commandDeveloperDir } from './host.ts'; + +/** + * Apple toolchain identity, read from the files that `xcodebuild -version`, `xcrun + * --show-sdk-version`, and `xcrun --show-sdk-build-version` print from. Nothing here execs: a + * hosted macOS runner can stall an `xcrun` exec for tens of seconds, both on a fresh boot's + * signature scan (#2422) and right after an `xcodebuild` build, and a cache key must not depend on + * that. + */ + +export type AppleSdkName = + | 'iphonesimulator' + | 'iphoneos' + | 'appletvsimulator' + | 'appletvos' + | 'macosx' + | 'xrsimulator' + | 'xros'; + +/** Toolchain half of the runner cache key. Every field is read from the selected Xcode. */ +export type RunnerToolchainFingerprint = { + xcodeVersion: string; + xcodeBuildVersion: string; + sdkName: AppleSdkName; + sdkVersion: string; + sdkBuildVersion: string; +}; + +/** The `.platform` and `.sdk` that `xcrun --sdk ` resolves in a developer dir. */ +const SDK_DIRECTORY_NAMES: Record = { + iphonesimulator: 'iPhoneSimulator', + iphoneos: 'iPhoneOS', + appletvsimulator: 'AppleTVSimulator', + appletvos: 'AppleTVOS', + macosx: 'MacOSX', + xrsimulator: 'XRSimulator', + xros: 'XROS', +}; + +/** The symlink `xcode-select -s` writes and `xcode-select -p` reads back. */ +export const XCODE_SELECT_LINK_PATH = '/var/db/xcode_select_link'; + +/** Where `xcrun` looks when neither `DEVELOPER_DIR` nor the selection link names a developer dir. */ +const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; + +const TOOLCHAIN_FILE_HINT = + 'The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Check that `xcode-select -p` or DEVELOPER_DIR names a full Xcode install.'; + +type BundleVersion = Readonly<{ version: string; buildVersion: string }>; + +/** A toolchain file that is missing or does not carry the value the identity needs. */ +class ToolchainFileError extends Error { + readonly file: string; + readonly detail: string; + + constructor(file: string, detail: string) { + super(`${file}: ${detail}`); + this.file = file; + this.detail = detail; + } +} + +/** + * The toolchain half of the cache key. It also names the derived-data directory, so an + * unreadable toolchain fails the cache decision instead of standing in for one. The read + * blocks on nothing, so an aborted owner is answered before it rather than during it. + */ +export function requireRunnerToolchainFingerprint( + sdkName: AppleSdkName, + signal: AbortSignal | undefined, +): RunnerToolchainFingerprint { + if (signal?.aborted) throw createRequestCanceledError({ phase: 'apple_toolchain_identity' }); + const chosenDir = commandDeveloperDir(); + try { + const developerDir = resolveSelectedDeveloperDir(chosenDir); + const xcode = readXcodeVersion(developerDir); + const sdk = readSdkVersion(developerDir, sdkName); + return { + xcodeVersion: xcode.version, + xcodeBuildVersion: xcode.buildVersion, + sdkName, + sdkVersion: sdk.version, + sdkBuildVersion: sdk.buildVersion, + }; + } catch (error) { + if (!(error instanceof ToolchainFileError)) throw error; + throw new AppError( + 'COMMAND_FAILED', + `Could not read the Xcode toolchain versions the Apple runner cache is keyed on (${error.message})`, + { + reason: 'apple_toolchain_unreadable', + ...(chosenDir === undefined ? {} : { developerDir: chosenDir }), + file: error.file, + detail: error.detail, + hint: TOOLCHAIN_FILE_HINT, + }, + ); + } +} + +/** The selected Xcode's version, for a failure report that names it; undefined when unreadable. */ +export function readRunnerXcodeVersion(): string | undefined { + try { + return readXcodeVersion(resolveSelectedDeveloperDir(commandDeveloperDir())).version; + } catch (error) { + if (error instanceof ToolchainFileError) return undefined; + throw error; + } +} + +/** + * The developer dir `xcrun` uses, resolved to its real path: the client's `DEVELOPER_DIR`, else the + * host's selection link, else the default Xcode when no link exists. A tool like xcodes swaps the + * Xcode.app bundle behind a stable path, so the real path is the one that names the toolchain. Like + * `xcrun`, it accepts an `Xcode.app` bundle for its `Contents/Developer`, and a link whose target is + * gone is a broken selection rather than a reason to read another Xcode. + */ +function resolveSelectedDeveloperDir(chosenDir: string | undefined): string { + const candidate = chosenDir?.trim() || selectionLinkTarget() || DEFAULT_DEVELOPER_DIR; + const resolved = realPath(candidate) ?? candidate; + const bundleDeveloperDir = path.join(resolved, 'Contents', 'Developer'); + return fs.existsSync(bundleDeveloperDir) ? bundleDeveloperDir : resolved; +} + +function selectionLinkTarget(): string | undefined { + try { + fs.readlinkSync(XCODE_SELECT_LINK_PATH); + } catch (error) { + // No selection link: `xcrun` falls back to the default Xcode as well. Any other failure is a + // selection this process cannot read, which must not stand in for the default Xcode. + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return undefined; + throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, code ?? String(error)); + } + const target = realPath(XCODE_SELECT_LINK_PATH); + if (target === undefined) { + throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, 'dangling selection link'); + } + return target; +} + +/** What `xcodebuild -version` prints for the Xcode that owns `developerDir`. */ +function readXcodeVersion(developerDir: string): BundleVersion { + const file = path.join(developerDir, '..', 'version.plist'); + const plist = readToolchainFile(file); + return { + version: plistString(plist, 'CFBundleShortVersionString', file), + buildVersion: plistString(plist, 'ProductBuildVersion', file), + }; +} + +/** What `xcrun --sdk --show-sdk-version` and `--show-sdk-build-version` print. */ +function readSdkVersion(developerDir: string, sdkName: AppleSdkName): BundleVersion { + const name = SDK_DIRECTORY_NAMES[sdkName]; + const sdkPath = path.join( + developerDir, + 'Platforms', + `${name}.platform`, + 'Developer', + 'SDKs', + `${name}.sdk`, + ); + const settingsFile = path.join(sdkPath, 'SDKSettings.json'); + const versionFile = path.join(sdkPath, 'System/Library/CoreServices/SystemVersion.plist'); + return { + version: jsonString(readToolchainFile(settingsFile), 'Version', settingsFile), + buildVersion: plistString(readToolchainFile(versionFile), 'ProductBuildVersion', versionFile), + }; +} + +function realPath(candidate: string): string | undefined { + try { + return fs.realpathSync(candidate) || undefined; + } catch { + return undefined; + } +} + +function readToolchainFile(file: string): string { + try { + return fs.readFileSync(file, 'utf8'); + } catch (error) { + throw new ToolchainFileError(file, (error as NodeJS.ErrnoException).code ?? String(error)); + } +} + +/** One top-level string of an XML property list, the format of every plist read here. */ +function plistString(plist: string, key: string, file: string): string { + const value = new RegExp(`${key}\\s*([^<]*)`) + .exec(plist)?.[1] + ?.trim(); + if (!value) throw new ToolchainFileError(file, `no ${key}`); + return value; +} + +function jsonString(json: string, key: string, file: string): string { + let value: unknown; + try { + value = (JSON.parse(json) as Record)[key]; + } catch { + throw new ToolchainFileError(file, 'not JSON'); + } + if (typeof value !== 'string' || !value.trim()) throw new ToolchainFileError(file, `no ${key}`); + return value.trim(); +} diff --git a/packages/platform-apple/src/runner/runner-toolchain-probe.ts b/packages/platform-apple/src/runner/runner-toolchain-probe.ts deleted file mode 100644 index 0eb4cc9cf5..0000000000 --- a/packages/platform-apple/src/runner/runner-toolchain-probe.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { - AppError, - createRequestCanceledError, - isRequestCanceledError, - normalizeError, -} from '@agent-device/kernel/errors'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { TtlMemo } from '@agent-device/kernel/ttl-memo'; -import { - commandDeveloperDir, - createTtlMemo, - Deadline, - emitDiagnostic, - isCommandTimeoutError, - runCmdSync, -} from './host.ts'; -import fs from 'node:fs'; -import path from 'node:path'; -import { - COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, - XCODE_SELECT_LINK_PATH, - resolveRunnerPlatformName, - resolveRunnerSdkName, -} from './apple-runner-platform.ts'; -import { - isRunnerPhaseBudgetExhaustedError, - runnerPhaseBudgetExhaustedError, - type RunnerPhaseBudget, -} from './runner-phase-budget.ts'; - -/** - * Ceiling on the wall clock the whole toolchain fingerprint may spend, across the cache-key probe, - * the three value probes, and their retries, when the owning phase carries no shorter budget: one - * stalled probe, its warm retry, and the two probes still to run (#2422). The cache-key probe takes - * at most {@link XCODE_SELECT_PROBE_TIMEOUT_MS} of it and half of what the phase has left, so a - * stalled `xcode-select` costs a value probe that headroom and nothing more. - */ -const TOOLCHAIN_FINGERPRINT_BUDGET_MS = 45_000; - -/** - * Ceiling on one `xcode-select -p` attempt, which the cache key needs only when the host's selection - * link cannot be read. `xcode-select -p` answers in tens of milliseconds, so this is headroom rather - * than an expected cost, and it stays small enough that a stalled probe cannot take the cold-start - * ceiling away from the value probes whose entry it is deciding. A selection this process cannot - * resolve leaves the read with no dir to key on. - */ -const XCODE_SELECT_PROBE_TIMEOUT_MS = 2_000; - -const TOOLCHAIN_PROBE_MAX_BUFFER = 128 * 1024; -const TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH = 200; -const TOOLCHAIN_PROBE_HINT = - 'The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Retry once the host is less loaded, or check `xcode-select -p` and `xcodebuild -version`.'; - -/** Toolchain half of the runner cache key. Every field is a probed value. */ -export type RunnerToolchainFingerprint = { - xcodeVersion: string; - xcodeBuildVersion: string; - sdkName: string; - sdkVersion: string; - sdkBuildVersion: string; -}; - -type ToolchainProbeFailure = { - probe: string; - reason: 'probe_error' | 'nonzero_exit' | 'empty_output' | 'unparsable_output'; - detail: string; -}; - -type ProbeResult = - | { ok: true; value: Value } - | { ok: false; failure: ToolchainProbeFailure }; - -/** - * The remaining-time and cancellation view the probes consult: one per fingerprint read, - * so the cache-key probe, the three value probes, and their retries share a single budget. - * A phase with no deadline still gets {@link TOOLCHAIN_FINGERPRINT_BUDGET_MS} as the ceiling. - * - * `spawnSync` cannot be interrupted once it has started, so cancellation is observed - * between attempts; the per-attempt cap is what bounds how long that takes. - */ -type ToolchainProbeClock = { - /** Milliseconds the next attempt may block for; 0 once the budget is spent. */ - attemptTimeoutMs(): number; - /** - * Milliseconds the cache-key probe may block for. Deciding which entry to read is optional - * work that precedes the read which answers for it, so it may spend neither more than - * {@link XCODE_SELECT_PROBE_TIMEOUT_MS} nor more than half of what that read still needs. - * Zero once the budget is spent, which skips the optional probe entirely. - */ - keyProbeTimeoutMs(): number; - /** Throws the owning request's cancellation error once it has aborted. */ - throwIfCanceled(): void; -}; - -function createToolchainProbeClock(budget: RunnerPhaseBudget | undefined): ToolchainProbeClock { - const phaseDeadline = budget?.deadline; - const deadline = Deadline.fromTimeoutMs( - Math.min( - TOOLCHAIN_FINGERPRINT_BUDGET_MS, - phaseDeadline ? phaseDeadline.remainingMs() : Number.POSITIVE_INFINITY, - ), - ); - return { - attemptTimeoutMs: () => - Math.min(COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, Math.floor(deadline.remainingMs())), - keyProbeTimeoutMs: () => - Math.min(XCODE_SELECT_PROBE_TIMEOUT_MS, Math.floor(deadline.remainingMs() / 2)), - throwIfCanceled: () => { - if (budget?.signal?.aborted) { - throw createRequestCanceledError({ phase: 'apple_toolchain_probe' }); - } - }, - }; -} - -// Lazy: createTtlMemo is a host capability, and module evaluation happens -// before the composition root binds the host. Only a complete, parsed -// fingerprint is ever memoized, so nothing unavailable can outlive the probe -// that could not answer. The key carries the developer dir the read answers for -// and the identity of the bundle standing in it, so an entry is stale only until -// its own key stops describing the host's selection, which is what let a daemon -// serve the Xcode a host had left (#3300). A hit therefore renews the entry, as -// it did before: an idle entry still goes away after -// TOOLCHAIN_FINGERPRINT_TTL_MS, which also bounds how long a failure report -// can name a selection no request is using any more. -const TOOLCHAIN_FINGERPRINT_TTL_MS = 10 * 60_000; -let lazyToolchainFingerprintCache: TtlMemo | undefined; -function toolchainFingerprintCache(): TtlMemo { - lazyToolchainFingerprintCache ??= createTtlMemo({ - ttlMs: TOOLCHAIN_FINGERPRINT_TTL_MS, - scheduleExpiry: true, - }); - return lazyToolchainFingerprintCache; -} - -/** - * The halves of a key that answered, kept so a report reaches the same entry without resolving, - * stating, or spawning anything: a report has no budget to spend and no deadline to answer to. - */ -type RecordedSelection = { - developerDir: string; - bundleIdentity: string | undefined; -}; - -// One clock with the fingerprints it points at, and expiry scheduled for the same reason: nothing -// renews a client dir whose reads stopped, so its record cannot outlive the entry it names. -let lazyRecordedClients: TtlMemo | undefined; -function lazyRecordedClientSelections(): TtlMemo | undefined { - lazyRecordedClients ??= createTtlMemo({ - ttlMs: TOOLCHAIN_FINGERPRINT_TTL_MS, - scheduleExpiry: true, - }); - return lazyRecordedClients; -} - -/** - * What the last default-selection read answered for, which is what a report names when the client - * chose no `DEVELOPER_DIR`. A read that threw or could not resolve its selection leaves it alone, - * and the entry behind it can expire, so a report that finds nothing names no Xcode. - */ -let recordedDefaultSelection: RecordedSelection | undefined; - -/** - * What each client-named selection answered for, under the exact name that client used. Kept per - * name rather than in one slot because a daemon serves clients with different request-scoped - * `DEVELOPER_DIR`s at once: one global record would let the last read erase what an earlier client - * needs to report on, even though its fingerprint is still cached. It is a memo and not a map, so a - * name drops out on the same clock as the entry it points at. - */ -function recordedClientSelection(named: string): RecordedSelection | undefined { - return lazyRecordedClientSelections()?.get(named); -} - -function rememberRecordedClientSelection(named: string, answered: RecordedSelection): void { - lazyRecordedClientSelections()?.set(named, answered); -} - -/** - * The toolchain half of the cache key. It also names the derived-data directory, so an - * unreadable toolchain fails the cache decision instead of standing in for one. - */ -export function requireRunnerToolchainFingerprint( - sdkName: string, - budget: RunnerPhaseBudget | undefined, -): RunnerToolchainFingerprint { - // Before the cache, not just before the probes: a hit must not hide a cancellation. - const clock = createToolchainProbeClock(budget); - clock.throwIfCanceled(); - const chosenDir = commandDeveloperDir(); - // Both sources are resolved to the dir they really name, so a client naming a symlinked Xcode and - // a daemon reading the selection link land on one entry for one toolchain. - const resolvedDir = - chosenDir !== undefined - ? (resolveRealDeveloperDir(chosenDir) ?? chosenDir) - : resolveDefaultDeveloperDir(clock); - // A read that cannot tell which Xcode it answers for has no entry to read or write: a shared - // unknown key would let one host selection answer for the next, which is the fault here. - if (resolvedDir === undefined) return requireFreshRunnerToolchainFingerprint(sdkName, clock); - const bundleIdentity = readBundleIdentity(resolvedDir); - // The key's two filesystem reads cannot be interrupted, so an owner that aborted while they ran - // is answered here rather than by a memo hit: a hit must not hide a cancellation either. - clock.throwIfCanceled(); - const cacheKey = toolchainFingerprintCacheKey(resolvedDir, bundleIdentity, sdkName); - const fingerprint = requireMemoizedRunnerToolchainFingerprint(cacheKey, sdkName, clock); - const answered = { developerDir: resolvedDir, bundleIdentity }; - // A report finds its entry by the key this read built, whether the read stored it or hit it, so a - // report never has to resolve or stat anything itself. - if (chosenDir !== undefined) rememberRecordedClientSelection(chosenDir, answered); - else recordedDefaultSelection = answered; - return fingerprint; -} - -/** - * A cheap identity for the toolchain bundle at `developerDir`, or undefined when it has none to - * offer. The dir alone cannot see an update that replaces the bundle in place — an installer or App - * Store update at `/Applications/Xcode.app` keeps the path — and a hit renews its entry, so a key - * blind to that would keep answering with the fingerprint the update replaced for as long as the - * daemon stays busy. The version file `xcodebuild -version` reads is the marker: replacing or - * rewriting the bundle moves its inode or mtime, which moves the key, and the entry the old key held - * expires once nothing renews it. - */ -function readBundleIdentity(developerDir: string): string | undefined { - try { - const versionFile = fs.statSync(path.join(developerDir, '..', 'version.plist')); - return `${versionFile.ino}:${Math.trunc(versionFile.mtimeMs)}`; - } catch { - return undefined; - } -} - -/** This key's fingerprint, read from the memo when one answers for it and stored when it does not. */ -function requireMemoizedRunnerToolchainFingerprint( - cacheKey: string, - sdkName: string, - clock: ToolchainProbeClock, -): RunnerToolchainFingerprint { - const cached = toolchainFingerprintCache().get(cacheKey); - if (cached) { - // A hit is an entry keyed on both this dir and the identity of the bundle standing in it, so the - // toolchain behind the key is still the one in place and the entry renews itself. Only an entry - // no request reads expires (#3300). - toolchainFingerprintCache().set(cacheKey, cached); - return cached; - } - const fingerprint = requireFreshRunnerToolchainFingerprint(sdkName, clock); - toolchainFingerprintCache().set(cacheKey, fingerprint); - return fingerprint; -} - -function toolchainFingerprintCacheKey( - developerDir: string, - bundleIdentity: string | undefined, - sdkName: string, -): string { - return `${developerDir}\0${bundleIdentity ?? ''}\0${sdkName}`; -} - -function requireFreshRunnerToolchainFingerprint( - sdkName: string, - clock: ToolchainProbeClock, -): RunnerToolchainFingerprint { - const fingerprint = readRunnerToolchainFingerprint(sdkName, clock); - if (!fingerprint.ok) throw unavailableToolchainError(fingerprint.failures); - return fingerprint.value; -} - -/** - * The dir the host's default selection points at, symlinks followed. The selection link is read - * first because the memo needs its key on every hit and not only on a miss, and spawning - * `xcode-select` costs a few milliseconds on a warm read. A link this process cannot read falls back - * to `xcode-select -p`, and a selection neither can name leaves the read with no key at all. - */ -function resolveDefaultDeveloperDir(clock: ToolchainProbeClock): string | undefined { - const selectedLink = resolveRealDeveloperDir(XCODE_SELECT_LINK_PATH); - if (selectedLink) return selectedLink; - const reported = probeSelectedDeveloperDir(clock); - if (reported === undefined) return undefined; - // A selection the host names but this process cannot follow is still that host's selection, so it - // keys on the name. Keying on nothing instead would let one host answer for the next. - return resolveRealDeveloperDir(reported) ?? reported; -} - -/** - * The dir `candidate` really names, or undefined when nothing is there to name. `realpath` rather - * than the link's raw target because a tool like xcodes swaps the Xcode.app bundle behind a stable - * path: the raw target survives that swap, so a key built from it would keep answering for the Xcode - * the host replaced. - */ -function resolveRealDeveloperDir(candidate: string | undefined): string | undefined { - if (candidate === undefined) return undefined; - try { - return fs.realpathSync(candidate.trim()) || undefined; - } catch { - return undefined; - } -} - -/** - * The dir `xcode-select` currently selects, or undefined when it will not say. Best effort: a read - * that cannot resolve its own entry spends what is left of its budget on the probes that carry the - * answer rather than on deciding which answer to keep. - */ -function probeSelectedDeveloperDir(clock: ToolchainProbeClock): string | undefined { - const timeoutMs = clock.keyProbeTimeoutMs(); - if (timeoutMs <= 0) return undefined; - let selectedDir: string | undefined; - try { - const output = runCmdSync('xcode-select', ['-p'], { - allowFailure: true, - timeoutMs, - maxBuffer: TOOLCHAIN_PROBE_MAX_BUFFER, - }); - selectedDir = output.exitCode === 0 ? output.stdout.trim() : undefined; - } catch { - selectedDir = undefined; - } - // Also covers a request that aborted while `spawnSync` blocked: that is the caller's error, not a - // host whose default selection cannot be read. - clock.throwIfCanceled(); - return selectedDir || undefined; -} - -/** - * The selected Xcode's version as this process's runner cache decision memoized it, for a failure - * report that names it; undefined when no decision has read the toolchain in the last - * `TOOLCHAIN_FINGERPRINT_TTL_MS`. Reads nothing but the memo: a report has no budget to spend and - * must not wait on the toolchain it describes, so it reuses the key a real read already built. - */ -export function memoizedRunnerXcodeVersion(device: DeviceInfo): string | undefined { - const chosenDir = commandDeveloperDir(); - const answered = - chosenDir === undefined ? recordedDefaultSelection : recordedClientSelection(chosenDir); - if (answered === undefined) return undefined; - return toolchainFingerprintCache().get( - toolchainFingerprintCacheKey( - answered.developerDir, - answered.bundleIdentity, - resolveRunnerSdkName(resolveRunnerPlatformName(device), device.kind), - ), - )?.xcodeVersion; -} - -function readRunnerToolchainFingerprint( - sdkName: string, - clock: ToolchainProbeClock, -): - | { ok: true; value: RunnerToolchainFingerprint } - | { ok: false; failures: readonly ToolchainProbeFailure[] } { - const xcode = parseXcodeVersionOutput(runToolchainProbe('xcodebuild', ['-version'], clock)); - const sdkVersion = runToolchainProbe('xcrun', ['--sdk', sdkName, '--show-sdk-version'], clock); - const sdkBuildVersion = runToolchainProbe( - 'xcrun', - ['--sdk', sdkName, '--show-sdk-build-version'], - clock, - ); - if (!xcode.ok || !sdkVersion.ok || !sdkBuildVersion.ok) { - return { - ok: false, - failures: [xcode, sdkVersion, sdkBuildVersion].flatMap((probe) => - probe.ok ? [] : [probe.failure], - ), - }; - } - return { - ok: true, - value: { - xcodeVersion: xcode.value.version, - xcodeBuildVersion: xcode.value.buildVersion, - sdkName, - sdkVersion: sdkVersion.value, - sdkBuildVersion: sdkBuildVersion.value, - }, - }; -} - -function unavailableToolchainError(failures: readonly ToolchainProbeFailure[]): AppError { - return new AppError( - 'COMMAND_FAILED', - `Could not read the Xcode toolchain versions the Apple runner cache is keyed on (${failures - .map((failure) => `${failure.probe}: ${failure.detail}`) - .join('; ')})`, - { - reason: 'apple_toolchain_probe_unavailable', - retriable: true, - probes: failures, - hint: TOOLCHAIN_PROBE_HINT, - }, - ); -} - -function runToolchainProbe( - cmd: string, - args: string[], - clock: ToolchainProbeClock, -): ProbeResult { - const probe = [cmd, ...args].join(' '); - let output: { exitCode: number; stdout: string; stderr: string }; - try { - output = runToolchainProbeCommand(cmd, args, clock); - } catch (error) { - // A cancellation or a spent budget is the caller's error, not an unreadable toolchain. - clock.throwIfCanceled(); - if (isRequestCanceledError(error) || isRunnerPhaseBudgetExhaustedError(error)) throw error; - return probeFailure(probe, 'probe_error', error instanceof Error ? error.message : `${error}`); - } - if (output.exitCode !== 0) { - return probeFailure( - probe, - 'nonzero_exit', - `exit ${output.exitCode}${output.stderr.trim() ? `: ${output.stderr.trim()}` : ''}`, - ); - } - const value = output.stdout.trim(); - return value ? { ok: true, value } : probeFailure(probe, 'empty_output', 'no output'); -} - -/** - * Retries exactly once, and only the exec layer's structured timeout: the stall - * {@link COLD_TOOLCHAIN_PROBE_TIMEOUT_MS} names clears on the next exec of the same tool, - * while a tool that failed on its own and said "timed out" in its output is not it. - */ -function runToolchainProbeCommand( - cmd: string, - args: string[], - clock: ToolchainProbeClock, -): { exitCode: number; stdout: string; stderr: string } { - try { - return attemptToolchainProbe(cmd, args, clock); - } catch (error) { - if (!isCommandTimeoutError(error)) throw error; - return attemptToolchainProbe(cmd, args, clock); - } -} - -/** The guard for a value probe: cancellation and a spent budget both throw here, before any exec. */ -function attemptToolchainProbe( - cmd: string, - args: string[], - clock: ToolchainProbeClock, -): { exitCode: number; stdout: string; stderr: string } { - clock.throwIfCanceled(); - const timeoutMs = clock.attemptTimeoutMs(); - if (timeoutMs <= 0) throw runnerPhaseBudgetExhaustedError('apple_toolchain_probe'); - const startedAtMs = Date.now(); - const data = { cmd, args, timeoutMs }; - emitDiagnostic({ phase: 'apple_toolchain_probe', data: { ...data, event: 'started' } }); - try { - const result = runCmdSync(cmd, args, { - allowFailure: true, - timeoutMs, - maxBuffer: TOOLCHAIN_PROBE_MAX_BUFFER, - }); - emitDiagnostic({ - phase: 'apple_toolchain_probe', - durationMs: Date.now() - startedAtMs, - data: { - ...data, - event: 'completed', - exitCode: result.exitCode, - stderr: result.stderr.slice(0, TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH), - }, - }); - return result; - } catch (error) { - emitDiagnostic({ - level: 'error', - phase: 'apple_toolchain_probe', - durationMs: Date.now() - startedAtMs, - data: { ...data, event: 'failed', error: normalizeError(error) }, - }); - throw error; - } -} - -function parseXcodeVersionOutput( - output: ProbeResult, -): ProbeResult<{ version: string; buildVersion: string }> { - if (!output.ok) { - return output; - } - const version = output.value.match(/^Xcode\s+(.+)$/m)?.[1]?.trim(); - const buildVersion = output.value.match(/^Build version\s+(.+)$/m)?.[1]?.trim(); - if (!version || !buildVersion) { - return probeFailure( - 'xcodebuild -version', - 'unparsable_output', - `unrecognized output: ${output.value.replaceAll('\n', ' ')}`, - ); - } - return { ok: true, value: { version, buildVersion } }; -} - -function probeFailure( - probe: string, - reason: ToolchainProbeFailure['reason'], - detail: string, -): { ok: false; failure: ToolchainProbeFailure } { - const bounded = - detail.length > TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH - ? `${detail.slice(0, TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH)}…` - : detail; - const failure = { probe, reason, detail: bounded }; - emitDiagnostic({ - level: 'error', - phase: 'apple_toolchain_probe', - data: { event: 'rejected', ...failure }, - }); - return { ok: false, failure }; -} diff --git a/packages/platform-apple/src/snapshot-source/preparation.ts b/packages/platform-apple/src/snapshot-source/preparation.ts index 1126c951f1..2fcde7cb33 100644 --- a/packages/platform-apple/src/snapshot-source/preparation.ts +++ b/packages/platform-apple/src/snapshot-source/preparation.ts @@ -1,5 +1,5 @@ import { BUILD_TIMEOUT_MS, ensureSnapshotBridgeBinary } from './cache.ts'; -import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; +import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../native-build/toolchain-identity.ts'; import { createDetachedAttempts } from '../detached-attempt.ts'; import { createSnapshotSourceDeadline, From 805c81890249f2272a9166db2ef70714e57b5468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 27 Oct 2026 17:11:44 +0100 Subject: [PATCH 2/2] chore(gates): keep exec out of the runner toolchain identity Lint the identity reader and the runner fingerprint so neither can import node:child_process, host-kit's command helpers, or any runner host export but commandDeveloperDir. A planted runCmd import and a planted node:child_process import each fail the rule. Co-Authored-By: Claude Opus 5.5 --- oxlint.config.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/oxlint.config.ts b/oxlint.config.ts index f4d8b84457..642bfc0537 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -33,6 +33,9 @@ const NODE_OS_PATH = { name: 'node:os', }; +const TOOLCHAIN_IDENTITY_EXEC_MESSAGE = + 'Read the toolchain identity from the selected Xcode files; an exec here can stall the runner cache decision.'; + const CHILD_PROCESS_BAN_ROOTS = ['src', 'packages/host-kit/src']; const PROVIDER_BAN_ROOTS = ['src/commands', 'src/cli/commands']; @@ -244,6 +247,27 @@ export default defineConfig({ 'no-restricted-imports': ['error', { paths: [CHILD_PROCESS_PATH] }], }, }, + { + // The runner cache key reads the selected Xcode's files and execs nothing: a hosted macOS + // runner can stall an `xcrun` exec longer than any budget (Perf Nightly run 40441065511). + files: ['packages/platform-apple/src/runner/runner-toolchain-identity.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { ...CHILD_PROCESS_PATH, message: TOOLCHAIN_IDENTITY_EXEC_MESSAGE }, + { name: '@agent-device/host-kit/command', message: TOOLCHAIN_IDENTITY_EXEC_MESSAGE }, + { + name: './host.ts', + allowImportNames: ['commandDeveloperDir'], + message: TOOLCHAIN_IDENTITY_EXEC_MESSAGE, + }, + ], + }, + ], + }, + }, { // The host-kit process helpers are the sanctioned node:child_process importer. files: ['packages/host-kit/src/internal/exec.ts'],