Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion packages/platform-apple/src/foldable/fold-helper-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { beforeAll, describe, test } from 'vitest';
import { runCmd } from '@agent-device/host-kit/command';
import { isRequestCanceledError } from '@agent-device/kernel/errors';
import { mkdtempForTest } from '../__tests__/tmp-dir.ts';
import { execKillTimeoutError } from '../snapshot-source/__tests__/exec-timeout-fixture.ts';
import { execKillTimeoutError } from '../native-build/__tests__/exec-timeout-fixture.ts';
import { createSnapshotSourceHost } from '../snapshot-source/host.ts';
import type { SnapshotSourceHost } from '../snapshot-source/types.ts';
import {
Expand Down Expand Up @@ -156,6 +157,64 @@ test('a compile exec killed at its budget reports the fold-helper build, not the
}
});

test('an abort while a second caller waits on the fold-helper lock reports a canceled request', async () => {
const root = await mkdtempForTest('agent-device-fold-helper-cache-lock-wait-');
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(path.join(sourceRoot, 'Fold.m'), 'fold source');

let releaseClang!: () => void;
const clangGate = new Promise<void>((resolve) => {
releaseClang = resolve;
});
let clangStarted!: () => void;
const clangStartedSignal = new Promise<void>((resolve) => {
clangStarted = resolve;
});
const host = fakeFoldHelperHost(() => 'binary');
const holdingHost: SnapshotSourceHost = {
...host,
run: async (command, args, options) => {
if (command === 'xcrun' && args.includes('clang')) {
clangStarted();
await clangGate;
return await host.run(command, args, options);
}
return await host.run(command, args, options);
},
};

try {
// The first call acquires the fold-helper lock and holds it in its build step (gated on
// `clangGate`) until this test releases it, so the second call below is guaranteed to find
// the lock already held rather than racing for it.
const holder = ensureFoldHelperBinary({ host: holdingHost, sourceRoot, cacheRoot });
await clangStartedSignal;

const controller = new AbortController();
const waiter = ensureFoldHelperBinary({
host,
sourceRoot,
cacheRoot,
signal: controller.signal,
});
// Give the waiter time to reach the lock's poll loop before aborting it.
await new Promise((resolve) => setTimeout(resolve, 50));

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The 50 ms sleep is the only mechanism aiming the abort at the lock-wait branch, and the test still passes when it misses: if the abort lands before the waiter reaches acquireLock, remainingNativeBuildMs or the aborted-signal checks in createNativeBuildDeadline/acquireNativeBuildLock reject with NativeBuildError('cancelled', 'abort-signal'), so isRequestCanceledError holds without exercising the lock-wait path this test is named for. That silently weakens the regression coverage the fix (lock-wait abort keeping reason: 'request_canceled') needs. Signal from the waiter's lock acquisition instead of sleeping: wrap host.acquireLock in the waiter's host so it resolves a lockWaitStarted promise, then await lockWaitStarted before controller.abort(). The outcome is not flaky either way: every pre-lock abort path also yields a cancelled error, so the assertion itself is deterministic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/foldable/fold-helper-cache.test.ts, line 203:

<comment>The 50 ms sleep is the only mechanism aiming the abort at the lock-wait branch, and the test still passes when it misses: if the abort lands before the waiter reaches `acquireLock`, `remainingNativeBuildMs` or the aborted-signal checks in `createNativeBuildDeadline`/`acquireNativeBuildLock` reject with `NativeBuildError('cancelled', 'abort-signal')`, so `isRequestCanceledError` holds without exercising the lock-wait path this test is named for. That silently weakens the regression coverage the fix (lock-wait abort keeping `reason: 'request_canceled'`) needs. Signal from the waiter's lock acquisition instead of sleeping: wrap `host.acquireLock` in the waiter's host so it resolves a `lockWaitStarted` promise, then `await lockWaitStarted` before `controller.abort()`. The outcome is not flaky either way: every pre-lock abort path also yields a cancelled error, so the assertion itself is deterministic.</comment>

<file context>
@@ -156,6 +157,64 @@ test('a compile exec killed at its budget reports the fold-helper build, not the
+      signal: controller.signal,
+    });
+    // Give the waiter time to reach the lock's poll loop before aborting it.
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    controller.abort();
+
</file context>
Fix with cubic

controller.abort();

await assert.rejects(waiter, (error: unknown) => {
assert.ok(isRequestCanceledError(error), 'expected a canceled-request error');
return true;
});

releaseClang();

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: releaseClang() and await holder only run when the cancellation assertion passes. If the waiter rejects with a non-cancelled error, assert.rejects throws, the gate is never released, and the holder build stays pending forever while the finally already removes the cache root. Release the gate in a finally so the gated build always settles on every exit path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/foldable/fold-helper-cache.test.ts, line 211:

<comment>`releaseClang()` and `await holder` only run when the cancellation assertion passes. If the waiter rejects with a non-cancelled error, `assert.rejects` throws, the gate is never released, and the `holder` build stays pending forever while the `finally` already removes the cache root. Release the gate in a `finally` so the gated build always settles on every exit path.</comment>

<file context>
@@ -156,6 +157,64 @@ test('a compile exec killed at its budget reports the fold-helper build, not the
+      return true;
+    });
+
+    releaseClang();
+    await holder;
+  } finally {
</file context>
Fix with cubic

await holder;
} finally {
await rm(root, { recursive: true, force: true });
}
});

