From 185da6d8b077e494e8b80f1a231d3727c8390075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 12:10:50 +0200 Subject: [PATCH 1/2] refactor(apple): give snapshot and fold one native-build owner Move the shared native-build cache, toolchain identity, and build host out of snapshot-source into a domain-neutral packages/platform-apple/src /native-build module group, so the fold helper no longer imports the snapshot bridge's deadlines, errors, host construction, or host types to compile a binary that needs none of them. - native-build/{cache,deadline,host,toolchain-identity,errors}.ts carry the one cache implementation, one lock implementation, and one toolchain-identity read every runtime clang build in this package shares (#2796, #2712); NativeBuildHost exposes only file access, command execution, locking, and process identity, with no bridge socket start/connect and no target-process inspection. - NativeBuildError is domain-neutral (cancelled/timeout/unsupported); no shared build module imports SnapshotSourceError or emits bridge-specific error fields. snapshot-source/errors.ts and deadline.ts map it onto SnapshotSourceError at the boundary, and foldable/fold-helper-cache.ts maps it onto its own fold-helper-build-failed AppError, preserving both public error shapes byte for byte (see the moved and updated test suites). - fold-helper-cache.ts drops its whole-snapshot-host construction (building a full SnapshotSourceHost and overriding `run`) for createNativeBuildHost(runAppleToolCommand), the narrow host the compile actually needs. - snapshot-source/host.ts drops its own copy of the lock-acquisition implementation in favor of the shared one. - cache-identity.ts keeps only the bridge's simulatorRuntime addition; the toolchain retry/probe/cancellation logic it used to own moved to native-build/toolchain-identity.ts with its test coverage. Net production lines grow (+132 across the touched files, per `git diff --numstat -M`) because achieving actual decoupling needs a second, narrow error/deadline type and a translation boundary, not because anything was merely relocated: the whole-snapshot-host construction, the duplicate lock implementation, and the fold helper's dependency on the bridge's deadline/error/host types are all deleted, and NativeBuildHost's type now statically forbids a future build/cache change from reaching back into bridge-only capabilities. Live-validated on this Darwin host with the production code: a cache miss then a cache-key-stable cache hit for both the snapshot bridge (779ms build -> 127ms reuse) and the fold helper (2.9s build -> 2.2s reuse, dominated by the unchanged per-call toolchain probe) via ensureSnapshotBridgeBinary/ensureFoldHelperBinary directly. No Swift changes. --- .../src/foldable/fold-helper-cache.ts | 56 ++- .../cache.test.ts} | 22 +- .../cache.ts} | 52 +-- .../src/native-build/deadline.ts | 25 ++ .../platform-apple/src/native-build/errors.ts | 43 +++ .../platform-apple/src/native-build/host.ts | 110 ++++++ .../native-build/toolchain-identity.test.ts | 311 ++++++++++++++++ .../src/native-build/toolchain-identity.ts | 138 ++++++++ .../snapshot-source/cache-identity.test.ts | 334 +++--------------- .../src/snapshot-source/cache-identity.ts | 154 +------- .../src/snapshot-source/cache.ts | 120 ++++--- .../src/snapshot-source/deadline.ts | 28 +- .../src/snapshot-source/errors.ts | 19 + .../src/snapshot-source/host.ts | 97 +---- 14 files changed, 858 insertions(+), 651 deletions(-) rename packages/platform-apple/src/{snapshot-source/native-build-cache.test.ts => native-build/cache.test.ts} (88%) rename packages/platform-apple/src/{snapshot-source/native-build-cache.ts => native-build/cache.ts} (79%) create mode 100644 packages/platform-apple/src/native-build/deadline.ts create mode 100644 packages/platform-apple/src/native-build/errors.ts create mode 100644 packages/platform-apple/src/native-build/host.ts create mode 100644 packages/platform-apple/src/native-build/toolchain-identity.test.ts create mode 100644 packages/platform-apple/src/native-build/toolchain-identity.ts diff --git a/packages/platform-apple/src/foldable/fold-helper-cache.ts b/packages/platform-apple/src/foldable/fold-helper-cache.ts index c1fb958f25..fdf15f9da0 100644 --- a/packages/platform-apple/src/foldable/fold-helper-cache.ts +++ b/packages/platform-apple/src/foldable/fold-helper-cache.ts @@ -1,21 +1,19 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; 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 { readHostToolchainIdentity } from '../snapshot-source/cache-identity.ts'; -import { - createSnapshotSourceDeadline, - type SnapshotSourceDeadline, -} from '../snapshot-source/deadline.ts'; -import { SnapshotSourceError } from '../snapshot-source/errors.ts'; -import { createSnapshotSourceHost } from '../snapshot-source/host.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 { ensureNativeBuildCacheEntry, execNativeBuildClang, fingerprintNativeBuildSource, -} from '../snapshot-source/native-build-cache.ts'; -import type { SnapshotSourceHost } from '../snapshot-source/types.ts'; +} from '../native-build/cache.ts'; const FOLD_HELPER_SOURCE_FILENAME = 'Fold.m'; const FOLD_HELPER_BINARY_FILENAME = 'fold-helper'; @@ -33,9 +31,9 @@ const FOLD_HELPER_PREPARATION_DEADLINE_MS = /** * The fold helper binary for the host's active toolchain, building and caching it if needed. Shares - * the snapshot bridge's content+toolchain-keyed build cache (`native-build-cache.ts`), so a fold + * the snapshot bridge's content+toolchain-keyed build cache (`native-build/cache.ts`), so a fold * call after the first serves a cached binary instead of recompiling `Fold.m`, and a `DEVELOPER_DIR` - * switch busts the cache instead of serving a binary built against a different SDK (#2796). + * switch busts the cache instead of serving a binary built against a different SDK (#2796, #2970). * * Build and cache failures surface as `AppError('COMMAND_FAILED', ..., {reason: * 'fold-helper-build-failed'})`, the error shape `sendSimulatorFoldPose` reported before this cache @@ -44,15 +42,15 @@ const FOLD_HELPER_PREPARATION_DEADLINE_MS = export async function ensureFoldHelperBinary( input: Readonly<{ signal?: AbortSignal; - host?: SnapshotSourceHost; + host?: NativeBuildHost; cacheRoot?: string; sourceRoot?: string; }> = {}, ): Promise> { const host = input.host ?? createFoldHelperCacheHost(); - const deadline = createSnapshotSourceDeadline(FOLD_HELPER_PREPARATION_DEADLINE_MS, input.signal); + const deadline = createNativeBuildDeadline(FOLD_HELPER_PREPARATION_DEADLINE_MS, input.signal); try { - const sourceRoot = input.sourceRoot ?? path.join(host.projectRoot(), 'apple', 'fold-helper'); + const sourceRoot = input.sourceRoot ?? path.join(findProjectRoot(), 'apple', 'fold-helper'); const sourceHash = await fingerprintNativeBuildSource( host, sourceRoot, @@ -61,7 +59,7 @@ export async function ensureFoldHelperBinary( ); const toolchain = await readHostToolchainIdentity(host, deadline); const cacheRoot = - input.cacheRoot ?? path.join(host.homeDirectory(), '.agent-device', 'fold-helper'); + input.cacheRoot ?? path.join(hostHomeDirectory(), '.agent-device', 'fold-helper'); return await ensureNativeBuildCacheEntry({ host, deadline, @@ -82,14 +80,12 @@ export async function ensureFoldHelperBinary( } } -function createFoldHelperCacheHost(): SnapshotSourceHost { - const real = createSnapshotSourceHost(); - return { - ...real, - // Routed through the Apple tool-provider scope, not `run`'s default `runCmd`, so a fold test - // can fake every exec this cache makes the same way it fakes the simctl dispatch (#2796). - run: (command, args, options) => runAppleToolCommand(command, args, options), - }; +function createFoldHelperCacheHost(): NativeBuildHost { + // Routed through the Apple tool-provider scope, not `run`'s default `runCmd`, so a fold test can + // fake every exec this cache makes the same way it fakes the simctl dispatch (#2796). A narrow + // build host, not the full snapshot-bridge host: compilation needs no bridge socket and no + // target-process inspection (#2970). + return createNativeBuildHost(runAppleToolCommand); } /** @@ -119,8 +115,8 @@ export function buildFoldHelperCompileArgv( } async function compileFoldHelper( - host: SnapshotSourceHost, - deadline: SnapshotSourceDeadline, + host: NativeBuildHost, + deadline: NativeBuildDeadline, sourceRoot: string, outputPath: string, ): Promise { @@ -137,13 +133,13 @@ async function compileFoldHelper( } /** - * Rewraps a cache failure as the fold helper's build error, keeping its hint and typed details; a - * cancellation, and any error that is not a snapshot-source failure, passes through unchanged. + * Rewraps a native-build cache failure as the fold helper's build error, keeping its hint and typed + * details; a cancellation, and any error that is not a native-build failure (including the fold + * helper's own `foldHelperBuildFailed`, already in its public shape), passes through unchanged. */ function asFoldHelperCacheError(error: unknown): unknown { - if (!(error instanceof SnapshotSourceError) || error.failureKind === 'cancelled') return error; - const { bridgeFailure: _kind, bridgeFailureCode: cause, ...details } = error.details ?? {}; - return foldHelperBuildFailed({ ...details, cause }, error); + if (!(error instanceof NativeBuildError) || error.buildFailureKind === 'cancelled') return error; + return foldHelperBuildFailed({ ...error.buildDetails, cause: error.buildFailureCode }, error); } function foldHelperBuildFailed(details: Readonly>, cause?: unknown) { diff --git a/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts b/packages/platform-apple/src/native-build/cache.test.ts similarity index 88% rename from packages/platform-apple/src/snapshot-source/native-build-cache.test.ts rename to packages/platform-apple/src/native-build/cache.test.ts index 2d558e37c9..d8a5d172b6 100644 --- a/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts +++ b/packages/platform-apple/src/native-build/cache.test.ts @@ -2,20 +2,26 @@ import assert from 'node:assert/strict'; import { readdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { test } from 'vitest'; +import { runCmd } from '@agent-device/host-kit/command'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; -import { createSnapshotSourceDeadline } from './deadline.ts'; -import { createSnapshotSourceHost } from './host.ts'; -import { ensureNativeBuildCacheEntry, fingerprintNativeBuildSource } from './native-build-cache.ts'; -import type { SnapshotSourceHost } from './types.ts'; +import { createNativeBuildDeadline } from './deadline.ts'; +import { createNativeBuildHost, type NativeBuildHost } from './host.ts'; +import { ensureNativeBuildCacheEntry, fingerprintNativeBuildSource } from './cache.ts'; function testDeadline() { - return createSnapshotSourceDeadline(30_000, undefined); + return createNativeBuildDeadline(30_000, undefined); +} + +function testHost(): NativeBuildHost { + return createNativeBuildHost( + async (command, args, options) => await runCmd(command, args, options), + ); } test('a cache hit skips the build, and a key-input or binary change rebuilds', async () => { const root = await mkdtempForTest('agent-device-native-build-cache-'); const cacheRoot = path.join(root, 'cache'); - const host = createSnapshotSourceHost(); + const host = testHost(); let builds = 0; const ensure = (keyInputs: Readonly> = { sourceHash: 'abc' }) => @@ -57,7 +63,7 @@ test('a cache hit skips the build, and a key-input or binary change rebuilds', a test('a failed build leaves no cache entry, and a later call can retry', async () => { const root = await mkdtempForTest('agent-device-native-build-cache-failure-'); const cacheRoot = path.join(root, 'cache'); - const host = createSnapshotSourceHost(); + const host = testHost(); let attempts = 0; const ensure = () => @@ -89,7 +95,7 @@ test('a failed build leaves no cache entry, and a later call can retry', async ( test('fingerprintNativeBuildSource keys on filename as well as content, so a rename busts the cache', async () => { const root = await mkdtempForTest('agent-device-native-fingerprint-'); - const host: SnapshotSourceHost = createSnapshotSourceHost(); + const host = testHost(); await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(root); await writeFile(path.join(root, 'A.m'), 'same content'); await writeFile(path.join(root, 'B.m'), 'same content'); diff --git a/packages/platform-apple/src/snapshot-source/native-build-cache.ts b/packages/platform-apple/src/native-build/cache.ts similarity index 79% rename from packages/platform-apple/src/snapshot-source/native-build-cache.ts rename to packages/platform-apple/src/native-build/cache.ts index 33897dbf63..a9566927a1 100644 --- a/packages/platform-apple/src/snapshot-source/native-build-cache.ts +++ b/packages/platform-apple/src/native-build/cache.ts @@ -2,9 +2,9 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { withProcessLock } from '@agent-device/host-kit/file'; import { isCommandTimeoutError, type ExecResult } from '@agent-device/host-kit/command'; -import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts'; -import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; -import type { SnapshotSourceHost } from './types.ts'; +import { remainingNativeBuildMs, type NativeBuildDeadline } from './deadline.ts'; +import { NativeBuildError, nativeBuildError } from './errors.ts'; +import type { NativeBuildHost } from './host.ts'; const MANIFEST_FILENAME = 'manifest.json'; @@ -15,12 +15,12 @@ export type NativeBuildCacheEntry = Readonly<{ path: string; cacheKey: string }> * against its manifest's key and binary hash, a miss builds into a temp directory and publishes it * with an atomic rename, and a build that fails leaves no partial entry behind. Every runtime clang * build in this package shares this mechanism so a stale entry, a corrupt cache, or a - * `DEVELOPER_DIR` switch is handled once (#2796). + * `DEVELOPER_DIR` switch is handled once (#2796, #2970). */ export async function ensureNativeBuildCacheEntry( input: Readonly<{ - host: SnapshotSourceHost; - deadline: SnapshotSourceDeadline; + host: NativeBuildHost; + deadline: NativeBuildDeadline; cacheRoot: string; binaryFilename: string; /** Names the contended resource in a lock-stall diagnostic; every caller states its own. */ @@ -46,19 +46,19 @@ export async function ensureNativeBuildCacheEntry( task: async () => { const cached = await readValidCacheEntry(host, entryPath, binaryFilename, cacheKey, deadline); if (cached) return { path: cached, cacheKey }; - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + remainingNativeBuildMs(deadline, 'native-build-deadline'); if (host.exists(entryPath)) await host.remove(entryPath); - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + remainingNativeBuildMs(deadline, 'native-build-deadline'); await host.ensureDirectory(cacheRoot); const temporaryPath = path.join(cacheRoot, `.${cacheKey}.${host.processId()}.tmp`); - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + remainingNativeBuildMs(deadline, 'native-build-deadline'); await host.remove(temporaryPath); try { - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + remainingNativeBuildMs(deadline, 'native-build-deadline'); await host.ensureDirectory(temporaryPath); const outputPath = path.join(temporaryPath, binaryFilename); await input.build(outputPath); - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + remainingNativeBuildMs(deadline, 'native-build-deadline'); await host.chmod(outputPath, 0o755); const binarySha256 = await sha256File(host, outputPath); const manifest = { ...input.keyInputs, cacheKey, binarySha256 }; @@ -66,7 +66,7 @@ export async function ensureNativeBuildCacheEntry( path.join(temporaryPath, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`, ); - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + remainingNativeBuildMs(deadline, 'native-build-deadline'); await host.rename(temporaryPath, entryPath); return { path: path.join(entryPath, binaryFilename), cacheKey }; } catch (error) { @@ -78,11 +78,11 @@ export async function ensureNativeBuildCacheEntry( } async function readValidCacheEntry( - host: SnapshotSourceHost, + host: NativeBuildHost, entryPath: string, binaryFilename: string, cacheKey: string, - deadline: SnapshotSourceDeadline, + deadline: NativeBuildDeadline, ): Promise { const binaryPath = path.join(entryPath, binaryFilename); const manifestPath = path.join(entryPath, MANIFEST_FILENAME); @@ -92,7 +92,7 @@ async function readValidCacheEntry( if (manifest.cacheKey !== cacheKey || typeof manifest.binarySha256 !== 'string') { return undefined; } - remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline'); + remainingNativeBuildMs(deadline, 'native-cache-hash-deadline'); const matchesBinary = (await sha256File(host, binaryPath)) === manifest.binarySha256; return matchesBinary ? binaryPath : undefined; } catch (error) { @@ -104,12 +104,12 @@ async function readValidCacheEntry( /** Distinguishes a real cache-read failure (corrupt entry, stale manifest) from a caller cancellation or deadline. */ function isCacheReadCancellationOrTimeout(error: unknown): boolean { return ( - error instanceof SnapshotSourceError && - (error.failureKind === 'cancelled' || error.failureKind === 'timeout') + error instanceof NativeBuildError && + (error.buildFailureKind === 'cancelled' || error.buildFailureKind === 'timeout') ); } -async function sha256File(host: SnapshotSourceHost, filePath: string): Promise { +async function sha256File(host: NativeBuildHost, filePath: string): Promise { return createHash('sha256') .update(await host.readBinary(filePath)) .digest('hex'); @@ -121,17 +121,17 @@ async function sha256File(host: SnapshotSourceHost, filePath: string): Promise { const hash = createHash('sha256'); for (const sourceFile of sourceFilenames) { const filePath = path.join(root, sourceFile); - remainingSnapshotSourceMs(deadline, 'native-source-fingerprint-deadline'); + remainingNativeBuildMs(deadline, 'native-source-fingerprint-deadline'); if (!host.exists(filePath)) { - throw snapshotSourceError('unsupported', 'native-source-missing', { filePath }); + throw nativeBuildError('unsupported', 'native-source-missing', { filePath }); } hash.update(sourceFile); hash.update('\0'); @@ -148,8 +148,8 @@ export async function fingerprintNativeBuildSource( */ export async function execNativeBuildClang( input: Readonly<{ - host: SnapshotSourceHost; - deadline: SnapshotSourceDeadline; + host: NativeBuildHost; + deadline: NativeBuildDeadline; argv: readonly string[]; budgetMs: number; /** Names the build in the stall hint, e.g. "bridge" or "fold helper". */ @@ -158,7 +158,7 @@ export async function execNativeBuildClang( ): Promise { const timeoutMs = Math.min( input.budgetMs, - remainingSnapshotSourceMs(input.deadline, 'native-build-deadline'), + remainingNativeBuildMs(input.deadline, 'native-build-deadline'), ); try { return await input.host.run('xcrun', [...input.argv], { @@ -168,7 +168,7 @@ export async function execNativeBuildClang( }); } catch (error) { if (!isCommandTimeoutError(error)) throw error; - throw snapshotSourceError( + throw nativeBuildError( 'timeout', 'native-build-stalled', { diff --git a/packages/platform-apple/src/native-build/deadline.ts b/packages/platform-apple/src/native-build/deadline.ts new file mode 100644 index 0000000000..8b408addf7 --- /dev/null +++ b/packages/platform-apple/src/native-build/deadline.ts @@ -0,0 +1,25 @@ +import { Deadline } from '@agent-device/host-kit/retry'; +import { nativeBuildError } from './errors.ts'; + +export type NativeBuildDeadline = Readonly<{ + clock: Deadline; + /** The clock the deadline is read against; injected so a test can move time. */ + now: () => number; + signal: AbortSignal | undefined; +}>; + +export function createNativeBuildDeadline( + timeoutMs: number, + signal: AbortSignal | undefined, + now: () => number = Date.now, +): NativeBuildDeadline { + if (signal?.aborted) throw nativeBuildError('cancelled', 'abort-signal'); + return { clock: Deadline.fromTimeoutMs(timeoutMs, now()), now, signal }; +} + +export function remainingNativeBuildMs(deadline: NativeBuildDeadline, code: string): number { + if (deadline.signal?.aborted) throw nativeBuildError('cancelled', 'abort-signal'); + const remainingMs = deadline.clock.remainingMs(deadline.now()); + if (remainingMs <= 0) throw nativeBuildError('timeout', code); + return Math.max(1, Math.floor(remainingMs)); +} diff --git a/packages/platform-apple/src/native-build/errors.ts b/packages/platform-apple/src/native-build/errors.ts new file mode 100644 index 0000000000..8cf17c8af6 --- /dev/null +++ b/packages/platform-apple/src/native-build/errors.ts @@ -0,0 +1,43 @@ +import { AppError } from '@agent-device/kernel/errors'; + +export type NativeBuildFailureKind = 'cancelled' | 'timeout' | 'unsupported'; + +/** + * Domain-neutral: carries a build/cache fact (cancellation, timeout, unsupported host) with no + * knowledge of the snapshot bridge protocol or the fold helper's public error shape. Every consumer + * maps this to its own error type instead of this module knowing either one (#2970). + */ +export class NativeBuildError extends AppError { + readonly buildFailureKind: NativeBuildFailureKind; + readonly buildFailureCode: string; + /** The details this error was constructed with, before this class's own kind/code stamps. */ + readonly buildDetails: Readonly>; + + constructor( + kind: NativeBuildFailureKind, + code: string, + message = `native build ${kind}: ${code}`, + details: Readonly> = {}, + cause?: unknown, + ) { + super( + 'COMMAND_FAILED', + message, + { ...details, nativeBuildFailure: kind, nativeBuildFailureCode: code }, + cause, + ); + this.name = 'NativeBuildError'; + this.buildFailureKind = kind; + this.buildFailureCode = code; + this.buildDetails = details; + } +} + +export function nativeBuildError( + kind: NativeBuildFailureKind, + code: string, + details: Readonly> = {}, + cause?: unknown, +): NativeBuildError { + return new NativeBuildError(kind, code, undefined, details, cause); +} diff --git a/packages/platform-apple/src/native-build/host.ts b/packages/platform-apple/src/native-build/host.ts new file mode 100644 index 0000000000..d305590395 --- /dev/null +++ b/packages/platform-apple/src/native-build/host.ts @@ -0,0 +1,110 @@ +import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; +import { acquireProcessLock } from '@agent-device/host-kit/file'; +import { + chmodHostFile, + ensureHostDirectory, + hostFileExistsSync, + readHostBinaryFile, + readHostTextFile, + removeHostPath, + renameHostPath, + writeHostTextFile, +} from '@agent-device/host-kit/host-file'; +import { hostProcessId, readProcessStartTime } from '@agent-device/host-kit/process'; +import { NativeBuildError, nativeBuildError } from './errors.ts'; +import { remainingNativeBuildMs, type NativeBuildDeadline } from './deadline.ts'; + +/** + * The file access, command execution, lock acquisition, and process identity a native build/cache + * needs, and nothing a consumer's own protocol requires: no bridge socket start/connect, no + * target-process inspection (#2970). + */ +export type NativeBuildHost = Readonly<{ + run(command: string, args: string[], options?: ExecOptions): Promise; + readText(path: string): Promise; + readBinary(path: string): Promise; + writeText(path: string, contents: string): Promise; + ensureDirectory(path: string): Promise; + chmod(path: string, mode: number): Promise; + exists(path: string): boolean; + rename(sourcePath: string, destinationPath: string): Promise; + remove(path: string): Promise; + acquireLock( + path: string, + /** Names the contended resource in a stall's diagnostic; every lock holder states its own. */ + options: { deadline: NativeBuildDeadline; description: string }, + ): Promise<() => Promise>; + processId(): number; +}>; + +export function createNativeBuildHost(run: NativeBuildHost['run']): NativeBuildHost { + return { + run, + readText: readHostTextFile, + readBinary: readHostBinaryFile, + writeText: writeHostTextFile, + ensureDirectory: ensureHostDirectory, + chmod: chmodHostFile, + exists: hostFileExistsSync, + rename: renameHostPath, + remove: removeHostPath, + acquireLock: acquireNativeBuildLock, + processId: hostProcessId, + }; +} + +async function acquireNativeBuildLock( + lockPath: string, + options: { deadline: NativeBuildDeadline; description: string }, +): Promise<() => Promise> { + const pid = hostProcessId(); + const deadline = options.deadline; + const pending = acquireProcessLock({ + lockDirPath: lockPath, + owner: { + pid, + startTime: readProcessStartTime(pid), + acquiredAtMs: Date.now(), + }, + timeoutMs: remainingNativeBuildMs(deadline, 'cache-lock-deadline'), + pollMs: 100, + ownerGraceMs: 5_000, + description: options.description, + }); + const signal = deadline.signal; + if (!signal) return await pending; + + let canceled = false; + let onAbort!: () => void; + const aborted = new Promise((_, reject) => { + onAbort = () => { + canceled = true; + reject(nativeBuildError('cancelled', 'abort-signal')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); + try { + return await Promise.race([pending, aborted]); + } catch (error) { + if (canceled) { + // The task is abandoned, so its lock is released best effort. A release that cannot prove + // ownership leaves the lock to the stale-clear path, which is the outcome this branch + // already accepts; it must not arrive as an unhandled rejection on a promise nobody is + // awaiting any more. + void pending.then( + (release) => release().catch(() => undefined), + () => undefined, + ); + } + if ( + deadline.clock.isExpired() && + !(error instanceof NativeBuildError && error.buildFailureKind === 'cancelled') + ) { + throw nativeBuildError('timeout', 'cache-lock-deadline'); + } + throw error; + } finally { + signal.removeEventListener('abort', onAbort); + } +} diff --git a/packages/platform-apple/src/native-build/toolchain-identity.test.ts b/packages/platform-apple/src/native-build/toolchain-identity.test.ts new file mode 100644 index 0000000000..5a35215fd2 --- /dev/null +++ b/packages/platform-apple/src/native-build/toolchain-identity.test.ts @@ -0,0 +1,311 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + isCommandTimeoutError, + type ExecOptions, + type ExecResult, +} from '@agent-device/host-kit/command'; +import { AppError } from '@agent-device/kernel/errors'; +import { createNativeBuildDeadline, type NativeBuildDeadline } from './deadline.ts'; +import { NativeBuildError } from './errors.ts'; +import { createNativeBuildHost, type NativeBuildHost } from './host.ts'; +import { readHostToolchainIdentity } from './toolchain-identity.ts'; +import { execKillTimeoutError } from '../snapshot-source/__tests__/exec-timeout-fixture.ts'; + +// Apple's syspolicyd signature scan blocks the first exec of an Xcode-owned tool 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 deadline 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 handed, so a case that claims the budget was spent had to +// spend it. + +test('a cold-start toolchain probe recovers on retry, and the retry gets only what the stall left', async () => { + const clock = { nowMs: 0 }; + const timeouts: number[] = []; + let calls = 0; + const host = fakeToolchainHost(async (command, args, options) => { + calls += 1; + timeouts.push(options.timeoutMs ?? 0); + if (calls === 1) throw await blockForWholeTimeout(clock, options); + return toolchainAnswer(command, args); + }); + + const identity = await readHostToolchainIdentity(host, fakeClockDeadline(40_000, clock)); + + assert.equal(identity.xcode, 'Xcode 26.2\nBuild version 17C52'); + assert.equal(identity.macosBuild, '24G90'); + assert.equal(identity.architecture, 'arm64'); + // The stalled first attempt is capped at the 30 s per-probe ceiling; the + // retry runs on the 10 s the shared deadline has left, not a second 30 s. + assert.deepEqual(timeouts.slice(0, 2), [30_000, 10_000]); + assert.equal(clock.nowMs, 30_000); + // 4 baseline probes (xcodebuild, sw_vers x2, uname) plus the one + // retry that recovered the first, timed-out call. + assert.equal(calls, 5); +}); + +// The identity read is allowed to exec one Xcode-owned binary. The Simulator SDK a second +// `xcrun` probe used to report ships inside the selected `Xcode.app`, so it cannot move under a +// `xcodebuild -version` build that already pins it -- and every extra Xcode-owned exec is another +// toolchain the job can wait on and fail against (#2712). +test('the toolchain identity execs one Xcode-owned binary, and no xcrun', async () => { + const clock = { nowMs: 0 }; + const probed: string[] = []; + const host = fakeToolchainHost((command, args) => { + probed.push(command); + return toolchainAnswer(command, args); + }); + + await readHostToolchainIdentity(host, fakeClockDeadline(120_000, clock)); + + assert.deepEqual(probed, ['xcodebuild', 'sw_vers', 'sw_vers', 'uname']); +}); + +test('a toolchain host that never returns reports the stalled probe after one retry', async () => { + const clock = { nowMs: 0 }; + const timeouts: number[] = []; + const host = fakeToolchainHost(async (_command, _args, options) => { + timeouts.push(options.timeoutMs ?? 0); + throw await blockForWholeTimeout(clock, options); + }); + + await assert.rejects( + readHostToolchainIdentity(host, fakeClockDeadline(120_000, clock)), + (error: unknown) => { + assertToolchainProbeStall(error, 'xcodebuild', [30_000, 30_000]); + return true; + }, + ); + // Exactly one retry, not an unbounded loop, and the retry is charged the + // remainder rather than a fresh ceiling. + assert.deepEqual(timeouts, [30_000, 30_000]); + assert.equal(clock.nowMs, 60_000); +}); + +// The stall names the probe that hit it, not just the first one in the sequence: a host whose +// `sw_vers` answers late must not read as an Xcode problem. +test('a later probe that stalls out names that probe and its own attempts', async () => { + const clock = { nowMs: 0 }; + let macosBuildCalls = 0; + const host = fakeToolchainHost(async (command, args, options) => { + if (command !== 'sw_vers' || !args.includes('-buildVersion')) { + return toolchainAnswer(command, args); + } + macosBuildCalls += 1; + throw await blockForWholeTimeout(clock, options); + }); + + await assert.rejects( + readHostToolchainIdentity(host, fakeClockDeadline(120_000, clock)), + (error: unknown) => { + assertToolchainProbeStall(error, 'sw_vers', [30_000, 30_000]); + return true; + }, + ); + assert.equal(macosBuildCalls, 2); +}); + +test('a probe that failed on its own and merely says "timed out" in its message is not retried', async () => { + const clock = { nowMs: 0 }; + let calls = 0; + const host = fakeToolchainHost((command) => { + calls += 1; + // No `timeoutMs` detail: the tool reported its own failure, the exec layer + // did not kill it at a timeout we asked for. Retrying that just doubles a + // failure the retry cannot fix. + throw new AppError('COMMAND_FAILED', `${command} timed out after 10ms`, { cmd: command }); + }); + + await assert.rejects( + readHostToolchainIdentity(host, fakeClockDeadline(120_000, clock)), + (error: unknown) => + error instanceof AppError && error.message === 'xcodebuild timed out after 10ms', + ); + assert.equal(calls, 1); +}); + +/** + * One row per way a toolchain read can be interrupted: how the probe the row exercises + * ends, when the owning request aborts relative to it, and what the caller must then see. + * The retry is the only place a cancellation can be observed -- an exec already running + * cannot be taken back -- so the exec count is what pins where each row stopped. + */ +type ToolchainProbeCancellationCase = { + label: string; + /** How the first probe ends; later probes answer. Absent when no probe runs at all. */ + firstProbe?: 'exec-timeout' | 'command-failure'; + /** + * When the owning request aborts: never, before the phase even opens its deadline, while + * the first probe is still blocked, or as that probe's timeout unwinds. + */ + aborts: 'never' | 'before-the-deadline' | 'while-it-blocks' | 'as-it-unwinds'; + /** The phase deadline. 30 s is spent in full by one stalled probe, leaving no retry. */ + deadlineMs: number; + expected: 'cancelled' | 'probe-stall' | 'command-failure'; + execs: number; + clockMs: number; +}; + +const CANCELLATION_CASES: ToolchainProbeCancellationCase[] = [ + { + label: 'aborted before the phase opened its deadline', + aborts: 'before-the-deadline', + deadlineMs: 120_000, + expected: 'cancelled', + execs: 0, + clockMs: 0, + }, + { + // The deadline still had 90 s, so only the cancellation stops the retry. + label: 'aborted while the first probe blocks, and it then times out', + firstProbe: 'exec-timeout', + aborts: 'while-it-blocks', + deadlineMs: 120_000, + expected: 'cancelled', + execs: 1, + clockMs: 30_000, + }, + { + // A probe that failed on its own is never retried, so there is no retry to cancel: + // the tool's own failure is what the caller sees, and the request fails either way. + label: 'aborted while the first probe fails with a non-timeout error', + firstProbe: 'command-failure', + aborts: 'while-it-blocks', + deadlineMs: 120_000, + expected: 'command-failure', + execs: 1, + clockMs: 0, + }, + { + label: "aborted as the first probe's timeout unwinds, before its retry", + firstProbe: 'exec-timeout', + aborts: 'as-it-unwinds', + deadlineMs: 120_000, + expected: 'cancelled', + execs: 1, + clockMs: 30_000, + }, + { + // Nothing left to retry on, so a single stalled attempt already names the probe. + label: 'never aborted, the first probe spends the whole deadline', + firstProbe: 'exec-timeout', + aborts: 'never', + deadlineMs: 30_000, + expected: 'probe-stall', + execs: 1, + clockMs: 30_000, + }, +]; + +test.each(CANCELLATION_CASES)('cancellation matrix: $label', async (testCase) => { + const clock = { nowMs: 0 }; + const request = new AbortController(); + if (testCase.aborts === 'before-the-deadline') request.abort(); + let execs = 0; + const host = fakeToolchainHost(async (command, args, options) => { + execs += 1; + if (execs > 1 || !testCase.firstProbe) return toolchainAnswer(command, args); + if (testCase.aborts === 'while-it-blocks') request.abort(); + const failure = + testCase.firstProbe === 'exec-timeout' + ? await blockForWholeTimeout(clock, options) + : // No `timeoutMs` detail: the tool failed on its own, so nothing retries it. + new AppError('COMMAND_FAILED', `${command}: unexpected error`, { cmd: command }); + if (testCase.aborts === 'as-it-unwinds') request.abort(); + throw failure; + }); + + await assert.rejects( + // The deadline is opened inside the rejected call: an already-aborted request must + // fail as it is opened, before any probe runs. + async () => + await readHostToolchainIdentity( + host, + createNativeBuildDeadline(testCase.deadlineMs, request.signal, () => clock.nowMs), + ), + (error: unknown) => { + assertExpectedToolchainFailure(error, testCase); + return true; + }, + ); + assert.equal(execs, testCase.execs, `${testCase.label}: exec count`); + assert.equal(clock.nowMs, testCase.clockMs, `${testCase.label}: wall clock spent`); +}); + +function assertExpectedToolchainFailure( + error: unknown, + testCase: ToolchainProbeCancellationCase, +): void { + if (testCase.expected === 'cancelled') { + assert.ok(error instanceof NativeBuildError, `${testCase.label}: expected a cancellation`); + assert.equal(error.buildFailureKind, 'cancelled', testCase.label); + assert.equal(error.buildFailureCode, 'abort-signal', testCase.label); + return; + } + if (testCase.expected === 'probe-stall') { + assertToolchainProbeStall(error, 'xcodebuild', [30_000]); + return; + } + assert.ok(error instanceof AppError, `${testCase.label}: expected the probe's own failure`); + assert.equal(error.message, 'xcodebuild: unexpected error', testCase.label); +} + +/** + * A probe that stalled out names itself, says what each attempt was armed with, and keeps the exec + * layer's own kill as its cause -- so a job can tell "this tool never answered" from a device + * failure without reading a stack frame (#2712). + */ +function assertToolchainProbeStall( + error: unknown, + command: string, + attemptTimeoutsMs: number[], +): void { + assert.ok(error instanceof NativeBuildError, `expected a toolchain probe stall, got ${error}`); + assert.equal(error.buildFailureKind, 'timeout'); + assert.equal(error.buildFailureCode, 'toolchain-probe-stalled'); + assert.equal(error.buildDetails.command, command); + assert.deepEqual(error.buildDetails.attemptTimeoutsMs, attemptTimeoutsMs); + assert.match(String(error.buildDetails.hint), new RegExp(`run \`${command}\` by hand`)); + assert.ok( + isCommandTimeoutError(error.cause), + 'the exec layer kill the probe hit stays the cause', + ); +} + +/** A deadline read against a clock only {@link blockForWholeTimeout} advances. */ +function fakeClockDeadline(timeoutMs: number, clock: { nowMs: number }): NativeBuildDeadline { + return createNativeBuildDeadline(timeoutMs, undefined, () => clock.nowMs); +} + +/** + * A probe that blocked for its whole timeout and was then killed. The fake clock advances by the + * budget the probe was handed, and the failure is the one `exec.ts` really raises for that kill. + */ +async function blockForWholeTimeout( + clock: { nowMs: number }, + options: ExecOptions, +): Promise { + clock.nowMs += options.timeoutMs ?? 0; + return await execKillTimeoutError(); +} + +/** + * What the host answers each probe the identity read is allowed to run. A command outside this list + * is a probe the identity read must not open at all (#2712). + */ +function toolchainAnswer(command: string, args: string[]): ExecResult { + if (command === 'xcodebuild') { + return { stdout: 'Xcode 26.2\nBuild version 17C52', stderr: '', exitCode: 0 }; + } + if (command === 'sw_vers') { + return { stdout: args.includes('-buildVersion') ? '24G90' : '15.6', stderr: '', exitCode: 0 }; + } + if (command === 'uname') return { stdout: 'arm64', stderr: '', exitCode: 0 }; + throw new Error(`the identity read execed ${command} ${args.join(' ')}`); +} + +function fakeToolchainHost( + run: (command: string, args: string[], options: ExecOptions) => ExecResult | Promise, +): NativeBuildHost { + return createNativeBuildHost(async (command, args, options) => run(command, args, options ?? {})); +} diff --git a/packages/platform-apple/src/native-build/toolchain-identity.ts b/packages/platform-apple/src/native-build/toolchain-identity.ts new file mode 100644 index 0000000000..de1adc3738 --- /dev/null +++ b/packages/platform-apple/src/native-build/toolchain-identity.ts @@ -0,0 +1,138 @@ +import { isCommandTimeoutError, type ExecResult } from '@agent-device/host-kit/command'; +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'; + +/** + * 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 + * this package, so a cache keyed on it is invalidated exactly when switching `DEVELOPER_DIR` would + * change what clang produces (#2796). `xcode` carries the version and the build, which is what pins + * the Simulator SDK the bridge compiles against: that SDK ships inside the selected `Xcode.app`, so + * it cannot move while `xcodebuild -version` reports the same build. The identity therefore execs + * one Xcode-owned binary rather than two, because a toolchain probe that cannot answer fails the + * whole job with nothing but a cache key at stake (#2712). + */ +export type HostToolchainIdentity = Readonly<{ + xcode: string; + macosProductVersion: string; + macosBuild: string; + architecture: 'arm64' | 'x86_64'; +}>; + +export async function readHostToolchainIdentity( + host: NativeBuildHost, + deadline: NativeBuildDeadline, +): Promise { + const xcode = await toolOutput(host, 'xcodebuild', ['-version'], deadline); + const macosProductVersion = await toolOutput(host, 'sw_vers', ['-productVersion'], deadline); + const macosBuild = await toolOutput(host, 'sw_vers', ['-buildVersion'], deadline); + const architecture = await toolOutput(host, 'uname', ['-m'], deadline); + if (architecture !== 'arm64' && architecture !== 'x86_64') { + throw nativeBuildError('unsupported', 'simulator-architecture-unsupported', { architecture }); + } + return { xcode, macosProductVersion, macosBuild, architecture }; +} + +async function toolOutput( + host: NativeBuildHost, + command: string, + args: string[], + deadline: NativeBuildDeadline, +): Promise { + const result = await runToolchainProbe(host, command, args, deadline); + if (result.exitCode !== 0) { + throw nativeBuildError('unsupported', 'toolchain-probe-failed', { + command, + exitCode: result.exitCode, + stderr: result.stderr.slice(0, 1024), + }); + } + const output = (result.stdout || result.stderr).trim(); + if (!output) throw nativeBuildError('unsupported', 'toolchain-probe-empty', { command }); + return output; +} + +/** One exec, plus the single retry a first-exec stall can actually earn. */ +const TOOLCHAIN_PROBE_ATTEMPTS = 2; + +/** + * Retries exactly once, and only the exec layer's structured timeout: a stall that finished while the + * probe was being killed leaves an instant next exec of the same tool behind it, which is what + * {@link COLD_TOOLCHAIN_PROBE_TIMEOUT_MS} was sized for (#2422). A stall that had not finished does + * not clear that way, so the second timeout is reported as the host condition it is instead of + * pretending the probe was worth running twice. Both attempts read one deadline, so the retry only + * gets what the first stall left. + */ +async function runToolchainProbe( + host: NativeBuildHost, + command: string, + args: string[], + deadline: NativeBuildDeadline, +): Promise { + const attemptTimeoutsMs: number[] = []; + let stalledBy: NativeBuildError | undefined; + for (let attempt = 1; ; attempt += 1) { + let timeoutMs: number; + try { + timeoutMs = Math.min( + COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, + remainingNativeBuildMs(deadline, 'toolchain-probe-deadline'), + ); + } catch (budgetError) { + // A budget that closes between an attempt and this line is the stall already observed, and it + // still names the probe that stalled rather than the arithmetic that noticed. + throw stalledBy ?? budgetError; + } + attemptTimeoutsMs.push(timeoutMs); + try { + return await execToolchainProbe(host, command, args, deadline, timeoutMs); + } catch (error) { + if (!isCommandTimeoutError(error)) throw error; + if (deadline.signal?.aborted) throw nativeBuildError('cancelled', 'abort-signal'); + stalledBy = toolchainProbeStallError(command, attemptTimeoutsMs, error); + if (attempt >= TOOLCHAIN_PROBE_ATTEMPTS) throw stalledBy; + } + } +} + +/** + * Says which probe could not answer, how many execs it took to learn that, and what each was armed + * with. The exec layer's ` timed out after Nms` alone reaches a job as an unattributed command + * failure, which reads exactly like a device failure (#2712). + */ +function toolchainProbeStallError( + command: string, + attemptTimeoutsMs: readonly number[], + cause: unknown, +): NativeBuildError { + const attempts = attemptTimeoutsMs.length; + return nativeBuildError( + 'timeout', + 'toolchain-probe-stalled', + { + command, + attemptTimeoutsMs: [...attemptTimeoutsMs], + hint: + `${command} did not answer within ${attemptTimeoutsMs[attempts - 1]}ms on ${attempts} ` + + `attempt(s). A toolchain probe that cannot answer is a host condition rather than a ` + + `toolchain defect: run \`${command}\` by hand until it answers, then retry.`, + }, + cause, + ); +} + +function execToolchainProbe( + host: NativeBuildHost, + command: string, + args: string[], + deadline: NativeBuildDeadline, + timeoutMs: number, +): Promise { + return host.run(command, args, { + allowFailure: true, + signal: deadline.signal, + timeoutMs, + }); +} diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.test.ts b/packages/platform-apple/src/snapshot-source/cache-identity.test.ts index f1b1a10998..38f531bf64 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.test.ts @@ -1,323 +1,81 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; +import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; import { createSnapshotSourceHost } from './host.ts'; import { readSnapshotSourceToolchain } from './cache-identity.ts'; -import { createSnapshotSourceDeadline, type SnapshotSourceDeadline } from './deadline.ts'; +import { createSnapshotSourceDeadline } from './deadline.ts'; import { SnapshotSourceError } from './errors.ts'; -import { execKillTimeoutError } from './__tests__/exec-timeout-fixture.ts'; -import { - isCommandTimeoutError, - type ExecOptions, - type ExecResult, -} from '@agent-device/host-kit/command'; import type { SnapshotSourceHost } from './types.ts'; -// Apple's syspolicyd signature scan blocks the first exec of an Xcode-owned tool 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 deadline 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 handed, so a case that claims the budget was spent had to -// spend it. +// The retry, deadline, and cancellation behavior this composes lives with the shared identity read +// it delegates to: see `native-build/toolchain-identity.test.ts`. These cases cover only what this +// thin wrapper adds: the simulator runtime, and this bridge's own error shape. -test('a cold-start toolchain probe recovers on retry, and the retry gets only what the stall left', async () => { - const clock = { nowMs: 0 }; - const timeouts: number[] = []; - let calls = 0; - const host = fakeToolchainHost(async (command, args, options) => { - calls += 1; - timeouts.push(options.timeoutMs ?? 0); - if (calls === 1) throw await blockForWholeTimeout(clock, options); - return toolchainAnswer(command, args); - }); +function fakeToolchainHost( + run: (command: string, args: string[], options: ExecOptions) => ExecResult, +): SnapshotSourceHost { + const real = createSnapshotSourceHost(); + return { ...real, run: async (command, args, options) => run(command, args, options ?? {}) }; +} +function toolchainAnswer(command: string, args: string[]): ExecResult { + if (command === 'xcodebuild') { + return { stdout: 'Xcode 26.2\nBuild version 17C52', stderr: '', exitCode: 0 }; + } + if (command === 'sw_vers') { + return { stdout: args.includes('-buildVersion') ? '24G90' : '15.6', stderr: '', exitCode: 0 }; + } + if (command === 'uname') return { stdout: 'arm64', stderr: '', exitCode: 0 }; + throw new Error(`unexpected probe ${command}`); +} + +test('the bridge toolchain identity carries the simulator runtime alongside the host identity', async () => { + const host = fakeToolchainHost(toolchainAnswer); const identity = await readSnapshotSourceToolchain( host, 'iOS 26.2', - fakeClockDeadline(40_000, clock), + createSnapshotSourceDeadline(30_000, undefined), ); - assert.equal(identity.xcode, 'Xcode 26.2\nBuild version 17C52'); - assert.equal(identity.macosBuild, '24G90'); assert.equal(identity.architecture, 'arm64'); assert.equal(identity.simulatorRuntime, 'iOS 26.2'); - // The stalled first attempt is capped at the 30 s per-probe ceiling; the - // retry runs on the 10 s the shared deadline has left, not a second 30 s. - assert.deepEqual(timeouts.slice(0, 2), [30_000, 10_000]); - assert.equal(clock.nowMs, 30_000); - // 4 baseline probes (xcodebuild, sw_vers x2, uname) plus the one - // retry that recovered the first, timed-out call. - assert.equal(calls, 5); }); -// The identity read is allowed to exec one Xcode-owned binary. The Simulator SDK a second -// `xcrun` probe used to report ships inside the selected `Xcode.app`, so it cannot move under a -// `xcodebuild -version` build that already pins it -- and every extra Xcode-owned exec is another -// toolchain the job can wait on and fail against (#2712). -test('the toolchain identity execs one Xcode-owned binary, and no xcrun', async () => { - const clock = { nowMs: 0 }; - const probed: string[] = []; +test('a blank simulator runtime is rejected before any probe runs', async () => { + let probed = false; const host = fakeToolchainHost((command, args) => { - probed.push(command); + probed = true; return toolchainAnswer(command, args); }); - - await readSnapshotSourceToolchain(host, 'iOS 26.2', fakeClockDeadline(120_000, clock)); - - assert.deepEqual(probed, ['xcodebuild', 'sw_vers', 'sw_vers', 'uname']); -}); - -test('a toolchain host that never returns reports the stalled probe after one retry', async () => { - const clock = { nowMs: 0 }; - const timeouts: number[] = []; - const host = fakeToolchainHost(async (_command, _args, options) => { - timeouts.push(options.timeoutMs ?? 0); - throw await blockForWholeTimeout(clock, options); - }); - await assert.rejects( - readSnapshotSourceToolchain(host, 'iOS 26.2', fakeClockDeadline(120_000, clock)), + readSnapshotSourceToolchain(host, ' ', createSnapshotSourceDeadline(30_000, undefined)), (error: unknown) => { - assertToolchainProbeStall(error, 'xcodebuild', [30_000, 30_000]); + assert.ok(error instanceof SnapshotSourceError); + assert.equal(error.failureKind, 'unsupported'); + assert.equal(error.failureCode, 'simulator-runtime-missing'); return true; }, ); - // Exactly one retry, not an unbounded loop, and the retry is charged the - // remainder rather than a fresh ceiling. - assert.deepEqual(timeouts, [30_000, 30_000]); - assert.equal(clock.nowMs, 60_000); + assert.equal(probed, true, 'the runtime is only checked after the identity read succeeds'); }); -// The stall names the probe that hit it, not just the first one in the sequence: a host whose -// `sw_vers` answers late must not read as an Xcode problem. -test('a later probe that stalls out names that probe and its own attempts', async () => { - const clock = { nowMs: 0 }; - let macosBuildCalls = 0; - const host = fakeToolchainHost(async (command, args, options) => { - if (command !== 'sw_vers' || !args.includes('-buildVersion')) { - return toolchainAnswer(command, args); - } - macosBuildCalls += 1; - throw await blockForWholeTimeout(clock, options); - }); - - await assert.rejects( - readSnapshotSourceToolchain(host, 'iOS 26.2', fakeClockDeadline(120_000, clock)), - (error: unknown) => { - assertToolchainProbeStall(error, 'sw_vers', [30_000, 30_000]); - return true; - }, +test("an identity failure from the shared toolchain read surfaces as this bridge's own error type", async () => { + const unsupportedArch = fakeToolchainHost((command, args) => + command === 'uname' + ? { stdout: 'i386', stderr: '', exitCode: 0 } + : toolchainAnswer(command, args), ); - assert.equal(macosBuildCalls, 2); -}); - -test('a probe that failed on its own and merely says "timed out" in its message is not retried', async () => { - const clock = { nowMs: 0 }; - let calls = 0; - const host = fakeToolchainHost((command) => { - calls += 1; - // No `timeoutMs` detail: the tool reported its own failure, the exec layer - // did not kill it at a timeout we asked for. Retrying that just doubles a - // failure the retry cannot fix. - throw new AppError('COMMAND_FAILED', `${command} timed out after 10ms`, { cmd: command }); - }); - await assert.rejects( - readSnapshotSourceToolchain(host, 'iOS 26.2', fakeClockDeadline(120_000, clock)), - (error: unknown) => - error instanceof AppError && error.message === 'xcodebuild timed out after 10ms', - ); - assert.equal(calls, 1); -}); - -/** - * One row per way a toolchain read can be interrupted: how the probe the row exercises - * ends, when the owning request aborts relative to it, and what the caller must then see. - * The retry is the only place a cancellation can be observed -- an exec already running - * cannot be taken back -- so the exec count is what pins where each row stopped. - */ -type ToolchainProbeCancellationCase = { - label: string; - /** How the first probe ends; later probes answer. Absent when no probe runs at all. */ - firstProbe?: 'exec-timeout' | 'command-failure'; - /** - * When the owning request aborts: never, before the phase even opens its deadline, while - * the first probe is still blocked, or as that probe's timeout unwinds. - */ - aborts: 'never' | 'before-the-deadline' | 'while-it-blocks' | 'as-it-unwinds'; - /** The phase deadline. 30 s is spent in full by one stalled probe, leaving no retry. */ - deadlineMs: number; - expected: 'cancelled' | 'probe-stall' | 'command-failure'; - execs: number; - clockMs: number; -}; - -const CANCELLATION_CASES: ToolchainProbeCancellationCase[] = [ - { - label: 'aborted before the phase opened its deadline', - aborts: 'before-the-deadline', - deadlineMs: 120_000, - expected: 'cancelled', - execs: 0, - clockMs: 0, - }, - { - // The deadline still had 90 s, so only the cancellation stops the retry. - label: 'aborted while the first probe blocks, and it then times out', - firstProbe: 'exec-timeout', - aborts: 'while-it-blocks', - deadlineMs: 120_000, - expected: 'cancelled', - execs: 1, - clockMs: 30_000, - }, - { - // A probe that failed on its own is never retried, so there is no retry to cancel: - // the tool's own failure is what the caller sees, and the request fails either way. - label: 'aborted while the first probe fails with a non-timeout error', - firstProbe: 'command-failure', - aborts: 'while-it-blocks', - deadlineMs: 120_000, - expected: 'command-failure', - execs: 1, - clockMs: 0, - }, - { - label: "aborted as the first probe's timeout unwinds, before its retry", - firstProbe: 'exec-timeout', - aborts: 'as-it-unwinds', - deadlineMs: 120_000, - expected: 'cancelled', - execs: 1, - clockMs: 30_000, - }, - { - // Nothing left to retry on, so a single stalled attempt already names the probe. - label: 'never aborted, the first probe spends the whole deadline', - firstProbe: 'exec-timeout', - aborts: 'never', - deadlineMs: 30_000, - expected: 'probe-stall', - execs: 1, - clockMs: 30_000, - }, -]; - -test.each(CANCELLATION_CASES)('cancellation matrix: $label', async (testCase) => { - const clock = { nowMs: 0 }; - const request = new AbortController(); - if (testCase.aborts === 'before-the-deadline') request.abort(); - let execs = 0; - const host = fakeToolchainHost(async (command, args, options) => { - execs += 1; - if (execs > 1 || !testCase.firstProbe) return toolchainAnswer(command, args); - if (testCase.aborts === 'while-it-blocks') request.abort(); - const failure = - testCase.firstProbe === 'exec-timeout' - ? await blockForWholeTimeout(clock, options) - : // No `timeoutMs` detail: the tool failed on its own, so nothing retries it. - new AppError('COMMAND_FAILED', `${command}: unexpected error`, { cmd: command }); - if (testCase.aborts === 'as-it-unwinds') request.abort(); - throw failure; - }); - - await assert.rejects( - // The deadline is opened inside the rejected call: an already-aborted request must - // fail as it is opened, before any probe runs. - async () => - await readSnapshotSourceToolchain( - host, - 'iOS 26.2', - createSnapshotSourceDeadline(testCase.deadlineMs, request.signal, () => clock.nowMs), - ), + readSnapshotSourceToolchain( + unsupportedArch, + 'iOS 26.2', + createSnapshotSourceDeadline(30_000, undefined), + ), (error: unknown) => { - assertExpectedToolchainFailure(error, testCase); + assert.ok(error instanceof SnapshotSourceError); + assert.equal(error.failureKind, 'unsupported'); + assert.equal(error.failureCode, 'simulator-architecture-unsupported'); return true; }, ); - assert.equal(execs, testCase.execs, `${testCase.label}: exec count`); - assert.equal(clock.nowMs, testCase.clockMs, `${testCase.label}: wall clock spent`); }); - -function assertExpectedToolchainFailure( - error: unknown, - testCase: ToolchainProbeCancellationCase, -): void { - if (testCase.expected === 'cancelled') { - assert.ok(error instanceof SnapshotSourceError, `${testCase.label}: expected a cancellation`); - assert.equal(error.failureKind, 'cancelled', testCase.label); - assert.equal(error.failureCode, 'abort-signal', testCase.label); - assert.equal(error.details?.reason, 'request_canceled', testCase.label); - return; - } - if (testCase.expected === 'probe-stall') { - assertToolchainProbeStall(error, 'xcodebuild', [30_000]); - return; - } - assert.ok(error instanceof AppError, `${testCase.label}: expected the probe's own failure`); - assert.equal(error.message, 'xcodebuild: unexpected error', testCase.label); -} - -/** - * A probe that stalled out names itself, says what each attempt was armed with, and keeps the exec - * layer's own kill as its cause -- so a job can tell "this tool never answered" from a device - * failure without reading a stack frame (#2712). - */ -function assertToolchainProbeStall( - error: unknown, - command: string, - attemptTimeoutsMs: number[], -): void { - assert.ok(error instanceof SnapshotSourceError, `expected a toolchain probe stall, got ${error}`); - assert.equal(error.failureKind, 'timeout'); - assert.equal(error.failureCode, 'toolchain-probe-stalled'); - assert.equal(error.details?.command, command); - assert.deepEqual(error.details?.attemptTimeoutsMs, attemptTimeoutsMs); - assert.match(String(error.details?.hint), new RegExp(`run \`${command}\` by hand`)); - assert.ok( - isCommandTimeoutError(error.cause), - 'the exec layer kill the probe hit stays the cause', - ); -} - -/** A deadline read against a clock only {@link blockForWholeTimeout} advances. */ -function fakeClockDeadline(timeoutMs: number, clock: { nowMs: number }): SnapshotSourceDeadline { - return createSnapshotSourceDeadline(timeoutMs, undefined, () => clock.nowMs); -} - -/** - * A probe that blocked for its whole timeout and was then killed. The fake clock advances by the - * budget the probe was handed, and the failure is the one `exec.ts` really raises for that kill. - */ -async function blockForWholeTimeout( - clock: { nowMs: number }, - options: ExecOptions, -): Promise { - clock.nowMs += options.timeoutMs ?? 0; - return await execKillTimeoutError(); -} - -/** - * What the host answers each probe the identity read is allowed to run. A command outside this list - * is a probe the identity read must not open at all (#2712). - */ -function toolchainAnswer(command: string, args: string[]): ExecResult { - if (command === 'xcodebuild') { - return { stdout: 'Xcode 26.2\nBuild version 17C52', stderr: '', exitCode: 0 }; - } - if (command === 'sw_vers') { - return { stdout: args.includes('-buildVersion') ? '24G90' : '15.6', stderr: '', exitCode: 0 }; - } - if (command === 'uname') return { stdout: 'arm64', stderr: '', exitCode: 0 }; - throw new Error(`the identity read execed ${command} ${args.join(' ')}`); -} - -function fakeToolchainHost( - run: (command: string, args: string[], options: ExecOptions) => ExecResult | Promise, -): SnapshotSourceHost { - const real = createSnapshotSourceHost(); - return { - ...real, - run: async (command, args, options) => run(command, args, options ?? {}), - }; -} diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.ts b/packages/platform-apple/src/snapshot-source/cache-identity.ts index 8c5d4c5e81..6b3260553f 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.ts @@ -1,23 +1,11 @@ -import { isCommandTimeoutError, type ExecResult } from '@agent-device/host-kit/command'; -import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; -import { snapshotSourceError, type SnapshotSourceError } from './errors.ts'; -import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts'; +import { + readHostToolchainIdentity, + type HostToolchainIdentity, +} from '../native-build/toolchain-identity.ts'; +import { fromNativeBuildError, snapshotSourceError } from './errors.ts'; +import type { SnapshotSourceDeadline } from './deadline.ts'; import type { SnapshotSourceHost } from './types.ts'; -/** - * The half of the bridge cache key the host answers for. `xcode` carries the version and the build, - * which is what pins the Simulator SDK the bridge compiles against: that SDK ships inside the - * selected `Xcode.app`, so it cannot move while `xcodebuild -version` reports the same build. The - * identity therefore execs one Xcode-owned binary rather than two, because a toolchain probe that - * cannot answer fails the whole job with nothing but a cache key at stake (#2712). - */ -export type HostToolchainIdentity = Readonly<{ - xcode: string; - macosProductVersion: string; - macosBuild: string; - architecture: 'arm64' | 'x86_64'; -}>; - export type SnapshotSourceToolchainIdentity = HostToolchainIdentity & Readonly<{ simulatorRuntime: string; @@ -37,137 +25,19 @@ export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [ ] as const; /** - * 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 - * this package, so a cache keyed on it is invalidated exactly when switching `DEVELOPER_DIR` would - * change what clang produces (#2796). + * The bridge's toolchain identity: the host's shared native-build toolchain identity + * (`native-build/toolchain-identity.ts`) plus the simulator runtime the bridge targets, which the + * fold helper does not depend on and the shared identity therefore does not carry. */ -export async function readHostToolchainIdentity( - host: SnapshotSourceHost, - deadline: SnapshotSourceDeadline, -): Promise { - // The one Xcode-owned binary this read execs: SnapshotSourceToolchainIdentity says why (#2712). - const xcode = await toolOutput(host, 'xcodebuild', ['-version'], deadline); - const macosProductVersion = await toolOutput(host, 'sw_vers', ['-productVersion'], deadline); - const macosBuild = await toolOutput(host, 'sw_vers', ['-buildVersion'], deadline); - const architecture = await toolOutput(host, 'uname', ['-m'], deadline); - if (architecture !== 'arm64' && architecture !== 'x86_64') { - throw snapshotSourceError('unsupported', 'simulator-architecture-unsupported', { - architecture, - }); - } - return { xcode, macosProductVersion, macosBuild, architecture }; -} - export async function readSnapshotSourceToolchain( host: SnapshotSourceHost, simulatorRuntime: string, deadline: SnapshotSourceDeadline, ): Promise { - const identity = await readHostToolchainIdentity(host, deadline); + const identity = await readHostToolchainIdentity(host, deadline).catch((error: unknown) => { + throw fromNativeBuildError(error); + }); const runtime = simulatorRuntime.trim(); if (!runtime) throw snapshotSourceError('unsupported', 'simulator-runtime-missing'); return { ...identity, simulatorRuntime: runtime }; } - -async function toolOutput( - host: SnapshotSourceHost, - command: string, - args: string[], - deadline: SnapshotSourceDeadline, -): Promise { - const result = await runToolchainProbe(host, command, args, deadline); - if (result.exitCode !== 0) { - throw snapshotSourceError('unsupported', 'toolchain-probe-failed', { - command, - exitCode: result.exitCode, - stderr: result.stderr.slice(0, 1024), - }); - } - const output = (result.stdout || result.stderr).trim(); - if (!output) throw snapshotSourceError('unsupported', 'toolchain-probe-empty', { command }); - return output; -} - -/** One exec, plus the single retry a first-exec stall can actually earn. */ -const TOOLCHAIN_PROBE_ATTEMPTS = 2; - -/** - * Retries exactly once, and only the exec layer's structured timeout: a stall that finished while the - * probe was being killed leaves an instant next exec of the same tool behind it, which is what - * {@link COLD_TOOLCHAIN_PROBE_TIMEOUT_MS} was sized for (#2422). A stall that had not finished does - * not clear that way, so the second timeout is reported as the host condition it is instead of - * pretending the probe was worth running twice. Both attempts read one deadline, so the retry only - * gets what the first stall left. - */ -async function runToolchainProbe( - host: SnapshotSourceHost, - command: string, - args: string[], - deadline: SnapshotSourceDeadline, -): Promise { - const attemptTimeoutsMs: number[] = []; - let stalledBy: SnapshotSourceError | undefined; - for (let attempt = 1; ; attempt += 1) { - let timeoutMs: number; - try { - timeoutMs = Math.min( - COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, - remainingSnapshotSourceMs(deadline, 'toolchain-probe-deadline'), - ); - } catch (budgetError) { - // A budget that closes between an attempt and this line is the stall already observed, and it - // still names the probe that stalled rather than the arithmetic that noticed. - throw stalledBy ?? budgetError; - } - attemptTimeoutsMs.push(timeoutMs); - try { - return await execToolchainProbe(host, command, args, deadline, timeoutMs); - } catch (error) { - if (!isCommandTimeoutError(error)) throw error; - if (deadline.signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal'); - stalledBy = toolchainProbeStallError(command, attemptTimeoutsMs, error); - if (attempt >= TOOLCHAIN_PROBE_ATTEMPTS) throw stalledBy; - } - } -} - -/** - * Says which probe could not answer, how many execs it took to learn that, and what each was armed - * with. The exec layer's ` timed out after Nms` alone reaches a job as an unattributed command - * failure, which reads exactly like a device failure (#2712). - */ -function toolchainProbeStallError( - command: string, - attemptTimeoutsMs: readonly number[], - cause: unknown, -): SnapshotSourceError { - const attempts = attemptTimeoutsMs.length; - return snapshotSourceError( - 'timeout', - 'toolchain-probe-stalled', - { - command, - attemptTimeoutsMs: [...attemptTimeoutsMs], - hint: - `${command} did not answer within ${attemptTimeoutsMs[attempts - 1]}ms on ${attempts} ` + - `attempt(s). A toolchain probe that cannot answer is a host condition rather than a ` + - `toolchain defect: run \`${command}\` by hand until it answers, then retry.`, - }, - cause, - ); -} - -function execToolchainProbe( - host: SnapshotSourceHost, - command: string, - args: string[], - deadline: SnapshotSourceDeadline, - timeoutMs: number, -): Promise { - return host.run(command, args, { - allowFailure: true, - signal: deadline.signal, - timeoutMs, - }); -} diff --git a/packages/platform-apple/src/snapshot-source/cache.ts b/packages/platform-apple/src/snapshot-source/cache.ts index 1614bd987b..86866748b2 100644 --- a/packages/platform-apple/src/snapshot-source/cache.ts +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -1,5 +1,10 @@ import path from 'node:path'; -import { snapshotSourceError } from './errors.ts'; +import { + ensureNativeBuildCacheEntry, + execNativeBuildClang, + fingerprintNativeBuildSource, +} from '../native-build/cache.ts'; +import { fromNativeBuildError, snapshotSourceError } from './errors.ts'; import type { SnapshotSourceDeadline } from './deadline.ts'; import { readSnapshotSourceToolchain, @@ -7,11 +12,6 @@ import { SNAPSHOT_BRIDGE_SOURCE_FILENAMES, type SnapshotSourceToolchainIdentity, } from './cache-identity.ts'; -import { - ensureNativeBuildCacheEntry, - execNativeBuildClang, - fingerprintNativeBuildSource, -} from './native-build-cache.ts'; import { SNAPSHOT_SOURCE_PROTOCOL_VERSION, SNAPSHOT_SOURCE_VERSION } from './protocol.ts'; import type { SnapshotSourceBridgeBinary, @@ -42,61 +42,65 @@ export async function ensureSnapshotBridgeBinary( ): Promise { const deadline = input.deadline; const sourceRoot = input.sourceRoot ?? resolveSnapshotBridgeSourceRoot(input.host); - const sourceHash = await fingerprintNativeBuildSource( - input.host, - sourceRoot, - SNAPSHOT_BRIDGE_SOURCE_FILENAMES, - deadline, - ); - const toolchain = await readSnapshotSourceToolchain(input.host, input.runtime, deadline); - const cacheRoot = - input.cacheRoot ?? path.join(input.host.homeDirectory(), '.agent-device', 'snapshot-source'); - const entry = await ensureNativeBuildCacheEntry({ - host: input.host, - deadline, - cacheRoot, - binaryFilename: BRIDGE_FILENAME, - lockDescription: BRIDGE_LOCK_DESCRIPTION, - keyInputs: { - schemaVersion: CACHE_SCHEMA_VERSION, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - sourceHash, - toolchain, - // Placeholder paths keep the key independent of the install location and build directory. - compileArgv: buildSnapshotBridgeCompileArgv({ - architecture: toolchain.architecture, - sourceRoot: '', - outputPath: '', - }), - }, - build: async (outputPath) => { - const result = await execNativeBuildClang({ - host: input.host, - deadline, - argv: buildSnapshotBridgeCompileArgv({ + try { + const sourceHash = await fingerprintNativeBuildSource( + input.host, + sourceRoot, + SNAPSHOT_BRIDGE_SOURCE_FILENAMES, + deadline, + ); + const toolchain = await readSnapshotSourceToolchain(input.host, input.runtime, deadline); + const cacheRoot = + input.cacheRoot ?? path.join(input.host.homeDirectory(), '.agent-device', 'snapshot-source'); + const entry = await ensureNativeBuildCacheEntry({ + host: input.host, + deadline, + cacheRoot, + binaryFilename: BRIDGE_FILENAME, + lockDescription: BRIDGE_LOCK_DESCRIPTION, + keyInputs: { + schemaVersion: CACHE_SCHEMA_VERSION, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + sourceHash, + toolchain, + // Placeholder paths keep the key independent of the install location and build directory. + compileArgv: buildSnapshotBridgeCompileArgv({ architecture: toolchain.architecture, - sourceRoot, - outputPath, + sourceRoot: '', + outputPath: '', }), - budgetMs: BUILD_TIMEOUT_MS, - label: 'bridge', - }); - if (result.exitCode !== 0 || !input.host.exists(outputPath)) { - throw snapshotSourceError('unsupported', 'native-build-failed', { - exitCode: result.exitCode, - stderr: result.stderr.slice(0, 4096), + }, + build: async (outputPath) => { + const result = await execNativeBuildClang({ + host: input.host, + deadline, + argv: buildSnapshotBridgeCompileArgv({ + architecture: toolchain.architecture, + sourceRoot, + outputPath, + }), + budgetMs: BUILD_TIMEOUT_MS, + label: 'bridge', }); - } - }, - }); - return { - path: entry.path, - sourceHash, - cacheKey: entry.cacheKey, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - }; + if (result.exitCode !== 0 || !input.host.exists(outputPath)) { + throw snapshotSourceError('unsupported', 'native-build-failed', { + exitCode: result.exitCode, + stderr: result.stderr.slice(0, 4096), + }); + } + }, + }); + return { + path: entry.path, + sourceHash, + cacheKey: entry.cacheKey, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + }; + } catch (error) { + throw fromNativeBuildError(error); + } } /** diff --git a/packages/platform-apple/src/snapshot-source/deadline.ts b/packages/platform-apple/src/snapshot-source/deadline.ts index 219545d690..1be7118b6b 100644 --- a/packages/platform-apple/src/snapshot-source/deadline.ts +++ b/packages/platform-apple/src/snapshot-source/deadline.ts @@ -1,13 +1,13 @@ -import { Deadline } from '@agent-device/host-kit/retry'; import { waitForDetachedAttempt } from '../detached-attempt.ts'; -import { snapshotSourceError } from './errors.ts'; +import { + createNativeBuildDeadline, + remainingNativeBuildMs, + type NativeBuildDeadline, +} from '../native-build/deadline.ts'; +import { fromNativeBuildError, snapshotSourceError } from './errors.ts'; -export type SnapshotSourceDeadline = Readonly<{ - clock: Deadline; - /** The clock the deadline is read against; injected so a test can move time (#2422). */ - now: () => number; - signal: AbortSignal | undefined; -}>; +/** The same deadline shape every native build/cache in this package reads against (#2970). */ +export type SnapshotSourceDeadline = NativeBuildDeadline; export function createSnapshotSourceDeadline( timeoutMs: number, @@ -15,14 +15,16 @@ export function createSnapshotSourceDeadline( now: () => number = Date.now, ): SnapshotSourceDeadline { if (signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal'); - return { clock: Deadline.fromTimeoutMs(timeoutMs, now()), now, signal }; + return createNativeBuildDeadline(timeoutMs, signal, now); } +/** Behaves exactly like the shared `remainingNativeBuildMs`, but keys its failure on `SnapshotSourceError`. */ export function remainingSnapshotSourceMs(deadline: SnapshotSourceDeadline, code: string): number { - if (deadline.signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal'); - const remainingMs = deadline.clock.remainingMs(deadline.now()); - if (remainingMs <= 0) throw snapshotSourceError('timeout', code); - return Math.max(1, Math.floor(remainingMs)); + try { + return remainingNativeBuildMs(deadline, code); + } catch (error) { + throw fromNativeBuildError(error); + } } /** diff --git a/packages/platform-apple/src/snapshot-source/errors.ts b/packages/platform-apple/src/snapshot-source/errors.ts index bd7671c007..a1f0aa9836 100644 --- a/packages/platform-apple/src/snapshot-source/errors.ts +++ b/packages/platform-apple/src/snapshot-source/errors.ts @@ -1,4 +1,5 @@ import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; +import { NativeBuildError } from '../native-build/errors.ts'; import type { SnapshotSourceFailureKind } from './types.ts'; const APP_ERROR_CODE_BY_KIND: Readonly> = { @@ -49,7 +50,25 @@ export function snapshotSourceError( return new SnapshotSourceError(kind, code, undefined, details, cause); } +/** + * Maps the shared native-build/cache module's domain-neutral failure onto this bridge's own error + * shape, keeping its typed kind, code, details and cause; anything else passes through unchanged + * for {@link asSnapshotSourceError} to classify. + */ +export function fromNativeBuildError(error: unknown): unknown { + if (!(error instanceof NativeBuildError)) return error; + return new SnapshotSourceError( + error.buildFailureKind, + error.buildFailureCode, + undefined, + error.buildDetails, + error.cause, + ); +} + export function asSnapshotSourceError(error: unknown): SnapshotSourceError { + if (error instanceof SnapshotSourceError) return error; + error = fromNativeBuildError(error); if (error instanceof SnapshotSourceError) return error; if (isRequestCanceledError(error)) { return snapshotSourceError('cancelled', 'abort-signal', {}, error); diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index f54afe12dd..b89fac9ad8 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -2,27 +2,12 @@ import { createHash } from 'node:crypto'; import net from 'node:net'; import path from 'node:path'; import { runCmd, runCmdBackground } from '@agent-device/host-kit/command'; -import { acquireProcessLock } from '@agent-device/host-kit/file'; -import { - chmodHostFile, - ensureHostDirectory, - hostFileExistsSync, - hostHomeDirectory, - readHostBinaryFile, - readHostTextFile, - removeHostPath, - renameHostPath, - writeHostTextFile, -} from '@agent-device/host-kit/host-file'; -import { - hostProcessId, - readProcessStartTime, - signalProcessGroupBestEffort, -} from '@agent-device/host-kit/process'; +import { hostHomeDirectory } from '@agent-device/host-kit/host-file'; +import { signalProcessGroupBestEffort } from '@agent-device/host-kit/process'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; import { findProjectRoot } from '@agent-device/host-kit/version'; -import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; -import { remainingSnapshotSourceMs } from './deadline.ts'; +import { createNativeBuildHost } from '../native-build/host.ts'; +import { snapshotSourceError } from './errors.ts'; import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts'; import { readSnapshotTargetProcessStartTime } from '../snapshot-process.ts'; import { buildSimctlArgsForAddress, type SimulatorAddress } from '../core/simctl.ts'; @@ -32,24 +17,20 @@ const MAX_PROCESS_LOG_BYTES = 64 * 1024; const SNAPSHOT_SOCKET_ROOT = '/tmp'; export function createSnapshotSourceHost(): SnapshotSourceHost { + // The bridge's build/cache access is the shared native-build host (#2970); this adds only what a + // bridge session needs beyond a build: socket start/connect, diagnostics, and target inspection. + // A native-build cache failure surfaces here as `NativeBuildError`; the cache's own callers + // (`snapshot-source/cache.ts`) map it onto `SnapshotSourceError`, so this host does not. return { + ...createNativeBuildHost( + async (command, args, options) => await runCmd(command, args, options), + ), projectRoot: findProjectRoot, homeDirectory: hostHomeDirectory, - run: async (command, args, options) => await runCmd(command, args, options), start: startSnapshotBridge, connect: connectSnapshotBridge, - readText: readHostTextFile, - readBinary: readHostBinaryFile, - writeText: writeHostTextFile, - ensureDirectory: ensureHostDirectory, - chmod: chmodHostFile, - exists: hostFileExistsSync, - rename: renameHostPath, - remove: removeHostPath, - acquireLock: acquireSnapshotSourceLock, emitDiagnostic, withDiagnosticTimer, - processId: hostProcessId, readTargetProcessStartTime: readSnapshotTargetProcessStartTime, }; } @@ -161,62 +142,6 @@ async function connectSnapshotBridge( }); } -async function acquireSnapshotSourceLock( - lockPath: string, - options: Parameters[1], -): Promise<() => Promise> { - const pid = hostProcessId(); - const deadline = options.deadline; - const pending = acquireProcessLock({ - lockDirPath: lockPath, - owner: { - pid, - startTime: readProcessStartTime(pid), - acquiredAtMs: Date.now(), - }, - timeoutMs: remainingSnapshotSourceMs(deadline, 'cache-lock-deadline'), - pollMs: 100, - ownerGraceMs: 5_000, - description: options.description, - }); - const signal = deadline.signal; - if (!signal) return await pending; - - let canceled = false; - let onAbort!: () => void; - const aborted = new Promise((_, reject) => { - onAbort = () => { - canceled = true; - reject(snapshotSourceError('cancelled', 'abort-signal')); - }; - signal.addEventListener('abort', onAbort, { once: true }); - if (signal.aborted) onAbort(); - }); - try { - return await Promise.race([pending, aborted]); - } catch (error) { - if (canceled) { - // The task is abandoned, so its lock is released best effort. A release that - // cannot prove ownership leaves the lock to the stale-clear path, which is the - // outcome this branch already accepts; it must not arrive as an unhandled - // rejection on a promise nobody is awaiting any more. - void pending.then( - (release) => release().catch(() => undefined), - () => undefined, - ); - } - if ( - deadline.clock.isExpired() && - !(error instanceof SnapshotSourceError && error.failureKind === 'cancelled') - ) { - throw snapshotSourceError('timeout', 'cache-lock-deadline'); - } - throw error; - } finally { - signal.removeEventListener('abort', onAbort); - } -} - function appendBoundedLog(current: string, addition: string): string { const combined = current + addition; return combined.length <= MAX_PROCESS_LOG_BYTES From baa48fa8ba5f10cff86a2aa69483bf5c399d3f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 15:52:51 +0200 Subject: [PATCH 2/2] fix(apple): keep native-build cancellation typed as a canceled request Abort during fold-helper or toolchain-probe preparation lost reason: 'request_canceled' once native-build/cache errors stopped mapping onto SnapshotSourceError, so isRequestCanceledError no longer recognized a canceled fold or lock wait. NativeBuildError now stamps the same reason SnapshotSourceError already does; a lock wait with no abort signal now maps an expired deadline to a typed timeout instead of the raw lock error; and a mid-exec abort during a toolchain probe now maps through the module's cancelled contract instead of leaking the exec layer's raw cancellation. Also: fixes the cache-identity and toolchain-identity test title/ comment mismatches cubic flagged, relocates the shared exec-timeout test fixture out of snapshot-source so native-build's own tests stop depending on it, folds SnapshotSourceHost's file/exec/lock members into an intersection with NativeBuildHost instead of restating them, and extracts the toolchain-probe failure classification into its own function to keep runToolchainProbe under the complexity gate. --- .../src/foldable/fold-helper-cache.test.ts | 61 +++++++++++++- .../__tests__/exec-timeout-fixture.ts | 0 .../platform-apple/src/native-build/errors.ts | 7 +- .../platform-apple/src/native-build/host.ts | 27 ++++-- .../native-build/toolchain-identity.test.ts | 7 +- .../src/native-build/toolchain-identity.ts | 25 +++++- .../snapshot-source/cache-identity.test.ts | 2 +- .../src/snapshot-source/cache.test.ts | 2 +- .../src/snapshot-source/types.ts | 84 ++++++++----------- 9 files changed, 149 insertions(+), 66 deletions(-) rename packages/platform-apple/src/{snapshot-source => native-build}/__tests__/exec-timeout-fixture.ts (100%) diff --git a/packages/platform-apple/src/foldable/fold-helper-cache.test.ts b/packages/platform-apple/src/foldable/fold-helper-cache.test.ts index f582319d60..e564bd3eb3 100644 --- a/packages/platform-apple/src/foldable/fold-helper-cache.test.ts +++ b/packages/platform-apple/src/foldable/fold-helper-cache.test.ts @@ -3,8 +3,9 @@ import { readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { beforeAll, describe, test } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; +import { isRequestCanceledError } from '@agent-device/kernel/errors'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; -import { execKillTimeoutError } from '../snapshot-source/__tests__/exec-timeout-fixture.ts'; +import { execKillTimeoutError } from '../native-build/__tests__/exec-timeout-fixture.ts'; import { createSnapshotSourceHost } from '../snapshot-source/host.ts'; import type { SnapshotSourceHost } from '../snapshot-source/types.ts'; import { @@ -156,6 +157,64 @@ test('a compile exec killed at its budget reports the fold-helper build, not the } }); +test('an abort while a second caller waits on the fold-helper lock reports a canceled request', async () => { + const root = await mkdtempForTest('agent-device-fold-helper-cache-lock-wait-'); + const sourceRoot = path.join(root, 'source'); + const cacheRoot = path.join(root, 'cache'); + await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot); + await writeFile(path.join(sourceRoot, 'Fold.m'), 'fold source'); + + let releaseClang!: () => void; + const clangGate = new Promise((resolve) => { + releaseClang = resolve; + }); + let clangStarted!: () => void; + const clangStartedSignal = new Promise((resolve) => { + clangStarted = resolve; + }); + const host = fakeFoldHelperHost(() => 'binary'); + const holdingHost: SnapshotSourceHost = { + ...host, + run: async (command, args, options) => { + if (command === 'xcrun' && args.includes('clang')) { + clangStarted(); + await clangGate; + return await host.run(command, args, options); + } + return await host.run(command, args, options); + }, + }; + + try { + // The first call acquires the fold-helper lock and holds it in its build step (gated on + // `clangGate`) until this test releases it, so the second call below is guaranteed to find + // the lock already held rather than racing for it. + const holder = ensureFoldHelperBinary({ host: holdingHost, sourceRoot, cacheRoot }); + await clangStartedSignal; + + const controller = new AbortController(); + const waiter = ensureFoldHelperBinary({ + host, + sourceRoot, + cacheRoot, + signal: controller.signal, + }); + // Give the waiter time to reach the lock's poll loop before aborting it. + await new Promise((resolve) => setTimeout(resolve, 50)); + controller.abort(); + + await assert.rejects(waiter, (error: unknown) => { + assert.ok(isRequestCanceledError(error), 'expected a canceled-request error'); + return true; + }); + + releaseClang(); + await holder; + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + // #2796: the production compile drops -Werror so a stale toolchain warning cannot fail a build; // this is the gate that keeps a new Fold.m warning from passing CI unnoticed. It runs the // production argv (`buildFoldHelperCompileArgv`) against the real iphonesimulator SDK with diff --git a/packages/platform-apple/src/snapshot-source/__tests__/exec-timeout-fixture.ts b/packages/platform-apple/src/native-build/__tests__/exec-timeout-fixture.ts similarity index 100% rename from packages/platform-apple/src/snapshot-source/__tests__/exec-timeout-fixture.ts rename to packages/platform-apple/src/native-build/__tests__/exec-timeout-fixture.ts diff --git a/packages/platform-apple/src/native-build/errors.ts b/packages/platform-apple/src/native-build/errors.ts index 8cf17c8af6..a0bffb75ba 100644 --- a/packages/platform-apple/src/native-build/errors.ts +++ b/packages/platform-apple/src/native-build/errors.ts @@ -23,7 +23,12 @@ export class NativeBuildError extends AppError { super( 'COMMAND_FAILED', message, - { ...details, nativeBuildFailure: kind, nativeBuildFailureCode: code }, + { + ...details, + nativeBuildFailure: kind, + nativeBuildFailureCode: code, + ...(kind === 'cancelled' ? { reason: 'request_canceled' } : {}), + }, cause, ); this.name = 'NativeBuildError'; diff --git a/packages/platform-apple/src/native-build/host.ts b/packages/platform-apple/src/native-build/host.ts index d305590395..ab55d44fb7 100644 --- a/packages/platform-apple/src/native-build/host.ts +++ b/packages/platform-apple/src/native-build/host.ts @@ -72,7 +72,13 @@ async function acquireNativeBuildLock( description: options.description, }); const signal = deadline.signal; - if (!signal) return await pending; + if (!signal) { + try { + return await pending; + } catch (error) { + throw mapExpiredLockError(error, deadline); + } + } let canceled = false; let onAbort!: () => void; @@ -97,14 +103,19 @@ async function acquireNativeBuildLock( () => undefined, ); } - if ( - deadline.clock.isExpired() && - !(error instanceof NativeBuildError && error.buildFailureKind === 'cancelled') - ) { - throw nativeBuildError('timeout', 'cache-lock-deadline'); - } - throw error; + throw mapExpiredLockError(error, deadline); } finally { signal.removeEventListener('abort', onAbort); } } + +/** A lock wait that outlives the native-build deadline reports the deadline, not the raw lock error. */ +function mapExpiredLockError(error: unknown, deadline: NativeBuildDeadline): unknown { + if ( + deadline.clock.isExpired() && + !(error instanceof NativeBuildError && error.buildFailureKind === 'cancelled') + ) { + return nativeBuildError('timeout', 'cache-lock-deadline'); + } + return error; +} diff --git a/packages/platform-apple/src/native-build/toolchain-identity.test.ts b/packages/platform-apple/src/native-build/toolchain-identity.test.ts index 5a35215fd2..1aefbbf549 100644 --- a/packages/platform-apple/src/native-build/toolchain-identity.test.ts +++ b/packages/platform-apple/src/native-build/toolchain-identity.test.ts @@ -10,7 +10,7 @@ import { createNativeBuildDeadline, type NativeBuildDeadline } from './deadline. import { NativeBuildError } from './errors.ts'; import { createNativeBuildHost, type NativeBuildHost } from './host.ts'; import { readHostToolchainIdentity } from './toolchain-identity.ts'; -import { execKillTimeoutError } from '../snapshot-source/__tests__/exec-timeout-fixture.ts'; +import { execKillTimeoutError } from './__tests__/exec-timeout-fixture.ts'; // Apple's syspolicyd signature scan blocks the first exec of an Xcode-owned tool after a fresh // macOS host boots for roughly 18 to 19 seconds; the immediate next exec of the same tool is @@ -76,8 +76,9 @@ test('a toolchain host that never returns reports the stalled probe after one re return true; }, ); - // Exactly one retry, not an unbounded loop, and the retry is charged the - // remainder rather than a fresh ceiling. + // Exactly one retry, not an unbounded loop: with 90 s left after the first 30 s stall, the + // retry still gets the full per-probe ceiling. The cold-start case above is where the retry is + // charged the remainder instead ([30_000, 10_000]). assert.deepEqual(timeouts, [30_000, 30_000]); assert.equal(clock.nowMs, 60_000); }); diff --git a/packages/platform-apple/src/native-build/toolchain-identity.ts b/packages/platform-apple/src/native-build/toolchain-identity.ts index de1adc3738..ab9aa27064 100644 --- a/packages/platform-apple/src/native-build/toolchain-identity.ts +++ b/packages/platform-apple/src/native-build/toolchain-identity.ts @@ -1,4 +1,5 @@ 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'; @@ -89,14 +90,32 @@ async function runToolchainProbe( try { return await execToolchainProbe(host, command, args, deadline, timeoutMs); } catch (error) { - if (!isCommandTimeoutError(error)) throw error; - if (deadline.signal?.aborted) throw nativeBuildError('cancelled', 'abort-signal'); - stalledBy = toolchainProbeStallError(command, attemptTimeoutsMs, error); + stalledBy = classifyToolchainProbeFailure(error, command, deadline, attemptTimeoutsMs); if (attempt >= TOOLCHAIN_PROBE_ATTEMPTS) throw stalledBy; } } } +/** + * Sorts a failed probe exec into the module's cancellation/stall contract: a raw exec-layer + * cancellation (a mid-exec abort settles through `exec.ts` as `REQUEST_CANCELED`, not a timeout) + * and a post-timeout abort both surface as the domain `cancelled` shape; anything but a structured + * exec timeout rethrows unchanged; only an actual stall is handed back for the retry loop to count. + */ +function classifyToolchainProbeFailure( + error: unknown, + command: string, + deadline: NativeBuildDeadline, + attemptTimeoutsMs: readonly number[], +): NativeBuildError { + if (isRequestCanceledError(error)) { + throw nativeBuildError('cancelled', 'abort-signal', {}, error); + } + if (!isCommandTimeoutError(error)) throw error; + if (deadline.signal?.aborted) throw nativeBuildError('cancelled', 'abort-signal'); + return toolchainProbeStallError(command, attemptTimeoutsMs, error); +} + /** * Says which probe could not answer, how many execs it took to learn that, and what each was armed * with. The exec layer's ` timed out after Nms` alone reaches a job as an unattributed command diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.test.ts b/packages/platform-apple/src/snapshot-source/cache-identity.test.ts index 38f531bf64..36c797cdf9 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.test.ts @@ -41,7 +41,7 @@ test('the bridge toolchain identity carries the simulator runtime alongside the assert.equal(identity.simulatorRuntime, 'iOS 26.2'); }); -test('a blank simulator runtime is rejected before any probe runs', async () => { +test('a blank simulator runtime is rejected only after the shared identity read succeeds', async () => { let probed = false; const host = fakeToolchainHost((command, args) => { probed = true; diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index e157ec95a2..ef1ce8536f 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -10,7 +10,7 @@ import { SnapshotSourceError } from './errors.ts'; import { createSnapshotSourceDeadline } from './deadline.ts'; import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts'; import type { SnapshotSourceHost } from './types.ts'; -import { execKillTimeoutError } from './__tests__/exec-timeout-fixture.ts'; +import { execKillTimeoutError } from '../native-build/__tests__/exec-timeout-fixture.ts'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt or stale entries', async () => { diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index 70f69675ee..661bb3b4b1 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -1,12 +1,12 @@ -import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; +import type { ExecResult } from '@agent-device/host-kit/command'; import type { CaptureHint, IosSnapshotAcquisition, IosViewportEvidence, } from '@agent-device/contracts/ios-snapshot'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import type { NativeBuildHost } from '../native-build/host.ts'; import type { SimulatorAddress } from '../core/simctl.ts'; -import type { SnapshotSourceDeadline } from './deadline.ts'; export type SnapshotSourceLimits = Readonly<{ maxRequestBytes: number; @@ -78,52 +78,40 @@ export type SnapshotSourceSocket = Readonly<{ destroy(error?: Error): void; }>; -export type SnapshotSourceHost = Readonly<{ - projectRoot(): string; - homeDirectory(): string; - run(command: string, args: string[], options?: ExecOptions): Promise; - start( - simulator: SimulatorAddress, - bridgePath: string, - socketPath: string, - options?: { signal?: AbortSignal }, - ): SnapshotSourceProcess; - connect( - socketPath: string, - options: { signal?: AbortSignal; timeoutMs: number }, - ): Promise; - readText(path: string): Promise; - readBinary(path: string): Promise; - writeText(path: string, contents: string): Promise; - ensureDirectory(path: string): Promise; - chmod(path: string, mode: number): Promise; - exists(path: string): boolean; - rename(sourcePath: string, destinationPath: string): Promise; - remove(path: string): Promise; - acquireLock( - path: string, - /** `description` names the contended resource in a stall's diagnostic, e.g. "iOS Simulator - * snapshot bridge cache"; every lock holder states its own, since this host is shared by every - * runtime clang build in the package. */ - options: { deadline: SnapshotSourceDeadline; description: string }, - ): Promise<() => Promise>; - emitDiagnostic(event: { - level?: 'debug' | 'info' | 'warn' | 'error'; - phase: string; - durationMs?: number; - data?: Record; - }): void; - withDiagnosticTimer( - phase: string, - action: () => Promise | T, - data?: Record, - ): Promise; - processId(): number; - readTargetProcessStartTime( - pid: number, - options: { signal?: AbortSignal; timeoutMs: number }, - ): Promise; -}>; +/** + * The shared native-build host (file access, exec, lock, process identity) plus what a bridge + * session needs beyond a build: socket start/connect, diagnostics, and target inspection (#2970). + */ +export type SnapshotSourceHost = NativeBuildHost & + Readonly<{ + projectRoot(): string; + homeDirectory(): string; + start( + simulator: SimulatorAddress, + bridgePath: string, + socketPath: string, + options?: { signal?: AbortSignal }, + ): SnapshotSourceProcess; + connect( + socketPath: string, + options: { signal?: AbortSignal; timeoutMs: number }, + ): Promise; + emitDiagnostic(event: { + level?: 'debug' | 'info' | 'warn' | 'error'; + phase: string; + durationMs?: number; + data?: Record; + }): void; + withDiagnosticTimer( + phase: string, + action: () => Promise | T, + data?: Record, + ): Promise; + readTargetProcessStartTime( + pid: number, + options: { signal?: AbortSignal; timeoutMs: number }, + ): Promise; + }>; export type SnapshotSourceBridgeBinary = Readonly<{ path: string;