Skip to content

Commit f71d2e5

Browse files
committed
test: a failed build outranks an unverifiable lock release at both artifact caches
One test each for the Swift recorder cache and the snapshot bridge cache: the compile fails while the lock's record is made unreadable under it, and the caller hears the build failure with the lock left standing. Reverting the precedence in `withProcessLock` turns exactly these two red.
1 parent 6aa2f85 commit f71d2e5

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

packages/capture-kit/src/recording/swift-cache.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,32 @@ test('compileSwiftSourceText falls back to swift-helper when the cache name sani
162162
expect(fs.statSync(executablePath).mode & 0o111).not.toBe(0);
163163
});
164164

165+
test('a compile that failed is reported over a cache lock that could not be given back', async () => {
166+
const sourcePath = writeSourceFile();
167+
const buildFailure = new Error('swiftc: error: build failed');
168+
let lockDir = '';
169+
mockRunCmd.mockImplementationOnce(async (_cmd: string, args: string[]) => {
170+
// The temp executable sits one directory under the cache entry, and the lock beside it.
171+
const outputPath = args[args.indexOf('-o') + 1]!;
172+
const executablePath = path.join(
173+
path.dirname(path.dirname(outputPath)),
174+
path.basename(outputPath),
175+
);
176+
lockDir = `${executablePath}.lock`;
177+
// A record that cannot be read is a release that cannot prove ownership.
178+
const ownerFile = path.join(lockDir, 'owner.json');
179+
fs.rmSync(ownerFile);
180+
fs.mkdirSync(ownerFile);
181+
throw buildFailure;
182+
});
183+
184+
await expect(compileSwiftSourceFile({ sourcePath, cacheName: 'recording-overlay' })).rejects.toBe(
185+
buildFailure,
186+
);
187+
// The release really could not verify itself: the lock is still standing.
188+
expect(fs.existsSync(lockDir)).toBe(true);
189+
});
190+
165191
function writeSourceFile(source = 'print("recording")'): string {
166192
const sourcePath = path.join(tmpDir, 'recording-overlay.swift');
167193
fs.writeFileSync(sourcePath, source);

packages/platform-apple/src/snapshot-source/cache.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import assert from 'node:assert/strict';
2-
import { readFile, rm, writeFile } from 'node:fs/promises';
2+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
33

44
import path from 'node:path';
55
import { test } from 'vitest';
66
import { createSnapshotSourceHost } from './host.ts';
77
import { ensureSnapshotBridgeBinary } from './cache.ts';
8+
import { SnapshotSourceError } from './errors.ts';
89
import { createSnapshotSourceDeadline } from './deadline.ts';
910
import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts';
1011
import type { SnapshotSourceHost } from './types.ts';
@@ -212,6 +213,58 @@ test('an aborted cache waiter does not cancel an independent preparation', async
212213
}
213214
});
214215

216+
test('a bridge build that failed is reported over a cache lock that could not be given back', async () => {
217+
const root = await mkdtempForTest('agent-device-snapshot-source-build-failure-');
218+
const sourceRoot = path.join(root, 'source');
219+
const cacheRoot = path.join(root, 'cache');
220+
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
221+
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
222+
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
223+
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
224+
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header');
225+
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header');
226+
const buildHost = createFakeBuildHost('unused');
227+
const host: SnapshotSourceHost = {
228+
...buildHost,
229+
run: async (command, args, options) => {
230+
if (command !== 'xcrun' || !args.includes('clang')) {
231+
return await buildHost.run(command, args, options);
232+
}
233+
// The lock beside the cache entry loses its record while the build runs, so the release
234+
// that follows cannot prove ownership; the build itself fails.
235+
const lockDir = (await readdir(cacheRoot)).find((entry) => entry.endsWith('.lock'));
236+
assert.ok(lockDir, 'the build runs under the cache lock');
237+
const ownerFile = path.join(cacheRoot, lockDir, 'owner.json');
238+
await rm(ownerFile);
239+
await mkdir(ownerFile);
240+
return { stdout: '', stderr: 'clang: error: build failed', exitCode: 1 };
241+
},
242+
};
243+
244+
try {
245+
await assert.rejects(
246+
ensureSnapshotBridgeBinary({
247+
host,
248+
runtime: 'iOS 26.2',
249+
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
250+
deadline: testDeadline(),
251+
sourceRoot,
252+
cacheRoot,
253+
}),
254+
(error: unknown) => {
255+
assert.ok(error instanceof SnapshotSourceError);
256+
assert.equal(error.failureKind, 'unsupported');
257+
assert.equal(error.failureCode, 'native-build-failed');
258+
return true;
259+
},
260+
);
261+
// The release really could not verify itself: the lock is still standing.
262+
assert.ok((await readdir(cacheRoot)).some((entry) => entry.endsWith('.lock')));
263+
} finally {
264+
await rm(root, { recursive: true, force: true });
265+
}
266+
});
267+
215268
async function expectRejectedCancellation(value: Promise<unknown>): Promise<void> {
216269
await assert.rejects(value, (error: unknown) => {
217270
return (

0 commit comments

Comments
 (0)