// #2796: the production compile drops -Werror so a stale toolchain warning cannot fail a build;
// this is the gate that keeps a new Fold.m warning from passing CI unnoticed. It runs the
// production argv (`buildFoldHelperCompileArgv`) against the real iphonesimulator SDK with
Expand Down
56 changes: 26 additions & 30 deletions packages/platform-apple/src/foldable/fold-helper-cache.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { execFailureDetails } from '@agent-device/host-kit/command';
import { hostHomeDirectory } from '@agent-device/host-kit/host-file';
import { findProjectRoot } from '@agent-device/host-kit/version';
import { runAppleToolCommand } from '../core/tool-provider.ts';
import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts';
import { readHostToolchainIdentity } from '../snapshot-source/cache-identity.ts';
import {
createSnapshotSourceDeadline,
type SnapshotSourceDeadline,
} from '../snapshot-source/deadline.ts';
import { SnapshotSourceError } from '../snapshot-source/errors.ts';
import { createSnapshotSourceHost } from '../snapshot-source/host.ts';
import { createNativeBuildDeadline, type NativeBuildDeadline } from '../native-build/deadline.ts';
import { NativeBuildError } from '../native-build/errors.ts';
import { createNativeBuildHost, type NativeBuildHost } from '../native-build/host.ts';
import { readHostToolchainIdentity } from '../native-build/toolchain-identity.ts';
import {
ensureNativeBuildCacheEntry,
execNativeBuildClang,
fingerprintNativeBuildSource,
} from '../snapshot-source/native-build-cache.ts';
import type { SnapshotSourceHost } from '../snapshot-source/types.ts';
} from '../native-build/cache.ts';

const FOLD_HELPER_SOURCE_FILENAME = 'Fold.m';
const FOLD_HELPER_BINARY_FILENAME = 'fold-helper';
Expand All @@ -33,9 +31,9 @@ const FOLD_HELPER_PREPARATION_DEADLINE_MS =

