From 43c03f34ff9db5d433714a3082bdf4aae789a8ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 10:17:20 +0200 Subject: [PATCH 1/4] refactor(ios): extract the native build cache from the snapshot bridge Move the snapshot bridge's content+toolchain-keyed build cache (lock, atomic publish, manifest matching, source fingerprinting, and the budgeted xcrun exec) into native-build-cache.ts as generic, reusable primitives. The bridge's manifest shape, cache key derivation, and compile argv (including -Werror) are unchanged; this is a pure move. --- .../src/snapshot-source/cache-identity.ts | 22 -- .../src/snapshot-source/cache.ts | 276 ++++++------------ .../native-build-cache.test.ts | 136 +++++++++ .../src/snapshot-source/native-build-cache.ts | 206 +++++++++++++ 4 files changed, 427 insertions(+), 213 deletions(-) create mode 100644 packages/platform-apple/src/snapshot-source/native-build-cache.test.ts create mode 100644 packages/platform-apple/src/snapshot-source/native-build-cache.ts diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.ts b/packages/platform-apple/src/snapshot-source/cache-identity.ts index 5bf7e42a1e..e0a72da4bf 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.ts @@ -1,5 +1,3 @@ -import { createHash } from 'node:crypto'; -import path from 'node:path'; 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'; @@ -34,26 +32,6 @@ export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [ 'SnapshotBridgeCapture.m', ] as const; -export async function fingerprintSnapshotBridgeSource( - host: SnapshotSourceHost, - root: string, - deadline: SnapshotSourceDeadline, -): Promise { - const hash = createHash('sha256'); - for (const sourceFile of SNAPSHOT_BRIDGE_SOURCE_FILENAMES) { - const filePath = path.join(root, sourceFile); - remainingSnapshotSourceMs(deadline, 'native-source-fingerprint-deadline'); - if (!host.exists(filePath)) { - throw snapshotSourceError('unsupported', 'native-source-missing', { filePath }); - } - hash.update(sourceFile); - hash.update('\0'); - hash.update(await host.readBinary(filePath)); - hash.update('\0'); - } - return hash.digest('hex'); -} - export async function readSnapshotSourceToolchain( host: SnapshotSourceHost, simulatorRuntime: string, diff --git a/packages/platform-apple/src/snapshot-source/cache.ts b/packages/platform-apple/src/snapshot-source/cache.ts index 4d5f4bf088..f127d8d53c 100644 --- a/packages/platform-apple/src/snapshot-source/cache.ts +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -1,16 +1,20 @@ -import { createHash } from 'node:crypto'; import path from 'node:path'; -import { isCommandTimeoutError, type ExecResult } from '@agent-device/host-kit/command'; -import { withProcessLock } from '@agent-device/host-kit/file'; -import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; -import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts'; +import type { ExecResult } from '@agent-device/host-kit/command'; +import { snapshotSourceError } from './errors.ts'; +import type { SnapshotSourceDeadline } from './deadline.ts'; import { - fingerprintSnapshotBridgeSource, readSnapshotSourceToolchain, SNAPSHOT_BRIDGE_COMPILE_FILENAMES, SNAPSHOT_BRIDGE_SOURCE_FILENAMES, type SnapshotSourceToolchainIdentity, } from './cache-identity.ts'; +import { + execNativeBuildClang, + fingerprintNativeBuildSource, + nativeBuildCacheKey, + nativeBuildManifestFieldsMatch, + ensureNativeBuildCacheEntry, +} from './native-build-cache.ts'; import { SNAPSHOT_SOURCE_PROTOCOL_VERSION, SNAPSHOT_SOURCE_VERSION } from './protocol.ts'; import type { SnapshotSourceBridgeBinary, @@ -18,19 +22,16 @@ import type { SnapshotSourceLimits, } from './types.ts'; -type SnapshotBridgeCacheManifest = Readonly<{ - schemaVersion: 1; - protocolVersion: number; - sourceVersion: string; - sourceHash: string; - cacheKey: string; - toolchain: SnapshotSourceToolchainIdentity; - binarySha256: string; -}>; - const CACHE_SCHEMA_VERSION = 1 as const; const BRIDGE_FILENAME = 'snapshot-bridge'; -const MANIFEST_FILENAME = 'manifest.json'; +const MANIFEST_FIELDS = [ + 'schemaVersion', + 'protocolVersion', + 'sourceVersion', + 'sourceHash', + 'cacheKey', + 'toolchain', +] as const; /** * @internal Upper bound on a single snapshot-bridge clang invocation, exposed for the host bridge @@ -51,9 +52,14 @@ export async function ensureSnapshotBridgeBinary( ): Promise { const deadline = input.deadline; const sourceRoot = input.sourceRoot ?? resolveSnapshotBridgeSourceRoot(input.host); - const sourceHash = await fingerprintSnapshotBridgeSource(input.host, sourceRoot, deadline); + const sourceHash = await fingerprintNativeBuildSource( + input.host, + sourceRoot, + SNAPSHOT_BRIDGE_SOURCE_FILENAMES, + deadline, + ); const toolchain = await readSnapshotSourceToolchain(input.host, input.runtime, deadline); - const cacheKey = hashJson({ + const cacheKey = nativeBuildCacheKey({ schemaVersion: CACHE_SCHEMA_VERSION, protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, sourceVersion: SNAPSHOT_SOURCE_VERSION, @@ -62,77 +68,46 @@ export async function ensureSnapshotBridgeBinary( }); const cacheRoot = input.cacheRoot ?? path.join(input.host.homeDirectory(), '.agent-device', 'snapshot-source'); - const entryPath = path.join(cacheRoot, cacheKey); - return await withProcessLock({ - acquire: () => input.host.acquireLock(path.join(cacheRoot, `${cacheKey}.lock`), { deadline }), - task: async () => { - const cached = await readValidCache( + const manifest = { + schemaVersion: CACHE_SCHEMA_VERSION, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + sourceHash, + cacheKey, + toolchain, + }; + const entry = await ensureNativeBuildCacheEntry({ + host: input.host, + deadline, + cacheRoot, + cacheKey, + binaryFilename: BRIDGE_FILENAME, + manifest, + manifestMatches: (candidate) => + nativeBuildManifestFieldsMatch(candidate, manifest, MANIFEST_FIELDS), + build: async (outputPath) => { + const result = await compileSnapshotBridge( input.host, - entryPath, - { - sourceHash, - cacheKey, - toolchain, - }, deadline, + toolchain.architecture, + sourceRoot, + outputPath, ); - if (cached) return cached; - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); - if (input.host.exists(entryPath)) await input.host.remove(entryPath); - - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); - await input.host.ensureDirectory(cacheRoot); - const temporaryPath = path.join(cacheRoot, `.${cacheKey}.${input.host.processId()}.tmp`); - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); - await input.host.remove(temporaryPath); - try { - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); - await input.host.ensureDirectory(temporaryPath); - const outputPath = path.join(temporaryPath, BRIDGE_FILENAME); - const result = await compileSnapshotBridge( - input.host, - deadline, - toolchain.architecture, - sourceRoot, - outputPath, - ); - if (result.exitCode !== 0 || !input.host.exists(outputPath)) { - throw snapshotSourceError('unsupported', 'native-build-failed', { - exitCode: result.exitCode, - stderr: result.stderr.slice(0, 4096), - }); - } - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); - await input.host.chmod(outputPath, 0o755); - const binarySha256 = await sha256File(input.host, outputPath, deadline); - const manifest: SnapshotBridgeCacheManifest = { - schemaVersion: CACHE_SCHEMA_VERSION, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - sourceHash, - cacheKey, - toolchain, - binarySha256, - }; - await input.host.writeText( - path.join(temporaryPath, MANIFEST_FILENAME), - `${JSON.stringify(manifest, null, 2)}\n`, - ); - remainingSnapshotSourceMs(deadline, 'native-build-deadline'); - await input.host.rename(temporaryPath, entryPath); - return { - path: path.join(entryPath, BRIDGE_FILENAME), - sourceHash, - cacheKey, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - }; - } catch (error) { - await input.host.remove(temporaryPath); - throw error; + 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, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + }; } /** @@ -148,49 +123,32 @@ async function compileSnapshotBridge( sourceRoot: string, outputPath: string, ): Promise { - const timeoutMs = Math.min( - BUILD_TIMEOUT_MS, - remainingSnapshotSourceMs(deadline, 'native-build-deadline'), - ); - try { - return await host.run( - 'xcrun', - [ - '--sdk', - 'iphonesimulator', - 'clang', - '-arch', - architecture, - '-mios-simulator-version-min=15.0', - '-fobjc-arc', - '-Werror', - '-Wall', - '-Wextra', - '-framework', - 'Foundation', - '-framework', - 'CoreGraphics', - ...SNAPSHOT_BRIDGE_COMPILE_FILENAMES.map((sourceFile) => path.join(sourceRoot, sourceFile)), - '-o', - outputPath, - ], - { signal: deadline.signal, timeoutMs, allowFailure: true }, - ); - } catch (error) { - if (!isCommandTimeoutError(error)) throw error; - throw snapshotSourceError( - 'timeout', - 'native-build-stalled', - { - timeoutMs, - hint: - `The Simulator SDK toolchain did not answer within ${timeoutMs}ms, which stopped the bridge ` + - `build before clang reported anything. Run \`xcrun --sdk iphonesimulator clang --version\` ` + - `by hand until it answers, then retry.`, - }, - error, - ); - } + return execNativeBuildClang({ + host, + deadline, + argv: [ + '--sdk', + 'iphonesimulator', + 'clang', + '-arch', + architecture, + '-mios-simulator-version-min=15.0', + '-fobjc-arc', + '-Werror', + '-Wall', + '-Wextra', + '-framework', + 'Foundation', + '-framework', + 'CoreGraphics', + ...SNAPSHOT_BRIDGE_COMPILE_FILENAMES.map((sourceFile) => path.join(sourceRoot, sourceFile)), + '-o', + outputPath, + ], + budgetMs: BUILD_TIMEOUT_MS, + deadlineReason: 'native-build-deadline', + label: 'bridge', + }); } function resolveSnapshotBridgeSourceRoot(host: SnapshotSourceHost): string { @@ -213,67 +171,3 @@ function resolveSnapshotBridgeSourceRoot(host: SnapshotSourceHost): string { } throw snapshotSourceError('unsupported', 'native-source-missing', { projectRoot }); } - -// fallow-ignore-next-line complexity -async function readValidCache( - host: SnapshotSourceHost, - entryPath: string, - expected: Readonly<{ - sourceHash: string; - cacheKey: string; - toolchain: SnapshotSourceToolchainIdentity; - }>, - deadline: SnapshotSourceDeadline, -): Promise { - const binaryPath = path.join(entryPath, BRIDGE_FILENAME); - if (!host.exists(binaryPath) || !host.exists(path.join(entryPath, MANIFEST_FILENAME))) { - return undefined; - } - try { - const manifest = JSON.parse( - await host.readText(path.join(entryPath, MANIFEST_FILENAME)), - ) as Partial; - if ( - manifest.schemaVersion !== CACHE_SCHEMA_VERSION || - manifest.protocolVersion !== SNAPSHOT_SOURCE_PROTOCOL_VERSION || - manifest.sourceVersion !== SNAPSHOT_SOURCE_VERSION || - manifest.sourceHash !== expected.sourceHash || - manifest.cacheKey !== expected.cacheKey || - JSON.stringify(manifest.toolchain) !== JSON.stringify(expected.toolchain) || - typeof manifest.binarySha256 !== 'string' - ) { - return undefined; - } - if ((await sha256File(host, binaryPath, deadline)) !== manifest.binarySha256) return undefined; - return { - path: binaryPath, - sourceHash: expected.sourceHash, - cacheKey: expected.cacheKey, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - }; - } catch (error) { - if ( - error instanceof SnapshotSourceError && - (error.failureKind === 'cancelled' || error.failureKind === 'timeout') - ) { - throw error; - } - return undefined; - } -} - -async function sha256File( - host: SnapshotSourceHost, - filePath: string, - deadline: SnapshotSourceDeadline, -): Promise { - remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline'); - return createHash('sha256') - .update(await host.readBinary(filePath)) - .digest('hex'); -} - -function hashJson(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32); -} diff --git a/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts b/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts new file mode 100644 index 0000000000..007068eca5 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; +import { createSnapshotSourceDeadline } from './deadline.ts'; +import { createSnapshotSourceHost } from './host.ts'; +import { + ensureNativeBuildCacheEntry, + fingerprintNativeBuildSource, + nativeBuildCacheKey, + nativeBuildManifestFieldsMatch, +} from './native-build-cache.ts'; +import type { SnapshotSourceHost } from './types.ts'; + +function testDeadline() { + return createSnapshotSourceDeadline(30_000, undefined); +} + +test('a cache hit skips the build, and a manifest or binary mismatch rebuilds', async () => { + const root = await mkdtempForTest('agent-device-native-build-cache-'); + const cacheRoot = path.join(root, 'cache'); + const host = createSnapshotSourceHost(); + const manifest = { schemaVersion: 1, sourceHash: 'abc' }; + const cacheKey = nativeBuildCacheKey(manifest); + let builds = 0; + + const ensure = () => + ensureNativeBuildCacheEntry({ + host, + deadline: testDeadline(), + cacheRoot, + cacheKey, + binaryFilename: 'built', + manifest, + manifestMatches: (candidate) => + nativeBuildManifestFieldsMatch(candidate, manifest, ['schemaVersion', 'sourceHash']), + build: async (outputPath) => { + builds += 1; + await writeFile(outputPath, `binary-${builds}`); + }, + }); + + const first = await ensure(); + assert.equal(builds, 1); + + const hit = await ensure(); + assert.equal(hit.path, first.path); + assert.equal(builds, 1, 'a matching cache entry is served without rebuilding'); + + await writeFile(first.path, 'tampered'); + const afterTamper = await ensure(); + assert.equal(builds, 2, 'a binary hash mismatch rebuilds instead of serving a corrupt entry'); + assert.equal(await readFile(afterTamper.path, 'utf8'), 'binary-2'); +}); + +test('manifest field matching compares by JSON value, not by reference or type coercion', () => { + assert.equal( + nativeBuildManifestFieldsMatch({ a: 1 }, { a: 1 }, ['a']), + true, + 'equal primitives on the same field match', + ); + assert.equal( + nativeBuildManifestFieldsMatch({ a: '1' }, { a: 1 }, ['a']), + false, + 'a string does not coerce to match a number', + ); + assert.equal( + nativeBuildManifestFieldsMatch({ a: { nested: 1 } }, { a: { nested: 1 } }, ['a']), + true, + 'structurally equal objects on the same field match', + ); + assert.equal( + nativeBuildManifestFieldsMatch({}, { a: undefined }, ['a']), + true, + 'a missing field matches an explicit undefined, since JSON.stringify drops both', + ); + assert.equal( + nativeBuildManifestFieldsMatch({ a: 1, b: 'x' }, { a: 1, b: 'y' }, ['a']), + true, + 'only the named fields are compared', + ); + assert.equal( + nativeBuildManifestFieldsMatch({ a: 1, b: 'x' }, { a: 1, b: 'y' }, ['a', 'b']), + false, + 'adding a field to the comparison set can turn a match into a mismatch', + ); +}); + +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 manifest = { schemaVersion: 1, sourceHash: 'def' }; + const cacheKey = nativeBuildCacheKey(manifest); + let attempts = 0; + + const ensure = () => + ensureNativeBuildCacheEntry({ + host, + deadline: testDeadline(), + cacheRoot, + cacheKey, + binaryFilename: 'built', + manifest, + manifestMatches: (candidate) => + nativeBuildManifestFieldsMatch(candidate, manifest, ['schemaVersion', 'sourceHash']), + build: async (outputPath) => { + attempts += 1; + if (attempts === 1) throw new Error('build failed'); + await writeFile(outputPath, 'binary-2'); + }, + }); + + await assert.rejects(ensure(), /build failed/); + assert.equal(host.exists(path.join(cacheRoot, cacheKey)), false); + + const recovered = await ensure(); + assert.equal(attempts, 2); + assert.equal(await readFile(recovered.path, 'utf8'), 'binary-2'); +}); + +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(); + 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'); + + const asA = await fingerprintNativeBuildSource(host, root, ['A.m'], testDeadline()); + const asB = await fingerprintNativeBuildSource(host, root, ['B.m'], testDeadline()); + assert.notEqual(asA, asB, 'identical bytes under a different filename fingerprint differently'); + + const again = await fingerprintNativeBuildSource(host, root, ['A.m'], testDeadline()); + assert.equal(asA, again, 'fingerprinting is deterministic for the same root and filenames'); +}); diff --git a/packages/platform-apple/src/snapshot-source/native-build-cache.ts b/packages/platform-apple/src/snapshot-source/native-build-cache.ts new file mode 100644 index 0000000000..426bb13155 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/native-build-cache.ts @@ -0,0 +1,206 @@ +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'; + +const MANIFEST_FILENAME = 'manifest.json'; + +export type NativeBuildCacheEntry = Readonly<{ path: string }>; + +/** + * One locked, content+toolchain-keyed cache entry: a candidate hit is verified against its own + * manifest 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). + */ +export async function ensureNativeBuildCacheEntry( + input: Readonly<{ + host: SnapshotSourceHost; + deadline: SnapshotSourceDeadline; + cacheRoot: string; + cacheKey: string; + binaryFilename: string; + /** Written alongside `cacheKey` and the built binary's sha256 once a build publishes. */ + manifest: Readonly>; + /** Whether a candidate manifest still describes `manifest`; the binary hash is checked separately. */ + manifestMatches: (candidate: Readonly>) => boolean; + /** Builds `outputPath` and throws its own typed error on failure. */ + build: (outputPath: string) => Promise; + }>, +): Promise { + const { host, deadline, cacheRoot, cacheKey, binaryFilename } = input; + const entryPath = path.join(cacheRoot, cacheKey); + return await withProcessLock({ + acquire: () => host.acquireLock(path.join(cacheRoot, `${cacheKey}.lock`), { deadline }), + task: async () => { + const cached = await readValidCacheEntry( + host, + entryPath, + binaryFilename, + input.manifestMatches, + deadline, + ); + if (cached) return { path: cached }; + remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + if (host.exists(entryPath)) await host.remove(entryPath); + remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + await host.ensureDirectory(cacheRoot); + const temporaryPath = path.join(cacheRoot, `.${cacheKey}.${host.processId()}.tmp`); + remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + await host.remove(temporaryPath); + try { + remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + await host.ensureDirectory(temporaryPath); + const outputPath = path.join(temporaryPath, binaryFilename); + await input.build(outputPath); + remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + await host.chmod(outputPath, 0o755); + const binarySha256 = await sha256File(host, outputPath); + const manifest = { ...input.manifest, cacheKey, binarySha256 }; + await host.writeText( + path.join(temporaryPath, MANIFEST_FILENAME), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + remainingSnapshotSourceMs(deadline, 'native-build-deadline'); + await host.rename(temporaryPath, entryPath); + return { path: path.join(entryPath, binaryFilename) }; + } catch (error) { + await host.remove(temporaryPath); + throw error; + } + }, + }); +} + +/** Every field named here matches `expected`'s value exactly, compared as JSON. */ +export function nativeBuildManifestFieldsMatch( + candidate: Readonly>, + expected: Readonly>, + fields: readonly string[], +): boolean { + return fields.every( + (field) => JSON.stringify(candidate[field]) === JSON.stringify(expected[field]), + ); +} + +async function readValidCacheEntry( + host: SnapshotSourceHost, + entryPath: string, + binaryFilename: string, + manifestMatches: (candidate: Readonly>) => boolean, + deadline: SnapshotSourceDeadline, +): Promise { + const binaryPath = path.join(entryPath, binaryFilename); + const manifestPath = path.join(entryPath, MANIFEST_FILENAME); + if (!host.exists(binaryPath) || !host.exists(manifestPath)) return undefined; + try { + const manifest = JSON.parse(await host.readText(manifestPath)) as Record; + if (!describesReusableEntry(manifest, manifestMatches)) return undefined; + remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline'); + const matchesBinary = (await sha256File(host, binaryPath)) === manifest.binarySha256; + return matchesBinary ? binaryPath : undefined; + } catch (error) { + if (isCacheReadCancellationOrTimeout(error)) throw error; + return undefined; + } +} + +/** A parsed manifest is reusable when it carries a binary hash and still describes `manifestMatches`. */ +function describesReusableEntry( + manifest: Readonly>, + manifestMatches: (candidate: Readonly>) => boolean, +): boolean { + return typeof manifest.binarySha256 === 'string' && manifestMatches(manifest); +} + +/** 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') + ); +} + +async function sha256File(host: SnapshotSourceHost, filePath: string): Promise { + return createHash('sha256') + .update(await host.readBinary(filePath)) + .digest('hex'); +} + +/** Canonicalized-JSON content hash for a build's cache key, shared so every cache key is derived the same way. */ +export function nativeBuildCacheKey(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32); +} + +/** + * SHA-256 over `sourceFilenames`, read from `root` in the given order and keyed by filename so a + * rename busts the cache. Every runtime clang build in this package fingerprints its sources this + * way, over its own filename list (#2796). + */ +export async function fingerprintNativeBuildSource( + host: SnapshotSourceHost, + root: string, + sourceFilenames: readonly string[], + deadline: SnapshotSourceDeadline, +): Promise { + const hash = createHash('sha256'); + for (const sourceFile of sourceFilenames) { + const filePath = path.join(root, sourceFile); + remainingSnapshotSourceMs(deadline, 'native-source-fingerprint-deadline'); + if (!host.exists(filePath)) { + throw snapshotSourceError('unsupported', 'native-source-missing', { filePath }); + } + hash.update(sourceFile); + hash.update('\0'); + hash.update(await host.readBinary(filePath)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +/** + * One budgeted `xcrun` invocation, shared by every runtime clang build in this package so a compile + * exec this module asked to be killed is reported once, as `'native-build-stalled'`, with the + * budget it hit and a hint naming `label`'s build (#2796). + */ +export async function execNativeBuildClang( + input: Readonly<{ + host: SnapshotSourceHost; + deadline: SnapshotSourceDeadline; + argv: readonly string[]; + budgetMs: number; + deadlineReason: string; + /** Names the build in the stall hint, e.g. "bridge" or "fold helper". */ + label: string; + }>, +): Promise { + const timeoutMs = Math.min( + input.budgetMs, + remainingSnapshotSourceMs(input.deadline, input.deadlineReason), + ); + try { + return await input.host.run('xcrun', [...input.argv], { + signal: input.deadline.signal, + timeoutMs, + allowFailure: true, + }); + } catch (error) { + if (!isCommandTimeoutError(error)) throw error; + throw snapshotSourceError( + 'timeout', + 'native-build-stalled', + { + timeoutMs, + hint: + `The Simulator SDK toolchain did not answer within ${timeoutMs}ms, which stopped the ${input.label} ` + + `build before clang reported anything. Run \`xcrun --sdk iphonesimulator clang --version\` ` + + `by hand until it answers, then retry.`, + }, + error, + ); + } +} From 17193fc17ae77aaeb5750d94d925ec96142b0552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 10:21:28 +0200 Subject: [PATCH 2/4] fix(ios): drop -Werror from runtime clang builds and cache the fold helper Runtime clang builds no longer use -Werror. A new SDK warning in the AX snapshot bridge or the fold helper can no longer break snapshot or fold on a user's machine. A darwin-only compile of each helper's exact production argv with -Werror appended keeps the warning gate in CI instead. The fold helper no longer recompiles Fold.m into a temp dir on every call: it now shares the bridge's content- and toolchain-keyed build cache (native-build-cache.ts) and is built once per source hash and Xcode toolchain, cached under ~/.agent-device/fold-helper. Both cache keys now also fold in the compile argv, so a compiler-flag or framework-list change that touches neither the source nor the toolchain cannot serve a stale binary. The cache's process-lock description is caller-supplied, since the bridge and the fold helper now share one lock implementation over two different resources. FOLD_REQUEST_TIMEOUT_MS rises from 210s to 240s to cover the new preparation budget; its comment lists each step's budget, mirrored in command-descriptor-timeout-policy.test.ts's pinned envelope. Help and docs describe the cache. --- CHANGELOG.md | 8 + .../command-registry/src/timeout-policy.ts | 13 +- .../src/foldable/fold-helper-cache.test.ts | 179 +++++++++++++++ .../src/foldable/fold-helper-cache.ts | 202 +++++++++++++++++ .../src/foldable/simulator-hid.test.ts | 211 +++++++++++++----- .../src/foldable/simulator-hid.ts | 75 ++----- .../src/snapshot-source/cache-identity.ts | 40 ++-- .../src/snapshot-source/cache.test.ts | 66 +++++- .../src/snapshot-source/cache.ts | 95 +++++--- .../src/snapshot-source/host.ts | 2 +- .../native-build-cache.test.ts | 2 + .../src/snapshot-source/native-build-cache.ts | 8 +- .../snapshot-source/native-runtime.test.ts | 38 +++- .../src/snapshot-source/types.ts | 5 +- .../command-descriptor-timeout-policy.test.ts | 7 +- src/commands/schema/cli-help.ts | 2 +- .../provider-scenarios/ios-fold.test.ts | 38 +++- website/docs/docs/commands.md | 2 +- 18 files changed, 821 insertions(+), 172 deletions(-) create mode 100644 packages/platform-apple/src/foldable/fold-helper-cache.test.ts create mode 100644 packages/platform-apple/src/foldable/fold-helper-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a21de44e..fd1913a2c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,14 @@ report no UIKit class names — the XCTest runner, whose own queries answered 76 nodes for that same state, plus `appium-source` and `limrun-ios-tree` — never trigger the cut. All 39 flows of React Navigation's Maestro suite pass on an iPhone 17 Simulator running iOS 26.2 with this change, including two that never passed on the bridge. +- Fixed (ios): runtime clang builds no longer compile with `-Werror`, so a new warning from a future + Xcode SDK cannot break the AX bridge or fold on a user's machine that this repository cannot fix + for them. The fold helper is now built through the same content- and toolchain-keyed build cache + as the AX bridge, so a fold call after the first serves a cached binary instead of recompiling + `Fold.m` on every call, and switching `DEVELOPER_DIR` busts the cache instead of serving a binary + built against a different SDK. A darwin-only CI step (`.github/workflows/ios.yml`) compiles each + build's production argv with `-Werror` appended whenever its sources change, so a new warning still + fails CI (#2796). - Fixed (ios): a local Simulator snapshot taken through the host AX bridge once again publishes the geometric `hittable` fact, so `is hittable` and a `hittable:` selector resolve the same controls on the bridge and the XCTest runner. The snapshot capability table has declared `hittable = diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index 29bddc8c1d..ca17ee37b7 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -43,11 +43,16 @@ export const DEFAULT_TIMEOUT_POLICY: CommandTimeoutPolicy = { }; /** - * `fold` spends up to four bounded CoreDevice hinge reads (`IOS_HINGE_ANGLE_TIMEOUT_MS` each on a - * wedged host) after a 30s helper build and up to 60s of timed HID motion, which can sum past the - * standard envelope; the envelope covers that worst case with the usual margin. + * `fold`'s worst case sums every step budget on the route (platform-apple owns the constants; + * command-registry does not import them, so the figures below are copied, not derived): + * - display-inventory query (foldable check): 5s + * - fold-helper preparation (toolchain probe + build): 60s + * - HID dispatch (60s max keyframe duration + 10s): 70s + * - hinge settle reads (4 attempts x 20s): 80s + * - lit-panel display-inventory query: 5s + * total: 220s. The envelope below covers that with margin. */ -const FOLD_REQUEST_TIMEOUT_MS = 210_000; +const FOLD_REQUEST_TIMEOUT_MS = 240_000; export const FOLD_TIMEOUT_POLICY: CommandTimeoutPolicy = { budget: { source: 'none' }, diff --git a/packages/platform-apple/src/foldable/fold-helper-cache.test.ts b/packages/platform-apple/src/foldable/fold-helper-cache.test.ts new file mode 100644 index 0000000000..a4e65778af --- /dev/null +++ b/packages/platform-apple/src/foldable/fold-helper-cache.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +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 { mkdtempForTest } from '../__tests__/tmp-dir.ts'; +import { execKillTimeoutError } from '../snapshot-source/__tests__/exec-timeout-fixture.ts'; +import { createSnapshotSourceHost } from '../snapshot-source/host.ts'; +import type { SnapshotSourceHost } from '../snapshot-source/types.ts'; +import { + buildFoldHelperCompileArgv, + ensureFoldHelperBinary, + foldHelperCacheKey, + FOLD_HELPER_BUILD_TIMEOUT_MS, +} from './fold-helper-cache.ts'; + +function fakeFoldHelperHost( + binary: () => string, + xcodeVersion: () => string = () => 'Xcode 16.4\nBuild version 16F6', +): SnapshotSourceHost { + const real = createSnapshotSourceHost(); + return { + ...real, + run: async (command, args) => { + if (command === 'xcrun' && args.includes('clang')) { + const outputPath = args.at(-1)!; + await writeFile(outputPath, binary()); + return { stdout: '', stderr: '', exitCode: 0 }; + } + const stdout = + command === 'xcodebuild' + ? xcodeVersion() + : command === 'sw_vers' + ? args.includes('-buildVersion') + ? '24G90' + : '15.6' + : command === 'uname' + ? 'arm64' + : ''; + return { stdout, stderr: '', exitCode: 0 }; + }, + }; +} + +test('a cache hit does not build, and a source or toolchain change does', async () => { + const root = await mkdtempForTest('agent-device-fold-helper-cache-'); + 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 v1'); + + let builds = 0; + let xcodeVersion = 'Xcode 16.4\nBuild version 16F6'; + const host = fakeFoldHelperHost( + () => { + builds += 1; + return `binary-${builds}`; + }, + () => xcodeVersion, + ); + + try { + const first = await ensureFoldHelperBinary({ host, sourceRoot, cacheRoot }); + assert.equal(builds, 1); + assert.equal(await readFile(first.path, 'utf8'), 'binary-1'); + + const hit = await ensureFoldHelperBinary({ host, sourceRoot, cacheRoot }); + assert.equal(hit.path, first.path); + assert.equal(builds, 1); + + await writeFile(path.join(sourceRoot, 'Fold.m'), 'fold source v2'); + const sourceChanged = await ensureFoldHelperBinary({ host, sourceRoot, cacheRoot }); + assert.notEqual(sourceChanged.path, first.path); + assert.equal(builds, 2); + + xcodeVersion = 'Xcode 16.5\nBuild version 16F5'; + const toolchainChanged = await ensureFoldHelperBinary({ host, sourceRoot, cacheRoot }); + assert.notEqual(toolchainChanged.path, sourceChanged.path); + assert.equal(builds, 3); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('the fold helper cache key changes with the compile argv, independent of source and toolchain', () => { + const sourceHash = 'same-source'; + const toolchain = { + xcode: 'Xcode 16.4\nBuild version 16F6', + macosProductVersion: '15.6', + macosBuild: '24G90', + architecture: 'arm64', + } as const; + const argv = ['clang', '-Wall']; + const changedArgv = ['clang', '-Wall', '-DSomethingNew']; + + const key = foldHelperCacheKey({ sourceHash, toolchain, compileArgv: argv }); + const sameKey = foldHelperCacheKey({ sourceHash, toolchain, compileArgv: argv }); + const keyAfterArgvChange = foldHelperCacheKey({ + sourceHash, + toolchain, + compileArgv: changedArgv, + }); + + assert.equal(key, sameKey, 'the same argv always keys the same'); + assert.notEqual( + key, + keyAfterArgvChange, + 'an argv-only change (same source, same toolchain) cannot serve a stale binary', + ); +}); + +test('a compile exec killed at its budget reports the fold-helper build, not the exec layer', async () => { + const root = await mkdtempForTest('agent-device-fold-helper-cache-stall-'); + 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'); + const okHost = fakeFoldHelperHost(() => 'binary'); + const host: SnapshotSourceHost = { + ...okHost, + run: async (command, args, options) => { + if (command === 'xcrun' && args.includes('clang')) throw await execKillTimeoutError(); + return await okHost.run(command, args, options); + }, + }; + + try { + await assert.rejects( + ensureFoldHelperBinary({ host, sourceRoot, cacheRoot }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.equal( + (error as { details?: { reason?: string } }).details?.reason, + 'fold-helper-build-failed', + ); + assert.equal( + (error as { details?: { cause?: string } }).details?.cause, + 'native-build-stalled', + ); + return true; + }, + ); + } 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 +// -Werror appended, so a warning fails here instead of nowhere. +// +// The compile runs in beforeAll, not in the test body: it is a real clang invocation (see the +// unit slow-test budget in docs/agents/testing.md), and the snapshot-bridge sibling gate in +// native-runtime.test.ts keeps its compile out of test-case wall time the same way. +describe.skipIf(process.platform !== 'darwin')('fold helper warning gate', () => { + let compiled: { exitCode: number; stderr: string }; + beforeAll(async () => { + const sourceRoot = path.resolve(import.meta.dirname, '../../../../apple/fold-helper'); + const binary = path.join(await mkdtempForTest('fold-helper-werror-'), 'fold-helper'); + const argv = buildFoldHelperCompileArgv({ sourceRoot, outputPath: binary }); + const wextraIndex = argv.indexOf('-Wextra'); + assert.ok(wextraIndex >= 0, 'the production argv carries -Wextra'); + const werrorArgv = [ + ...argv.slice(0, wextraIndex + 1), + '-Werror', + ...argv.slice(wextraIndex + 1), + ]; + compiled = await runCmd('xcrun', werrorArgv, { + allowFailure: true, + timeoutMs: FOLD_HELPER_BUILD_TIMEOUT_MS, + }); + }, FOLD_HELPER_BUILD_TIMEOUT_MS + 30_000); + + test('the production fold helper argv compiles clean under -Werror', () => { + assert.equal(compiled.exitCode, 0, compiled.stderr); + }); +}); diff --git a/packages/platform-apple/src/foldable/fold-helper-cache.ts b/packages/platform-apple/src/foldable/fold-helper-cache.ts new file mode 100644 index 0000000000..05c08f6697 --- /dev/null +++ b/packages/platform-apple/src/foldable/fold-helper-cache.ts @@ -0,0 +1,202 @@ +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { execFailureDetails } from '@agent-device/host-kit/command'; +import { runAppleToolCommand } from '../core/tool-provider.ts'; +import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; +import { + readHostToolchainIdentity, + type HostToolchainIdentity, +} 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 { + ensureNativeBuildCacheEntry, + execNativeBuildClang, + fingerprintNativeBuildSource, + nativeBuildCacheKey, + nativeBuildManifestFieldsMatch, +} from '../snapshot-source/native-build-cache.ts'; +import type { SnapshotSourceHost } from '../snapshot-source/types.ts'; + +const FOLD_HELPER_SOURCE_FILENAME = 'Fold.m'; +const FOLD_HELPER_BINARY_FILENAME = 'fold-helper'; +const FOLD_HELPER_SCHEMA_VERSION = 1 as const; +const FOLD_HELPER_LOCK_DESCRIPTION = 'iOS Simulator fold helper cache'; +const MANIFEST_FIELDS = [ + 'schemaVersion', + 'sourceHash', + 'cacheKey', + 'toolchain', + 'compileArgv', +] as const; + +/** + * The fold helper cache key, folding in the compile argv (fingerprinted with a placeholder + * `sourceRoot` and `outputPath`) alongside `sourceHash` and `toolchain`, so a change to a compiler + * flag or framework list cannot serve a binary built from a different command line (#2796 + * follow-up). + */ +export function foldHelperCacheKey( + input: Readonly<{ + sourceHash: string; + toolchain: HostToolchainIdentity; + compileArgv: readonly string[]; + }>, +): string { + return nativeBuildCacheKey({ + schemaVersion: FOLD_HELPER_SCHEMA_VERSION, + sourceHash: input.sourceHash, + toolchain: input.toolchain, + compileArgv: input.compileArgv, + }); +} + +/** Upper bound on a single fold-helper clang invocation; the same budget the prior per-call build used. */ +export const FOLD_HELPER_BUILD_TIMEOUT_MS = 30_000; + +/** Ceiling on locating, probing and (if needed) building a cached fold-helper binary. */ +const FOLD_HELPER_PREPARATION_DEADLINE_MS = + COLD_TOOLCHAIN_PROBE_TIMEOUT_MS + FOLD_HELPER_BUILD_TIMEOUT_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 + * 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). + * + * Failures surface as `AppError('COMMAND_FAILED', ..., {reason: 'fold-helper-build-failed'})`, the + * error shape `sendSimulatorFoldPose` reported before this cache existed. + */ +export async function ensureFoldHelperBinary( + input: Readonly<{ + signal?: AbortSignal; + host?: SnapshotSourceHost; + cacheRoot?: string; + sourceRoot?: string; + }> = {}, +): Promise> { + const host = input.host ?? createFoldHelperCacheHost(); + const deadline = createSnapshotSourceDeadline(FOLD_HELPER_PREPARATION_DEADLINE_MS, input.signal); + try { + const sourceRoot = input.sourceRoot ?? path.join(host.projectRoot(), 'apple', 'fold-helper'); + const sourceHash = await fingerprintNativeBuildSource( + host, + sourceRoot, + [FOLD_HELPER_SOURCE_FILENAME], + deadline, + ); + const toolchain = await readHostToolchainIdentity(host, deadline); + const compileArgv = buildFoldHelperCompileArgv({ sourceRoot: '', outputPath: '' }); + const cacheKey = foldHelperCacheKey({ sourceHash, toolchain, compileArgv }); + const cacheRoot = + input.cacheRoot ?? path.join(host.homeDirectory(), '.agent-device', 'fold-helper'); + const manifest = { + schemaVersion: FOLD_HELPER_SCHEMA_VERSION, + sourceHash, + cacheKey, + toolchain, + compileArgv, + }; + return await ensureNativeBuildCacheEntry({ + host, + deadline, + lockDescription: FOLD_HELPER_LOCK_DESCRIPTION, + cacheRoot, + cacheKey, + binaryFilename: FOLD_HELPER_BINARY_FILENAME, + manifest, + manifestMatches: (candidate) => + nativeBuildManifestFieldsMatch(candidate, manifest, MANIFEST_FIELDS), + build: (outputPath) => compileFoldHelper(host, deadline, sourceRoot, outputPath), + }); + } catch (error) { + throw asFoldHelperCacheError(error); + } +} + +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), + }; +} + +/** + * The production `xcrun`/clang argv for the fold helper source, exposed so a darwin-only + * conformance test can compile it with `-Werror` appended and a unit test can assert it never + * carries `-Werror` on its own (#2796). + */ +export function buildFoldHelperCompileArgv( + input: Readonly<{ sourceRoot: string; outputPath: string }>, +): readonly string[] { + return [ + '--sdk', + 'iphonesimulator', + 'clang', + '-mios-simulator-version-min=15.0', + '-fobjc-arc', + '-Wall', + '-Wextra', + '-framework', + 'Foundation', + '-framework', + 'IOKit', + path.join(input.sourceRoot, FOLD_HELPER_SOURCE_FILENAME), + '-o', + input.outputPath, + ]; +} + +async function compileFoldHelper( + host: SnapshotSourceHost, + deadline: SnapshotSourceDeadline, + sourceRoot: string, + outputPath: string, +): Promise { + const result = await execNativeBuildClang({ + host, + deadline, + argv: buildFoldHelperCompileArgv({ sourceRoot, outputPath }), + budgetMs: FOLD_HELPER_BUILD_TIMEOUT_MS, + deadlineReason: 'fold-helper-build-deadline', + label: 'fold helper', + }); + if (result.exitCode !== 0 || !host.exists(outputPath)) { + throw new AppError( + 'COMMAND_FAILED', + 'Unable to build the simulator fold helper', + execFailureDetails(result, { + reason: 'fold-helper-build-failed', + hint: 'Select an Xcode with the iOS simulator SDK and foldable HID support using DEVELOPER_DIR.', + }), + ); + } +} + +/** + * `AppError('COMMAND_FAILED', ..., {reason: 'fold-helper-build-failed'})` for every cache failure, + * matching the error `sendSimulatorFoldPose` reported before this cache existed, except a genuine + * cancellation: `compileFoldHelper` already throws that exact shape on a build failure, so it + * passes through unchanged. + */ +function asFoldHelperCacheError(error: unknown): unknown { + if (!(error instanceof SnapshotSourceError)) return error; + if (error.failureKind === 'cancelled') return error; + return new AppError( + 'COMMAND_FAILED', + 'Unable to build the simulator fold helper', + { + reason: 'fold-helper-build-failed', + hint: 'Select an Xcode with the iOS simulator SDK and foldable HID support using DEVELOPER_DIR.', + cause: error.failureCode, + }, + error, + ); +} diff --git a/packages/platform-apple/src/foldable/simulator-hid.test.ts b/packages/platform-apple/src/foldable/simulator-hid.test.ts index 3fc7c397c6..3f11d7dd89 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.test.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.test.ts @@ -1,91 +1,188 @@ import { expect, test } from 'vitest'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; import { withAppleToolProvider, createLocalAppleToolProvider } from '../core/tool-provider.ts'; import { IOS_SIMULATOR } from '../__tests__/device-fixtures.ts'; import { sendSimulatorFoldPose } from './simulator-hid.ts'; const selectedDuo = { ...IOS_SIMULATOR, id: 'selected-duo' }; -test.each(['success', 'build', 'dispatch', 'cancel'] as const)( - 'HID route targets the UDID, cleans temporary artifacts, and handles %s', +type RecordedCall = Readonly<{ command: string; args: readonly string[] }>; + +type ExecResponse = { stdout: string; stderr: string; exitCode: number }; + +/** The fixed response for the host toolchain probes `sendSimulatorFoldPose` reads, or `undefined` for `xcrun`. */ +function toolchainProbeResponse( + command: string, + args: readonly string[], +): ExecResponse | undefined { + if (command === 'xcodebuild') + return { stdout: 'Xcode 16.4\nBuild version 16F6', 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 }; + return undefined; +} + +async function respondToClang( + args: readonly string[], + onClang?: (args: readonly string[]) => ExecResponse | undefined, +): Promise { + const outcome = onClang?.(args); + // The build cache checks the binary actually landed at `-o`'s path, so every clang response + // short of a compiler failure has to leave that file behind. + if (!outcome || outcome.exitCode === 0) { + const outputPath = args.at(-1)!; + await writeFile(outputPath, 'fold-helper-binary'); + } + return outcome ?? { stdout: '', stderr: '', exitCode: 0 }; +} + +/** + * Answers every exec `sendSimulatorFoldPose` can make: the toolchain probes the fold-helper cache + * reads, the fold-helper clang build, and the `simctl spawn` dispatch. `onClang`/`onSpawn` override + * the default success behavior for one of the two `xcrun` calls. + */ +function createFoldToolMock( + calls: RecordedCall[], + overrides: { + onClang?: (args: readonly string[]) => ExecResponse | undefined; + onSpawn?: (args: readonly string[]) => ExecResponse; + } = {}, +) { + return async (command: string, args: readonly string[]) => { + calls.push({ command, args }); + const probeResponse = toolchainProbeResponse(command, args); + if (probeResponse) return probeResponse; + expect(command).toBe('xcrun'); + if (args.includes('clang')) return respondToClang(args, overrides.onClang); + if (overrides.onSpawn) return overrides.onSpawn(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }; +} + +function clangArgs(calls: readonly RecordedCall[]): readonly string[] { + return calls.find((call) => call.args.includes('clang'))!.args; +} + +test('the runtime clang build never uses -Werror', async () => { + const cacheRoot = await mkdtempForTest('agent-device-fold-cache-'); + const calls: RecordedCall[] = []; + await withAppleToolProvider( + createLocalAppleToolProvider({ runCommand: createFoldToolMock(calls) }), + () => sendSimulatorFoldPose(selectedDuo, 'half-open', undefined, { cacheRoot }), + ); + expect(clangArgs(calls)).not.toContain('-Werror'); +}); + +test('a second call reuses the cached fold helper and does not invoke clang again', async () => { + const cacheRoot = await mkdtempForTest('agent-device-fold-cache-hit-'); + const calls: RecordedCall[] = []; + const runCommand = createFoldToolMock(calls); + + await withAppleToolProvider(createLocalAppleToolProvider({ runCommand }), () => + sendSimulatorFoldPose(selectedDuo, 'half-open', undefined, { cacheRoot }), + ); + const clangCallsAfterFirst = calls.filter((call) => call.args.includes('clang')).length; + expect(clangCallsAfterFirst).toBe(1); + + await withAppleToolProvider(createLocalAppleToolProvider({ runCommand }), () => + sendSimulatorFoldPose(selectedDuo, 'open', undefined, { cacheRoot }), + ); + + expect(calls.filter((call) => call.args.includes('clang'))).toHaveLength(1); + expect(calls.filter((call) => call.args.includes('spawn'))).toHaveLength(2); +}); + +test.each(['build', 'dispatch', 'cancel'] as const)( + 'HID route targets the UDID and handles %s', async (failure) => { - const calls: string[][] = []; + const cacheRoot = await mkdtempForTest(`agent-device-fold-cache-${failure}-`); + const calls: RecordedCall[] = []; const controller = new AbortController(); - let binary = ''; - await withAppleToolProvider( - createLocalAppleToolProvider({ - runCommand: async (command, args, options) => { - expect(command).toBe('xcrun'); - expect(options?.signal).toBe(controller.signal); - expect(options?.timeoutMs).toBeGreaterThan(0); - calls.push(args); - if (args.includes('clang')) { - binary = args.at(-1)!; - expect(existsSync(path.dirname(binary))).toBe(true); - expect(existsSync(args[args.indexOf('-o') - 1]!)).toBe(true); - if (failure === 'cancel') controller.abort(new Error('cancelled')); - return { stdout: '', stderr: 'compiler detail', exitCode: failure === 'build' ? 1 : 0 }; - } - expect(args).toEqual(['simctl', 'spawn', 'selected-duo', binary, 'half-open']); - return { stdout: '', stderr: 'spawn detail', exitCode: failure === 'dispatch' ? 1 : 0 }; - }, - }), - async () => { - const operation = sendSimulatorFoldPose(selectedDuo, 'half-open', controller.signal); - if (failure === 'success') await expect(operation).resolves.toBeUndefined(); - else if (failure === 'cancel') await expect(operation).rejects.toThrow('cancelled'); - else - await expect(operation).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { - reason: failure === 'build' ? 'fold-helper-build-failed' : 'fold-hid-dispatch-failed', - }, - }); + const runCommand = createFoldToolMock(calls, { + onClang: () => { + if (failure === 'build') return { stdout: '', stderr: 'compiler detail', exitCode: 1 }; + if (failure === 'cancel') controller.abort(new Error('cancelled')); + return undefined; + }, + onSpawn: (args) => { + expect(args.slice(0, 3)).toEqual(['simctl', 'spawn', 'selected-duo']); + expect(args.at(-2)).toMatch(/fold-helper$/); + return { stdout: '', stderr: 'spawn detail', exitCode: failure === 'dispatch' ? 1 : 0 }; }, - ); - expect(calls).toHaveLength(failure === 'build' || failure === 'cancel' ? 1 : 2); - expect(existsSync(path.dirname(binary))).toBe(false); + }); + await withAppleToolProvider(createLocalAppleToolProvider({ runCommand }), async () => { + const operation = sendSimulatorFoldPose(selectedDuo, 'half-open', controller.signal, { + cacheRoot, + }); + if (failure === 'build') { + await expect(operation).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'fold-helper-build-failed' }, + }); + } else if (failure === 'dispatch') { + await expect(operation).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'fold-hid-dispatch-failed' }, + }); + } else { + await expect(operation).rejects.toThrow(/cancelled/); + } + }); }, ); test('streams all keyframes in one process with a duration-derived timeout', async () => { + const cacheRoot = await mkdtempForTest('agent-device-fold-cache-keyframes-'); const keyframes = [ { atMs: 0, angle: 0 }, { atMs: 60000, angle: 100 }, ]; - let dispatches = 0; + const calls: RecordedCall[] = []; + const baseMock = createFoldToolMock(calls); + let dispatchTimeoutMs: number | undefined; await withAppleToolProvider( createLocalAppleToolProvider({ - runCommand: async (_command, args, options) => { + runCommand: async (command, args, options) => { if (args[0] === 'simctl') { - dispatches++; + calls.push({ command, args }); expect(JSON.parse(args.at(-1)!)).toEqual(keyframes); - expect(options?.timeoutMs).toBe(70000); + dispatchTimeoutMs = options?.timeoutMs; expect(options?.kill).toEqual({ signal: 'SIGTERM', graceMs: 1000 }); + return { stdout: '', stderr: '', exitCode: 0 }; } - return { stdout: '', stderr: '', exitCode: 0 }; + return await baseMock(command, args); }, }), - () => sendSimulatorFoldPose(selectedDuo, keyframes), + () => sendSimulatorFoldPose(selectedDuo, keyframes, undefined, { cacheRoot }), ); - expect(dispatches).toBe(1); + expect(dispatchTimeoutMs).toBe(70000); + expect(calls.filter((call) => call.args[0] === 'simctl')).toHaveLength(1); }); test('HID dispatch addresses the UDID inside its scoped simulator set', async () => { - const dispatches: string[][] = []; - let binary = ''; + const cacheRoot = await mkdtempForTest('agent-device-fold-cache-scoped-'); + const calls: RecordedCall[] = []; await withAppleToolProvider( - createLocalAppleToolProvider({ - runCommand: async (_command, args) => { - if (args.includes('clang')) binary = args.at(-1)!; - else dispatches.push(args); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }), - () => sendSimulatorFoldPose({ ...selectedDuo, simulatorSetPath: '/tmp/scoped-set' }, 'closed'), + createLocalAppleToolProvider({ runCommand: createFoldToolMock(calls) }), + () => + sendSimulatorFoldPose( + { ...selectedDuo, simulatorSetPath: '/tmp/scoped-set' }, + 'closed', + undefined, + { cacheRoot }, + ), ); - expect(dispatches).toEqual([ - ['simctl', '--set', '/tmp/scoped-set', 'spawn', 'selected-duo', binary, 'closed'], + const dispatch = calls.find((call) => call.args[0] === 'simctl')!; + expect(dispatch.args.slice(0, 5)).toEqual([ + 'simctl', + '--set', + '/tmp/scoped-set', + 'spawn', + 'selected-duo', ]); + expect(dispatch.args.at(-2)).toMatch(/fold-helper$/); + expect(dispatch.args.at(-1)).toBe('closed'); }); diff --git a/packages/platform-apple/src/foldable/simulator-hid.ts b/packages/platform-apple/src/foldable/simulator-hid.ts index b59c90cb92..4b0acb98d7 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.ts @@ -1,71 +1,34 @@ -import path from 'node:path'; import type { FoldKeyframe, FoldPose } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { execFailureDetails } from '@agent-device/host-kit/command'; -import { makeHostTemporaryDirectory, removeHostDirectory } from '@agent-device/host-kit/host-file'; -import { findProjectRoot } from '@agent-device/host-kit/version'; import { runSimctlForDevice } from '../core/simctl.ts'; -import { runXcrun } from '../core/tool-provider.ts'; +import { ensureFoldHelperBinary } from './fold-helper-cache.ts'; -/** Compiles for the selected Xcode and dispatches inside exactly the requested simulator. */ +/** Builds (or reuses the cached build of) the fold helper, then dispatches inside the requested simulator. */ export async function sendSimulatorFoldPose( device: DeviceInfo, pose: FoldPose | readonly FoldKeyframe[], signal?: AbortSignal, + options: Readonly<{ cacheRoot?: string }> = {}, ): Promise { signal?.throwIfAborted(); - const directory = await makeHostTemporaryDirectory('agent-device-fold-'); - try { - const binary = path.join(directory, 'fold'); - const build = await runXcrun( - [ - '--sdk', - 'iphonesimulator', - 'clang', - '-mios-simulator-version-min=15.0', - '-fobjc-arc', - '-Wall', - '-Wextra', - '-Werror', - '-framework', - 'Foundation', - '-framework', - 'IOKit', - path.join(findProjectRoot(), 'apple', 'fold-helper', 'Fold.m'), - '-o', - binary, - ], - { signal, timeoutMs: 30_000, allowFailure: true }, + const binary = await ensureFoldHelperBinary({ signal, cacheRoot: options.cacheRoot }); + signal?.throwIfAborted(); + const durationMs = typeof pose === 'string' ? 0 : pose.at(-1)!.atMs; + const payload = typeof pose === 'string' ? pose : JSON.stringify(pose); + const sent = await runSimctlForDevice(device, ['spawn', device.id, binary.path, payload], { + signal, + timeoutMs: durationMs + 10_000, + // simctl must forward termination to the guest before the host kills it. + kill: { signal: 'SIGTERM', graceMs: 1000 }, + allowFailure: true, + }); + if (sent.exitCode !== 0) { + throw new AppError( + 'COMMAND_FAILED', + 'Unable to send the simulator hinge pose', + execFailureDetails(sent, { reason: 'fold-hid-dispatch-failed', deviceId: device.id }), ); - if (build.exitCode !== 0) { - throw new AppError( - 'COMMAND_FAILED', - 'Unable to build the simulator fold helper', - execFailureDetails(build, { - reason: 'fold-helper-build-failed', - hint: 'Select an Xcode with the iOS simulator SDK and foldable HID support using DEVELOPER_DIR.', - }), - ); - } - signal?.throwIfAborted(); - const durationMs = typeof pose === 'string' ? 0 : pose.at(-1)!.atMs; - const payload = typeof pose === 'string' ? pose : JSON.stringify(pose); - const sent = await runSimctlForDevice(device, ['spawn', device.id, binary, payload], { - signal, - timeoutMs: durationMs + 10_000, - // simctl must forward termination to the guest before the host kills it. - kill: { signal: 'SIGTERM', graceMs: 1000 }, - allowFailure: true, - }); - if (sent.exitCode !== 0) { - throw new AppError( - 'COMMAND_FAILED', - 'Unable to send the simulator hinge pose', - execFailureDetails(sent, { reason: 'fold-hid-dispatch-failed', deviceId: device.id }), - ); - } - } finally { - await removeHostDirectory(directory); } } diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.ts b/packages/platform-apple/src/snapshot-source/cache-identity.ts index e0a72da4bf..8c5d4c5e81 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.ts @@ -11,14 +11,18 @@ import type { SnapshotSourceHost } from './types.ts'; * 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 SnapshotSourceToolchainIdentity = Readonly<{ +export type HostToolchainIdentity = Readonly<{ xcode: string; macosProductVersion: string; macosBuild: string; architecture: 'arm64' | 'x86_64'; - simulatorRuntime: string; }>; +export type SnapshotSourceToolchainIdentity = HostToolchainIdentity & + Readonly<{ + simulatorRuntime: string; + }>; + export const SNAPSHOT_BRIDGE_SOURCE_FILENAMES = [ 'SnapshotBridge.m', 'SnapshotBridgeRuntime.m', @@ -32,30 +36,38 @@ export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [ 'SnapshotBridgeCapture.m', ] as const; -export async function readSnapshotSourceToolchain( +/** + * 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). + */ +export async function readHostToolchainIdentity( host: SnapshotSourceHost, - simulatorRuntime: string, deadline: SnapshotSourceDeadline, -): Promise { +): 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); - const runtime = simulatorRuntime.trim(); - if (!runtime) throw snapshotSourceError('unsupported', 'simulator-runtime-missing'); if (architecture !== 'arm64' && architecture !== 'x86_64') { throw snapshotSourceError('unsupported', 'simulator-architecture-unsupported', { architecture, }); } - return { - xcode, - macosProductVersion, - macosBuild, - architecture, - simulatorRuntime: runtime, - }; + return { xcode, macosProductVersion, macosBuild, architecture }; +} + +export async function readSnapshotSourceToolchain( + host: SnapshotSourceHost, + simulatorRuntime: string, + deadline: SnapshotSourceDeadline, +): Promise { + const identity = await readHostToolchainIdentity(host, deadline); + const runtime = simulatorRuntime.trim(); + if (!runtime) throw snapshotSourceError('unsupported', 'simulator-runtime-missing'); + return { ...identity, simulatorRuntime: runtime }; } async function toolOutput( diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index bfbb6da496..d18f90839d 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { test } from 'vitest'; import { isCommandTimeoutError } from '@agent-device/host-kit/command'; import { createSnapshotSourceHost } from './host.ts'; -import { ensureSnapshotBridgeBinary } from './cache.ts'; +import { ensureSnapshotBridgeBinary, snapshotBridgeCacheKey } from './cache.ts'; import { SnapshotSourceError } from './errors.ts'; import { createSnapshotSourceDeadline } from './deadline.ts'; import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts'; @@ -125,6 +125,70 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt } }); +test('the runtime clang build never uses -Werror', async () => { + const root = await mkdtempForTest('agent-device-snapshot-source-werror-'); + 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, 'SnapshotBridge.m'), 'native source'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); + const buildHost = createFakeBuildHost('binary'); + let clangArgs: readonly string[] = []; + const host: SnapshotSourceHost = { + ...buildHost, + run: async (command, args, options) => { + if (command === 'xcrun' && args.includes('clang')) clangArgs = args; + return await buildHost.run(command, args, options); + }, + }; + + try { + await ensureSnapshotBridgeBinary({ + host, + runtime: 'iOS 26.2', + limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, + deadline: testDeadline(), + sourceRoot, + cacheRoot, + }); + assert.ok(clangArgs.length > 0, 'the compile exec ran'); + assert.ok(!clangArgs.includes('-Werror')); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('the bridge cache key changes with the compile argv, independent of source and toolchain', () => { + const sourceHash = 'same-source'; + const toolchain = { + xcode: 'Xcode 16.4\nBuild version 16F6', + macosProductVersion: '15.6', + macosBuild: '24G90', + architecture: 'arm64', + simulatorRuntime: 'iOS 26.2', + } as const; + const argv = ['clang', '-Wall']; + const changedArgv = ['clang', '-Wall', '-DSomethingNew']; + + const key = snapshotBridgeCacheKey({ sourceHash, toolchain, compileArgv: argv }); + const sameKey = snapshotBridgeCacheKey({ sourceHash, toolchain, compileArgv: argv }); + const keyAfterArgvChange = snapshotBridgeCacheKey({ + sourceHash, + toolchain, + compileArgv: changedArgv, + }); + + assert.equal(key, sameKey, 'the same argv always keys the same'); + assert.notEqual( + key, + keyAfterArgvChange, + 'an argv-only change (same source, same toolchain) cannot serve a stale binary', + ); +}); + test('concurrent snapshot bridge preparation publishes one cache entry', async () => { const root = await mkdtempForTest('agent-device-snapshot-source-concurrent-'); const sourceRoot = path.join(root, 'source'); diff --git a/packages/platform-apple/src/snapshot-source/cache.ts b/packages/platform-apple/src/snapshot-source/cache.ts index f127d8d53c..c891f9afc2 100644 --- a/packages/platform-apple/src/snapshot-source/cache.ts +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -24,6 +24,7 @@ import type { const CACHE_SCHEMA_VERSION = 1 as const; const BRIDGE_FILENAME = 'snapshot-bridge'; +const BRIDGE_LOCK_DESCRIPTION = 'iOS Simulator snapshot bridge cache'; const MANIFEST_FIELDS = [ 'schemaVersion', 'protocolVersion', @@ -31,8 +32,33 @@ const MANIFEST_FIELDS = [ 'sourceHash', 'cacheKey', 'toolchain', + 'compileArgv', ] as const; +/** + * The bridge cache key, folding in the compile argv (built with placeholder `sourceRoot` and + * `outputPath` values, which vary by install and by build and would otherwise make the key + * unstable) alongside `sourceHash` and `toolchain`, so a change to a compiler flag or framework + * list — covered by neither — cannot serve a binary built from a different command line (#2796 + * follow-up). + */ +export function snapshotBridgeCacheKey( + input: Readonly<{ + sourceHash: string; + toolchain: SnapshotSourceToolchainIdentity; + compileArgv: readonly string[]; + }>, +): string { + return nativeBuildCacheKey({ + schemaVersion: CACHE_SCHEMA_VERSION, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + sourceHash: input.sourceHash, + toolchain: input.toolchain, + compileArgv: input.compileArgv, + }); +} + /** * @internal Upper bound on a single snapshot-bridge clang invocation, exposed for the host bridge * tests so they budget their own compile from the same ceiling instead of a stricter constant. The @@ -59,13 +85,12 @@ export async function ensureSnapshotBridgeBinary( deadline, ); const toolchain = await readSnapshotSourceToolchain(input.host, input.runtime, deadline); - const cacheKey = nativeBuildCacheKey({ - schemaVersion: CACHE_SCHEMA_VERSION, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - sourceHash, - toolchain, + const compileArgv = buildSnapshotBridgeCompileArgv({ + architecture: toolchain.architecture, + sourceRoot: '', + outputPath: '', }); + const cacheKey = snapshotBridgeCacheKey({ sourceHash, toolchain, compileArgv }); const cacheRoot = input.cacheRoot ?? path.join(input.host.homeDirectory(), '.agent-device', 'snapshot-source'); const manifest = { @@ -75,6 +100,7 @@ export async function ensureSnapshotBridgeBinary( sourceHash, cacheKey, toolchain, + compileArgv, }; const entry = await ensureNativeBuildCacheEntry({ host: input.host, @@ -82,6 +108,7 @@ export async function ensureSnapshotBridgeBinary( cacheRoot, cacheKey, binaryFilename: BRIDGE_FILENAME, + lockDescription: BRIDGE_LOCK_DESCRIPTION, manifest, manifestMatches: (candidate) => nativeBuildManifestFieldsMatch(candidate, manifest, MANIFEST_FIELDS), @@ -111,11 +138,39 @@ export async function ensureSnapshotBridgeBinary( } /** - * One clang invocation for the bridge sources. A compile exec this module asked to be killed is - * reported with the budget it hit: after the identity read stopped opening `xcrun` of its own - * (#2712), this is the process's first `xcrun` exec, and the exec layer's bare - * `xcrun timed out after Nms` would land on a job as an unattributed command failure again. + * The production `xcrun`/clang argv for the bridge sources, exposed so a darwin-only conformance + * test can compile it with `-Werror` appended and a unit test can assert it never carries `-Werror` + * on its own (#2796). */ +export function buildSnapshotBridgeCompileArgv( + input: Readonly<{ + architecture: SnapshotSourceToolchainIdentity['architecture']; + sourceRoot: string; + outputPath: string; + }>, +): readonly string[] { + return [ + '--sdk', + 'iphonesimulator', + 'clang', + '-arch', + input.architecture, + '-mios-simulator-version-min=15.0', + '-fobjc-arc', + '-Wall', + '-Wextra', + '-framework', + 'Foundation', + '-framework', + 'CoreGraphics', + ...SNAPSHOT_BRIDGE_COMPILE_FILENAMES.map((sourceFile) => + path.join(input.sourceRoot, sourceFile), + ), + '-o', + input.outputPath, + ]; +} + async function compileSnapshotBridge( host: SnapshotSourceHost, deadline: SnapshotSourceDeadline, @@ -126,25 +181,7 @@ async function compileSnapshotBridge( return execNativeBuildClang({ host, deadline, - argv: [ - '--sdk', - 'iphonesimulator', - 'clang', - '-arch', - architecture, - '-mios-simulator-version-min=15.0', - '-fobjc-arc', - '-Werror', - '-Wall', - '-Wextra', - '-framework', - 'Foundation', - '-framework', - 'CoreGraphics', - ...SNAPSHOT_BRIDGE_COMPILE_FILENAMES.map((sourceFile) => path.join(sourceRoot, sourceFile)), - '-o', - outputPath, - ], + argv: buildSnapshotBridgeCompileArgv({ architecture, sourceRoot, outputPath }), budgetMs: BUILD_TIMEOUT_MS, deadlineReason: 'native-build-deadline', label: 'bridge', diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index b80421d1fe..9c11d40d3e 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -185,7 +185,7 @@ async function acquireSnapshotSourceLock( timeoutMs: remainingSnapshotSourceMs(deadline, 'cache-lock-deadline'), pollMs: 100, ownerGraceMs: 5_000, - description: 'iOS Simulator snapshot bridge cache', + description: options.description, }); const signal = deadline.signal; if (!signal) return await pending; diff --git a/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts b/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts index 007068eca5..7794eb890f 100644 --- a/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts @@ -32,6 +32,7 @@ test('a cache hit skips the build, and a manifest or binary mismatch rebuilds', cacheRoot, cacheKey, binaryFilename: 'built', + lockDescription: 'test native build cache', manifest, manifestMatches: (candidate) => nativeBuildManifestFieldsMatch(candidate, manifest, ['schemaVersion', 'sourceHash']), @@ -102,6 +103,7 @@ test('a failed build leaves no cache entry, and a later call can retry', async ( cacheRoot, cacheKey, binaryFilename: 'built', + lockDescription: 'test native build cache', manifest, manifestMatches: (candidate) => nativeBuildManifestFieldsMatch(candidate, manifest, ['schemaVersion', 'sourceHash']), diff --git a/packages/platform-apple/src/snapshot-source/native-build-cache.ts b/packages/platform-apple/src/snapshot-source/native-build-cache.ts index 426bb13155..33b502021c 100644 --- a/packages/platform-apple/src/snapshot-source/native-build-cache.ts +++ b/packages/platform-apple/src/snapshot-source/native-build-cache.ts @@ -24,6 +24,8 @@ export async function ensureNativeBuildCacheEntry( cacheRoot: string; cacheKey: string; binaryFilename: string; + /** Names the contended resource in a lock-stall diagnostic; every caller states its own. */ + lockDescription: string; /** Written alongside `cacheKey` and the built binary's sha256 once a build publishes. */ manifest: Readonly>; /** Whether a candidate manifest still describes `manifest`; the binary hash is checked separately. */ @@ -35,7 +37,11 @@ export async function ensureNativeBuildCacheEntry( const { host, deadline, cacheRoot, cacheKey, binaryFilename } = input; const entryPath = path.join(cacheRoot, cacheKey); return await withProcessLock({ - acquire: () => host.acquireLock(path.join(cacheRoot, `${cacheKey}.lock`), { deadline }), + acquire: () => + host.acquireLock(path.join(cacheRoot, `${cacheKey}.lock`), { + deadline, + description: input.lockDescription, + }), task: async () => { const cached = await readValidCacheEntry( host, diff --git a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts index 9e8939e6d4..5452fc23a4 100644 --- a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts +++ b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { beforeAll, describe, test } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; -import { BUILD_TIMEOUT_MS } from './cache.ts'; +import { buildSnapshotBridgeCompileArgv, BUILD_TIMEOUT_MS } from './cache.ts'; import { createSnapshotSourceDeadline, remainingSnapshotSourceMs } from './deadline.ts'; // The host bridge compile is budgeted from a deadline sized to production's build ceiling, not a @@ -94,6 +94,42 @@ const recoveryFixture = JSON.parse(readFileSync(recoveryFixturePath, 'utf8')) as recoveryCases: readonly { name: string }[]; }; +// #2796: the production compile drops -Werror so a stale toolchain warning cannot fail a build; this +// is the gate that keeps a new SnapshotBridge*.m warning from passing CI unnoticed. It runs the +// production argv (`buildSnapshotBridgeCompileArgv`) against the real iphonesimulator SDK with +// -Werror appended, so a warning fails here instead of nowhere. +describe.skipIf(process.platform !== 'darwin')('bridge warning gate', () => { + // The compile runs in beforeAll, not in the test body: it is a real clang invocation (see the + // unit slow-test budget in docs/agents/testing.md), and this file's other describe blocks + // already keep their compiles out of test-case wall time the same way. + let compiled: { exitCode: number; stderr: string }; + beforeAll(async () => { + const nativeRoot = path.resolve(import.meta.dirname, '../../../../apple/snapshot-bridge'); + const binary = path.join(await mkdtempForTest('snapshot-bridge-werror-'), 'snapshot-bridge'); + const architecture = process.arch === 'arm64' ? 'arm64' : 'x86_64'; + const argv = buildSnapshotBridgeCompileArgv({ + architecture, + sourceRoot: nativeRoot, + outputPath: binary, + }); + const wextraIndex = argv.indexOf('-Wextra'); + assert.ok(wextraIndex >= 0, 'the production argv carries -Wextra'); + const werrorArgv = [ + ...argv.slice(0, wextraIndex + 1), + '-Werror', + ...argv.slice(wextraIndex + 1), + ]; + compiled = await runCmd('xcrun', werrorArgv, { + allowFailure: true, + timeoutMs: BUILD_TIMEOUT_MS, + }); + }, COMPILE_HOOK_TIMEOUT_MS); + + test('the production bridge argv compiles clean under -Werror', () => { + assert.equal(compiled.exitCode, 0, compiled.stderr); + }); +}); + describe.skipIf(process.platform !== 'darwin')( 'shared AX recovery conformance (host bridge)', () => { diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index 2cec940121..fa0b20c838 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -103,7 +103,10 @@ export type SnapshotSourceHost = Readonly<{ remove(path: string): Promise; acquireLock( path: string, - options: { deadline: SnapshotSourceDeadline }, + /** `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'; diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index 89888805d6..906cf289c0 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -143,9 +143,10 @@ test('request envelopes deviating from the default are bounded, reviewed sets', reinstall: 180_000, install_source: 180_000, longpress: 210_000, - // fold: one macOS helper press (30s) plus up to four bounded CoreDevice hinge reads (20s - // each on a wedged host) can pass the default envelope; the policy covers that worst case. - fold: 210_000, + // fold: display-inventory query + fold-helper preparation + HID dispatch + hinge settle + // reads + lit-panel display-inventory query can sum to 220s worst case; the policy covers + // that with margin. + fold: 240_000, // #1774: base allocation budget (300s) + client/daemon race margin (30s). lease_allocate: 330_000, test: 'unbounded', diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index d72fcd1264..9177607575 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -753,7 +753,7 @@ Changing the pose: Expect a fold to take 10-16 seconds: each hinge read is a five-second devicectl stream, and half-open waits for the hinge to stop moving. Re-snapshot after every fold; refs and coordinates from before it are stale, and the command's message says so. Timed motion: fold --keyframes '[{"atMs":0,"angle":0},{"atMs":5000,"angle":180}]'. Use 2–64 frames starting at 0ms, increasing integer timestamps up to 60000ms, and angles from 0 to 180. The last timestamp sets motion duration, excluding setup and verification. Equal angles hold; cancellation stops motion. See the fold examples in the command and Node API documentation for trajectories. - Requirements: an iOS simulator session on a foldable device and an Xcode toolchain with the iOS simulator SDK and foldable HID support (verified on Xcode 27.1). Device Hub and host Accessibility permission are not required. The command builds a small temporary helper with the selected Xcode and runs it through simctl spawn for the session UDID; build or dispatch failures are reported without a UI fallback. The app under test reads the resulting pose as UIHinge.status. + Requirements: an iOS simulator session on a foldable device and an Xcode toolchain with the iOS simulator SDK and foldable HID support (verified on Xcode 27.1). Device Hub and host Accessibility permission are not required. The command runs a small helper through simctl spawn for the session UDID; the helper is built once per Fold.m source hash and Xcode toolchain, cached under ~/.agent-device/fold-helper, and rebuilt only when the source or the toolchain changes. Build or dispatch failures are reported without a UI fallback. The app under test reads the resulting pose as UIHinge.status. If a task asserts behavior for more than one pose, fold to each pose and re-snapshot, and report which poses the run covered.`, }, remote: { diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index 7ba5beef58..49beb7d31e 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -4,11 +4,32 @@ import { assertRpcOk } from './assertions.ts'; import { makeIosAppSession } from '../../../src/__tests__/test-utils/session-factories.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { test } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, test } from 'vitest'; import { createProviderScenarioHarness } from './harness.ts'; import { createRecordingAppleToolProvider } from './providers.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; +// The fold helper's build cache lives under the host home directory (#2796), so this test scopes +// HOME to a throwaway directory: otherwise it would read and write the real developer/CI machine's +// `~/.agent-device/fold-helper` cache and the `builds` assertion below would depend on whatever that +// machine's cache already held. +let previousHome: string | undefined; +let isolatedHome: string; + +beforeEach(() => { + previousHome = process.env.HOME; + isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-fold-home-')); + process.env.HOME = isolatedHome; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + fs.rmSync(isolatedHome, { recursive: true, force: true }); +}); + test('timed fold keyframes reach simulator HID through the public client and daemon', async () => { const trajectory = [ { atMs: 0, angle: 0 }, @@ -49,9 +70,21 @@ test('timed fold keyframes reach simulator HID through the public client and dae appleToolProvider: () => ({ ...tool.provider, runCommand: async (command, args) => { + if (command === 'xcodebuild') { + return { stdout: 'Xcode 16.4\nBuild version 16F6', 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 }; assert.equal(command, 'xcrun'); assert.ok(args.includes('clang')); builds++; + fs.writeFileSync(args.at(-1)!, 'fold-helper-binary'); return ok; }, }), @@ -83,7 +116,8 @@ test('timed fold keyframes reach simulator HID through the public client and dae await daemon.callCommand(parsed.command, parsed.positionals ?? [], parsed.flags), ); assert.equal(replayed.hingeAngleDegrees, 100); - assert.equal(builds, 2); + // The second fold call reuses the cached fold helper binary instead of rebuilding (#2796). + assert.equal(builds, 1); assert.equal(tool.calls.filter((call) => call.includes('spawn')).length, 2); assert.equal(tool.calls.filter((call) => call.includes('hinge-angle')).length, 4); } finally { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b2a54416e2..55319492a5 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -92,7 +92,7 @@ agent-device fold open - `action-button` reports that the press was dispatched, not what the system did with it. Simulators run no Shortcuts and no App Intents, so what a press triggers can only be verified on a physical iPhone; on a Simulator the command proves the press was accepted and that the session app was not brought forward. - `fold ` puts a foldable iPhone simulator (iPhone Duo) into a hinge pose. The command sends a private HID hinge event inside the selected simulator (ADR 0025), then reads the hinge angle back with `devicectl device motion hinge-angle` and reports the pose only when that reading agrees: `closed` is 0°, `open` is 180°, and `half-open` is any angle between them (requested at 130°). An angle inside that interval only proves the category, so `half-open` is reported once two consecutive readings both fall inside it and agree within 0.5°. The response names the panel the device now lights and its native panel point size, marked `coordinateSpace: "native-panel"`; that size is the panel's own geometry, not the next snapshot's viewport (a 669x951 inner panel can host a 951x669 app window), so it cannot place a tap. Re-snapshot afterwards, and never carry refs or coordinates across a `fold`. After that snapshot, taps, long presses, and scrolling follow the app window on the active panel in closed, half-open, and open poses. - For timed motion, use `fold --keyframes '[{"atMs":0,"angle":0},{"atMs":1667,"angle":160},{"atMs":3333,"angle":100},{"atMs":5000,"angle":180}]'`. This runs the opening/reversal/reopening sequence over five seconds. Supply either a preset or keyframes, never both. Use 2–64 keyframes starting at 0ms with strictly increasing integer timestamps up to 60,000ms and finite angles in 0–180°. Linear interpolation runs at roughly 60Hz; equal consecutive angles hold the hinge. Motion duration excludes preparation and final-angle verification. Cancellation stops at the current angle; re-snapshot even after an interrupted trajectory. -- `fold` is simulator-only and requires an Xcode toolchain with the iOS simulator SDK and foldable HID support (verified on Xcode 27.1). It compiles a temporary helper and runs it against the session UDID. Device Hub and host Accessibility permission are not required. Build failures report `fold-helper-build-failed`; dispatch failures report `fold-hid-dispatch-failed`. There is no UI fallback. Single-panel simulators and physical devices are refused. +- `fold` is simulator-only and requires an Xcode toolchain with the iOS simulator SDK and foldable HID support (verified on Xcode 27.1). It runs a helper against the session UDID; the helper is built once per `Fold.m` source hash and Xcode toolchain, cached under `~/.agent-device/fold-helper`, and rebuilt only when the source or the toolchain changes. Device Hub and host Accessibility permission are not required. Build failures report `fold-helper-build-failed`; dispatch failures report `fold-hid-dispatch-failed`. There is no UI fallback. Single-panel simulators and physical devices are refused. - `fold` costs one bounded hinge stream per read, and devicectl's smallest stream is five seconds: `closed` and `open` take about ten seconds, `half-open` about sixteen, because the hinge animates and the command waits for it to stop. A hinge whose last reading is some other pose fails with `COMMAND_FAILED` and `reason: fold-pose-unverified`, naming the angle CoreDevice still reports. A hinge seen `half-open` but never at rest fails with `reason: fold-pose-unsettled`, naming the observed and previous angles: the requested category was observed, and what is missing is a pose the hinge holds (#2730). - `action-button` is not a cheap command to loop. On an iPhone 17 Pro Simulator the press itself spent about five seconds inside XCUITest, while `home` and `app-switcher` on the same session took under two seconds each. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs require an active app in the session. From 40e4fb217d7c2bb0888766ba443ab6791060d2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 10:21:36 +0200 Subject: [PATCH 3/4] chore(gates): cover the fold helper's -Werror gate in the iOS macOS CI step ios.yml's clean-installed preparation step now also runs the fold helper's -Werror conformance test and watches apple/fold-helper and packages/platform-apple/src/foldable for changes, matching the existing bridge gate. --- .github/workflows/ios.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index ac9e3f9775..d04fa8f140 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -126,7 +126,7 @@ jobs: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} preferred-device-name: iPhone 17 Pro - - name: Verify clean-installed Simulator snapshot bridge preparation + - name: Verify clean-installed Simulator snapshot bridge preparation and the fold-helper -Werror gate if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -134,15 +134,19 @@ jobs: git fetch origin "$BASE_SHA" --depth=1 if git diff --quiet "$BASE_SHA"...HEAD -- \ apple/snapshot-bridge \ + apple/fold-helper \ packages/platform-apple/src/snapshot-source \ + packages/platform-apple/src/foldable \ scripts/check-package.ts \ scripts/size-report-install.mjs \ scripts/size-report-package.mjs; then - echo "Snapshot bridge packaging is unchanged; skipping preparation proof." + echo "Snapshot bridge and fold-helper sources are unchanged; skipping preparation proof." exit 0 fi pnpm build - pnpm exec vitest run packages/platform-apple/src/snapshot-source/native-runtime.test.ts + pnpm exec vitest run \ + packages/platform-apple/src/snapshot-source/native-runtime.test.ts \ + packages/platform-apple/src/foldable/fold-helper-cache.test.ts pnpm check:package -- --verify-snapshot-bridge-preparation - name: Run targeted iOS runner XCTest regressions From cf4d07353b16fa70c05d6abff90fed09df383620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 11:41:11 +0200 Subject: [PATCH 4/4] refactor(ios): key native build cache hits on the cache key and keep fold build error details ensureNativeBuildCacheEntry now takes the build's key inputs, derives the cache key itself, and returns it. A hit only needs the manifest's cacheKey and binary hash to match, because the key already hashes every field the per-caller MANIFEST_FIELDS lists compared again. That removes nativeBuildManifestFieldsMatch, both field lists, and the snapshotBridgeCacheKey/foldHelperCacheKey wrappers. Keys are byte-identical to before, so existing cache entries still hit. A fold helper cache failure now keeps the underlying failure's hint and typed details (the native-build-stalled hint and timeoutMs, toolchain-probe stderr, a missing source's filePath) instead of replacing them with a fixed hint. Tests: simulator-hid.test.ts mocks the fold helper cache instead of retesting it through exec fakes, which drops sendSimulatorFoldPose's test-only cacheRoot option. The -Werror absence checks assert the argv directly, and the darwin gates append -Werror instead of splicing it in. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/foldable/fold-helper-cache.test.ts | 75 +++--- .../src/foldable/fold-helper-cache.ts | 94 ++------ .../src/foldable/simulator-hid.test.ts | 219 +++++------------- .../src/foldable/simulator-hid.ts | 3 +- .../src/snapshot-source/cache.test.ts | 67 +----- .../src/snapshot-source/cache.ts | 105 ++------- .../native-build-cache.test.ts | 73 ++---- .../src/snapshot-source/native-build-cache.ts | 72 ++---- .../snapshot-source/native-runtime.test.ts | 9 +- 9 files changed, 196 insertions(+), 521 deletions(-) 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 a4e65778af..f582319d60 100644 --- a/packages/platform-apple/src/foldable/fold-helper-cache.test.ts +++ b/packages/platform-apple/src/foldable/fold-helper-cache.test.ts @@ -10,7 +10,6 @@ import type { SnapshotSourceHost } from '../snapshot-source/types.ts'; import { buildFoldHelperCompileArgv, ensureFoldHelperBinary, - foldHelperCacheKey, FOLD_HELPER_BUILD_TIMEOUT_MS, } from './fold-helper-cache.ts'; @@ -82,31 +81,42 @@ test('a cache hit does not build, and a source or toolchain change does', async } }); -test('the fold helper cache key changes with the compile argv, independent of source and toolchain', () => { - const sourceHash = 'same-source'; - const toolchain = { - xcode: 'Xcode 16.4\nBuild version 16F6', - macosProductVersion: '15.6', - macosBuild: '24G90', - architecture: 'arm64', - } as const; - const argv = ['clang', '-Wall']; - const changedArgv = ['clang', '-Wall', '-DSomethingNew']; +test('the runtime clang build never uses -Werror', () => { + assert.ok(!buildFoldHelperCompileArgv({ sourceRoot: '', outputPath: '' }).includes('-Werror')); +}); - const key = foldHelperCacheKey({ sourceHash, toolchain, compileArgv: argv }); - const sameKey = foldHelperCacheKey({ sourceHash, toolchain, compileArgv: argv }); - const keyAfterArgvChange = foldHelperCacheKey({ - sourceHash, - toolchain, - compileArgv: changedArgv, - }); +test('a failed compile reports fold-helper-build-failed with the compiler output', async () => { + const root = await mkdtempForTest('agent-device-fold-helper-cache-failure-'); + 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'); + const okHost = fakeFoldHelperHost(() => 'binary'); + const host: SnapshotSourceHost = { + ...okHost, + run: async (command, args, options) => { + if (command === 'xcrun' && args.includes('clang')) { + return { stdout: '', stderr: 'compiler detail', exitCode: 1 }; + } + return await okHost.run(command, args, options); + }, + }; - assert.equal(key, sameKey, 'the same argv always keys the same'); - assert.notEqual( - key, - keyAfterArgvChange, - 'an argv-only change (same source, same toolchain) cannot serve a stale binary', - ); + try { + await assert.rejects(ensureFoldHelperBinary({ host, sourceRoot, cacheRoot }), { + code: 'COMMAND_FAILED', + details: { + stdout: '', + stderr: 'compiler detail', + exitCode: 1, + processExitError: true, + hint: 'Select an Xcode with the iOS simulator SDK and foldable HID support using DEVELOPER_DIR.', + reason: 'fold-helper-build-failed', + }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } }); test('a compile exec killed at its budget reports the fold-helper build, not the exec layer', async () => { @@ -134,10 +144,10 @@ test('a compile exec killed at its budget reports the fold-helper build, not the (error as { details?: { reason?: string } }).details?.reason, 'fold-helper-build-failed', ); - assert.equal( - (error as { details?: { cause?: string } }).details?.cause, - 'native-build-stalled', - ); + const details = (error as { details?: Record }).details; + assert.equal(details?.cause, 'native-build-stalled'); + assert.equal(details?.timeoutMs, FOLD_HELPER_BUILD_TIMEOUT_MS); + assert.match(String(details?.hint), /stopped the fold helper build/); return true; }, ); @@ -160,14 +170,7 @@ describe.skipIf(process.platform !== 'darwin')('fold helper warning gate', () => const sourceRoot = path.resolve(import.meta.dirname, '../../../../apple/fold-helper'); const binary = path.join(await mkdtempForTest('fold-helper-werror-'), 'fold-helper'); const argv = buildFoldHelperCompileArgv({ sourceRoot, outputPath: binary }); - const wextraIndex = argv.indexOf('-Wextra'); - assert.ok(wextraIndex >= 0, 'the production argv carries -Wextra'); - const werrorArgv = [ - ...argv.slice(0, wextraIndex + 1), - '-Werror', - ...argv.slice(wextraIndex + 1), - ]; - compiled = await runCmd('xcrun', werrorArgv, { + compiled = await runCmd('xcrun', [...argv, '-Werror'], { allowFailure: true, timeoutMs: FOLD_HELPER_BUILD_TIMEOUT_MS, }); diff --git a/packages/platform-apple/src/foldable/fold-helper-cache.ts b/packages/platform-apple/src/foldable/fold-helper-cache.ts index 05c08f6697..c1fb958f25 100644 --- a/packages/platform-apple/src/foldable/fold-helper-cache.ts +++ b/packages/platform-apple/src/foldable/fold-helper-cache.ts @@ -3,10 +3,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { execFailureDetails } from '@agent-device/host-kit/command'; import { runAppleToolCommand } from '../core/tool-provider.ts'; import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; -import { - readHostToolchainIdentity, - type HostToolchainIdentity, -} from '../snapshot-source/cache-identity.ts'; +import { readHostToolchainIdentity } from '../snapshot-source/cache-identity.ts'; import { createSnapshotSourceDeadline, type SnapshotSourceDeadline, @@ -17,8 +14,6 @@ import { ensureNativeBuildCacheEntry, execNativeBuildClang, fingerprintNativeBuildSource, - nativeBuildCacheKey, - nativeBuildManifestFieldsMatch, } from '../snapshot-source/native-build-cache.ts'; import type { SnapshotSourceHost } from '../snapshot-source/types.ts'; @@ -26,34 +21,8 @@ const FOLD_HELPER_SOURCE_FILENAME = 'Fold.m'; const FOLD_HELPER_BINARY_FILENAME = 'fold-helper'; const FOLD_HELPER_SCHEMA_VERSION = 1 as const; const FOLD_HELPER_LOCK_DESCRIPTION = 'iOS Simulator fold helper cache'; -const MANIFEST_FIELDS = [ - 'schemaVersion', - 'sourceHash', - 'cacheKey', - 'toolchain', - 'compileArgv', -] as const; - -/** - * The fold helper cache key, folding in the compile argv (fingerprinted with a placeholder - * `sourceRoot` and `outputPath`) alongside `sourceHash` and `toolchain`, so a change to a compiler - * flag or framework list cannot serve a binary built from a different command line (#2796 - * follow-up). - */ -export function foldHelperCacheKey( - input: Readonly<{ - sourceHash: string; - toolchain: HostToolchainIdentity; - compileArgv: readonly string[]; - }>, -): string { - return nativeBuildCacheKey({ - schemaVersion: FOLD_HELPER_SCHEMA_VERSION, - sourceHash: input.sourceHash, - toolchain: input.toolchain, - compileArgv: input.compileArgv, - }); -} +const FOLD_HELPER_BUILD_HINT = + 'Select an Xcode with the iOS simulator SDK and foldable HID support using DEVELOPER_DIR.'; /** Upper bound on a single fold-helper clang invocation; the same budget the prior per-call build used. */ export const FOLD_HELPER_BUILD_TIMEOUT_MS = 30_000; @@ -68,8 +37,9 @@ const FOLD_HELPER_PREPARATION_DEADLINE_MS = * 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). * - * Failures surface as `AppError('COMMAND_FAILED', ..., {reason: 'fold-helper-build-failed'})`, the - * error shape `sendSimulatorFoldPose` reported before this cache existed. + * Build and cache failures surface as `AppError('COMMAND_FAILED', ..., {reason: + * 'fold-helper-build-failed'})`, the error shape `sendSimulatorFoldPose` reported before this cache + * existed, carrying the underlying failure's hint and details. */ export async function ensureFoldHelperBinary( input: Readonly<{ @@ -90,27 +60,21 @@ export async function ensureFoldHelperBinary( deadline, ); const toolchain = await readHostToolchainIdentity(host, deadline); - const compileArgv = buildFoldHelperCompileArgv({ sourceRoot: '', outputPath: '' }); - const cacheKey = foldHelperCacheKey({ sourceHash, toolchain, compileArgv }); const cacheRoot = input.cacheRoot ?? path.join(host.homeDirectory(), '.agent-device', 'fold-helper'); - const manifest = { - schemaVersion: FOLD_HELPER_SCHEMA_VERSION, - sourceHash, - cacheKey, - toolchain, - compileArgv, - }; return await ensureNativeBuildCacheEntry({ host, deadline, lockDescription: FOLD_HELPER_LOCK_DESCRIPTION, cacheRoot, - cacheKey, binaryFilename: FOLD_HELPER_BINARY_FILENAME, - manifest, - manifestMatches: (candidate) => - nativeBuildManifestFieldsMatch(candidate, manifest, MANIFEST_FIELDS), + keyInputs: { + schemaVersion: FOLD_HELPER_SCHEMA_VERSION, + sourceHash, + toolchain, + // Placeholder paths keep the key independent of the install location and build directory. + compileArgv: buildFoldHelperCompileArgv({ sourceRoot: '', outputPath: '' }), + }, build: (outputPath) => compileFoldHelper(host, deadline, sourceRoot, outputPath), }); } catch (error) { @@ -165,38 +129,28 @@ async function compileFoldHelper( deadline, argv: buildFoldHelperCompileArgv({ sourceRoot, outputPath }), budgetMs: FOLD_HELPER_BUILD_TIMEOUT_MS, - deadlineReason: 'fold-helper-build-deadline', label: 'fold helper', }); if (result.exitCode !== 0 || !host.exists(outputPath)) { - throw new AppError( - 'COMMAND_FAILED', - 'Unable to build the simulator fold helper', - execFailureDetails(result, { - reason: 'fold-helper-build-failed', - hint: 'Select an Xcode with the iOS simulator SDK and foldable HID support using DEVELOPER_DIR.', - }), - ); + throw foldHelperBuildFailed(execFailureDetails(result)); } } /** - * `AppError('COMMAND_FAILED', ..., {reason: 'fold-helper-build-failed'})` for every cache failure, - * matching the error `sendSimulatorFoldPose` reported before this cache existed, except a genuine - * cancellation: `compileFoldHelper` already throws that exact shape on a build failure, so it - * passes through unchanged. + * 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. */ function asFoldHelperCacheError(error: unknown): unknown { - if (!(error instanceof SnapshotSourceError)) return error; - if (error.failureKind === 'cancelled') return error; + if (!(error instanceof SnapshotSourceError) || error.failureKind === 'cancelled') return error; + const { bridgeFailure: _kind, bridgeFailureCode: cause, ...details } = error.details ?? {}; + return foldHelperBuildFailed({ ...details, cause }, error); +} + +function foldHelperBuildFailed(details: Readonly>, cause?: unknown) { return new AppError( 'COMMAND_FAILED', 'Unable to build the simulator fold helper', - { - reason: 'fold-helper-build-failed', - hint: 'Select an Xcode with the iOS simulator SDK and foldable HID support using DEVELOPER_DIR.', - cause: error.failureCode, - }, - error, + { hint: FOLD_HELPER_BUILD_HINT, ...details, reason: 'fold-helper-build-failed' }, + cause, ); } diff --git a/packages/platform-apple/src/foldable/simulator-hid.test.ts b/packages/platform-apple/src/foldable/simulator-hid.test.ts index 3f11d7dd89..9cccc35e8f 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.test.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.test.ts @@ -1,188 +1,87 @@ -import { expect, test } from 'vitest'; -import { writeFile } from 'node:fs/promises'; -import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; +import { expect, test, vi } from 'vitest'; import { withAppleToolProvider, createLocalAppleToolProvider } from '../core/tool-provider.ts'; import { IOS_SIMULATOR } from '../__tests__/device-fixtures.ts'; +import { ensureFoldHelperBinary } from './fold-helper-cache.ts'; import { sendSimulatorFoldPose } from './simulator-hid.ts'; -const selectedDuo = { ...IOS_SIMULATOR, id: 'selected-duo' }; - -type RecordedCall = Readonly<{ command: string; args: readonly string[] }>; - -type ExecResponse = { stdout: string; stderr: string; exitCode: number }; - -/** The fixed response for the host toolchain probes `sendSimulatorFoldPose` reads, or `undefined` for `xcrun`. */ -function toolchainProbeResponse( - command: string, - args: readonly string[], -): ExecResponse | undefined { - if (command === 'xcodebuild') - return { stdout: 'Xcode 16.4\nBuild version 16F6', 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 }; - return undefined; -} - -async function respondToClang( - args: readonly string[], - onClang?: (args: readonly string[]) => ExecResponse | undefined, -): Promise { - const outcome = onClang?.(args); - // The build cache checks the binary actually landed at `-o`'s path, so every clang response - // short of a compiler failure has to leave that file behind. - if (!outcome || outcome.exitCode === 0) { - const outputPath = args.at(-1)!; - await writeFile(outputPath, 'fold-helper-binary'); - } - return outcome ?? { stdout: '', stderr: '', exitCode: 0 }; -} - -/** - * Answers every exec `sendSimulatorFoldPose` can make: the toolchain probes the fold-helper cache - * reads, the fold-helper clang build, and the `simctl spawn` dispatch. `onClang`/`onSpawn` override - * the default success behavior for one of the two `xcrun` calls. - */ -function createFoldToolMock( - calls: RecordedCall[], - overrides: { - onClang?: (args: readonly string[]) => ExecResponse | undefined; - onSpawn?: (args: readonly string[]) => ExecResponse; - } = {}, -) { - return async (command: string, args: readonly string[]) => { - calls.push({ command, args }); - const probeResponse = toolchainProbeResponse(command, args); - if (probeResponse) return probeResponse; - expect(command).toBe('xcrun'); - if (args.includes('clang')) return respondToClang(args, overrides.onClang); - if (overrides.onSpawn) return overrides.onSpawn(args); - return { stdout: '', stderr: '', exitCode: 0 }; - }; -} - -function clangArgs(calls: readonly RecordedCall[]): readonly string[] { - return calls.find((call) => call.args.includes('clang'))!.args; -} - -test('the runtime clang build never uses -Werror', async () => { - const cacheRoot = await mkdtempForTest('agent-device-fold-cache-'); - const calls: RecordedCall[] = []; - await withAppleToolProvider( - createLocalAppleToolProvider({ runCommand: createFoldToolMock(calls) }), - () => sendSimulatorFoldPose(selectedDuo, 'half-open', undefined, { cacheRoot }), - ); - expect(clangArgs(calls)).not.toContain('-Werror'); -}); - -test('a second call reuses the cached fold helper and does not invoke clang again', async () => { - const cacheRoot = await mkdtempForTest('agent-device-fold-cache-hit-'); - const calls: RecordedCall[] = []; - const runCommand = createFoldToolMock(calls); +vi.mock('./fold-helper-cache.ts', () => ({ + ensureFoldHelperBinary: vi.fn(async () => ({ path: '/cache/fold-helper' })), +})); - await withAppleToolProvider(createLocalAppleToolProvider({ runCommand }), () => - sendSimulatorFoldPose(selectedDuo, 'half-open', undefined, { cacheRoot }), - ); - const clangCallsAfterFirst = calls.filter((call) => call.args.includes('clang')).length; - expect(clangCallsAfterFirst).toBe(1); - - await withAppleToolProvider(createLocalAppleToolProvider({ runCommand }), () => - sendSimulatorFoldPose(selectedDuo, 'open', undefined, { cacheRoot }), - ); - - expect(calls.filter((call) => call.args.includes('clang'))).toHaveLength(1); - expect(calls.filter((call) => call.args.includes('spawn'))).toHaveLength(2); -}); +const selectedDuo = { ...IOS_SIMULATOR, id: 'selected-duo' }; -test.each(['build', 'dispatch', 'cancel'] as const)( - 'HID route targets the UDID and handles %s', - async (failure) => { - const cacheRoot = await mkdtempForTest(`agent-device-fold-cache-${failure}-`); - const calls: RecordedCall[] = []; +test.each(['success', 'dispatch', 'cancel'] as const)( + 'HID route spawns the cached helper on the UDID and handles %s', + async (outcome) => { const controller = new AbortController(); - const runCommand = createFoldToolMock(calls, { - onClang: () => { - if (failure === 'build') return { stdout: '', stderr: 'compiler detail', exitCode: 1 }; - if (failure === 'cancel') controller.abort(new Error('cancelled')); - return undefined; - }, - onSpawn: (args) => { - expect(args.slice(0, 3)).toEqual(['simctl', 'spawn', 'selected-duo']); - expect(args.at(-2)).toMatch(/fold-helper$/); - return { stdout: '', stderr: 'spawn detail', exitCode: failure === 'dispatch' ? 1 : 0 }; - }, - }); - await withAppleToolProvider(createLocalAppleToolProvider({ runCommand }), async () => { - const operation = sendSimulatorFoldPose(selectedDuo, 'half-open', controller.signal, { - cacheRoot, + if (outcome === 'cancel') { + vi.mocked(ensureFoldHelperBinary).mockImplementationOnce(async () => { + controller.abort(new Error('cancelled')); + return { path: '/cache/fold-helper' }; }); - if (failure === 'build') { - await expect(operation).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'fold-helper-build-failed' }, - }); - } else if (failure === 'dispatch') { - await expect(operation).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'fold-hid-dispatch-failed' }, - }); - } else { - await expect(operation).rejects.toThrow(/cancelled/); - } - }); + } + const dispatches: string[][] = []; + await withAppleToolProvider( + createLocalAppleToolProvider({ + runCommand: async (command, args, options) => { + expect(command).toBe('xcrun'); + expect(options?.signal).toBe(controller.signal); + dispatches.push([...args]); + return { stdout: '', stderr: 'spawn detail', exitCode: outcome === 'dispatch' ? 1 : 0 }; + }, + }), + async () => { + const operation = sendSimulatorFoldPose(selectedDuo, 'half-open', controller.signal); + if (outcome === 'success') await expect(operation).resolves.toBeUndefined(); + else if (outcome === 'cancel') await expect(operation).rejects.toThrow('cancelled'); + else + await expect(operation).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'fold-hid-dispatch-failed', deviceId: 'selected-duo' }, + }); + }, + ); + expect(dispatches).toEqual( + outcome === 'cancel' + ? [] + : [['simctl', 'spawn', 'selected-duo', '/cache/fold-helper', 'half-open']], + ); }, ); test('streams all keyframes in one process with a duration-derived timeout', async () => { - const cacheRoot = await mkdtempForTest('agent-device-fold-cache-keyframes-'); const keyframes = [ { atMs: 0, angle: 0 }, { atMs: 60000, angle: 100 }, ]; - const calls: RecordedCall[] = []; - const baseMock = createFoldToolMock(calls); - let dispatchTimeoutMs: number | undefined; + let dispatches = 0; await withAppleToolProvider( createLocalAppleToolProvider({ - runCommand: async (command, args, options) => { - if (args[0] === 'simctl') { - calls.push({ command, args }); - expect(JSON.parse(args.at(-1)!)).toEqual(keyframes); - dispatchTimeoutMs = options?.timeoutMs; - expect(options?.kill).toEqual({ signal: 'SIGTERM', graceMs: 1000 }); - return { stdout: '', stderr: '', exitCode: 0 }; - } - return await baseMock(command, args); + runCommand: async (_command, args, options) => { + dispatches++; + expect(JSON.parse(args.at(-1)!)).toEqual(keyframes); + expect(options?.timeoutMs).toBe(70000); + expect(options?.kill).toEqual({ signal: 'SIGTERM', graceMs: 1000 }); + return { stdout: '', stderr: '', exitCode: 0 }; }, }), - () => sendSimulatorFoldPose(selectedDuo, keyframes, undefined, { cacheRoot }), + () => sendSimulatorFoldPose(selectedDuo, keyframes), ); - expect(dispatchTimeoutMs).toBe(70000); - expect(calls.filter((call) => call.args[0] === 'simctl')).toHaveLength(1); + expect(dispatches).toBe(1); }); test('HID dispatch addresses the UDID inside its scoped simulator set', async () => { - const cacheRoot = await mkdtempForTest('agent-device-fold-cache-scoped-'); - const calls: RecordedCall[] = []; + const dispatches: string[][] = []; await withAppleToolProvider( - createLocalAppleToolProvider({ runCommand: createFoldToolMock(calls) }), - () => - sendSimulatorFoldPose( - { ...selectedDuo, simulatorSetPath: '/tmp/scoped-set' }, - 'closed', - undefined, - { cacheRoot }, - ), + createLocalAppleToolProvider({ + runCommand: async (_command, args) => { + dispatches.push([...args]); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }), + () => sendSimulatorFoldPose({ ...selectedDuo, simulatorSetPath: '/tmp/scoped-set' }, 'closed'), ); - const dispatch = calls.find((call) => call.args[0] === 'simctl')!; - expect(dispatch.args.slice(0, 5)).toEqual([ - 'simctl', - '--set', - '/tmp/scoped-set', - 'spawn', - 'selected-duo', + expect(dispatches).toEqual([ + ['simctl', '--set', '/tmp/scoped-set', 'spawn', 'selected-duo', '/cache/fold-helper', 'closed'], ]); - expect(dispatch.args.at(-2)).toMatch(/fold-helper$/); - expect(dispatch.args.at(-1)).toBe('closed'); }); diff --git a/packages/platform-apple/src/foldable/simulator-hid.ts b/packages/platform-apple/src/foldable/simulator-hid.ts index 4b0acb98d7..f16cf59811 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.ts @@ -10,10 +10,9 @@ export async function sendSimulatorFoldPose( device: DeviceInfo, pose: FoldPose | readonly FoldKeyframe[], signal?: AbortSignal, - options: Readonly<{ cacheRoot?: string }> = {}, ): Promise { signal?.throwIfAborted(); - const binary = await ensureFoldHelperBinary({ signal, cacheRoot: options.cacheRoot }); + const binary = await ensureFoldHelperBinary({ signal }); signal?.throwIfAborted(); const durationMs = typeof pose === 'string' ? 0 : pose.at(-1)!.atMs; const payload = typeof pose === 'string' ? pose : JSON.stringify(pose); diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index d18f90839d..e157ec95a2 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { test } from 'vitest'; import { isCommandTimeoutError } from '@agent-device/host-kit/command'; import { createSnapshotSourceHost } from './host.ts'; -import { ensureSnapshotBridgeBinary, snapshotBridgeCacheKey } from './cache.ts'; +import { buildSnapshotBridgeCompileArgv, ensureSnapshotBridgeBinary } from './cache.ts'; import { SnapshotSourceError } from './errors.ts'; import { createSnapshotSourceDeadline } from './deadline.ts'; import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts'; @@ -125,68 +125,13 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt } }); -test('the runtime clang build never uses -Werror', async () => { - const root = await mkdtempForTest('agent-device-snapshot-source-werror-'); - 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, 'SnapshotBridge.m'), 'native source'); - await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime'); - await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); - await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); - await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); - const buildHost = createFakeBuildHost('binary'); - let clangArgs: readonly string[] = []; - const host: SnapshotSourceHost = { - ...buildHost, - run: async (command, args, options) => { - if (command === 'xcrun' && args.includes('clang')) clangArgs = args; - return await buildHost.run(command, args, options); - }, - }; - - try { - await ensureSnapshotBridgeBinary({ - host, - runtime: 'iOS 26.2', - limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS, - deadline: testDeadline(), - sourceRoot, - cacheRoot, - }); - assert.ok(clangArgs.length > 0, 'the compile exec ran'); - assert.ok(!clangArgs.includes('-Werror')); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('the bridge cache key changes with the compile argv, independent of source and toolchain', () => { - const sourceHash = 'same-source'; - const toolchain = { - xcode: 'Xcode 16.4\nBuild version 16F6', - macosProductVersion: '15.6', - macosBuild: '24G90', +test('the runtime clang build never uses -Werror', () => { + const argv = buildSnapshotBridgeCompileArgv({ architecture: 'arm64', - simulatorRuntime: 'iOS 26.2', - } as const; - const argv = ['clang', '-Wall']; - const changedArgv = ['clang', '-Wall', '-DSomethingNew']; - - const key = snapshotBridgeCacheKey({ sourceHash, toolchain, compileArgv: argv }); - const sameKey = snapshotBridgeCacheKey({ sourceHash, toolchain, compileArgv: argv }); - const keyAfterArgvChange = snapshotBridgeCacheKey({ - sourceHash, - toolchain, - compileArgv: changedArgv, + sourceRoot: '', + outputPath: '', }); - - assert.equal(key, sameKey, 'the same argv always keys the same'); - assert.notEqual( - key, - keyAfterArgvChange, - 'an argv-only change (same source, same toolchain) cannot serve a stale binary', - ); + assert.ok(!argv.includes('-Werror')); }); test('concurrent snapshot bridge preparation publishes one cache entry', async () => { diff --git a/packages/platform-apple/src/snapshot-source/cache.ts b/packages/platform-apple/src/snapshot-source/cache.ts index c891f9afc2..1614bd987b 100644 --- a/packages/platform-apple/src/snapshot-source/cache.ts +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -1,5 +1,4 @@ import path from 'node:path'; -import type { ExecResult } from '@agent-device/host-kit/command'; import { snapshotSourceError } from './errors.ts'; import type { SnapshotSourceDeadline } from './deadline.ts'; import { @@ -9,11 +8,9 @@ import { type SnapshotSourceToolchainIdentity, } from './cache-identity.ts'; import { + ensureNativeBuildCacheEntry, execNativeBuildClang, fingerprintNativeBuildSource, - nativeBuildCacheKey, - nativeBuildManifestFieldsMatch, - ensureNativeBuildCacheEntry, } from './native-build-cache.ts'; import { SNAPSHOT_SOURCE_PROTOCOL_VERSION, SNAPSHOT_SOURCE_VERSION } from './protocol.ts'; import type { @@ -25,39 +22,6 @@ import type { const CACHE_SCHEMA_VERSION = 1 as const; const BRIDGE_FILENAME = 'snapshot-bridge'; const BRIDGE_LOCK_DESCRIPTION = 'iOS Simulator snapshot bridge cache'; -const MANIFEST_FIELDS = [ - 'schemaVersion', - 'protocolVersion', - 'sourceVersion', - 'sourceHash', - 'cacheKey', - 'toolchain', - 'compileArgv', -] as const; - -/** - * The bridge cache key, folding in the compile argv (built with placeholder `sourceRoot` and - * `outputPath` values, which vary by install and by build and would otherwise make the key - * unstable) alongside `sourceHash` and `toolchain`, so a change to a compiler flag or framework - * list — covered by neither — cannot serve a binary built from a different command line (#2796 - * follow-up). - */ -export function snapshotBridgeCacheKey( - input: Readonly<{ - sourceHash: string; - toolchain: SnapshotSourceToolchainIdentity; - compileArgv: readonly string[]; - }>, -): string { - return nativeBuildCacheKey({ - schemaVersion: CACHE_SCHEMA_VERSION, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - sourceHash: input.sourceHash, - toolchain: input.toolchain, - compileArgv: input.compileArgv, - }); -} /** * @internal Upper bound on a single snapshot-bridge clang invocation, exposed for the host bridge @@ -85,41 +49,39 @@ export async function ensureSnapshotBridgeBinary( deadline, ); const toolchain = await readSnapshotSourceToolchain(input.host, input.runtime, deadline); - const compileArgv = buildSnapshotBridgeCompileArgv({ - architecture: toolchain.architecture, - sourceRoot: '', - outputPath: '', - }); - const cacheKey = snapshotBridgeCacheKey({ sourceHash, toolchain, compileArgv }); const cacheRoot = input.cacheRoot ?? path.join(input.host.homeDirectory(), '.agent-device', 'snapshot-source'); - const manifest = { - schemaVersion: CACHE_SCHEMA_VERSION, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - sourceHash, - cacheKey, - toolchain, - compileArgv, - }; const entry = await ensureNativeBuildCacheEntry({ host: input.host, deadline, cacheRoot, - cacheKey, binaryFilename: BRIDGE_FILENAME, lockDescription: BRIDGE_LOCK_DESCRIPTION, - manifest, - manifestMatches: (candidate) => - nativeBuildManifestFieldsMatch(candidate, manifest, MANIFEST_FIELDS), + 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 compileSnapshotBridge( - input.host, + const result = await execNativeBuildClang({ + host: input.host, deadline, - toolchain.architecture, - sourceRoot, - outputPath, - ); + argv: buildSnapshotBridgeCompileArgv({ + architecture: toolchain.architecture, + 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, @@ -131,7 +93,7 @@ export async function ensureSnapshotBridgeBinary( return { path: entry.path, sourceHash, - cacheKey, + cacheKey: entry.cacheKey, protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, sourceVersion: SNAPSHOT_SOURCE_VERSION, }; @@ -171,23 +133,6 @@ export function buildSnapshotBridgeCompileArgv( ]; } -async function compileSnapshotBridge( - host: SnapshotSourceHost, - deadline: SnapshotSourceDeadline, - architecture: SnapshotSourceToolchainIdentity['architecture'], - sourceRoot: string, - outputPath: string, -): Promise { - return execNativeBuildClang({ - host, - deadline, - argv: buildSnapshotBridgeCompileArgv({ architecture, sourceRoot, outputPath }), - budgetMs: BUILD_TIMEOUT_MS, - deadlineReason: 'native-build-deadline', - label: 'bridge', - }); -} - function resolveSnapshotBridgeSourceRoot(host: SnapshotSourceHost): string { const projectRoot = host.projectRoot(); const checkoutRoot = path.join(projectRoot, 'apple', 'snapshot-bridge'); diff --git a/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts b/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts index 7794eb890f..2d558e37c9 100644 --- a/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts @@ -1,41 +1,31 @@ import assert from 'node:assert/strict'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { test } from 'vitest'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; import { createSnapshotSourceDeadline } from './deadline.ts'; import { createSnapshotSourceHost } from './host.ts'; -import { - ensureNativeBuildCacheEntry, - fingerprintNativeBuildSource, - nativeBuildCacheKey, - nativeBuildManifestFieldsMatch, -} from './native-build-cache.ts'; +import { ensureNativeBuildCacheEntry, fingerprintNativeBuildSource } from './native-build-cache.ts'; import type { SnapshotSourceHost } from './types.ts'; function testDeadline() { return createSnapshotSourceDeadline(30_000, undefined); } -test('a cache hit skips the build, and a manifest or binary mismatch rebuilds', async () => { +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 manifest = { schemaVersion: 1, sourceHash: 'abc' }; - const cacheKey = nativeBuildCacheKey(manifest); let builds = 0; - const ensure = () => + const ensure = (keyInputs: Readonly> = { sourceHash: 'abc' }) => ensureNativeBuildCacheEntry({ host, deadline: testDeadline(), cacheRoot, - cacheKey, binaryFilename: 'built', lockDescription: 'test native build cache', - manifest, - manifestMatches: (candidate) => - nativeBuildManifestFieldsMatch(candidate, manifest, ['schemaVersion', 'sourceHash']), + keyInputs, build: async (outputPath) => { builds += 1; await writeFile(outputPath, `binary-${builds}`); @@ -53,47 +43,21 @@ test('a cache hit skips the build, and a manifest or binary mismatch rebuilds', const afterTamper = await ensure(); assert.equal(builds, 2, 'a binary hash mismatch rebuilds instead of serving a corrupt entry'); assert.equal(await readFile(afterTamper.path, 'utf8'), 'binary-2'); -}); -test('manifest field matching compares by JSON value, not by reference or type coercion', () => { - assert.equal( - nativeBuildManifestFieldsMatch({ a: 1 }, { a: 1 }, ['a']), - true, - 'equal primitives on the same field match', - ); - assert.equal( - nativeBuildManifestFieldsMatch({ a: '1' }, { a: 1 }, ['a']), - false, - 'a string does not coerce to match a number', - ); - assert.equal( - nativeBuildManifestFieldsMatch({ a: { nested: 1 } }, { a: { nested: 1 } }, ['a']), - true, - 'structurally equal objects on the same field match', - ); - assert.equal( - nativeBuildManifestFieldsMatch({}, { a: undefined }, ['a']), - true, - 'a missing field matches an explicit undefined, since JSON.stringify drops both', - ); - assert.equal( - nativeBuildManifestFieldsMatch({ a: 1, b: 'x' }, { a: 1, b: 'y' }, ['a']), - true, - 'only the named fields are compared', - ); - assert.equal( - nativeBuildManifestFieldsMatch({ a: 1, b: 'x' }, { a: 1, b: 'y' }, ['a', 'b']), - false, - 'adding a field to the comparison set can turn a match into a mismatch', - ); + const changedInputs = await ensure({ sourceHash: 'abc', compileArgv: ['-DNew'] }); + assert.notEqual(changedInputs.cacheKey, first.cacheKey); + assert.equal(builds, 3, 'any key-input change, such as the compile argv, rebuilds'); + const manifest = JSON.parse( + await readFile(path.join(path.dirname(changedInputs.path), 'manifest.json'), 'utf8'), + ) as Record; + assert.deepEqual(manifest.compileArgv, ['-DNew'], 'the manifest records the key inputs'); + assert.equal(manifest.cacheKey, changedInputs.cacheKey); }); 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 manifest = { schemaVersion: 1, sourceHash: 'def' }; - const cacheKey = nativeBuildCacheKey(manifest); let attempts = 0; const ensure = () => @@ -101,12 +65,9 @@ test('a failed build leaves no cache entry, and a later call can retry', async ( host, deadline: testDeadline(), cacheRoot, - cacheKey, binaryFilename: 'built', lockDescription: 'test native build cache', - manifest, - manifestMatches: (candidate) => - nativeBuildManifestFieldsMatch(candidate, manifest, ['schemaVersion', 'sourceHash']), + keyInputs: { sourceHash: 'def' }, build: async (outputPath) => { attempts += 1; if (attempts === 1) throw new Error('build failed'); @@ -115,7 +76,11 @@ test('a failed build leaves no cache entry, and a later call can retry', async ( }); await assert.rejects(ensure(), /build failed/); - assert.equal(host.exists(path.join(cacheRoot, cacheKey)), false); + assert.deepEqual( + (await readdir(cacheRoot)).filter((name) => !name.endsWith('.lock')), + [], + 'neither an entry nor a temp directory survives the failed build', + ); const recovered = await ensure(); assert.equal(attempts, 2); diff --git a/packages/platform-apple/src/snapshot-source/native-build-cache.ts b/packages/platform-apple/src/snapshot-source/native-build-cache.ts index 33b502021c..33897dbf63 100644 --- a/packages/platform-apple/src/snapshot-source/native-build-cache.ts +++ b/packages/platform-apple/src/snapshot-source/native-build-cache.ts @@ -8,33 +8,34 @@ import type { SnapshotSourceHost } from './types.ts'; const MANIFEST_FILENAME = 'manifest.json'; -export type NativeBuildCacheEntry = Readonly<{ path: string }>; +export type NativeBuildCacheEntry = Readonly<{ path: string; cacheKey: string }>; /** - * One locked, content+toolchain-keyed cache entry: a candidate hit is verified against its own - * manifest 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). + * One locked cache entry keyed on everything its build depends on: a candidate hit is verified + * 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). */ export async function ensureNativeBuildCacheEntry( input: Readonly<{ host: SnapshotSourceHost; deadline: SnapshotSourceDeadline; cacheRoot: string; - cacheKey: string; binaryFilename: string; /** Names the contended resource in a lock-stall diagnostic; every caller states its own. */ lockDescription: string; - /** Written alongside `cacheKey` and the built binary's sha256 once a build publishes. */ - manifest: Readonly>; - /** Whether a candidate manifest still describes `manifest`; the binary hash is checked separately. */ - manifestMatches: (candidate: Readonly>) => boolean; + /** Everything the built binary depends on: hashed into the cache key and recorded in the manifest. */ + keyInputs: Readonly>; /** Builds `outputPath` and throws its own typed error on failure. */ build: (outputPath: string) => Promise; }>, ): Promise { - const { host, deadline, cacheRoot, cacheKey, binaryFilename } = input; + const { host, deadline, cacheRoot, binaryFilename } = input; + const cacheKey = createHash('sha256') + .update(JSON.stringify(input.keyInputs)) + .digest('hex') + .slice(0, 32); const entryPath = path.join(cacheRoot, cacheKey); return await withProcessLock({ acquire: () => @@ -43,14 +44,8 @@ export async function ensureNativeBuildCacheEntry( description: input.lockDescription, }), task: async () => { - const cached = await readValidCacheEntry( - host, - entryPath, - binaryFilename, - input.manifestMatches, - deadline, - ); - if (cached) return { path: cached }; + const cached = await readValidCacheEntry(host, entryPath, binaryFilename, cacheKey, deadline); + if (cached) return { path: cached, cacheKey }; remainingSnapshotSourceMs(deadline, 'native-build-deadline'); if (host.exists(entryPath)) await host.remove(entryPath); remainingSnapshotSourceMs(deadline, 'native-build-deadline'); @@ -66,14 +61,14 @@ export async function ensureNativeBuildCacheEntry( remainingSnapshotSourceMs(deadline, 'native-build-deadline'); await host.chmod(outputPath, 0o755); const binarySha256 = await sha256File(host, outputPath); - const manifest = { ...input.manifest, cacheKey, binarySha256 }; + const manifest = { ...input.keyInputs, cacheKey, binarySha256 }; await host.writeText( path.join(temporaryPath, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`, ); remainingSnapshotSourceMs(deadline, 'native-build-deadline'); await host.rename(temporaryPath, entryPath); - return { path: path.join(entryPath, binaryFilename) }; + return { path: path.join(entryPath, binaryFilename), cacheKey }; } catch (error) { await host.remove(temporaryPath); throw error; @@ -82,22 +77,11 @@ export async function ensureNativeBuildCacheEntry( }); } -/** Every field named here matches `expected`'s value exactly, compared as JSON. */ -export function nativeBuildManifestFieldsMatch( - candidate: Readonly>, - expected: Readonly>, - fields: readonly string[], -): boolean { - return fields.every( - (field) => JSON.stringify(candidate[field]) === JSON.stringify(expected[field]), - ); -} - async function readValidCacheEntry( host: SnapshotSourceHost, entryPath: string, binaryFilename: string, - manifestMatches: (candidate: Readonly>) => boolean, + cacheKey: string, deadline: SnapshotSourceDeadline, ): Promise { const binaryPath = path.join(entryPath, binaryFilename); @@ -105,7 +89,9 @@ async function readValidCacheEntry( if (!host.exists(binaryPath) || !host.exists(manifestPath)) return undefined; try { const manifest = JSON.parse(await host.readText(manifestPath)) as Record; - if (!describesReusableEntry(manifest, manifestMatches)) return undefined; + if (manifest.cacheKey !== cacheKey || typeof manifest.binarySha256 !== 'string') { + return undefined; + } remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline'); const matchesBinary = (await sha256File(host, binaryPath)) === manifest.binarySha256; return matchesBinary ? binaryPath : undefined; @@ -115,14 +101,6 @@ async function readValidCacheEntry( } } -/** A parsed manifest is reusable when it carries a binary hash and still describes `manifestMatches`. */ -function describesReusableEntry( - manifest: Readonly>, - manifestMatches: (candidate: Readonly>) => boolean, -): boolean { - return typeof manifest.binarySha256 === 'string' && manifestMatches(manifest); -} - /** Distinguishes a real cache-read failure (corrupt entry, stale manifest) from a caller cancellation or deadline. */ function isCacheReadCancellationOrTimeout(error: unknown): boolean { return ( @@ -137,11 +115,6 @@ async function sha256File(host: SnapshotSourceHost, filePath: string): Promise, ): Promise { const timeoutMs = Math.min( input.budgetMs, - remainingSnapshotSourceMs(input.deadline, input.deadlineReason), + remainingSnapshotSourceMs(input.deadline, 'native-build-deadline'), ); try { return await input.host.run('xcrun', [...input.argv], { diff --git a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts index 5452fc23a4..32e88106a9 100644 --- a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts +++ b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts @@ -112,14 +112,7 @@ describe.skipIf(process.platform !== 'darwin')('bridge warning gate', () => { sourceRoot: nativeRoot, outputPath: binary, }); - const wextraIndex = argv.indexOf('-Wextra'); - assert.ok(wextraIndex >= 0, 'the production argv carries -Wextra'); - const werrorArgv = [ - ...argv.slice(0, wextraIndex + 1), - '-Werror', - ...argv.slice(wextraIndex + 1), - ]; - compiled = await runCmd('xcrun', werrorArgv, { + compiled = await runCmd('xcrun', [...argv, '-Werror'], { allowFailure: true, timeoutMs: BUILD_TIMEOUT_MS, });