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 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..f582319d60 --- /dev/null +++ b/packages/platform-apple/src/foldable/fold-helper-cache.test.ts @@ -0,0 +1,182 @@ +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, + 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 runtime clang build never uses -Werror', () => { + assert.ok(!buildFoldHelperCompileArgv({ sourceRoot: '', outputPath: '' }).includes('-Werror')); +}); + +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); + }, + }; + + 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 () => { + 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', + ); + 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; + }, + ); + } 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 }); + compiled = await runCmd('xcrun', [...argv, '-Werror'], { + 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..c1fb958f25 --- /dev/null +++ b/packages/platform-apple/src/foldable/fold-helper-cache.ts @@ -0,0 +1,156 @@ +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 } 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, +} 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 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; + +/** 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). + * + * 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<{ + 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 cacheRoot = + input.cacheRoot ?? path.join(host.homeDirectory(), '.agent-device', 'fold-helper'); + return await ensureNativeBuildCacheEntry({ + host, + deadline, + lockDescription: FOLD_HELPER_LOCK_DESCRIPTION, + cacheRoot, + binaryFilename: FOLD_HELPER_BINARY_FILENAME, + 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) { + 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, + label: 'fold helper', + }); + if (result.exitCode !== 0 || !host.exists(outputPath)) { + throw foldHelperBuildFailed(execFailureDetails(result)); + } +} + +/** + * 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) || 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', + { 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 3fc7c397c6..9cccc35e8f 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.test.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.test.ts @@ -1,51 +1,51 @@ -import { expect, test } from 'vitest'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; +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'; +vi.mock('./fold-helper-cache.ts', () => ({ + ensureFoldHelperBinary: vi.fn(async () => ({ path: '/cache/fold-helper' })), +})); + 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', - async (failure) => { - const calls: string[][] = []; +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(); - let binary = ''; + if (outcome === 'cancel') { + vi.mocked(ensureFoldHelperBinary).mockImplementationOnce(async () => { + controller.abort(new Error('cancelled')); + return { path: '/cache/fold-helper' }; + }); + } + const dispatches: string[][] = []; 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 }; + dispatches.push([...args]); + return { stdout: '', stderr: 'spawn detail', exitCode: outcome === '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'); + 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: failure === 'build' ? 'fold-helper-build-failed' : 'fold-hid-dispatch-failed', - }, + details: { reason: 'fold-hid-dispatch-failed', deviceId: 'selected-duo' }, }); }, ); - expect(calls).toHaveLength(failure === 'build' || failure === 'cancel' ? 1 : 2); - expect(existsSync(path.dirname(binary))).toBe(false); + expect(dispatches).toEqual( + outcome === 'cancel' + ? [] + : [['simctl', 'spawn', 'selected-duo', '/cache/fold-helper', 'half-open']], + ); }, ); @@ -58,12 +58,10 @@ test('streams all keyframes in one process with a duration-derived timeout', asy await withAppleToolProvider( createLocalAppleToolProvider({ runCommand: async (_command, args, options) => { - if (args[0] === 'simctl') { - dispatches++; - expect(JSON.parse(args.at(-1)!)).toEqual(keyframes); - expect(options?.timeoutMs).toBe(70000); - expect(options?.kill).toEqual({ signal: 'SIGTERM', graceMs: 1000 }); - } + 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 }; }, }), @@ -74,18 +72,16 @@ test('streams all keyframes in one process with a duration-derived timeout', asy test('HID dispatch addresses the UDID inside its scoped simulator set', async () => { const dispatches: string[][] = []; - let binary = ''; await withAppleToolProvider( createLocalAppleToolProvider({ runCommand: async (_command, args) => { - if (args.includes('clang')) binary = args.at(-1)!; - else dispatches.push(args); + dispatches.push([...args]); return { stdout: '', stderr: '', exitCode: 0 }; }, }), () => sendSimulatorFoldPose({ ...selectedDuo, simulatorSetPath: '/tmp/scoped-set' }, 'closed'), ); expect(dispatches).toEqual([ - ['simctl', '--set', '/tmp/scoped-set', 'spawn', 'selected-duo', binary, 'closed'], + ['simctl', '--set', '/tmp/scoped-set', 'spawn', 'selected-duo', '/cache/fold-helper', 'closed'], ]); }); diff --git a/packages/platform-apple/src/foldable/simulator-hid.ts b/packages/platform-apple/src/foldable/simulator-hid.ts index b59c90cb92..f16cf59811 100644 --- a/packages/platform-apple/src/foldable/simulator-hid.ts +++ b/packages/platform-apple/src/foldable/simulator-hid.ts @@ -1,71 +1,33 @@ -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, ): 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 }); + 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 5bf7e42a1e..8c5d4c5e81 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'; @@ -13,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', @@ -34,50 +36,38 @@ 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( +/** + * 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..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 } 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,6 +125,15 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt } }); +test('the runtime clang build never uses -Werror', () => { + const argv = buildSnapshotBridgeCompileArgv({ + architecture: 'arm64', + sourceRoot: '', + outputPath: '', + }); + assert.ok(!argv.includes('-Werror')); +}); + 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 4d5f4bf088..1614bd987b 100644 --- a/packages/platform-apple/src/snapshot-source/cache.ts +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -1,16 +1,17 @@ -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 { 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 { + ensureNativeBuildCacheEntry, + execNativeBuildClang, + fingerprintNativeBuildSource, +} from './native-build-cache.ts'; import { SNAPSHOT_SOURCE_PROTOCOL_VERSION, SNAPSHOT_SOURCE_VERSION } from './protocol.ts'; import type { SnapshotSourceBridgeBinary, @@ -18,19 +19,9 @@ 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 BRIDGE_LOCK_DESCRIPTION = 'iOS Simulator snapshot bridge cache'; /** * @internal Upper bound on a single snapshot-bridge clang invocation, exposed for the host bridge @@ -51,146 +42,95 @@ 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({ - schemaVersion: CACHE_SCHEMA_VERSION, - protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, - sourceVersion: SNAPSHOT_SOURCE_VERSION, - sourceHash, - toolchain, - }); 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( - input.host, - entryPath, - { - sourceHash, - cacheKey, - toolchain, - }, + const entry = await ensureNativeBuildCacheEntry({ + host: input.host, + deadline, + cacheRoot, + binaryFilename: BRIDGE_FILENAME, + lockDescription: BRIDGE_LOCK_DESCRIPTION, + keyInputs: { + schemaVersion: CACHE_SCHEMA_VERSION, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + sourceHash, + toolchain, + // Placeholder paths keep the key independent of the install location and build directory. + compileArgv: buildSnapshotBridgeCompileArgv({ + architecture: toolchain.architecture, + sourceRoot: '', + outputPath: '', + }), + }, + build: async (outputPath) => { + const result = await execNativeBuildClang({ + host: input.host, deadline, - ); - 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, + argv: buildSnapshotBridgeCompileArgv({ + architecture: 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; + }), + budgetMs: BUILD_TIMEOUT_MS, + label: 'bridge', + }); + if (result.exitCode !== 0 || !input.host.exists(outputPath)) { + throw snapshotSourceError('unsupported', 'native-build-failed', { + exitCode: result.exitCode, + stderr: result.stderr.slice(0, 4096), + }); } }, }); + return { + path: entry.path, + sourceHash, + cacheKey: entry.cacheKey, + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + }; } /** - * 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). */ -async function compileSnapshotBridge( - host: SnapshotSourceHost, - deadline: SnapshotSourceDeadline, - architecture: SnapshotSourceToolchainIdentity['architecture'], - 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, - ); - } +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, + ]; } function resolveSnapshotBridgeSourceRoot(host: SnapshotSourceHost): string { @@ -213,67 +153,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/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 new file mode 100644 index 0000000000..2d558e37c9 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/native-build-cache.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +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 } 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 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(); + let builds = 0; + + const ensure = (keyInputs: Readonly> = { sourceHash: 'abc' }) => + ensureNativeBuildCacheEntry({ + host, + deadline: testDeadline(), + cacheRoot, + binaryFilename: 'built', + lockDescription: 'test native build cache', + keyInputs, + 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'); + + 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(); + let attempts = 0; + + const ensure = () => + ensureNativeBuildCacheEntry({ + host, + deadline: testDeadline(), + cacheRoot, + binaryFilename: 'built', + lockDescription: 'test native build cache', + keyInputs: { sourceHash: 'def' }, + 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.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); + 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..33897dbf63 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/native-build-cache.ts @@ -0,0 +1,184 @@ +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; cacheKey: string }>; + +/** + * 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; + binaryFilename: string; + /** Names the contended resource in a lock-stall diagnostic; every caller states its own. */ + lockDescription: string; + /** 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, 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: () => + host.acquireLock(path.join(cacheRoot, `${cacheKey}.lock`), { + deadline, + description: input.lockDescription, + }), + task: async () => { + 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'); + 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.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), cacheKey }; + } catch (error) { + await host.remove(temporaryPath); + throw error; + } + }, + }); +} + +async function readValidCacheEntry( + host: SnapshotSourceHost, + entryPath: string, + binaryFilename: string, + cacheKey: string, + 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 (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; + } catch (error) { + if (isCacheReadCancellationOrTimeout(error)) throw error; + return undefined; + } +} + +/** 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'); +} + +/** + * 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; + /** 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, 'native-build-deadline'), + ); + 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, + ); + } +} 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..32e88106a9 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,35 @@ 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, + }); + compiled = await runCmd('xcrun', [...argv, '-Werror'], { + 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.