/**
* The fold helper binary for the host's active toolchain, building and caching it if needed. Shares
* the snapshot bridge's content+toolchain-keyed build cache (`native-build-cache.ts`), so a fold
* the snapshot bridge's content+toolchain-keyed build cache (`native-build/cache.ts`), so a fold
* call after the first serves a cached binary instead of recompiling `Fold.m`, and a `DEVELOPER_DIR`
* switch busts the cache instead of serving a binary built against a different SDK (#2796).
* switch busts the cache instead of serving a binary built against a different SDK (#2796, #2970).
*
* Build and cache failures surface as `AppError('COMMAND_FAILED', ..., {reason:
* 'fold-helper-build-failed'})`, the error shape `sendSimulatorFoldPose` reported before this cache
Expand All @@ -44,15 +42,15 @@ const FOLD_HELPER_PREPARATION_DEADLINE_MS =
export async function ensureFoldHelperBinary(
input: Readonly<{
signal?: AbortSignal;
host?: SnapshotSourceHost;
host?: NativeBuildHost;
cacheRoot?: string;
sourceRoot?: string;
}> = {},
): Promise<Readonly<{ path: string }>> {
const host = input.host ?? createFoldHelperCacheHost();
const deadline = createSnapshotSourceDeadline(FOLD_HELPER_PREPARATION_DEADLINE_MS, input.signal);
const deadline = createNativeBuildDeadline(FOLD_HELPER_PREPARATION_DEADLINE_MS, input.signal);
try {
const sourceRoot = input.sourceRoot ?? path.join(host.projectRoot(), 'apple', 'fold-helper');
const sourceRoot = input.sourceRoot ?? path.join(findProjectRoot(), 'apple', 'fold-helper');
const sourceHash = await fingerprintNativeBuildSource(
host,
sourceRoot,
Expand All @@ -61,7 +59,7 @@ export async function ensureFoldHelperBinary(
);
const toolchain = await readHostToolchainIdentity(host, deadline);
const cacheRoot =
input.cacheRoot ?? path.join(host.homeDirectory(), '.agent-device', 'fold-helper');
input.cacheRoot ?? path.join(hostHomeDirectory(), '.agent-device', 'fold-helper');
return await ensureNativeBuildCacheEntry({
host,
deadline,
Expand All @@ -82,14 +80,12 @@ export async function ensureFoldHelperBinary(
}
}

function createFoldHelperCacheHost(): SnapshotSourceHost {
const real = createSnapshotSourceHost();
return {
...real,
// Routed through the Apple tool-provider scope, not `run`'s default `runCmd`, so a fold test
// can fake every exec this cache makes the same way it fakes the simctl dispatch (#2796).
run: (command, args, options) => runAppleToolCommand(command, args, options),
};
function createFoldHelperCacheHost(): NativeBuildHost {
// Routed through the Apple tool-provider scope, not `run`'s default `runCmd`, so a fold test can
// fake every exec this cache makes the same way it fakes the simctl dispatch (#2796). A narrow
// build host, not the full snapshot-bridge host: compilation needs no bridge socket and no
// target-process inspection (#2970).
return createNativeBuildHost(runAppleToolCommand);
}

/**
Expand Down Expand Up @@ -119,8 +115,8 @@ export function buildFoldHelperCompileArgv(
}

async function compileFoldHelper(
host: SnapshotSourceHost,
deadline: SnapshotSourceDeadline,
host: NativeBuildHost,
deadline: NativeBuildDeadline,
sourceRoot: string,
outputPath: string,
): Promise<void> {
Expand All @@ -137,13 +133,13 @@ async function compileFoldHelper(
}

/**
* Rewraps a cache failure as the fold helper's build error, keeping its hint and typed details; a
* cancellation, and any error that is not a snapshot-source failure, passes through unchanged.
* Rewraps a native-build cache failure as the fold helper's build error, keeping its hint and typed
* details; a cancellation, and any error that is not a native-build failure (including the fold
* helper's own `foldHelperBuildFailed`, already in its public shape), passes through unchanged.
*/
function asFoldHelperCacheError(error: unknown): unknown {
if (!(error instanceof SnapshotSourceError) || error.failureKind === 'cancelled') return error;
const { bridgeFailure: _kind, bridgeFailureCode: cause, ...details } = error.details ?? {};
return foldHelperBuildFailed({ ...details, cause }, error);
if (!(error instanceof NativeBuildError) || error.buildFailureKind === 'cancelled') return error;

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Abort during fold-helper preparation now returns NativeBuildError unchanged, so callers lose reason: 'request_canceled' and isRequestCanceledError no longer recognizes the cancellation. Map native-build cancellation to the request-canceled error shape, including the deadline construction path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/foldable/fold-helper-cache.ts, line 141:

<comment>Abort during fold-helper preparation now returns `NativeBuildError` unchanged, so callers lose `reason: 'request_canceled'` and `isRequestCanceledError` no longer recognizes the cancellation. Map native-build cancellation to the request-canceled error shape, including the deadline construction path.</comment>

<file context>
@@ -137,13 +133,13 @@ async function compileFoldHelper(
-  if (!(error instanceof SnapshotSourceError) || error.failureKind === 'cancelled') return error;
-  const { bridgeFailure: _kind, bridgeFailureCode: cause, ...details } = error.details ?? {};
-  return foldHelperBuildFailed({ ...details, cause }, error);
+  if (!(error instanceof NativeBuildError) || error.buildFailureKind === 'cancelled') return error;
+  return foldHelperBuildFailed({ ...error.buildDetails, cause: error.buildFailureCode }, error);
 }
</file context>
Fix with cubic

return foldHelperBuildFailed({ ...error.buildDetails, cause: error.buildFailureCode }, error);
}

function foldHelperBuildFailed(details: Readonly<Record<string, unknown>>, cause?: unknown) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@ import assert from 'node:assert/strict';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { test } from 'vitest';
import { runCmd } from '@agent-device/host-kit/command';
import { mkdtempForTest } from '../__tests__/tmp-dir.ts';
import { createSnapshotSourceDeadline } from './deadline.ts';
import { createSnapshotSourceHost } from './host.ts';
import { ensureNativeBuildCacheEntry, fingerprintNativeBuildSource } from './native-build-cache.ts';
import type { SnapshotSourceHost } from './types.ts';
import { createNativeBuildDeadline } from './deadline.ts';
import { createNativeBuildHost, type NativeBuildHost } from './host.ts';
import { ensureNativeBuildCacheEntry, fingerprintNativeBuildSource } from './cache.ts';

