|
1 | 1 | 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'; |
3 | 3 |
|
4 | 4 | import path from 'node:path'; |
5 | 5 | import { test } from 'vitest'; |
6 | 6 | import { createSnapshotSourceHost } from './host.ts'; |
7 | 7 | import { ensureSnapshotBridgeBinary } from './cache.ts'; |
| 8 | +import { SnapshotSourceError } from './errors.ts'; |
8 | 9 | import { createSnapshotSourceDeadline } from './deadline.ts'; |
9 | 10 | import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts'; |
10 | 11 | import type { SnapshotSourceHost } from './types.ts'; |
@@ -212,6 +213,58 @@ test('an aborted cache waiter does not cancel an independent preparation', async |
212 | 213 | } |
213 | 214 | }); |
214 | 215 |
|
| 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 | + |
215 | 268 | async function expectRejectedCancellation(value: Promise<unknown>): Promise<void> { |
216 | 269 | await assert.rejects(value, (error: unknown) => { |
217 | 270 | return ( |
|
0 commit comments