function testDeadline() {
return createSnapshotSourceDeadline(30_000, undefined);
return createNativeBuildDeadline(30_000, undefined);
}

function testHost(): NativeBuildHost {
return createNativeBuildHost(
async (command, args, options) => await runCmd(command, args, options),
);
}

test('a cache hit skips the build, and a key-input or binary change rebuilds', async () => {
const root = await mkdtempForTest('agent-device-native-build-cache-');
const cacheRoot = path.join(root, 'cache');
const host = createSnapshotSourceHost();
const host = testHost();
let builds = 0;

const ensure = (keyInputs: Readonly<Record<string, unknown>> = { sourceHash: 'abc' }) =>
Expand Down Expand Up @@ -57,7 +63,7 @@ test('a cache hit skips the build, and a key-input or binary change rebuilds', a
test('a failed build leaves no cache entry, and a later call can retry', async () => {
const root = await mkdtempForTest('agent-device-native-build-cache-failure-');
const cacheRoot = path.join(root, 'cache');
const host = createSnapshotSourceHost();
const host = testHost();
let attempts = 0;

const ensure = () =>
Expand Down Expand Up @@ -89,7 +95,7 @@ test('a failed build leaves no cache entry, and a later call can retry', async (

test('fingerprintNativeBuildSource keys on filename as well as content, so a rename busts the cache', async () => {
const root = await mkdtempForTest('agent-device-native-fingerprint-');
const host: SnapshotSourceHost = createSnapshotSourceHost();
const host = testHost();
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(root);
await writeFile(path.join(root, 'A.m'), 'same content');
await writeFile(path.join(root, 'B.m'), 'same content');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { createHash } from 'node:crypto';
import path from 'node:path';
import { withProcessLock } from '@agent-device/host-kit/file';
import { isCommandTimeoutError, type ExecResult } from '@agent-device/host-kit/command';
import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts';
import { SnapshotSourceError, snapshotSourceError } from './errors.ts';
import type { SnapshotSourceHost } from './types.ts';
import { remainingNativeBuildMs, type NativeBuildDeadline } from './deadline.ts';
import { NativeBuildError, nativeBuildError } from './errors.ts';
import type { NativeBuildHost } from './host.ts';

const MANIFEST_FILENAME = 'manifest.json';

Expand All @@ -15,12 +15,12 @@ export type NativeBuildCacheEntry = Readonly<{ path: string; cacheKey: string }>
* against its manifest's key and binary hash, a miss builds into a temp directory and publishes it
* with an atomic rename, and a build that fails leaves no partial entry behind. Every runtime clang
* build in this package shares this mechanism so a stale entry, a corrupt cache, or a
* `DEVELOPER_DIR` switch is handled once (#2796).
* `DEVELOPER_DIR` switch is handled once (#2796, #2970).
*/
export async function ensureNativeBuildCacheEntry(
input: Readonly<{
host: SnapshotSourceHost;
deadline: SnapshotSourceDeadline;
host: NativeBuildHost;
deadline: NativeBuildDeadline;
cacheRoot: string;
binaryFilename: string;
/** Names the contended resource in a lock-stall diagnostic; every caller states its own. */
Expand All @@ -46,27 +46,27 @@ export async function ensureNativeBuildCacheEntry(
task: async () => {
const cached = await readValidCacheEntry(host, entryPath, binaryFilename, cacheKey, deadline);
if (cached) return { path: cached, cacheKey };
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
remainingNativeBuildMs(deadline, 'native-build-deadline');
if (host.exists(entryPath)) await host.remove(entryPath);
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
remainingNativeBuildMs(deadline, 'native-build-deadline');
await host.ensureDirectory(cacheRoot);
const temporaryPath = path.join(cacheRoot, `.${cacheKey}.${host.processId()}.tmp`);
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
remainingNativeBuildMs(deadline, 'native-build-deadline');
await host.remove(temporaryPath);
try {
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
remainingNativeBuildMs(deadline, 'native-build-deadline');
await host.ensureDirectory(temporaryPath);
const outputPath = path.join(temporaryPath, binaryFilename);
await input.build(outputPath);
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
remainingNativeBuildMs(deadline, 'native-build-deadline');
await host.chmod(outputPath, 0o755);
const binarySha256 = await sha256File(host, outputPath);
const manifest = { ...input.keyInputs, cacheKey, binarySha256 };
await host.writeText(
path.join(temporaryPath, MANIFEST_FILENAME),
`${JSON.stringify(manifest, null, 2)}\n`,
);
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
remainingNativeBuildMs(deadline, 'native-build-deadline');
await host.rename(temporaryPath, entryPath);
return { path: path.join(entryPath, binaryFilename), cacheKey };
} catch (error) {
Expand All @@ -78,11 +78,11 @@ export async function ensureNativeBuildCacheEntry(
}

async function readValidCacheEntry(
host: SnapshotSourceHost,
host: NativeBuildHost,
entryPath: string,
binaryFilename: string,
cacheKey: string,
deadline: SnapshotSourceDeadline,
deadline: NativeBuildDeadline,
): Promise<string | undefined> {
const binaryPath = path.join(entryPath, binaryFilename);
const manifestPath = path.join(entryPath, MANIFEST_FILENAME);
Expand All @@ -92,7 +92,7 @@ async function readValidCacheEntry(
if (manifest.cacheKey !== cacheKey || typeof manifest.binarySha256 !== 'string') {
return undefined;
}
remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline');
remainingNativeBuildMs(deadline, 'native-cache-hash-deadline');
const matchesBinary = (await sha256File(host, binaryPath)) === manifest.binarySha256;
return matchesBinary ? binaryPath : undefined;
} catch (error) {
Expand All @@ -104,12 +104,12 @@ async function readValidCacheEntry(
/** Distinguishes a real cache-read failure (corrupt entry, stale manifest) from a caller cancellation or deadline. */
function isCacheReadCancellationOrTimeout(error: unknown): boolean {
return (
error instanceof SnapshotSourceError &&
(error.failureKind === 'cancelled' || error.failureKind === 'timeout')
error instanceof NativeBuildError &&
(error.buildFailureKind === 'cancelled' || error.buildFailureKind === 'timeout')
);
}

async function sha256File(host: SnapshotSourceHost, filePath: string): Promise<string> {
async function sha256File(host: NativeBuildHost, filePath: string): Promise<string> {
return createHash('sha256')
.update(await host.readBinary(filePath))
.digest('hex');
Expand All @@ -121,17 +121,17 @@ async function sha256File(host: SnapshotSourceHost, filePath: string): Promise<s
* way, over its own filename list (#2796).
*/
export async function fingerprintNativeBuildSource(
host: SnapshotSourceHost,
host: NativeBuildHost,
root: string,
sourceFilenames: readonly string[],
deadline: SnapshotSourceDeadline,
deadline: NativeBuildDeadline,
): Promise<string> {
const hash = createHash('sha256');
for (const sourceFile of sourceFilenames) {
const filePath = path.join(root, sourceFile);
remainingSnapshotSourceMs(deadline, 'native-source-fingerprint-deadline');
remainingNativeBuildMs(deadline, 'native-source-fingerprint-deadline');
if (!host.exists(filePath)) {
throw snapshotSourceError('unsupported', 'native-source-missing', { filePath });
throw nativeBuildError('unsupported', 'native-source-missing', { filePath });
}
hash.update(sourceFile);
hash.update('\0');
Expand All @@ -148,8 +148,8 @@ export async function fingerprintNativeBuildSource(
*/
export async function execNativeBuildClang(
input: Readonly<{
host: SnapshotSourceHost;
deadline: SnapshotSourceDeadline;
host: NativeBuildHost;
deadline: NativeBuildDeadline;
argv: readonly string[];
budgetMs: number;
/** Names the build in the stall hint, e.g. "bridge" or "fold helper". */
Expand All @@ -158,7 +158,7 @@ export async function execNativeBuildClang(
): Promise<ExecResult> {
const timeoutMs = Math.min(
input.budgetMs,
remainingSnapshotSourceMs(input.deadline, 'native-build-deadline'),
remainingNativeBuildMs(input.deadline, 'native-build-deadline'),
);
try {
return await input.host.run('xcrun', [...input.argv], {
Expand All @@ -168,7 +168,7 @@ export async function execNativeBuildClang(
});
} catch (error) {
if (!isCommandTimeoutError(error)) throw error;
throw snapshotSourceError(
throw nativeBuildError(
'timeout',
'native-build-stalled',
{
Expand Down
Loading
Loading