diff --git a/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts b/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts index fb20b92967..4641e75583 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts @@ -32,6 +32,7 @@ test('a helper script path is resolved per host working directory, not once per const overlay = async (cwd: string) => { fs.writeFileSync(videoPath, 'original'); await overlayRecordingTouches({ + validator: undefined, host: { env: {}, cwd }, videoPath, telemetryPath, diff --git a/packages/capture-kit/src/recording/__tests__/overlay.test.ts b/packages/capture-kit/src/recording/__tests__/overlay.test.ts index 6895e2dae2..47780964d3 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -58,12 +58,18 @@ afterEach(() => { }); test('overlay burns touches through a cached helper and same-directory temp output', async () => { + const validator = { + kind: 'avfoundation' as const, + executablePath: '/prepared/video-validator', + env: {}, + }; const videoPath = path.join(tmpDir, 'recording.mp4'); const telemetryPath = path.join(tmpDir, 'recording.gesture-telemetry.json'); fs.writeFileSync(videoPath, 'original'); fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator, host: createTestHost(), videoPath, telemetryPath, @@ -71,6 +77,7 @@ test('overlay burns touches through a cached helper and same-directory temp outp }); fs.writeFileSync(videoPath, 'second original'); await overlayRecordingTouches({ + validator, host: createTestHost(), videoPath, telemetryPath, @@ -84,6 +91,7 @@ test('overlay burns touches through a cached helper and same-directory temp outp expect(compileCalls).toHaveLength(1); expect(helperCalls).toHaveLength(2); + expect(vi.mocked(waitForPlayableVideo).mock.calls[0]?.[1].validator).toBe(validator); const [helperCmd, helperArgs, helperOptions] = helperCalls[0]!; const inputPath = helperArgs[helperArgs.indexOf('--input') + 1]!; @@ -113,6 +121,7 @@ test('overlay preserves Swift helper compile hints', async () => { await expect( overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -134,6 +143,7 @@ test('overlay defaults to the fast medium export preset', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -152,6 +162,7 @@ test('overlay forwards the requested high export preset', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -171,6 +182,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -180,6 +192,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask mockRunCmd.mockClear(); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -197,6 +210,7 @@ test('overlay hands the helper what is left of its budget, inside the record req fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -225,7 +239,13 @@ test('overlay hands the helper only what the finalization left of the budget', a const deadline = startRecordingOverlayBudget(); vi.setSystemTime(Date.now() + spentMs); - await overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline }); + await overlayRecordingTouches({ + validator: undefined, + host: createTestHost(), + videoPath, + telemetryPath, + deadline, + }); const helperCall = mockRunCmd.mock.calls.find(([cmd]) => cmd !== 'xcrun'); expect(helperCall?.[2]?.timeoutMs).toBe(OVERLAY_BUDGET_MS - spentMs); @@ -248,6 +268,7 @@ test('overlay leaves the video as recorded when compiling the helper spent its b await expect( overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -269,6 +290,7 @@ test('overlay names its budget when the export ran out of it', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -282,6 +304,7 @@ test('overlay names its budget when the export ran out of it', async () => { await expect( overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -301,6 +324,7 @@ test('overlay keeps the plain failure when the export failed with budget to spar fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -312,6 +336,7 @@ test('overlay keeps the plain failure when the export failed with budget to spar await expect( overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -331,6 +356,7 @@ test('overlay keeps the video as recorded when the export never plays within its await expect( overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -356,13 +382,19 @@ test('overlay keeps the video as recorded when verifying the export spent its bu }); await expect( - overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline }), + overlayRecordingTouches({ + validator: undefined, + host: createTestHost(), + videoPath, + telemetryPath, + deadline, + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: `Failed to add touch overlays to the recording: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`, }); expect(vi.mocked(waitForPlayableVideo)).toHaveBeenCalledWith(expect.any(String), { - host: expect.objectContaining({ cwd: process.cwd() }), + validator: undefined, deadline, }); expect(fs.readFileSync(videoPath, 'utf8')).toBe('original'); diff --git a/packages/capture-kit/src/recording/overlay.ts b/packages/capture-kit/src/recording/overlay.ts index 670c43b4a4..f3687f6f12 100644 --- a/packages/capture-kit/src/recording/overlay.ts +++ b/packages/capture-kit/src/recording/overlay.ts @@ -10,7 +10,7 @@ import { compileSwiftSourceFile, resolveRecordingScriptPath, } from './swift-cache.ts'; -import { waitForPlayableVideo } from './video.ts'; +import { waitForPlayableVideo, type VideoValidator } from './video.ts'; import { DEFAULT_RECORDING_EXPORT_QUALITY, type RecordingExportQuality, @@ -29,9 +29,9 @@ export function getRecordingOverlaySupportWarning( * `record stop` is one daemon request, which the client abandons after `record`'s 90 s envelope: * the caller gets "Daemon request timed out" while the daemon finishes the stop. So the overlay must * end well inside it. The budget starts with the stop's finalization, so the playability checks - * before the overlay (on a cold host they compile their validator first) come out of it, as do - * compiling the helper, its export, its exit and verifying its output; past it the stop keeps the raw video and its - * gesture telemetry, with a warning. + * before the overlay come out of it, as do compiling the overlay helper, exporting, exiting and + * verifying its output. Past the budget, the stop keeps the raw video and gesture telemetry, with + * a warning. */ export const OVERLAY_BUDGET_MS = 70_000; @@ -79,6 +79,7 @@ async function exportProcessedVideo(params: { scriptArgs: string[]; commandDescription: string; deadline: Deadline; + validator: VideoValidator | undefined; }): Promise { const { host, videoPath, scriptPath, scriptArgs, commandDescription, deadline } = params; const outputPath = temporarySiblingVideoPath(videoPath); @@ -117,7 +118,7 @@ async function exportProcessedVideo(params: { exportOverran = deadline.remainingMs() <= HELPER_EXIT_GRACE_MS; throw error; } - if (!(await waitForPlayableVideo(outputPath, { host, deadline }))) { + if (!(await waitForPlayableVideo(outputPath, { validator: params.validator, deadline }))) { exportOverran = deadline.isExpired(); throw new AppError('COMMAND_FAILED', 'The composited video was not verified as playable'); } @@ -158,6 +159,7 @@ function temporarySiblingVideoPath(videoPath: string): string { export async function overlayRecordingTouches(params: { host: HostEnvironment; + validator: VideoValidator | undefined; videoPath: string; telemetryPath: string; exportQuality?: RecordingExportQuality; @@ -186,5 +188,6 @@ export async function overlayRecordingTouches(params: { ], commandDescription: `Failed to add touch overlays to the ${targetLabel}`, deadline: params.deadline, + validator: params.validator, }); } diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index 0503c34db5..53bb1fc84c 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -20,6 +20,7 @@ import { buildRecordingScriptPathCandidates, compileSwiftSourceFile, compileSwiftSourceText, + readCompiledSwiftSourceText, } from './swift-cache.ts'; const mockRunCmd = vi.mocked(runCmd); @@ -171,6 +172,25 @@ test('compileSwiftSourceText resolves a cache name with a long interior dash run expect(elapsedMs).toBeLessThan(1_000); }); +test('a compile timeout preserves the error and cleans unpublished output and its lock', async () => { + const timeout = new Error('compile timed out'); + mockRunCmd.mockRejectedValueOnce(timeout); + + await expect( + compileSwiftSourceText({ + host: createTestHost(), + source: 'print(1)', + cacheName: 'timed-out', + timeoutMs: 75, + }), + ).rejects.toBe(timeout); + expect(mockRunCmd.mock.calls[0]![2]).toMatchObject({ + timeoutMs: 75, + kill: { signal: 'SIGINT', graceMs: 2_000 }, + }); + expect(fs.readdirSync(path.join(tmpDir, 'swift-cache', 'bin'))).toEqual([]); +}); + test('a canceled compile kills swiftc through its signal and leaves no temp output', async () => { const cancel = new AbortController(); mockRunCmd.mockImplementationOnce( @@ -238,7 +258,7 @@ test('compileSwiftSourceText falls back to swift-helper when the cache name sani expect(fs.statSync(executablePath).mode & 0o111).not.toBe(0); }); -test('a compile that failed is reported over a cache lock that could not be given back', async () => { +test('a failed compile and an unverified lock release retain both errors', async () => { const sourcePath = writeSourceFile(); const buildFailure = new Error('swiftc: error: build failed'); let lockDir = ''; @@ -257,9 +277,17 @@ test('a compile that failed is reported over a cache lock that could not be give throw buildFailure; }); - await expect( - compileSwiftSourceFile({ host: createTestHost(), sourcePath, cacheName: 'recording-overlay' }), - ).rejects.toBe(buildFailure); + const error = await compileSwiftSourceFile({ + host: createTestHost(), + sourcePath, + cacheName: 'recording-overlay', + }).catch((error: unknown) => error); + expect(error).toMatchObject({ details: { reason: 'swift_cache_cleanup_failed' } }); + expect((error as Error).cause).toBeInstanceOf(AggregateError); + expect(((error as Error).cause as AggregateError).errors).toEqual([ + buildFailure, + expect.any(Error), + ]); // The release really could not verify itself: the lock is still standing. expect(fs.existsSync(lockDir)).toBe(true); }); @@ -365,3 +393,116 @@ test('the cache root and tool environment come from the host it is given, not th HOME: path.join(hostCacheDir, 'home'), }); }); + +test('reads only a published executable for the exact source and cache, without compiling', async () => { + const input = { host: createTestHost(), source: 'print("prepared")', cacheName: 'prepared' }; + expect(readCompiledSwiftSourceText(input)).toBeUndefined(); + expect(mockRunCmd).not.toHaveBeenCalled(); + const executable = await compileSwiftSourceText(input); + mockRunCmd.mockClear(); + expect(readCompiledSwiftSourceText(input)).toBe(executable); + expect(readCompiledSwiftSourceText({ ...input, source: 'print("changed")' })).toBeUndefined(); + expect( + readCompiledSwiftSourceText({ + ...input, + host: { + ...input.host, + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(tmpDir, 'other-cache') }, + }, + }), + ).toBeUndefined(); + fs.chmodSync(executable, 0o644); + expect(readCompiledSwiftSourceText(input)).toBeUndefined(); + expect(mockRunCmd).not.toHaveBeenCalled(); +}); + +test('a temp removal failure survives a canceled compile', async () => { + const removalFailure = new Error('removal failed'); + const cancelFailure = createRequestCanceledError(); + mockRunCmd.mockRejectedValueOnce(cancelFailure); + const original = fs.rmSync; + const spy = vi.spyOn(fs, 'rmSync').mockImplementation(((target, options) => { + if (typeof target === 'string' && path.basename(target).startsWith('.')) throw removalFailure; + return original(target, options); + }) as typeof fs.rmSync); + try { + const error = await compileSwiftSourceText({ + host: createTestHost(), + source: 'print(1)', + cacheName: 'removal', + }).catch((error: unknown) => error); + expect(error).toMatchObject({ details: { reason: 'swift_cache_cleanup_failed' } }); + expect(((error as Error).cause as AggregateError).errors).toEqual([ + cancelFailure, + expect.objectContaining({ + details: { reason: 'swift_cache_temp_removal_failed' }, + cause: removalFailure, + }), + ]); + } finally { + spy.mockRestore(); + } +}); + +test('simultaneous temp removal and lock release failures preserve all evidence', async () => { + const compileFailure = new Error('compile failed'); + const removalFailure = new Error('temp removal failed'); + mockRunCmd.mockImplementationOnce(async (_cmd, args) => { + const output = args[args.indexOf('-o') + 1]!; + const lock = path.join(path.dirname(path.dirname(output)), `${path.basename(output)}.lock`); + const owner = path.join(lock, 'owner.json'); + fs.rmSync(owner); + fs.mkdirSync(owner); + throw compileFailure; + }); + const original = fs.rmSync; + const spy = vi.spyOn(fs, 'rmSync').mockImplementation(((target, options) => { + if (typeof target === 'string' && path.basename(target).startsWith('.')) throw removalFailure; + return original(target, options); + }) as typeof fs.rmSync); + try { + const error = await compileSwiftSourceText({ + host: createTestHost(), + source: 'print(2)', + cacheName: 'both', + }).catch((error: unknown) => error); + expect(((error as Error).cause as AggregateError).errors).toEqual([ + compileFailure, + expect.objectContaining({ + details: { reason: 'swift_cache_temp_removal_failed' }, + cause: removalFailure, + }), + expect.objectContaining({ + details: { reason: 'swift_cache_lock_release_failed' }, + cause: expect.any(Error), + }), + ]); + } finally { + spy.mockRestore(); + } +}); + +test('successful compilation still reports failure to remove temporary output', async () => { + const removalFailure = new Error('temp removal failed'); + const original = fs.rmSync; + const spy = vi.spyOn(fs, 'rmSync').mockImplementation(((target, options) => { + if (typeof target === 'string' && path.basename(target).startsWith('.')) throw removalFailure; + return original(target, options); + }) as typeof fs.rmSync); + try { + const error = await compileSwiftSourceText({ + host: createTestHost(), + source: 'print(3)', + cacheName: 'successful', + }).catch((error: unknown) => error); + expect(error).toMatchObject({ details: { reason: 'swift_cache_cleanup_failed' } }); + expect(((error as Error).cause as AggregateError).errors).toEqual([ + expect.objectContaining({ + details: { reason: 'swift_cache_temp_removal_failed' }, + cause: removalFailure, + }), + ]); + } finally { + spy.mockRestore(); + } +}); diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index d2a7872b29..3359d4f803 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -8,11 +8,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readProcessStartTime } from '@agent-device/host-kit/process'; -import { - acquireProcessLock, - withProcessLock, - type ProcessLockRelease, -} from '@agent-device/host-kit/file'; +import { acquireProcessLock, type ProcessLockRelease } from '@agent-device/host-kit/file'; const SWIFT_CACHE_VERSION = '2'; const LOCK_RETRY_DELAY_MS = 25; @@ -128,20 +124,37 @@ export async function compileSwiftSourceFile(params: { return executablePath; } -export async function compileSwiftSourceText(params: { +type SwiftSourceTextInput = Readonly<{ host: HostEnvironment; source: string; cacheName: string; - timeoutMs?: number; - /** Cancels the compile: kills `swiftc`, or stops waiting for another process's compile. */ - signal?: AbortSignal; -}): Promise { +}>; + +function swiftSourceTextPaths(params: SwiftSourceTextInput) { const cacheName = sanitizeCacheName(params.cacheName); const key = hashParts([SWIFT_CACHE_VERSION, process.platform, process.arch, params.source]); const cacheRoot = getSwiftCacheRoot(params.host.env); const sourcePath = path.join(cacheRoot, 'sources', `${cacheName}-${key}.swift`); const executablePath = path.join(cacheRoot, 'bin', `${cacheName}-${key}`); + return { sourcePath, executablePath }; +} + +/** Reads an already published executable without acquiring a lock or starting a compiler. */ +export function readCompiledSwiftSourceText(params: SwiftSourceTextInput): string | undefined { + const { executablePath } = swiftSourceTextPaths(params); + return isExecutableFile(executablePath) ? executablePath : undefined; +} + +export async function compileSwiftSourceText( + params: SwiftSourceTextInput & { + timeoutMs?: number; + /** Cancels the compile: kills `swiftc`, or stops waiting for another process's compile. */ + signal?: AbortSignal; + }, +): Promise { + const { sourcePath, executablePath } = swiftSourceTextPaths(params); + await ensureSwiftExecutable({ host: params.host, sourcePaths: [sourcePath], @@ -177,9 +190,16 @@ async function ensureSwiftExecutable(params: { const executableDir = path.dirname(params.executablePath); fs.mkdirSync(executableDir, { recursive: true }); const timeoutMs = params.timeoutMs ?? 120_000; - await withProcessLock({ - acquire: () => acquireSwiftCacheLock(`${params.executablePath}.lock`, timeoutMs, params.signal), - task: async () => { + const release = await acquireSwiftCacheLock( + `${params.executablePath}.lock`, + timeoutMs, + params.signal, + ); + const cleanupFailures: unknown[] = []; + let taskFailure: unknown; + let taskFailed = false; + try { + await (async () => { // Another process may have published the executable while this one waited for the lock. if (isExecutableFile(params.executablePath)) { return; @@ -206,10 +226,45 @@ async function ensureSwiftExecutable(params: { }); fs.renameSync(tempExecutablePath, params.executablePath); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + cleanupFailures.push( + new AppError( + 'COMMAND_FAILED', + 'Swift temporary output removal failed', + { reason: 'swift_cache_temp_removal_failed' }, + error, + ), + ); + } } - }, - }); + })(); + } catch (error) { + taskFailed = true; + taskFailure = error; + } + try { + await release(); + } catch (error) { + cleanupFailures.push( + new AppError( + 'COMMAND_FAILED', + 'Swift cache lock release failed', + { reason: 'swift_cache_lock_release_failed' }, + error, + ), + ); + } + if (cleanupFailures.length) { + throw new AppError( + 'COMMAND_FAILED', + 'Swift cache cleanup failed', + { reason: 'swift_cache_cleanup_failed' }, + new AggregateError(taskFailed ? [taskFailure, ...cleanupFailures] : cleanupFailures), + ); + } + if (taskFailed) throw taskFailure; } async function acquireSwiftCacheLock( diff --git a/packages/capture-kit/src/recording/video-cache-isolation.test.ts b/packages/capture-kit/src/recording/video-cache-isolation.test.ts new file mode 100644 index 0000000000..c620b4c3f2 --- /dev/null +++ b/packages/capture-kit/src/recording/video-cache-isolation.test.ts @@ -0,0 +1,170 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { runCmd } from '@agent-device/host-kit/command'; +import { createTestHost } from '../host-environment.fixtures.ts'; +import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; +import { createVideoValidatorPreparation } from './video.ts'; + +vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ + ...(await importOriginal()), + runCmd: vi.fn(), +})); + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform); + vi.restoreAllMocks(); +}); + +async function runIsolationScenario(failBeforeClose: boolean, stallClose = false): Promise { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + const cacheDir = mkdtempForTestSync('video-cache-isolation-'); + const host = { + ...createTestHost(), + env: { ...process.env, AGENT_DEVICE_SWIFT_CACHE_DIR: cacheDir }, + }; + const first = createVideoValidatorPreparation(host); + const second = createVideoValidatorPreparation(host); + const signals: AbortSignal[] = []; + let finishSecond!: () => void; + let releaseSecond = false; + vi.mocked(runCmd).mockImplementation(async (_command, args, options) => { + const signal = options!.signal!; + signals.push(signal); + await new Promise((resolve, reject) => { + const abort = () => { + signal.removeEventListener('abort', abort); + reject(new Error('compiler cancelled')); + }; + const finish = () => { + signal.removeEventListener('abort', abort); + resolve(); + }; + if (signal.aborted) { + abort(); + } else { + signal.addEventListener('abort', abort, { once: true }); + if (signals.length > 1) { + finishSecond = finish; + if (releaseSecond) finish(); + } + } + }); + const output = args[args.indexOf('-o') + 1]!; + fs.writeFileSync(output, 'compiled'); + fs.chmodSync(output, 0o755); + return { exitCode: 0, stdout: '', stderr: '' }; + }); + const firstResult = first.prepare(); + void firstResult.catch(() => undefined); + let secondResult: ReturnType | undefined; + let earlyFailureReached = false; + try { + await expect.poll(() => signals.length, { timeout: 5_000 }).toBe(1); + const bin = path.join(cacheDir, 'bin'); + const lock = fs.readdirSync(bin).find((entry) => entry.endsWith('.lock'))!; + expect(lock).toBeTruthy(); + const read = vi.spyOn(fs, 'readFileSync'); + secondResult = second.prepare(); + void secondResult.catch(() => undefined); + await expect + .poll(() => read.mock.calls.some(([file]) => String(file).startsWith(path.join(bin, lock))), { + timeout: 5_000, + }) + .toBe(true); + expect(signals).toHaveLength(1); + if (failBeforeClose) { + earlyFailureReached = true; + throw new Error('intentional early assertion failure'); + } + await first.close(); + expect(signals[0]!.aborted).toBe(true); + await expect(firstResult).rejects.toThrow('compiler cancelled'); + await expect.poll(() => signals.length, { timeout: 5_000 }).toBe(2); + expect(signals[1]!.aborted).toBe(false); + finishSecond(); + const result = await secondResult; + expect(result.kind).toBe('avfoundation'); + expect(await second.read()).toEqual(result); + const warm = createVideoValidatorPreparation(host); + expect(await warm.prepare()).toEqual(result); + await warm.close(); + expect(signals).toHaveLength(2); + expect( + fs.readdirSync(bin).some((entry) => entry.endsWith('.lock') || entry.startsWith('.')), + ).toBe(false); + } finally { + try { + finishSecond?.(); + await first.close(); + if (earlyFailureReached) await expect.poll(() => signals.length, { timeout: 5_000 }).toBe(2); + } finally { + try { + const close = second.close(); + const closing = stallClose ? close.then(() => new Promise(() => {})) : close; + let timeout: ReturnType | undefined; + try { + await Promise.race([ + closing, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error('second owner cleanup did not settle')), + 1_000, + ); + }), + ]); + } finally { + clearTimeout(timeout); + releaseSecond = true; + finishSecond?.(); + } + } finally { + await Promise.allSettled([firstResult, ...(secondResult ? [secondResult] : [])]); + try { + const bin = path.join(cacheDir, 'bin'); + if (fs.existsSync(bin)) { + expect( + fs.readdirSync(bin).some((entry) => entry.endsWith('.lock') || entry.startsWith('.')), + ).toBe(false); + } + } finally { + fs.rmSync(cacheDir, { recursive: true, force: true }); + } + } + } + } +} + +test('closing a cache lock holder lets another pending owner compile and publish', async () => { + await runIsolationScenario(false); +}, 15_000); + +test('cleanup cancels a compiler that starts after an early failure', async () => { + await expect(runIsolationScenario(true)).rejects.toThrow('intentional early assertion failure'); +}, 15_000); + +test('cleanup reports a close that never settles and removes its cache', async () => { + const remove = vi.spyOn(fs, 'rmSync'); + const outcome = runIsolationScenario(true, true).then( + () => ({ status: 'resolved' as const }), + (error: unknown) => ({ status: 'rejected' as const, reason: error }), + ); + let timeout: ReturnType | undefined; + const result = await Promise.race([ + outcome, + new Promise<{ status: 'timed out' }>((resolve) => { + timeout = setTimeout(() => resolve({ status: 'timed out' }), 3_000); + }), + ]); + clearTimeout(timeout); + expect(result).toMatchObject({ + status: 'rejected', + reason: expect.objectContaining({ message: 'second owner cleanup did not settle' }), + }); + const cache = remove.mock.calls + .map(([file]) => String(file)) + .find((file) => path.basename(file).startsWith('video-cache-isolation-')); + expect(cache).toBeDefined(); + expect(fs.existsSync(cache!)).toBe(false); +}, 10_000); diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 0c8dd214c1..645149c6a3 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -1,15 +1,15 @@ import fs from 'node:fs'; import { createTestHost } from '../host-environment.fixtures.ts'; import path from 'node:path'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import { likelyPlayableWebmContainer } from '../__tests__/test-utils/video-fixtures.ts'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { Deadline } from '@agent-device/host-kit/retry'; -import { compileSwiftSourceText } from './swift-cache.ts'; -import { hasVideoContainer, isPlayableVideo, waitForPlayableVideo } from './video.ts'; +import { compileSwiftSourceText, readCompiledSwiftSourceText } from './swift-cache.ts'; +import { createVideoValidatorPreparation, isPlayableVideo, waitForPlayableVideo } from './video.ts'; vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -17,12 +17,23 @@ vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ })); vi.mock('./swift-cache.ts', () => ({ buildSwiftToolEnv: () => ({}), - getSwiftCacheRoot: (env: NodeJS.ProcessEnv) => env.AGENT_DEVICE_SWIFT_CACHE_DIR ?? '', + getSwiftCacheRoot: (env: NodeJS.ProcessEnv) => env.AGENT_DEVICE_SWIFT_CACHE_DIR, + readCompiledSwiftSourceText: vi.fn(() => undefined), compileSwiftSourceText: vi.fn(async () => '/bin/true'), })); +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; + beforeEach(() => { - vi.mocked(runCmd).mockClear(); + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + vi.stubEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', mkdtempForTestSync('agent-device-video-validator-')); + vi.mocked(runCmd).mockReset().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + vi.mocked(compileSwiftSourceText).mockReset().mockResolvedValue('/bin/true'); + vi.mocked(readCompiledSwiftSourceText).mockReset().mockReturnValue(undefined); +}); +afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, 'platform', originalPlatform); }); const directory = mkdtempForTestSync('agent-device-video-webm-'); @@ -30,14 +41,14 @@ const playableWebm = likelyPlayableWebmContainer(); test('accepts a real WebM video track and complete media block without AVFoundation', async () => { await expect( - isPlayableVideo(writeFixture('capture.webm', playableWebm), createTestHost()), + isPlayableVideo(writeFixture('capture.webm', playableWebm), undefined), ).resolves.toBe(true); }); test('does not infer WebM from bytes when the requested container is different', async () => { - await expect( - isPlayableVideo(writeFixture('capture.bin', playableWebm), createTestHost()), - ).resolves.toBe(false); + await expect(isPlayableVideo(writeFixture('capture.bin', playableWebm), undefined)).resolves.toBe( + false, + ); }); function writeFixture(name: string, bytes: Buffer): string { @@ -52,127 +63,377 @@ const moov = mp4Atom( mp4Atom('mvhd', mp4MovieHeader({ version: 0, timescale: 1_000, duration: 2_000 })), ); -test('each Swift cache root compiles its own validator, and a root reuses the one it has', async () => { - const mp4 = writeFixture('validated.mp4', Buffer.concat([ftyp, moov])); - const hostIn = (cacheDir: string) => ({ - env: { AGENT_DEVICE_SWIFT_CACHE_DIR: cacheDir }, - cwd: directory, +const validator = { + kind: 'avfoundation' as const, + executablePath: '/prepared/video-validator', + env: {}, +}; + +test.each(['linux', 'win32'] as const)( + '%s recording validation uses container authority without Swift preparation', + async (platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + vi.mocked(compileSwiftSourceText).mockRejectedValue(new AppError('TOOL_MISSING', 'no xcrun')); + const host = createTestHost(); + const preparation = createVideoValidatorPreparation(host); + const prepared = await preparation.prepare(); + expect(prepared).toEqual({ kind: 'container' }); + expect(await preparation.read()).toBe(prepared); + await expect(preparation.close()).resolves.toBeUndefined(); + const mp4 = writeFixture(`${platform}.mp4`, Buffer.concat([ftyp, moov])); + await expect(isPlayableVideo(mp4, prepared)).resolves.toBe(true); + await expect( + isPlayableVideo(writeFixture(`${platform}-invalid.mp4`, Buffer.from('invalid')), prepared), + ).resolves.toBe(false); + await expect(isPlayableVideo(mp4, undefined)).rejects.toMatchObject({ + details: { reason: 'video_validator_not_prepared' }, + }); + expect(compileSwiftSourceText).not.toHaveBeenCalled(); + expect(readCompiledSwiftSourceText).not.toHaveBeenCalled(); + expect(runCmd).not.toHaveBeenCalled(); + }, +); + +test('semantic authority still runs when the current platform differs from its construction host', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + vi.mocked(runCmd).mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' }); + await expect( + isPlayableVideo(writeFixture('semantic.mp4', Buffer.concat([ftyp, moov])), validator), + ).resolves.toBe(false); + expect(runCmd).toHaveBeenCalledOnce(); +}); + +test('validation reuses the prepared executable without compiling at completion', async () => { + const mp4 = writeFixture('prepared.mp4', Buffer.concat([ftyp, moov])); + const preparation = createVideoValidatorPreparation(createTestHost()); + const prepared = await preparation.prepare(); + await expect(isPlayableVideo(mp4, prepared)).resolves.toBe(true); + await expect(isPlayableVideo(mp4, prepared)).resolves.toBe(true); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(1); + expect(vi.mocked(runCmd).mock.calls.map(([executable]) => executable)).toEqual([ + '/bin/true', + '/bin/true', + ]); +}); + +test('a stop deadline leaves preparation running and a later stop uses the same compile', async () => { + const mp4 = writeFixture('pending.mp4', Buffer.concat([ftyp, moov])); + let finish!: (path: string) => void; + let entered!: () => void; + const preparationStarted = new Promise((resolve) => { + entered = resolve; + }); + vi.mocked(compileSwiftSourceText).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + entered(); + }), + ); + const preparation = createVideoValidatorPreparation(createTestHost()); + const preparing = preparation.prepare(); + await preparationStarted; + await expect(isPlayableVideo(mp4, preparing, Deadline.fromTimeoutMs(5))).rejects.toMatchObject({ + details: { reason: 'video_validator_not_prepared', retriable: true }, + }); + expect(runCmd).not.toHaveBeenCalled(); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(1); + expect(preparation.prepare()).toBe(preparing); + finish('/prepared/validator'); + await expect(isPlayableVideo(mp4, preparing)).resolves.toBe(true); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(1); +}); + +test('each runtime owner compiles for itself instead of sharing a module-global preparation', async () => { + const host = createTestHost(); + const first = createVideoValidatorPreparation(host); + const second = createVideoValidatorPreparation(createTestHost()); + const otherRoot = createVideoValidatorPreparation({ + ...host, + env: { + ...host.env, + AGENT_DEVICE_SWIFT_CACHE_DIR: `${host.env.AGENT_DEVICE_SWIFT_CACHE_DIR}-other`, + }, }); + const [firstResult, secondResult, otherResult] = await Promise.all([ + first.prepare(), + second.prepare(), + otherRoot.prepare(), + ]); + // Each owner memoizes its own compile: two runtimes on one cache compile twice, because no + // module-global map shares their work any more, and the Swift cache lock is what excludes them. + expect(firstResult).toEqual(secondResult); + expect(otherResult).toEqual(firstResult); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(3); + + // Closing one owner stops that owner's compile only; the other owners' work is untouched. + const startedSignals = vi + .mocked(compileSwiftSourceText) + .mock.calls.map(([params]) => params.signal); + expect(startedSignals.every((signal) => signal !== undefined && !signal.aborted)).toBe(true); + await first.close(); + expect(startedSignals[0]!.aborted).toBe(true); + expect(startedSignals[1]!.aborted).toBe(false); + expect(startedSignals[2]!.aborted).toBe(false); +}); + +/** Stands in for a real compile: reports its signal and settles only when its caller ends it. */ +function pendingCompile(): Readonly<{ + signals: AbortSignal[]; + enter: Promise; + finish: (error: Error) => void; +}> { + const signals: AbortSignal[] = []; + let entered!: () => void; + let settle!: (error: Error) => void; + const enter = new Promise((resolve) => { + entered = resolve; + }); + const outcome = new Promise((_resolve, reject) => { + settle = reject; + }); + vi.mocked(compileSwiftSourceText).mockImplementation((params) => { + signals.push(params.signal!); + entered(); + return outcome; + }); + return { signals, enter, finish: settle }; +} + +test('closing the owner cancels the compile it started and awaits its settlement', async () => { + const compile = pendingCompile(); + const preparation = createVideoValidatorPreparation(createTestHost()); + const preparing = preparation.prepare(); + await compile.enter; + + let closed = false; + const closing = preparation.close().then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + expect(compile.signals[0]!.aborted).toBe(true); + + // The compile owns the temp output and the cache lock, so closing waits for it to unwind. + compile.finish(new AppError('COMMAND_FAILED', 'swiftc was stopped', { reason: 'canceled' })); + await expect(closing).resolves.toBeUndefined(); + await expect(preparing).rejects.toMatchObject({ details: { reason: 'canceled' } }); +}); + +test('closing marks the owner closed synchronously and later preparation starts nothing', async () => { + const compile = pendingCompile(); + const preparation = createVideoValidatorPreparation(createTestHost()); + void preparation.prepare(); + await compile.enter; + + const closing = preparation.close(); + await expect(preparation.prepare()).rejects.toMatchObject({ + details: { reason: 'video_validator_preparation_closed', retriable: false }, + }); + expect(compile.signals[0]!.aborted).toBe(true); + + compile.finish(new AppError('COMMAND_FAILED', 'swiftc was stopped')); + await closing; vi.mocked(compileSwiftSourceText).mockClear(); + await expect(preparation.prepare()).rejects.toMatchObject({ + details: { reason: 'video_validator_preparation_closed' }, + }); + expect(compileSwiftSourceText).not.toHaveBeenCalled(); +}); - await isPlayableVideo(mp4, hostIn('/cache/one')); - await isPlayableVideo(mp4, hostIn('/cache/one')); - await isPlayableVideo(mp4, hostIn('/cache/two')); - - expect( - vi - .mocked(compileSwiftSourceText) - .mock.calls.map(([params]) => params.host.env.AGENT_DEVICE_SWIFT_CACHE_DIR), - ).toEqual(['/cache/one', '/cache/two']); -}); - -test('callers share one validator compile, and each waits for it within its own budget', async () => { - // A cold compile on a loaded CI runner outlasted a fixed 30 s wait while record stop had most of - // its 70 s finalization budget left. A caller without a deadline that starts the compile first must - // not impose its 30 s wait on a concurrent caller that has a deadline. - const mp4 = writeFixture('shared-compile.mp4', Buffer.concat([ftyp, moov])); - const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/shared' }, cwd: directory }; - await hasVideoContainer(mp4); - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); - try { - vi.mocked(compileSwiftSourceText) - .mockClear() - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve('/bin/true'), 45_000)), - ); - - const withoutDeadline = isPlayableVideo(mp4, host); - const withDeadline = isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(70_000)); - const withoutDeadlineRejected = expect(withoutDeadline).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { - reason: 'video_validator_compile_timeout', - timeoutMs: 30_000, - retriable: true, - hint: expect.stringContaining('Run record stop again'), - }, +test('closing a preparation that never started resolves without touching the cache', async () => { + const preparation = createVideoValidatorPreparation(createTestHost()); + + await expect(preparation.close()).resolves.toBeUndefined(); + expect(compileSwiftSourceText).not.toHaveBeenCalled(); +}); + +test('closing a settled preparation returns at once and keeps the published authority readable', async () => { + const preparation = createVideoValidatorPreparation(createTestHost()); + const prepared = await preparation.prepare(); + vi.mocked(readCompiledSwiftSourceText).mockReturnValue('/prepared/validator'); + + await expect(preparation.close()).resolves.toBeUndefined(); + await expect(preparation.read()).resolves.toMatchObject({ + executablePath: '/prepared/validator', + }); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(1); + expect(prepared.kind).toBe('avfoundation'); +}); + +test('one owner closing never cancels another runtime owner’s compile', async () => { + const compile = pendingCompile(); + const closing = createVideoValidatorPreparation(createTestHost()); + const recording = createVideoValidatorPreparation(createTestHost()); + const preparing = recording.prepare(); + await compile.enter; + expect(compile.signals).toHaveLength(1); + + await closing.close(); + expect(compile.signals[0]!.aborted).toBe(false); + + compile.finish(new Error('another owner kept compiling')); + await expect(preparing).rejects.toThrow('another owner kept compiling'); +}); + +const preparationFailures = [ + { + name: 'a contended cache lock', + failure: new AppError('COMMAND_FAILED', 'Swift cache lock was busy', { + reason: 'process_lock_timeout', + }), + reason: 'process_lock_timeout', + }, + { + name: 'an unavailable toolchain', + failure: new AppError('TOOL_MISSING', 'no swiftc'), + reason: 'video_validator_unavailable', + }, +] as const; + +test.each(preparationFailures)( + 'failed preparation can be retried after $name', + async ({ failure, reason }) => { + const preparation = createVideoValidatorPreparation(createTestHost()); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + const failed = preparation.prepare(); + expect(preparation.prepare()).toBe(failed); + await expect(failed).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason } }); + expect(preparation.read()).toBe(failed); + const originalError = await failed.catch((error: unknown) => error); + const mp4 = writeFixture('retried.mp4', Buffer.concat([ftyp, moov])); + await expect(isPlayableVideo(mp4, preparation.read())).rejects.toBe(originalError); + + const retry = preparation.prepare(); + expect(retry).not.toBe(failed); + await expect(retry).resolves.toMatchObject({ executablePath: '/bin/true' }); + expect(preparation.read()).toBe(retry); + await expect(isPlayableVideo(mp4, retry)).resolves.toBe(true); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); + }, +); + +test.each(preparationFailures)( + 'a cache-only stop observes an executable published after $name', + async ({ failure, reason }) => { + const preparation = createVideoValidatorPreparation(createTestHost()); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + const failed = preparation.prepare(); + await expect(failed).rejects.toMatchObject({ details: { reason } }); + expect(preparation.read()).toBe(failed); + + vi.mocked(readCompiledSwiftSourceText).mockReturnValue('/published/video-validator'); + const recovered = preparation.read(); + await expect(recovered).resolves.toMatchObject({ + executablePath: '/published/video-validator', }); - await vi.advanceTimersByTimeAsync(45_000); - - await withoutDeadlineRejected; - await expect(withDeadline).resolves.toBe(true); - expect(compileSwiftSourceText).toHaveBeenCalledOnce(); - const [compile] = vi.mocked(compileSwiftSourceText).mock.calls[0]!; - expect(compile.timeoutMs).toBeUndefined(); - expect(compile.signal?.aborted).toBe(false); - } finally { - vi.useRealTimers(); - } + const mp4 = writeFixture('published.mp4', Buffer.concat([ftyp, moov])); + await expect(isPlayableVideo(mp4, recovered)).resolves.toBe(true); + expect(runCmd).toHaveBeenCalledWith('/published/video-validator', [mp4], expect.anything()); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(1); + }, +); + +test('an MP4 stop without prior preparation or cache reports not-prepared without compiling', async () => { + const preparation = createVideoValidatorPreparation(createTestHost()); + expect(preparation.read()).toBeUndefined(); + await expect( + isPlayableVideo( + writeFixture('never-prepared.mp4', Buffer.concat([ftyp, moov])), + preparation.read(), + ), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'video_validator_not_prepared', retriable: true }, + }); + expect(compileSwiftSourceText).not.toHaveBeenCalled(); + expect(runCmd).not.toHaveBeenCalled(); }); -test('the last caller to stop waiting cancels an unfinished compile, and the next caller compiles again', async () => { - // Nothing owns swiftc once its requests end: a daemon that exits while it runs leaves it - // reparented to launchd, and the compile's own temp output behind. - const mp4 = writeFixture('cancel-compile.mp4', Buffer.concat([ftyp, moov])); - const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/cancel' }, cwd: directory }; - await hasVideoContainer(mp4); - let compileSignal: AbortSignal | undefined; - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); - try { - vi.mocked(compileSwiftSourceText) - .mockClear() - .mockImplementationOnce(async ({ signal }) => { - compileSignal = signal; - return await new Promise((_, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason)); - }); - }); - - const timedOut = expect( - isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(5_000)), - ).rejects.toMatchObject({ details: { reason: 'video_validator_compile_timeout' } }); - await vi.advanceTimersByTimeAsync(5_000); - await timedOut; - expect(compileSignal?.aborted).toBe(true); - } finally { - vi.useRealTimers(); - } +test('WebM validation ignores failed background MP4 preparation', async () => { + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce( + new AppError('TOOL_MISSING', 'no swiftc'), + ); + const preparing = createVideoValidatorPreparation(createTestHost()).prepare(); + await expect(preparing).rejects.toMatchObject({ + details: { reason: 'video_validator_unavailable' }, + }); + await expect( + isPlayableVideo(writeFixture('without-swift.webm', playableWebm), preparing), + ).resolves.toBe(true); + expect(runCmd).not.toHaveBeenCalled(); +}); + +test.each([ + `xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH`, + `/tmp/video-validator.swift:2:8: error: no such module 'AVFoundation'`, +])( + 'classifies a real unavailable toolchain message without claiming playability: %s', + async (stderr) => { + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'compile failed', { stderr }), + ); + await expect(createVideoValidatorPreparation(createTestHost()).prepare()).rejects.toMatchObject( + { + details: { reason: 'video_validator_unavailable', retriable: false }, + }, + ); + expect(runCmd).not.toHaveBeenCalled(); + }, +); - await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); - expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); +test('does not classify a different missing module as unavailable AVFoundation', async () => { + const error = new AppError('COMMAND_FAILED', 'compile failed', { + stderr: "error: no such module 'AVFoundationExtras'", + }); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(error); + await expect(createVideoValidatorPreparation(createTestHost()).prepare()).rejects.toBe(error); }); -test('a failed compile is dropped, and the next caller compiles again', async () => { - const mp4 = writeFixture('failed-compile.mp4', Buffer.concat([ftyp, moov])); - const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/failed' }, cwd: directory }; - vi.mocked(compileSwiftSourceText) - .mockClear() - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'xcrun timed out after 120000ms')); +test('an unavailable validator at execution never makes an MP4 playable', async () => { + const mp4 = writeFixture('unavailable.mp4', Buffer.concat([ftyp, moov])); + vi.mocked(runCmd).mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: `error: no such module 'AVFoundation'`, + }); + await expect(isPlayableVideo(mp4, validator)).rejects.toMatchObject({ + details: { + reason: 'video_validator_unavailable', + exitCode: 1, + stdout: '', + stderr: `error: no such module 'AVFoundation'`, + }, + cause: { + details: { stderr: `error: no such module 'AVFoundation'` }, + }, + }); +}); - await expect(isPlayableVideo(mp4, host)).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); - await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); - expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); +test('a removed prepared executable returns the typed unavailable outcome', async () => { + vi.mocked(runCmd).mockRejectedValueOnce(new AppError('TOOL_MISSING', 'validator disappeared')); + await expect( + isPlayableVideo(writeFixture('removed.mp4', Buffer.concat([ftyp, moov])), validator), + ).rejects.toMatchObject({ details: { reason: 'video_validator_unavailable' } }); }); test('a budget spent before the validator runs reads as not playable instead of a 1 ms run', async () => { const mp4 = writeFixture('spent-budget.mp4', Buffer.concat([ftyp, moov])); - const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/spent' }, cwd: directory }; - await isPlayableVideo(mp4, host); + await isPlayableVideo(mp4, validator); vi.mocked(runCmd).mockClear(); - await expect(isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(0))).resolves.toBe(false); + await expect(isPlayableVideo(mp4, validator, Deadline.fromTimeoutMs(0))).resolves.toBe(false); await expect( - waitForPlayableVideo(mp4, { host, pollMs: 0, deadline: Deadline.fromTimeoutMs(0) }), + waitForPlayableVideo(mp4, { validator, pollMs: 0, deadline: Deadline.fromTimeoutMs(0) }), ).resolves.toBe(false); expect(runCmd).not.toHaveBeenCalled(); }); -test('the validator run after a compile stays inside the caller deadline', async () => { +test('the prepared validator run stays inside the caller deadline', async () => { const mp4 = writeFixture('run-clamp.mp4', Buffer.concat([ftyp, moov])); - const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/run-clamp' }, cwd: directory }; - await isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(5_000)); - await isPlayableVideo(mp4, host); + await isPlayableVideo(mp4, validator, Deadline.fromTimeoutMs(5_000)); + await isPlayableVideo(mp4, validator); const [withDeadline, withoutDeadline] = vi .mocked(runCmd) @@ -184,7 +445,6 @@ test('the validator run after a compile stays inside the caller deadline', async test('a validator run that times out reads as not playable, whatever bound stopped it', async () => { const mp4 = writeFixture('run-cut-short.mp4', Buffer.concat([ftyp, moov])); - const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/run-cut-short' }, cwd: directory }; const timedOut = (timeoutMs: number) => new AppError('COMMAND_FAILED', `validator timed out after ${timeoutMs}ms`, { timeoutMs }); vi.mocked(runCmd).mockImplementation(async (_cmd, _args, options) => { @@ -193,8 +453,8 @@ test('a validator run that times out reads as not playable, whatever bound stopp // A frozen clock keeps the 5 ms budget from passing before the validator runs. vi.useFakeTimers({ toFake: ['Date'] }); try { - await expect(isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(5))).resolves.toBe(false); - await expect(isPlayableVideo(mp4, host)).resolves.toBe(false); + await expect(isPlayableVideo(mp4, validator, Deadline.fromTimeoutMs(5))).resolves.toBe(false); + await expect(isPlayableVideo(mp4, validator)).resolves.toBe(false); expect(vi.mocked(runCmd).mock.calls.map(([, , options]) => options?.timeoutMs)).toEqual([ 5, 10_000, ]); @@ -209,9 +469,9 @@ test('waiting for a playable video checks it once per attempt', async () => { const mp4 = writeFixture('never-plays.mp4', Buffer.concat([ftyp, moov])); vi.mocked(runCmd).mockResolvedValue({ exitCode: 1, stdout: '', stderr: '' }); try { - await expect( - waitForPlayableVideo(mp4, { host: createTestHost(), pollMs: 0, attempts: 2 }), - ).resolves.toBe(false); + await expect(waitForPlayableVideo(mp4, { validator, pollMs: 0, attempts: 2 })).resolves.toBe( + false, + ); expect(runCmd).toHaveBeenCalledTimes(2); } finally { vi.mocked(runCmd).mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); @@ -220,17 +480,17 @@ test('waiting for a playable video checks it once per attempt', async () => { test('reaches the semantic validator with an MP4 container whose last box runs past the file', async () => { const truncated = Buffer.concat([ftyp, moov]).subarray(0, ftyp.length + moov.length - 4); - await expect( - isPlayableVideo(writeFixture('truncated.mp4', truncated), createTestHost()), - ).resolves.toBe(true); + await expect(isPlayableVideo(writeFixture('truncated.mp4', truncated), validator)).resolves.toBe( + true, + ); expect(runCmd).toHaveBeenCalledTimes(1); }); test('refuses an MP4 container that never declares a movie header', async () => { const container = Buffer.concat([ftyp, mp4Atom('mdat', Buffer.alloc(8))]); - await expect( - isPlayableVideo(writeFixture('no-moov.mp4', container), createTestHost()), - ).resolves.toBe(false); + await expect(isPlayableVideo(writeFixture('no-moov.mp4', container), undefined)).resolves.toBe( + false, + ); expect(runCmd).not.toHaveBeenCalled(); }); @@ -239,9 +499,53 @@ test('waiting for a playable video stops polling once its deadline passes', asyn fs.writeFileSync(filePath, ''); const started = Date.now(); await waitForPlayableVideo(filePath, { - host: createTestHost(), + validator, pollMs: 60_000, deadline: Deadline.fromTimeoutMs(0), }); expect(Date.now() - started).toBeLessThan(1_000); }); + +test('close reports a typed Swift cache cleanup failure', async () => { + const failure = new AppError('COMMAND_FAILED', 'cleanup failed', { + reason: 'swift_cache_cleanup_failed', + }); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + const preparation = createVideoValidatorPreparation(createTestHost()); + await expect(preparation.prepare()).rejects.toBe(failure); + await expect(preparation.close()).rejects.toBe(failure); +}); + +test('a retry does not erase an earlier cleanup failure from close', async () => { + const failure = new AppError('COMMAND_FAILED', 'cleanup failed', { + reason: 'swift_cache_cleanup_failed', + }); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + const preparation = createVideoValidatorPreparation(createTestHost()); + await expect(preparation.prepare()).rejects.toBe(failure); + await expect(preparation.prepare()).resolves.toMatchObject({ kind: 'avfoundation' }); + await expect(preparation.close()).rejects.toBe(failure); +}); + +test('close retains cleanup failures from successive attempts after a successful retry', async () => { + const first = new AppError( + 'COMMAND_FAILED', + 'first cleanup', + { reason: 'swift_cache_cleanup_failed' }, + new Error('first cause'), + ); + const second = new AppError( + 'COMMAND_FAILED', + 'second cleanup', + { reason: 'swift_cache_cleanup_failed' }, + new Error('second cause'), + ); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(first).mockRejectedValueOnce(second); + const preparation = createVideoValidatorPreparation(createTestHost()); + await expect(preparation.prepare()).rejects.toBe(first); + await expect(preparation.prepare()).rejects.toBe(second); + await expect(preparation.prepare()).resolves.toMatchObject({ kind: 'avfoundation' }); + const failure = await preparation.close().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual([first, second]); +}); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index df9c38b350..b50c3b5cdd 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -2,7 +2,11 @@ import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; import { isCommandTimeoutError, runCmd } from '@agent-device/host-kit/command'; import { type Deadline, sleep } from '@agent-device/host-kit/retry'; -import { buildSwiftToolEnv, compileSwiftSourceText, getSwiftCacheRoot } from './swift-cache.ts'; +import { + buildSwiftToolEnv, + compileSwiftSourceText, + readCompiledSwiftSourceText, +} from './swift-cache.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { hasPlayableWebmStructure } from './video-webm.ts'; @@ -35,24 +39,175 @@ semaphore.wait() exit(exitCode) `.trim(); -// A cold Swift module cache makes the validator compile take over 30 s on a loaded CI runner, so a -// caller with a deadline, such as `record stop`, waits for the compile until that deadline instead. -const VALIDATOR_COMPILE_WAIT_MS = 30_000; const VALIDATOR_RUN_TIMEOUT_MS = 10_000; -type SharedValidatorCompile = { - readonly compiled: Promise; +/** Host-selected validation authority; validation cannot compile. */ +export type PreparedVideoValidator = + | Readonly<{ kind: 'container' }> + | Readonly<{ kind: 'avfoundation'; executablePath: string; env: NodeJS.ProcessEnv }>; + +export type VideoValidator = PreparedVideoValidator | Promise; +const containerVideoValidator = Object.freeze({ kind: 'container' } as const); + +/** + * One runtime host's validator preparation, with the lifetime its compile needs: `prepare` starts + * or reuses the background compile, `read` answers with what preparation has produced so far, and + * `close` cancels an unfinished compile and waits for its cleanup. The owner is per runtime host + * because that is who can close it: the daemon closes its recording host, and a compile that + * outlived the process which started it would keep `swiftc`, its temporary output and the Swift + * cache lock behind with nobody left to stop them. + */ +export type VideoValidatorPreparation = Readonly<{ + /** Warms the validator without waiting for it; never starts a second compile for this owner. */ + prepare(): Promise; + /** + * Selects container validation or reads prepared authority without compiling. Answers `undefined` + * when nothing was ever prepared and no executable is published: a stop reports that as its own + * retriable not-prepared outcome. + */ + read(): Promise | undefined; + /** Stops an unfinished compile and awaits its settlement, including temp output and lock cleanup. */ + close(): Promise; +}>; + +type VideoValidatorCompile = { + readonly promise: Promise; readonly cancel: AbortController; - waiters: number; - settled: boolean; + failed: boolean; }; -// A compiled helper is valid for the Swift cache it was built into, so each cache root keeps its own. -// Callers share one compile, and each waits for it only within its own budget. The last caller to -// stop waiting cancels an unfinished compile: that kills this process's `swiftc`, or stops waiting -// for another process's compile. Nothing owns `swiftc` once its requests end, so a daemon that -// exits would leave it running. -const videoValidatorCompiles = new Map(); +/** Owns one cache's validator preparation; cross-process compilation stays with the cache lock. */ +export function createVideoValidatorPreparation(host: HostEnvironment): VideoValidatorPreparation { + let compile: VideoValidatorCompile | undefined; + let closed = false; + const cleanupFailures: AppError[] = []; + return Object.freeze({ + prepare: () => { + if (closed) return Promise.reject(videoValidatorPreparationClosed()); + if (process.platform !== 'darwin') return Promise.resolve(containerVideoValidator); + if (!compile || compile.failed) { + const cancel = new AbortController(); + const next: VideoValidatorCompile = { + cancel, + failed: false, + promise: compileVideoValidator(host, cancel.signal), + }; + compile = next; + void next.promise.catch((error: unknown) => { + next.failed = true; + if (error instanceof AppError && error.details?.reason === 'swift_cache_cleanup_failed') { + cleanupFailures.push(error); + } + }); + } + return compile.promise; + }, + read: () => { + if (process.platform !== 'darwin') return Promise.resolve(containerVideoValidator); + const executablePath = readCompiledSwiftSourceText({ + host, + source: VIDEO_VALIDATION_SCRIPT, + cacheName: 'video-validator', + }); + // A published executable answers even after this process's own preparation failed, which is + // how another process's `prepare ios-runner` recovers a stop in this one. + if (executablePath) return Promise.resolve(preparedVideoValidator(host, executablePath)); + // A failed preparation stays readable as its own typed failure until an explicit prepare + // retries it; only a preparation that never started answers no authority at all. + return compile ? compile.promise : undefined; + }, + close: async () => { + closed = true; + const pending = compile; + if (!pending) return; + // The abort is this owner's shutdown, not a request's: it stops `swiftc` through its signal, + // or stops waiting for another process's compile, and the compile's own cleanup removes its + // temporary output and gives the cache lock back before this settles. + pending.cancel.abort(); + await pending.promise.catch(() => undefined); + if (cleanupFailures.length === 1) throw cleanupFailures[0]; + if (cleanupFailures.length > 1) { + throw new AggregateError( + cleanupFailures, + 'Video validator cleanup failed on multiple attempts', + ); + } + }, + }); +} + +function preparedVideoValidator( + host: HostEnvironment, + executablePath: string, +): PreparedVideoValidator { + return Object.freeze({ kind: 'avfoundation', executablePath, env: buildSwiftToolEnv(host.env) }); +} + +/** A preparation whose owner has closed will not compile again; this outcome is terminal. */ +function videoValidatorPreparationClosed(): AppError { + return new AppError( + 'COMMAND_FAILED', + 'The video validator preparation is closed and will not compile', + { + reason: 'video_validator_preparation_closed', + retriable: false, + hint: 'This recording host has shut down. Start a recording session on a running host to prepare the validator.', + }, + ); +} + +async function compileVideoValidator( + host: HostEnvironment, + signal: AbortSignal, +): Promise { + const { withDiagnosticTimer } = await import('@agent-device/host-kit/diagnostics'); + return await withDiagnosticTimer( + 'prepare_video_validator', + async () => { + try { + const executablePath = await compileSwiftSourceText({ + host, + source: VIDEO_VALIDATION_SCRIPT, + cacheName: 'video-validator', + signal, + }); + return preparedVideoValidator(host, executablePath); + } catch (error) { + if (isUnavailableVideoValidatorError(error)) { + throw unavailableVideoValidator(error); + } + throw error; + } + }, + undefined, + 'preparation_start', + ); +} + +function unavailableVideoValidator(cause?: Error): AppError { + return new AppError( + 'COMMAND_FAILED', + 'The video validator is unavailable; MP4 playability cannot be verified', + { + ...(cause instanceof AppError ? cause.details : {}), + reason: 'video_validator_unavailable', + retriable: false, + hint: 'Install the Xcode command line tools with AVFoundation on the recording host, then run agent-device prepare ios-runner --platform ios there using the same Swift cache. Once preparation is ready, retry record stop in the original recording session.', + }, + cause, + ); +} + +function isUnavailableVideoValidatorError(error: unknown): error is AppError { + return ( + error instanceof AppError && + (error.code === 'TOOL_MISSING' || + isSwiftVideoValidatorUnavailable( + String(error.details?.stderr ?? ''), + String(error.details?.stdout ?? ''), + )) + ); +} export async function waitForStableFile( filePath: string, @@ -85,16 +240,10 @@ export async function waitForStableFile( } } -/** - * Whether the file is a playable video. With a `deadline`, a cold validator compile runs until the - * deadline instead of its own fixed bound. A validator run that times out, or that the deadline - * leaves no time for, reads as not playable, like a poll in `waitForPlayableVideo` that runs out of - * time. A WebM - * verdict reads only the file's structure, with no validator, so the deadline does not apply to it. - */ +/** Uses prepared validation authority; executable runs stay within the finalization deadline. */ export async function isPlayableVideo( filePath: string, - host: HostEnvironment, + validator: VideoValidator | undefined, deadline?: Deadline, ): Promise { const container = await readVideoContainerKind(filePath); @@ -102,118 +251,60 @@ export async function isPlayableVideo( // AVFoundation is the MP4 semantic validator. It does not reliably load WebM on supported // macOS hosts, so WebM completion is established by its EBML document type + Segment marker. if (container === 'webm') return true; - try { - if (deadline?.isExpired()) return false; - const validatorPath = await waitForValidatorCompile( - host, - deadline?.remainingMs() ?? VALIDATOR_COMPILE_WAIT_MS, + const notPrepared = () => + new AppError( + 'COMMAND_FAILED', + 'The video validator is not ready within the finalization budget', + { + reason: 'video_validator_not_prepared', + retriable: true, + hint: 'Preparation continues in the background. Retry record stop in this session once the validator is ready.', + }, ); - const runTimeoutMs = Math.min( - VALIDATOR_RUN_TIMEOUT_MS, - deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, - ); - // A budget with no time left reads as not playable; `runCmd` would read a timeout of 0 as none. - if (runTimeoutMs <= 0) return false; - const result = await runCmd(validatorPath, [filePath], { - allowFailure: true, - timeoutMs: runTimeoutMs, - env: buildSwiftToolEnv(host.env), - }).catch((error: unknown) => { - if (isCommandTimeoutError(error)) return undefined; - throw error; + if (!validator) { + throw new AppError('COMMAND_FAILED', 'The video validator has not been prepared', { + reason: 'video_validator_not_prepared', + retriable: true, + hint: 'Run agent-device prepare ios-runner --platform ios on the recording host using the same Swift cache. Once preparation is ready, retry record stop in the original recording session.', }); - if (!result) return false; - if (result.exitCode === 0) { - return true; - } - return isSwiftVideoValidatorUnavailable(result.stderr, result.stdout); - } catch (error) { - if (isSwiftVideoValidatorError(error)) { - return true; - } - throw error; } -} - -function joinValidatorCompile(host: HostEnvironment, cacheRoot: string): SharedValidatorCompile { - const existing = videoValidatorCompiles.get(cacheRoot); - if (existing) return existing; - const cancel = new AbortController(); - const shared: SharedValidatorCompile = { - compiled: compileSwiftSourceText({ - host, - source: VIDEO_VALIDATION_SCRIPT, - cacheName: 'video-validator', - signal: cancel.signal, - }), - cancel, - waiters: 0, - settled: false, - }; - videoValidatorCompiles.set(cacheRoot, shared); - shared.compiled.then( - () => { - shared.settled = true; - }, - () => { - shared.settled = true; - if (videoValidatorCompiles.get(cacheRoot) === shared) - videoValidatorCompiles.delete(cacheRoot); - }, - ); - return shared; -} - -async function waitForValidatorCompile(host: HostEnvironment, waitMs: number): Promise { - const cacheRoot = getSwiftCacheRoot(host.env); - const shared = joinValidatorCompile(host, cacheRoot); - shared.waiters += 1; let timer: NodeJS.Timeout | undefined; - const expired = new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new AppError( - 'COMMAND_FAILED', - `The video validator did not compile within ${Math.round(waitMs)}ms`, - { - reason: 'video_validator_compile_timeout', - timeoutMs: waitMs, - retriable: true, - hint: - 'Run record stop again: the Swift modules this compile already built stay ' + - 'cached, so the next compile finishes sooner, and the recording keeps its ' + - 'evidence either way.', - }, - ), - ), - waitMs, - ); - }); + let prepared: PreparedVideoValidator; try { - return await Promise.race([shared.compiled, expired]); + prepared = await Promise.race([ + validator, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(notPrepared()), + deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, + ); + }), + ]); } finally { clearTimeout(timer); - shared.waiters -= 1; - if (shared.waiters === 0 && !shared.settled) { - if (videoValidatorCompiles.get(cacheRoot) === shared) - videoValidatorCompiles.delete(cacheRoot); - shared.cancel.abort(); - } } -} - -function isSwiftVideoValidatorError(error: unknown): boolean { - if (!(error instanceof AppError)) { - return false; - } - if (error.code === 'TOOL_MISSING') { - return true; - } - return isSwiftVideoValidatorUnavailable( - String(error.details?.stderr ?? ''), - String(error.details?.stdout ?? ''), + if (prepared.kind === 'container') return true; + const runTimeoutMs = Math.min( + VALIDATOR_RUN_TIMEOUT_MS, + deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, ); + if (runTimeoutMs <= 0) return false; + const result = await runCmd(prepared.executablePath, [filePath], { + allowFailure: true, + timeoutMs: runTimeoutMs, + env: prepared.env, + }).catch((error: unknown) => { + if (isCommandTimeoutError(error)) return undefined; + if (isUnavailableVideoValidatorError(error)) throw unavailableVideoValidator(error); + throw error; + }); + if (!result) return false; + if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) { + throw unavailableVideoValidator( + new AppError('COMMAND_FAILED', 'The prepared video validator could not run', result), + ); + } + return result.exitCode === 0; } /** @@ -222,13 +313,18 @@ function isSwiftVideoValidatorError(error: unknown): boolean { */ export async function waitForPlayableVideo( filePath: string, - options: { host: HostEnvironment; pollMs?: number; attempts?: number; deadline?: Deadline }, + options: { + validator: VideoValidator | undefined; + pollMs?: number; + attempts?: number; + deadline?: Deadline; + }, ): Promise { const pollMs = options.pollMs ?? 150; const attempts = options.attempts ?? 12; for (let attempt = 1; ; attempt += 1) { - if (await isPlayableVideo(filePath, options.host, options.deadline)) { + if (await isPlayableVideo(filePath, options.validator, options.deadline)) { return true; } if (attempt >= attempts || options.deadline?.isExpired()) { @@ -240,7 +336,7 @@ export async function waitForPlayableVideo( function isSwiftVideoValidatorUnavailable(stderr: string, stdout: string): boolean { const combined = `${stderr}\n${stdout}`; - return /\b(no such module ['"]AVFoundation['"]|unable to find utility ["']swiftc?["']|xcrun: error: unable to find utility ["']swiftc?["'])\b/i.test( + return /\b(no such module ['"]AVFoundation['"]|unable to find utility ["']swiftc?["'])/i.test( combined, ); } diff --git a/packages/capture-kit/src/screen-recording-transport.test.ts b/packages/capture-kit/src/screen-recording-transport.test.ts index 2bbffdf819..a9c123e158 100644 --- a/packages/capture-kit/src/screen-recording-transport.test.ts +++ b/packages/capture-kit/src/screen-recording-transport.test.ts @@ -56,6 +56,9 @@ function harness( const prepare = vi.fn(async () => { calls.push('prepare'); }); + const prepareValidator = vi.fn(async () => { + calls.push('prepare-validator'); + }); const complete = vi.fn(async () => { calls.push('finalize'); return await (options.finalize ?? (async () => ({})))(); @@ -65,7 +68,7 @@ function harness( host: { screenRecording: { outputs: { prepare, copy: async () => {}, remove: async () => 'removed' as const }, - finalize: { sniff: async () => {}, complete }, + finalize: { prepare: prepareValidator, sniff: async () => {}, complete }, }, }, device, @@ -76,7 +79,7 @@ function harness( support: { scopes: ['app'], fps: false, exportQuality: true, hideTouches: false }, unsupported: (unsupported) => `test recordings do not support ${unsupported.join(', ')}`, }); - return { start, calls, prepare, complete, transport }; + return { start, calls, prepare, prepareValidator, complete, transport }; } test('a finish stops once, collects to the output path, finalizes, and confirms the recorder', async () => { @@ -86,6 +89,7 @@ test('a finish stops once, collects to the output path, finalizes, and confirms const outcome = await started.pendingHandle.transfer().finish(); expect(calls).toEqual([ + 'prepare-validator', 'prepare', 'start', 'stop', @@ -160,12 +164,12 @@ test('cleanup after a failed finish stops the recorder once and does not collect }); test('unsupported options are refused with a typed error before the output is prepared', async () => { - const { prepare, calls } = harness(); + const { prepare, prepareValidator, calls } = harness(); const started = startTransportScreenRecording({ host: { screenRecording: { outputs: { prepare, copy: async () => {}, remove: async () => 'removed' as const }, - finalize: { sniff: async () => {}, complete: async () => ({}) }, + finalize: { prepare: prepareValidator, sniff: async () => {}, complete: async () => ({}) }, }, }, device, @@ -189,6 +193,7 @@ test('unsupported options are refused with a typed error before the output is pr message: 'test recordings do not support --fps', }); expect(prepare).not.toHaveBeenCalled(); + expect(prepareValidator).not.toHaveBeenCalled(); expect(calls).toEqual([]); }); @@ -206,7 +211,7 @@ test('a start cancelled after the recorder acquired is stopped again and reports }); await expect(start()).rejects.toBe(reason); - expect(calls).toEqual(['prepare', 'start', 'stop']); + expect(calls).toEqual(['prepare-validator', 'prepare', 'start', 'stop']); }); test('the durable descriptor round-trips through the backend-scoped codec', async () => { diff --git a/packages/capture-kit/src/screen-recording-transport.ts b/packages/capture-kit/src/screen-recording-transport.ts index 4d26954667..e936bfc77f 100644 --- a/packages/capture-kit/src/screen-recording-transport.ts +++ b/packages/capture-kit/src/screen-recording-transport.ts @@ -99,6 +99,11 @@ export async function startTransportScreenRecording(params: { const { host, device, owner, input, signal, transport } = params; assertScreenRecordingOptionsSupported(input, params.support, params.unsupported); signal.throwIfAborted(); + void host.screenRecording.finalize + .prepare({ + container: input.outputPath.toLowerCase().endsWith('.webm') ? 'webm' : 'mp4', + }) + .catch(() => {}); await host.screenRecording.outputs.prepare(input.outputPath); const stopOnce = memoizeStop(transport); let acquired = false; diff --git a/packages/command-surface/src/management/prepare.ts b/packages/command-surface/src/management/prepare.ts index 046e9e66d3..5b464e4654 100644 --- a/packages/command-surface/src/management/prepare.ts +++ b/packages/command-surface/src/management/prepare.ts @@ -93,7 +93,7 @@ export const PREPARE_COMMAND_OUTPUT_SCHEMAS = { const prepareCommandMetadata = defineFieldCommandMetadata( 'prepare', - 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In JSON output, top-level buildMs/connectMs/healthCheckMs are diagnostic fields and may overlap; use timing.additiveParts for additive wall-clock phase totals. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, stop the prepare daemon before replay/test so it does not keep the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', + 'Prepare platform helper infrastructure. ios-runner warms the MP4 video validator in the background without failing on validator build errors, then builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In JSON output, top-level buildMs/connectMs/healthCheckMs are diagnostic fields and may overlap; use timing.additiveParts for additive wall-clock phase totals. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, stop the prepare daemon before replay/test so it does not keep the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', { action: requiredField(enumField(PREPARE_ACTION_VALUES)), timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), diff --git a/packages/command-surface/src/recording/index.ts b/packages/command-surface/src/recording/index.ts index 435a6f4f5f..298b270284 100644 --- a/packages/command-surface/src/recording/index.ts +++ b/packages/command-surface/src/recording/index.ts @@ -127,7 +127,7 @@ export const recordCommandFacet = defineCommandFacet({ text: { summary: 'Start or stop screen recording', cliDetail: - 'The default --scope app requires an active app session from open ; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. Android screenrecord encodes a frame only when the screen changes, so a clip can end at the last frame the recorder encoded instead of at record stop; durationMs is host wall clock from record start until the export finished, and capturedDurationMs reports the video timeline when it can be measured, with a warning naming how much of the window that video covers. An Android manifest left by an unreachable recording is retired on the next start once its recorders are proven gone; one still owned refuses with non-retriable DEVICE_IN_USE and reason native_recovery_evidence_open naming the session to run record stop for, and a recorder that is still writing an artifact refuses with DEVICE_IN_USE and reason native_recording_artifact_claimed, which clears itself once that recorder ends at the 180s limit. HarmonyOS supports whole-screen recording on physical devices only: use --scope device/system; --fps, --quality, and --hide-touches are unsupported. Use --quality to choose medium or high export quality on supported backends. The touch overlay renders at most 30 frames a second, or at --fps when lower; a lower --fps makes record stop faster on a long recording, and an overlay export that runs past its budget returns the raw video with an overlayWarning. An iOS simulator host recording lock returns non-retriable DEVICE_IN_USE with reason apple_simulator_recording_busy. Stop the recording in its owning session; if a dead recorder left the host locked, ask the host operator to restart the CoreSimulator stream service. A record stop that cannot produce its export keeps what a retry reads back — on Android the device-side artifact and the native manifest — leaves the recording manifest of that session open, and returns its own error, so retry record stop in that session; only closing the session disposes them. Every stopped recording also reports what became of its recorder and of the file that recorder writes to: recorder is confirmed, or lost when the session holding it died, and nativePathDisposition is retirable while that file still owes a removal or retired once its removal was verified. Both are optional disclosures, not failures; ADR 0024 declares further states (unconfirmed, pending) for the steps that gain those probes, and no stop reports them yet. record contact-sheet [--out ] is a local report over a file you already exported: it decodes frames out of that MP4 and writes one PNG holding the cells where the screen visibly changed, each labeled with its elapsed time on the clip timeline. It needs no session and no device, rebuilds any past recording, and works for every backend that exports MP4; a WebM recording is refused with reason contact_sheet_container_unsupported. The sample grid is bounded and spread across the whole clip, so it reports coverage rather than a review: a flash that opens and closes entirely between two sample times is not in the sheet, which is why the result also reports how many times it sampled and how many frames came back. Frame decoding is Apple AVFoundation tooling, so the command runs only on macOS hosts and refuses elsewhere with reason contact_sheet_unsupported_host.', + 'On macOS, MP4 record start warms the host video validator in the background without delaying capture. record stop waits only within its finalization budget. If preparation is still pending, stop returns retriable video_validator_not_prepared; preparation continues, so retry stop in the same session. macOS MP4 verification requires Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable only at stop. Other hosts validate the MP4 container without Swift. If preparation is missing or failed, install any missing tooling, then run agent-device prepare ios-runner --platform ios on the recording host using the same Swift cache. This starts background preparation; once it is ready, retry record stop in the original recording session. WebM uses container validation without Swift. The default --scope app requires an active app session from open ; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. Android screenrecord encodes a frame only when the screen changes, so a clip can end at the last frame the recorder encoded instead of at record stop; durationMs is host wall clock from record start until the export finished, and capturedDurationMs reports the video timeline when it can be measured, with a warning naming how much of the window that video covers. An Android manifest left by an unreachable recording is retired on the next start once its recorders are proven gone; one still owned refuses with non-retriable DEVICE_IN_USE and reason native_recovery_evidence_open naming the session to run record stop for, and a recorder that is still writing an artifact refuses with DEVICE_IN_USE and reason native_recording_artifact_claimed, which clears itself once that recorder ends at the 180s limit. Limrun records MP4 and refuses .webm output paths with INVALID_ARGS. HarmonyOS supports whole-screen recording on physical devices only: use --scope device/system; --fps, --quality, and --hide-touches are unsupported. Use --quality to choose medium or high export quality on supported backends. The touch overlay renders at most 30 frames a second, or at --fps when lower; a lower --fps makes record stop faster on a long recording, and an overlay export that runs past its budget returns the raw video with an overlayWarning. An iOS simulator host recording lock returns non-retriable DEVICE_IN_USE with reason apple_simulator_recording_busy. Stop the recording in its owning session; if a dead recorder left the host locked, ask the host operator to restart the CoreSimulator stream service. A record stop that cannot produce its export keeps what a retry reads back — on Android the device-side artifact and the native manifest — leaves the recording manifest of that session open, and returns its own error, so retry record stop in that session; only closing the session disposes them. Every stopped recording also reports what became of its recorder and of the file that recorder writes to: recorder is confirmed, or lost when the session holding it died, and nativePathDisposition is retirable while that file still owes a removal or retired once its removal was verified. Both are optional disclosures, not failures; ADR 0024 declares further states (unconfirmed, pending) for the steps that gain those probes, and no stop reports them yet. record contact-sheet [--out ] is a local report over a file you already exported: it decodes frames out of that MP4 and writes one PNG holding the cells where the screen visibly changed, each labeled with its elapsed time on the clip timeline. It needs no session and no device, rebuilds any past recording, and works for every backend that exports MP4; a WebM recording is refused with reason contact_sheet_container_unsupported. The sample grid is bounded and spread across the whole clip, so it reports coverage rather than a review: a flash that opens and closes entirely between two sample times is not in the sheet, which is why the result also reports how many times it sampled and how many frames came back. Frame decoding is Apple AVFoundation tooling, so the command runs only on macOS hosts and refuses elsewhere with reason contact_sheet_unsupported_host.', }, metadata: recordCommandMetadata, run: (client, input) => client.recording.record(input as RecordOptions), diff --git a/packages/contracts/src/screen-recording-runtime-host.ts b/packages/contracts/src/screen-recording-runtime-host.ts index 0ef6b91402..484c726a1f 100644 --- a/packages/contracts/src/screen-recording-runtime-host.ts +++ b/packages/contracts/src/screen-recording-runtime-host.ts @@ -241,6 +241,8 @@ export type WebScreenRecordingHost = Readonly<{ /** Closed post-processing authority for stable/playable validation, telemetry, and overlays. */ export type ScreenRecordingFinalizer = Readonly<{ + /** Starts best-effort helper preparation without waiting for it. */ + prepare(input: Readonly<{ container: 'mp4' | 'webm' }>): Promise; /** * The container sniff a stop runs on its collected copy before it checkpoints that copy (ADR 0024 * 2.3): `ftyp` and `moov` for MP4, the EBML segment for WebM. It spawns no validator; `complete` diff --git a/packages/platform-android/src/recording/fixtures.ts b/packages/platform-android/src/recording/fixtures.ts index 9d22012bd7..4ac577137b 100644 --- a/packages/platform-android/src/recording/fixtures.ts +++ b/packages/platform-android/src/recording/fixtures.ts @@ -113,7 +113,7 @@ export function recordingHost(overrides: Record): PlatformRunti android: { resolve: async () => transport }, outputs: Object.assign({}, store.outputs, legacy.outputs), finalize: Object.assign( - { sniff: async () => {}, complete: async () => ({}) }, + { prepare: async () => {}, sniff: async () => {}, complete: async () => ({}) }, legacy.finalize, ), }, diff --git a/packages/platform-android/src/recording/runtime.ts b/packages/platform-android/src/recording/runtime.ts index 4ea5facdc8..fb9b7a0bfe 100644 --- a/packages/platform-android/src/recording/runtime.ts +++ b/packages/platform-android/src/recording/runtime.ts @@ -65,6 +65,7 @@ async function startAndroidRecording(params: { }) { const { host, transport, device, owner, input, signal } = params; signal.throwIfAborted(); + void host.screenRecording.finalize.prepare({ container: 'mp4' }).catch(() => {}); const startedAt = Date.now(); const initial = await startInitialTransaction({ transport, diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 343e1c2316..f07e274f95 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -251,14 +251,6 @@ function wedgedCoreSimulator( }; } -/** Real I/O turns until the provider saw `count` spawns; bounded so a lookup that stops early fails here, not at the test timeout. */ -async function untilSpawned(coreSimulator: { spawns: () => number }, count: number): Promise { - for (let turn = 0; turn < 1000 && coreSimulator.spawns() < count; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - expect(coreSimulator.spawns()).toBe(count); -} - const LISTED_APP = JSON.stringify({ 'com.example.app': { ApplicationType: 'User', @@ -294,7 +286,7 @@ test.each([ ).then((value) => (answered = value)), ); - await untilSpawned(coreSimulator, 2); + await vi.waitFor(() => expect(coreSimulator.spawns()).toBe(2)); await vi.advanceTimersByTimeAsync(URL_OWNER_LOOKUP_TIMEOUT_MS + 1); expect(answered).toEqual({ outcome: 'unreadable', step: 'url-owner' }); await answer; diff --git a/packages/platform-apple/src/recording/runtime.fixtures.ts b/packages/platform-apple/src/recording/runtime.fixtures.ts index 90381e10e4..d3c6e83f1f 100644 --- a/packages/platform-apple/src/recording/runtime.fixtures.ts +++ b/packages/platform-apple/src/recording/runtime.fixtures.ts @@ -81,6 +81,7 @@ export function simulatorRecorderStart(): Pick< export function appleRecordingHost( options: { apple?: Partial; + prepare?: ScreenRecordingFinalizer['prepare']; complete?: ScreenRecordingFinalizer['complete']; sniff?: ScreenRecordingFinalizer['sniff']; files?: ReturnType; @@ -127,6 +128,7 @@ export function appleRecordingHost( apple, outputs: Object.assign({}, store.outputs, options.outputs), finalize: { + prepare: options.prepare ?? (async () => {}), // The real sniff reads the file and refuses one that is not there or not a video, which is // what lets a recovery ask whether a recorder's file can still become an export. sniff: diff --git a/packages/platform-apple/src/recording/runtime.test.ts b/packages/platform-apple/src/recording/runtime.test.ts index 4f03744a3a..7239f9aefe 100644 --- a/packages/platform-apple/src/recording/runtime.test.ts +++ b/packages/platform-apple/src/recording/runtime.test.ts @@ -628,3 +628,38 @@ test('compensates a runner finalizer failure without stopping the runner twice', await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); expect(stop).toHaveBeenCalledOnce(); }); + +test('starts capture while video validation preparation is pending', async () => { + let ready!: () => void; + const preparing = new Promise((resolve) => { + ready = resolve; + }); + const host = appleHost({ + prepare: async () => { + await preparing; + }, + apple: { + startSimulator: async () => ({ + markers: [processIdentity], + wait: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }), + terminate: async () => {}, + }), + }, + }); + const clockAnchor = vi.spyOn(host.screenRecording.apple, 'captureClockAnchor'); + const startRecorder = vi.spyOn(host.screenRecording.apple, 'startSimulator'); + const prepareOutput = vi.spyOn(host.screenRecording.outputs, 'prepare'); + const operations = createAppleScreenRecordingOperations({ + host, + device: simulator, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const starting = operations.screenRecordingStart(input()); + const started = await starting; + expect(startRecorder).toHaveBeenCalledOnce(); + expect(prepareOutput).toHaveBeenCalledOnce(); + expect(clockAnchor).toHaveBeenCalledOnce(); + ready(); + await started.pendingHandle[Symbol.asyncDispose](); +}); diff --git a/packages/platform-apple/src/recording/runtime.ts b/packages/platform-apple/src/recording/runtime.ts index 01601fe0ba..f302d0c82e 100644 --- a/packages/platform-apple/src/recording/runtime.ts +++ b/packages/platform-apple/src/recording/runtime.ts @@ -98,6 +98,7 @@ async function startAppleRecording(params: AppleRecordingStartParams) { async function startAppleSimulatorRecording(params: AppleRecordingStartParams) { const { host, device, owner, input, signal } = params; await validateAppleSimulatorRecording(device, input, host.screenRecording.apple.isRunnerBundleId); + void host.screenRecording.finalize.prepare({ container: 'mp4' }).catch(() => {}); const clockAnchor = input.activeSessionApp ? await host.screenRecording.apple.captureClockAnchor( device, @@ -289,6 +290,7 @@ async function startAppleRunnerRecording(params: AppleRecordingStartParams) { if (!appBundleId) { throw new TypeError('Apple runner recording requires an active app session identity'); } + void host.screenRecording.finalize.prepare({ container: 'mp4' }).catch(() => {}); await host.screenRecording.outputs.prepare(input.outputPath); const result = await host.screenRecording.apple.runRunner( device, diff --git a/packages/platform-harmonyos/src/recording/runtime.fixtures.ts b/packages/platform-harmonyos/src/recording/runtime.fixtures.ts index 7f3e9aaef3..a38e641c86 100644 --- a/packages/platform-harmonyos/src/recording/runtime.fixtures.ts +++ b/packages/platform-harmonyos/src/recording/runtime.fixtures.ts @@ -37,6 +37,7 @@ export function harmonyRecordingHost( overrides: Partial & { complete?: ScreenRecordingRuntimeHost['finalize']['complete']; prepare?: ScreenRecordingRuntimeHost['outputs']['prepare']; + prepareValidator?: ScreenRecordingRuntimeHost['finalize']['prepare']; } = {}, ): HarmonyHost { const harmony: ScreenRecordingRuntimeHost['harmony'] = { @@ -56,7 +57,11 @@ export function harmonyRecordingHost( ...recordingFileStore().outputs, prepare: overrides.prepare ?? (async () => {}), }, - finalize: { sniff: async () => {}, complete: overrides.complete ?? (async () => ({})) }, + finalize: { + prepare: overrides.prepareValidator ?? (async () => {}), + sniff: async () => {}, + complete: overrides.complete ?? (async () => ({})), + }, }, clock: { now: () => 0, sleep: async () => {} }, }; diff --git a/packages/platform-harmonyos/src/recording/runtime.ts b/packages/platform-harmonyos/src/recording/runtime.ts index 6e5ebcfc2d..4650195a98 100644 --- a/packages/platform-harmonyos/src/recording/runtime.ts +++ b/packages/platform-harmonyos/src/recording/runtime.ts @@ -77,6 +77,7 @@ async function startHarmonyRecording(params: { (unsupported) => `HarmonyOS recordings do not support ${unsupported.join(', ')}`, ); signal.throwIfAborted(); + void host.screenRecording.finalize.prepare({ container: 'mp4' }).catch(() => {}); await host.screenRecording.outputs.prepare(input.outputPath); const fileName = `agent-device-recording-${randomUUID()}.mp4`; const remotePath = `/data/local/tmp/${fileName}`; diff --git a/packages/platform-web/src/recording/runtime.test.ts b/packages/platform-web/src/recording/runtime.test.ts index 6736bd69bf..2422053c31 100644 --- a/packages/platform-web/src/recording/runtime.test.ts +++ b/packages/platform-web/src/recording/runtime.test.ts @@ -186,7 +186,7 @@ async function runtime( host: { screenRecording: { web: { resolve: async () => transport }, - finalize: { sniff: async () => {}, complete }, + finalize: { prepare: async () => {}, sniff: async () => {}, complete }, outputs: { ...recordingFileStore().outputs, prepare }, }, }, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index c2f485e9e9..6ebfc3c572 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -456,7 +456,7 @@ function host( removeMedia: async () => true, }, web: { resolve: async () => webRecording }, - finalize: { complete: async () => ({}) }, + finalize: { prepare: async () => {}, complete: async () => ({}) }, }, } as unknown as PlatformRuntimeHost; } diff --git a/packages/provider-limrun/src/recording-runtime.test.ts b/packages/provider-limrun/src/recording-runtime.test.ts index 60e187f987..6c6ff4a5d4 100644 --- a/packages/provider-limrun/src/recording-runtime.test.ts +++ b/packages/provider-limrun/src/recording-runtime.test.ts @@ -50,6 +50,9 @@ function fakeRecordingHost(calls: string[], finalize?: () => Promise 'removed' as const), }, finalize: { + prepare: vi.fn(async ({ container }: { container: 'mp4' | 'webm' }) => { + calls.push(`prepare-validator:${container}`); + }), sniff: vi.fn(async () => {}), complete: vi.fn(async () => { calls.push('finalize'); @@ -94,7 +97,7 @@ test('start prepares the output, then asks the instance to record at the medium const started = await operations.screenRecordingStart(input()); - expect(calls).toEqual(['prepare', 'start:5']); + expect(calls).toEqual(['prepare-validator:mp4', 'prepare', 'start:5']); expect(started.envelope.descriptor.body).toMatchObject({ backend: 'limrun-recorder', outputPath: '/tmp/limrun-recording.mp4', @@ -122,6 +125,7 @@ test('stop asks the instance once, downloads the served MP4 to the output path, const outcome = await started.pendingHandle.transfer().finish(); expect(calls).toEqual([ + 'prepare-validator:mp4', 'prepare', 'start:5', 'stop', @@ -144,6 +148,14 @@ test('stop asks the instance once, downloads the served MP4 to the output path, }); }); +test('refuses a WebM destination before preparing output or starting the MP4 recorder', async () => { + const { operations, calls } = recorder(); + await expect( + operations.screenRecordingStart(input({ outputPath: '/tmp/clip.webm' })), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(calls).toEqual([]); +}); + test('a download that fails after the instance stopped is retried by the next stop without a second remote stop', async () => { const { operations, calls, session } = recorder({ failDownloads: 1 }); const handle = (await operations.screenRecordingStart(input())).pendingHandle.transfer(); @@ -153,6 +165,7 @@ test('a download that fails after the instance stopped is retried by the next st expect(session.stopRecording).toHaveBeenCalledOnce(); expect(calls).toEqual([ + 'prepare-validator:mp4', 'prepare', 'start:5', 'stop', @@ -251,6 +264,7 @@ test('the bound owner serves record start and stop through the live device sessi result: { backend: 'limrun-recorder', stopObservation: { recorder: 'confirmed' } }, }); expect(calls).toEqual([ + 'prepare-validator:mp4', 'prepare', 'start:8', 'stop', diff --git a/packages/provider-limrun/src/recording-runtime.ts b/packages/provider-limrun/src/recording-runtime.ts index 64cc54ed92..b03aed77a3 100644 --- a/packages/provider-limrun/src/recording-runtime.ts +++ b/packages/provider-limrun/src/recording-runtime.ts @@ -59,6 +59,9 @@ async function startLimrunRecording( params: Parameters[0], input: ScreenRecordingStartInput, ) { + if (input.outputPath.toLowerCase().endsWith('.webm')) { + throw new AppError('INVALID_ARGS', 'Limrun records MP4; use a .mp4 output path.'); + } const session = params.getDeviceSession(params.device); if (!session) { throw new AppError( diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 63fc9cf0b5..d1acde9c28 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -178,3 +178,5 @@ export { type StreamCapture, type StreamCaptureOptions, } from './stream-capture.ts'; + +export { prepareRecordingValidatorFixture } from './recording-video.ts'; diff --git a/packages/test-kit/src/recording-video.ts b/packages/test-kit/src/recording-video.ts new file mode 100644 index 0000000000..4c30ceba8c --- /dev/null +++ b/packages/test-kit/src/recording-video.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import { createVideoValidatorPreparation } from '@agent-device/capture-kit/recording-video'; +import { withCommandExecutorOverride } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** Prepares validation for synthetic recording fixtures without host Swift tools. */ +export async function prepareRecordingValidatorFixture(host: HostEnvironment): Promise { + await withCommandExecutorOverride( + (command, args) => { + if (command !== 'xcrun' || args[0] !== 'swiftc') { + throw new Error(`Unexpected validator preparation command: ${command}`); + } + const [outputFlag, output] = args.slice(-2); + if (outputFlag !== '-o' || !output) + throw new Error('Swift compiler fixture received no executable output'); + fs.writeFileSync(output, `#!${process.execPath}\nprocess.exit(0);\n`, { mode: 0o755 }); + return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); + }, + async () => { + await createVideoValidatorPreparation(host).prepare(); + }, + ); +} diff --git a/src/__tests__/platform-runtime-apple-application-tools.test.ts b/src/__tests__/platform-runtime-apple-application-tools.test.ts index fb76905088..7f463b348f 100644 --- a/src/__tests__/platform-runtime-apple-application-tools.test.ts +++ b/src/__tests__/platform-runtime-apple-application-tools.test.ts @@ -19,7 +19,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) // loses the mock this way starts the real local XCTest runner — whose stale-process cleanup // `pkill`s xcodebuild on the developer's host, failing whichever test is running when it lands. test('concurrent runner ports share one module resolution, so the mock always applies', async () => { - const tools = createAppleApplicationTools(process.env); + const tools = createAppleApplicationTools(process.env, async () => {}); await Promise.all([ tools.detachRunnerSessionsForShutdown(), diff --git a/src/owned-fake-compiler.fixtures.ts b/src/owned-fake-compiler.fixtures.ts new file mode 100644 index 0000000000..cb228cff40 --- /dev/null +++ b/src/owned-fake-compiler.fixtures.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; + +/** How long the never-finishing compiler outlives a test that failed to stop it, in ms. */ +const SELF_BOUND_MS = 30_000; + +/** + * A `xcrun` that never finishes, so a test can watch the compiler its own spawn started and prove + * who stops it. It answers only the Swift compile `agent-device` issues, records the pid of every + * process this harness turned into `xcrun`, and bounds itself if nobody closes its owner. Nothing + * here scans the process table or signals a pid the harness did not create. + */ +export type OwnedFakeCompiler = Readonly<{ + /** The one `PATH` the host under test may name: it carries only the fake `xcrun`. */ + toolchainDir: string; + /** The Swift cache the host under test compiles into. */ + cacheDir: string; + /** The environment the host under test must carry for the fake tool to report itself. */ + env: Readonly>; + /** Every compiler this harness started that is still running. */ + runningCompilerPids(): number[]; + /** The cache's `bin` entries, so a lock or unpublished output can be named. */ + cacheBinEntries(): string[]; +}>; + +const PID_DIR_ENV = 'AGENT_DEVICE_FAKE_COMPILER_PID_DIR'; + +export function createOwnedFakeCompiler(name: string): OwnedFakeCompiler { + const root = mkdtempForTestSync(name); + const toolchainDir = path.join(root, 'toolchain'); + const pidDir = path.join(root, 'pids'); + const cacheDir = path.join(root, 'swift-cache'); + fs.mkdirSync(toolchainDir, { recursive: true }); + fs.writeFileSync( + path.join(toolchainDir, 'xcrun'), + [ + `#!${process.execPath}`, + "const fs = require('node:fs');", + `const pidDir = process.env[${JSON.stringify(PID_DIR_ENV)}];`, + `const selfBoundMs = ${SELF_BOUND_MS};`, + 'const args = process.argv.slice(2);', + "if (args.length < 4 || args[0] !== 'swiftc' || args[args.length - 2] !== '-o' || args.slice(1, -2).some((arg) => !arg.endsWith('.swift')) || !args[args.length - 1]) {", + ' console.error(`fake xcrun: expected swiftc ... -o ; got ${JSON.stringify(args)}`);', + ' process.exit(2);', + '}', + "process.on('SIGINT', () => process.exit(0));", + "process.on('SIGTERM', () => process.exit(0));", + 'fs.mkdirSync(pidDir, { recursive: true });', + 'fs.writeFileSync(`${pidDir}/${process.pid}`, process.argv.slice(2).join(" "));', + // A test that never reached its shutdown still gives this compiler back on its own, so no + // harness leaves a spinning child behind for the next worker or the next run to inherit. + 'setTimeout(() => process.exit(0), selfBoundMs);', + 'setInterval(() => {}, 1_000);', + '', + ].join('\n'), + { mode: 0o755 }, + ); + return { + toolchainDir, + cacheDir, + env: { [PID_DIR_ENV]: pidDir, AGENT_DEVICE_SWIFT_CACHE_DIR: cacheDir }, + runningCompilerPids: () => readPids(pidDir).filter(isProcessAlive), + cacheBinEntries: () => { + try { + return fs.readdirSync(path.join(cacheDir, 'bin')); + } catch { + return []; + } + }, + }; +} + +function readPids(pidDir: string): number[] { + try { + return fs + .readdirSync(pidDir) + .map(Number) + .filter((pid) => Number.isSafeInteger(pid) && pid > 0); + } catch { + return []; + } +} + +function isProcessAlive(pid: number): boolean { + try { + // Signal 0 is a liveness probe, which the unit-test signal guard always allows. + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/src/platform-runtime-apple-application-tools-close-alerts.test.ts b/src/platform-runtime-apple-application-tools-close-alerts.test.ts index 7142a8ddd1..9c27a93dc3 100644 --- a/src/platform-runtime-apple-application-tools-close-alerts.test.ts +++ b/src/platform-runtime-apple-application-tools-close-alerts.test.ts @@ -28,7 +28,7 @@ beforeEach(() => { const finalization = { retainRunner: false, stateDir: '/tmp' } as const; test('close dismisses the automation alert of the closed macOS app', async () => { - await createAppleApplicationTools(process.env).dismissCloseAlerts(MACOS_DEVICE, { + await createAppleApplicationTools(process.env, async () => {}).dismissCloseAlerts(MACOS_DEVICE, { ...finalization, surface: 'app', appBundleId: 'com.apple.systempreferences', @@ -41,7 +41,7 @@ test('close dismisses the automation alert of the closed macOS app', async () => }); test('close of the frontmost-app surface dismisses the frontmost automation alert', async () => { - await createAppleApplicationTools(process.env).dismissCloseAlerts(MACOS_DEVICE, { + await createAppleApplicationTools(process.env, async () => {}).dismissCloseAlerts(MACOS_DEVICE, { ...finalization, surface: 'frontmost-app', appBundleId: 'com.apple.systempreferences', @@ -54,7 +54,7 @@ test('close of the frontmost-app surface dismisses the frontmost automation aler }); test('close without a macOS app bundle dismisses with no target', async () => { - await createAppleApplicationTools(process.env).dismissCloseAlerts(MACOS_DEVICE, { + await createAppleApplicationTools(process.env, async () => {}).dismissCloseAlerts(MACOS_DEVICE, { ...finalization, surface: 'app', }); @@ -63,7 +63,7 @@ test('close without a macOS app bundle dismisses with no target', async () => { }); test('close on a non-macOS device dismisses nothing', async () => { - await createAppleApplicationTools(process.env).dismissCloseAlerts(IOS_SIMULATOR, { + await createAppleApplicationTools(process.env, async () => {}).dismissCloseAlerts(IOS_SIMULATOR, { ...finalization, surface: 'app', appBundleId: 'com.example.app', diff --git a/src/platform-runtime-apple-application-tools.test.ts b/src/platform-runtime-apple-application-tools.test.ts index 414732110c..0af330ec0b 100644 --- a/src/platform-runtime-apple-application-tools.test.ts +++ b/src/platform-runtime-apple-application-tools.test.ts @@ -46,7 +46,7 @@ const macOsDevice: DeviceInfo = { test('the runner session prewarm forwards the request execution, signal, and error policy to the runner module', async () => { const signal = new AbortController().signal; - await createAppleApplicationTools(process.env).prewarmRunnerSession( + await createAppleApplicationTools(process.env, async () => {}).prewarmRunnerSession( iosSimulator, { requestId: 'req-1', logPath: '/tmp/daemon.log', verbose: true }, signal, @@ -68,7 +68,7 @@ test('the runner session prewarm forwards the request execution, signal, and err test('preparing the runner spends the requested budget on stale-bundle cleanup, build, health, and startup', async () => { const signal = new AbortController().signal; - const result = await createAppleApplicationTools(process.env).prepareRunner( + const result = await createAppleApplicationTools(process.env, async () => {}).prepareRunner( iosSimulator, { timeoutMs: 240_000, @@ -105,10 +105,29 @@ test('a macOS frontmost-app open resolves the identity of the frontmost applicat }); await expect( - createAppleApplicationTools(process.env).resolveOpenTarget(macOsDevice, { + createAppleApplicationTools(process.env, async () => {}).resolveOpenTarget(macOsDevice, { surface: 'frontmost-app', currentAppBundleId: 'com.apple.TextEdit', }), ).resolves.toEqual({ appBundleId: 'com.apple.TextEdit', appName: 'TextEdit' }); expect(mockResolveFrontmostMacOsApp).toHaveBeenCalledOnce(); }); + +test('runner preparation succeeds when the optional recording helper cannot be prepared', async () => { + mockPrepareIosRunner.mockClear(); + const failure = new Error('video validator compile failed'); + const tools = createAppleApplicationTools(process.env, async () => { + throw failure; + }); + await expect( + tools.prepareRunner( + iosSimulator, + { + timeoutMs: 10_000, + execution: {}, + }, + new AbortController().signal, + ), + ).resolves.toMatchObject({ runner: { currentUptimeMs: 42 } }); + expect(mockPrepareIosRunner).toHaveBeenCalledOnce(); +}); diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index af895d1a22..faac35fef0 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -7,6 +7,7 @@ import type { PrepareAppleRunnerInput, } from '@agent-device/contracts/application-lifecycle-runtime'; import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import type { ScreenRecordingFinalizer } from '@agent-device/contracts/screen-recording-runtime-host'; import type { EnvMap } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; @@ -48,7 +49,10 @@ let retryModule: Promise | undefi const loadRetry = () => (retryModule ??= import('@agent-device/host-kit/retry')); /** Lazy Apple tools; the Apple package owns lifecycle sequencing around these primitives. */ -export function createAppleApplicationTools(env: Readonly): AppleApplicationTools { +export function createAppleApplicationTools( + env: Readonly, + prepareRecording: ScreenRecordingFinalizer['prepare'], +): AppleApplicationTools { return Object.freeze({ resolveOpenTarget: async (device, input) => await resolveAppleOpenTarget(device, input, env), prewarmRunnerCache: async (device, execution, signal) => { @@ -92,9 +96,10 @@ export function createAppleApplicationTools(env: Readonly): AppleApplica await releaseIosRunnerOnClose(deviceId, options); }, prepareRunner: async (device, input, signal) => { + const startedAtMs = Date.now(); + void prepareRecording({ container: 'mp4' }).catch(() => {}); const { Deadline } = await loadRetry(); const { prepareIosRunner } = await loadRunnerOperations(); - const startedAtMs = Date.now(); return await prepareIosRunner(device, { ...appleRunnerOptions(input.execution, signal), cleanStaleBundles: true, diff --git a/src/platform-runtime-application-resources.test.ts b/src/platform-runtime-application-resources.test.ts index ea728b5cdf..238cb5b30e 100644 --- a/src/platform-runtime-application-resources.test.ts +++ b/src/platform-runtime-application-resources.test.ts @@ -1,3 +1,5 @@ +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { createPlatformRuntimeShutdownError } from './platform-runtime-shutdown-error.ts'; import { expect, test, vi } from 'vitest'; import type { AndroidApplicationTools, @@ -10,6 +12,7 @@ function lifecycle(overrides: { recoverTestImeStartup: AndroidApplicationTools['recoverTestImeStartup']; detachRunnerSessionsForShutdown?: AppleApplicationTools['detachRunnerSessionsForShutdown']; finalizeRunnerSessionsForShutdown?: AppleApplicationTools['finalizeRunnerSessionsForShutdown']; + closeValidatorPreparation?: () => Promise; }) { return createApplicationResourceLifecycle({ android: { @@ -22,6 +25,9 @@ function lifecycle(overrides: { finalizeRunnerSessionsForShutdown: overrides.finalizeRunnerSessionsForShutdown ?? (async () => {}), } as unknown as AppleApplicationTools, + recording: { + closeValidatorPreparation: overrides.closeValidatorPreparation ?? (async () => {}), + }, }); } @@ -45,11 +51,13 @@ test('startup recovery reaches Android mechanics only after validated marker evi test('daemon shutdown phases delegate to the Apple runner owner', async () => { const detachRunnerSessionsForShutdown = vi.fn(async () => ['ios-sim']); const finalizeRunnerSessionsForShutdown = vi.fn(async () => ['ios-sim']); + const closeValidatorPreparation = vi.fn(async () => {}); const resources = lifecycle({ hasTestImeRecoveryEvidence: async () => false, recoverTestImeStartup: async () => {}, detachRunnerSessionsForShutdown, finalizeRunnerSessionsForShutdown, + closeValidatorPreparation, }); await resources.detachForDaemonShutdown(); @@ -57,4 +65,197 @@ test('daemon shutdown phases delegate to the Apple runner owner', async () => { expect(detachRunnerSessionsForShutdown).toHaveBeenCalledOnce(); expect(finalizeRunnerSessionsForShutdown).toHaveBeenCalledOnce(); + expect(closeValidatorPreparation).toHaveBeenCalledOnce(); +}); + +test('a validator compile is closed before the device sweep that may wait on a recorder', async () => { + const order: string[] = []; + const resources = lifecycle({ + hasTestImeRecoveryEvidence: async () => false, + recoverTestImeStartup: async () => {}, + finalizeRunnerSessionsForShutdown: async () => { + order.push('runners'); + return []; + }, + closeValidatorPreparation: async () => { + order.push('validator'); + }, + }); + + await resources.finalizeDaemonShutdown(); + + expect(order).toEqual(['validator', 'runners']); +}); + +test('a failing runner sweep still closes the validator compile and reports its own failure', async () => { + const sweepFailure = new Error('runner finalization failed'); + const closeValidatorPreparation = vi.fn(async () => {}); + const resources = lifecycle({ + hasTestImeRecoveryEvidence: async () => false, + recoverTestImeStartup: async () => {}, + finalizeRunnerSessionsForShutdown: async () => { + throw sweepFailure; + }, + closeValidatorPreparation, + }); + + await expect(resources.finalizeDaemonShutdown()).rejects.toBe(sweepFailure); + expect(closeValidatorPreparation).toHaveBeenCalledOnce(); +}); + +test('a failed validator close is reported when the runner sweep succeeded', async () => { + const closeFailure = new Error('swiftc cleanup failed'); + const resources = lifecycle({ + hasTestImeRecoveryEvidence: async () => false, + recoverTestImeStartup: async () => {}, + finalizeRunnerSessionsForShutdown: async () => ['ios-sim'], + closeValidatorPreparation: async () => { + throw closeFailure; + }, + }); + + await expect(resources.finalizeDaemonShutdown()).rejects.toBe(closeFailure); +}); + +test('shutdown retains both runner and validator cleanup failures', async () => { + const runner = new Error('runner failure'); + const validator = new Error('validator failure'); + const resources = lifecycle({ + hasTestImeRecoveryEvidence: async () => false, + recoverTestImeStartup: async () => {}, + finalizeRunnerSessionsForShutdown: async () => { + throw runner; + }, + closeValidatorPreparation: async () => { + throw validator; + }, + }); + const failure = await resources.finalizeDaemonShutdown().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual([runner, validator]); + expect((failure as AggregateError).cause).toBe(runner); +}); + +test('normalized shutdown preserves sanitized runner and validator cleanup evidence', async () => { + const compile = new AppError('COMMAND_FAILED', 'compile failed token=private', { + reason: 'compile_failed', + }); + const temp = new AppError( + 'COMMAND_FAILED', + 'temp failed', + { reason: 'swift_cache_temp_removal_failed' }, + new Error('password=hidden'), + ); + const lock = new AppError('COMMAND_FAILED', 'lock failed', { + reason: 'swift_cache_lock_release_failed', + }); + const validator = new AppError( + 'COMMAND_FAILED', + 'Swift cache cleanup failed', + { reason: 'swift_cache_cleanup_failed' }, + new AggregateError([compile, temp, lock]), + ); + const runner = new AppError('COMMAND_FAILED', 'runner failed', { + reason: 'runner_failed', + token: 'private', + }); + const resources = lifecycle({ + hasTestImeRecoveryEvidence: async () => false, + recoverTestImeStartup: async () => {}, + finalizeRunnerSessionsForShutdown: async () => { + throw runner; + }, + closeValidatorPreparation: async () => { + throw validator; + }, + }); + const ownerFailure = await resources.finalizeDaemonShutdown().catch((error: unknown) => error); + const normalized = normalizeError( + createPlatformRuntimeShutdownError([ + { owner: { kind: 'local-family', family: 'apple' }, error: ownerFailure }, + ]), + ); + expect(normalized.details).toMatchObject({ + failures: [ + { + error: { + details: { + shutdownFailures: [ + { details: { reason: 'runner_failed', token: '[REDACTED]' } }, + { + details: { + reason: 'swift_cache_cleanup_failed', + shutdownFailures: [ + { + message: 'compile failed token=[REDACTED]', + details: { reason: 'compile_failed' }, + }, + { + details: { reason: 'swift_cache_temp_removal_failed' }, + cause: { message: 'password=[REDACTED]' }, + }, + { details: { reason: 'swift_cache_lock_release_failed' } }, + ], + }, + }, + ], + }, + }, + }, + ], + }); + expect(JSON.stringify(normalized)).not.toContain('private'); + expect(JSON.stringify(normalized)).not.toContain('hidden'); +}); + +test('normalized validator-only shutdown retains cleanup evidence from both attempts', async () => { + const attempt = (name: string) => + new AppError( + 'COMMAND_FAILED', + 'Swift cache cleanup failed', + { reason: 'swift_cache_cleanup_failed' }, + new AggregateError([ + new AppError('COMMAND_FAILED', `${name} compile`, { reason: 'compile_failed' }), + new AppError('COMMAND_FAILED', `${name} removal`, { + reason: 'swift_cache_temp_removal_failed', + }), + ]), + ); + const resources = lifecycle({ + hasTestImeRecoveryEvidence: async () => false, + recoverTestImeStartup: async () => {}, + closeValidatorPreparation: async () => { + throw new AggregateError([attempt('first'), attempt('second')], 'attempts failed'); + }, + }); + const error = await resources.finalizeDaemonShutdown().catch((error: unknown) => error); + const result = normalizeError( + createPlatformRuntimeShutdownError([ + { owner: { kind: 'local-family', family: 'apple' }, error }, + ]), + ); + expect(result.details).toMatchObject({ + failures: [ + { + error: { + details: { + shutdownFailures: [ + { + details: { + reason: 'swift_cache_cleanup_failed', + shutdownFailures: [{ message: 'first compile' }, { message: 'first removal' }], + }, + }, + { + details: { + reason: 'swift_cache_cleanup_failed', + shutdownFailures: [{ message: 'second compile' }, { message: 'second removal' }], + }, + }, + ], + }, + }, + }, + ], + }); }); diff --git a/src/platform-runtime-application-resources.ts b/src/platform-runtime-application-resources.ts index 7936439422..da9f47d838 100644 --- a/src/platform-runtime-application-resources.ts +++ b/src/platform-runtime-application-resources.ts @@ -13,6 +13,8 @@ import type { export function createApplicationResourceLifecycle(tools: { android: AndroidApplicationTools; apple: AppleApplicationTools; + /** The recording host's validator owner: its background compile outlives every request. */ + recording: Readonly<{ closeValidatorPreparation(): Promise }>; }): ApplicationLifecycleResourceLifecycle { return Object.freeze({ // Android test IME is the one durable device mutation that outlives a crashed daemon. The @@ -22,6 +24,30 @@ export function createApplicationResourceLifecycle(tools: { await tools.android.recoverTestImeStartup(input); }, detachForDaemonShutdown: async () => await tools.apple.detachRunnerSessionsForShutdown(), - finalizeDaemonShutdown: async () => await tools.apple.finalizeRunnerSessionsForShutdown(), + finalizeDaemonShutdown: async () => { + // A cold validator compile runs outside every request, so this is the last owner that can + // stop it before the process exits. It closes first and on every path: a runner sweep that + // waits on a recorder, or fails, must not leave the compiler and its cache lock behind. + // Both failures remain available when both owners fail. + const validatorFailure = await tools.recording + .closeValidatorPreparation() + .then(() => ({ failed: false }) as const) + .catch((error: unknown) => ({ failed: true, error }) as const); + const sweep = await tools.apple + .finalizeRunnerSessionsForShutdown() + .then((devices) => ({ failed: false, devices }) as const) + .catch((error: unknown) => ({ failed: true, error }) as const); + if (sweep.failed) { + if (validatorFailure.failed) + throw new AggregateError( + [sweep.error, validatorFailure.error], + 'Daemon shutdown cleanup failed', + { cause: sweep.error }, + ); + throw sweep.error; + } + if (validatorFailure.failed) throw validatorFailure.error; + return sweep.devices; + }, }); } diff --git a/src/platform-runtime-operation-host-fake-compiler.test.ts b/src/platform-runtime-operation-host-fake-compiler.test.ts new file mode 100644 index 0000000000..997c87aaeb --- /dev/null +++ b/src/platform-runtime-operation-host-fake-compiler.test.ts @@ -0,0 +1,49 @@ +import { spawn, execFile } from 'node:child_process'; +import { once } from 'node:events'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { expect, test } from 'vitest'; +import { createOwnedFakeCompiler } from './owned-fake-compiler.fixtures.ts'; + +const execFileAsync = promisify(execFile); +const fixtureTest = test.skipIf(process.platform === 'win32'); + +fixtureTest('the fake compiler remains pending until its own child is cancelled', async () => { + const compiler = createOwnedFakeCompiler('fake-compiler-direct-'); + const child = spawn( + path.join(compiler.toolchainDir, 'xcrun'), + ['swiftc', 'validator.swift', 'support.swift', '-o', 'validator'], + { env: { ...process.env, ...compiler.env } }, + ); + const exited = once(child, 'exit'); + try { + await expect.poll(() => compiler.runningCompilerPids()).toContain(child.pid); + expect(child.exitCode).toBeNull(); + child.kill('SIGTERM'); + await expect(exited).resolves.toEqual([0, null]); + expect(compiler.runningCompilerPids()).toEqual([]); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await exited; + } + } +}); + +fixtureTest.each([ + ['--find', 'swiftc'], + ['simctl', 'list'], + ['swiftc', '-typecheck', 'validator.swift'], +])('the fake rejects unrelated xcrun invocation %j', async (...args) => { + const compiler = createOwnedFakeCompiler('fake-compiler-reject-'); + await expect( + execFileAsync(path.join(compiler.toolchainDir, 'xcrun'), args, { + env: { ...process.env, ...compiler.env }, + timeout: 2_000, + }), + ).rejects.toMatchObject({ + code: 2, + stderr: expect.stringContaining('fake xcrun: expected swiftc ... -o '), + }); + expect(compiler.runningCompilerPids()).toEqual([]); +}); diff --git a/src/platform-runtime-operation-host-validator-shutdown.test.ts b/src/platform-runtime-operation-host-validator-shutdown.test.ts new file mode 100644 index 0000000000..34f2b3188f --- /dev/null +++ b/src/platform-runtime-operation-host-validator-shutdown.test.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { createPlatformRuntimeHost } from './platform-runtime-operation-host.ts'; +import { createOwnedFakeCompiler } from './owned-fake-compiler.fixtures.ts'; + +const snapshot = { + captureSurface: async () => ({ + backend: 'linux-atspi' as const, + producer: 'linux-atspi' as const, + nodes: [], + truncated: false, + }), + presentIosAcquisition: async () => ({ + backend: 'xctest' as const, + producer: 'appium-source' as const, + nodes: [], + }), +}; + +const shutdownLoaders = { + apple: async () => ({ + canShutdownTarget: () => true, + shutdownTarget: async () => ({ success: true, exitCode: 0, stdout: '', stderr: '' }), + }), + android: async () => ({ + canShutdownTarget: () => true, + shutdownTarget: async () => ({ success: true, exitCode: 0, stdout: '', stderr: '' }), + }), +}; + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; +// The only compiler this file can learn about is one its own spawn started. +const compiler = createOwnedFakeCompiler('operation-host-validator-shutdown-'); +const hostEnvironment: HostEnvironment = { + env: { PATH: compiler.toolchainDir, ...compiler.env }, + cwd: compiler.toolchainDir, +}; + +beforeAll(() => { + // Swift preparation compiles only on a macOS host, so the host under test reads as one. The fake + // tool it spawns stays this worker's own child, so the unit lane's signal guard still applies. + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); +}); + +afterAll(() => { + Object.defineProperty(process, 'platform', originalPlatform); +}); + +const validatorShutdownTest = test.skipIf(process.platform === 'win32'); + +validatorShutdownTest( + 'daemon shutdown stops the validator compiler a recording left running in the background', + async () => { + const host = createRuntimeHost( + hostEnvironment, + path.join(compiler.toolchainDir, 'sessions'), + 'sessions', + ); + + await host.screenRecording.finalize.prepare({ container: 'mp4' }); + await expect.poll(() => compiler.runningCompilerPids()).toHaveLength(1); + const compilerPid = compiler.runningCompilerPids()[0]!; + // The compile belongs to no request: the warm-up already returned, the compiler is still + // running, it holds the Swift cache lock, and its unpublished output sits inside the cache. + expect(compiler.cacheBinEntries().some((entry) => entry.endsWith('.lock'))).toBe(true); + expect(compiler.cacheBinEntries().filter((entry) => entry.startsWith('.'))).toHaveLength(1); + + await host.applicationResources.finalizeDaemonShutdown(); + + // The shutdown owns what no request ever did: the compiler is gone and left neither its lock + // nor its unpublished output behind. + expect(compiler.runningCompilerPids()).not.toContain(compilerPid); + await expect.poll(() => compiler.runningCompilerPids()).toEqual([]); + expect(compiler.cacheBinEntries().some((entry) => entry.endsWith('.lock'))).toBe(false); + expect(compiler.cacheBinEntries().filter((entry) => entry.startsWith('.'))).toEqual([]); + }, +); + +validatorShutdownTest( + 'a shutdown that never recorded starts no compiler and loads no Swift preparation', + async () => { + const idleCompiler = createOwnedFakeCompiler('operation-host-validator-idle-'); + const host = createRuntimeHost( + { env: { PATH: idleCompiler.toolchainDir, ...idleCompiler.env }, cwd: '/tmp' }, + '/tmp/platform-runtime-idle-sessions', + 'idle-sessions', + ); + + await expect(host.applicationResources.finalizeDaemonShutdown()).resolves.toEqual([]); + + // Nothing recorded, so shutdown reached no Swift material at all: no cache was created, and no + // compiler existed for it to stop. + expect(idleCompiler.runningCompilerPids()).toEqual([]); + expect(fs.existsSync(idleCompiler.cacheDir)).toBe(false); + }, +); + +validatorShutdownTest( + 'a warm-up that arrives after shutdown closes without starting a compiler', + async () => { + const lateCompiler = createOwnedFakeCompiler('operation-host-validator-late-'); + const host = createRuntimeHost( + { env: { PATH: lateCompiler.toolchainDir, ...lateCompiler.env }, cwd: '/tmp' }, + '/tmp/platform-runtime-late-sessions', + 'late-sessions', + ); + + await host.applicationResources.finalizeDaemonShutdown(); + await host.screenRecording.finalize.prepare({ container: 'mp4' }); + + // A warm-up that reaches a closed host must not leave a compiler nobody owns: the closed host + // hands out an owner that refuses to compile, so no Swift cache is even created. The warm-up + // starts its compile outside the call that asked for it, so keep watching past its return. + await expect + .poll(() => lateCompiler.runningCompilerPids(), { timeout: 2_000, interval: 50 }) + .toEqual([]); + expect(fs.existsSync(lateCompiler.cacheDir)).toBe(false); + }, +); + +validatorShutdownTest( + 'a shutdown while preparation is still loading closes the preparation it produces', + async () => { + const racingCompiler = createOwnedFakeCompiler('operation-host-validator-racing-'); + const host = createRuntimeHost( + { env: { PATH: racingCompiler.toolchainDir, ...racingCompiler.env }, cwd: '/tmp' }, + '/tmp/platform-runtime-racing-sessions', + 'racing-sessions', + ); + + // The warm-up is still importing its Swift material when the shutdown runs, so the preparation + // owner is created after the host is already closed. Shutdown must still settle it. + const warming = host.screenRecording.finalize.prepare({ container: 'mp4' }); + await host.applicationResources.finalizeDaemonShutdown(); + await warming; + + // The compile that the late import started is gone and left neither its lock nor its + // unpublished output behind. + await expect.poll(() => racingCompiler.runningCompilerPids(), { timeout: 5_000 }).toEqual([]); + expect(racingCompiler.cacheBinEntries().some((entry) => entry.endsWith('.lock'))).toBe(false); + expect(racingCompiler.cacheBinEntries().filter((entry) => entry.startsWith('.'))).toEqual([]); + }, +); + +function createRuntimeHost(host: HostEnvironment, sessionsDir: string, sessionPrefix: string) { + return createPlatformRuntimeHost({ + host, + sessionsDir, + resolveSessionArtifacts: (sessionId) => ({ + outputPath: `${sessionsDir}/${sessionPrefix}/${sessionId}/app.log`, + pidPath: `${sessionsDir}/${sessionPrefix}/${sessionId}/app-log.pid`, + }), + shutdownLoaders, + snapshot, + }); +} diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index 47e3f85a78..cc7db95dd3 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -75,7 +75,11 @@ export function createPlatformRuntimeHost(options: { return await resolveWebNetworkTransport(device, host); }, }); - const appleApplications = createAppleApplicationTools(env); + const screenRecording = createScreenRecordingRuntimeHost({ + ownedProcesses: options.ownedProcesses, + host, + }); + const appleApplications = createAppleApplicationTools(env, screenRecording.finalize.prepare); const androidApplications = createAndroidApplicationTools(); return Object.freeze({ hostEnvironment: host, @@ -121,10 +125,7 @@ export function createPlatformRuntimeHost(options: { options.shutdownLoaders, options.assertShutdownAllowed, ), - screenRecording: createScreenRecordingRuntimeHost({ - ownedProcesses: options.ownedProcesses, - host, - }), + screenRecording, audioProbe: createAudioProbeRuntimeHost({ ownedProcesses: options.ownedProcesses, host }), perf: createPerfRuntimeHost(), snapshot: options.snapshot, @@ -134,6 +135,7 @@ export function createPlatformRuntimeHost(options: { applicationResources: createApplicationResourceLifecycle({ android: androidApplications, apple: appleApplications, + recording: screenRecording, }), clock: Object.freeze({ now: () => Date.now(), diff --git a/src/platform-runtime-screen-recording-android-host.test.ts b/src/platform-runtime-screen-recording-android-host.test.ts index a493d2db9d..be97173fbf 100644 --- a/src/platform-runtime-screen-recording-android-host.test.ts +++ b/src/platform-runtime-screen-recording-android-host.test.ts @@ -1,8 +1,15 @@ import { expect, test, vi } from 'vitest'; import { withAndroidAdbProvider } from '@agent-device/platform-android/mechanics'; +import type { VideoValidatorPreparation } from '@agent-device/capture-kit/recording-video'; import { createAndroidScreenRecordingTransport } from './platform-runtime-screen-recording-android-host.ts'; import './platform-runtime-android-adb-host.ts'; -import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; + +/** The host's validator owner, at the port the transport consumes. */ +const preparedVideoValidator: VideoValidatorPreparation = { + prepare: async () => ({ kind: 'container' }), + read: () => Promise.resolve({ kind: 'container' }), + close: async () => {}, +}; const adbExecutor = vi.hoisted(() => ({ override: undefined as @@ -53,7 +60,7 @@ test('uses the request-scoped Android ADB executor rather than a host fallback', async () => { const transport = await createAndroidScreenRecordingTransport( android, - createLiveTestHostEnvironment(), + preparedVideoValidator, ); expect(transport.mode).toBe('transport-composed'); await expect(transport.start({ remotePath: '/sdcard/capture.mp4' })).resolves.toEqual({ @@ -102,7 +109,7 @@ test('finds only exact screenrecord processes for the canonical remote path', as async () => { const transport = await createAndroidScreenRecordingTransport( android, - createLiveTestHostEnvironment(), + preparedVideoValidator, ); await expect(transport.probeRunningWriters(remotePath)).resolves.toEqual({ writers: [ @@ -175,7 +182,7 @@ test.each(inconclusiveWriterScans)( async () => { const transport = await createAndroidScreenRecordingTransport( android, - createLiveTestHostEnvironment(), + preparedVideoValidator, ); await expect(transport.probeRunningWriters(writerScanRemotePath)).resolves.toEqual( expected, @@ -204,7 +211,7 @@ test('revalidates start-time and exact argv before SIGINT', async () => { async () => { const transport = await createAndroidScreenRecordingTransport( android, - createLiveTestHostEnvironment(), + preparedVideoValidator, ); const process = { pid: '51', @@ -238,7 +245,7 @@ test('distinguishes a missing proc directory from an unavailable identity probe' async () => { const transport = await createAndroidScreenRecordingTransport( android, - createLiveTestHostEnvironment(), + preparedVideoValidator, ); const expected = { remotePath: '/sdcard/capture.mp4', startTime: '42' }; await expect(transport.inspect({ ...expected, pid: '42' })).resolves.toBe('missing'); @@ -253,10 +260,7 @@ test('retains an interrupted empty-stderr presence probe as uncertain', async () return result('', '', null); }; try { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, preparedVideoValidator); await expect( transport.inspect({ pid: '44', @@ -281,10 +285,7 @@ test('credits a path as absent only from a probe that answered, not from one tha return result(''); }; try { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, preparedVideoValidator); await expect(transport.exists('/sdcard/capture.mp4')).resolves.toBe(false); await expect(transport.exists('/sdcard/capture.mp4')).resolves.toBe('uncertain'); await expect(transport.exists('/sdcard/capture.mp4')).resolves.toBe('uncertain'); @@ -301,10 +302,7 @@ test('retains an interrupted empty-stderr manifest probe as unavailable', async return result('', '', null); }; try { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, preparedVideoValidator); await expect(transport.readManifest('/sdcard/interrupted.json')).resolves.toEqual({ status: 'unavailable', message: 'Android recording manifest probe failed', @@ -341,7 +339,7 @@ test('proves termination from each ownership-lost producer without signalling', async () => { const transport = await createAndroidScreenRecordingTransport( android, - createLiveTestHostEnvironment(), + preparedVideoValidator, ); for (const replacement of [reassignedPid, otherArtifact, exitedTask]) { identity = replacement; @@ -377,7 +375,7 @@ test('classifies a replacement recorder on the same path as a foreign writer and async () => { const transport = await createAndroidScreenRecordingTransport( android, - createLiveTestHostEnvironment(), + preparedVideoValidator, ); await expect(transport.inspect(recorded)).resolves.toBe('foreign-writer'); await expect(transport.stop(recorded)).resolves.toBe('ownership-lost'); @@ -429,7 +427,7 @@ test('retains unavailable manifest reads and confirms manifest deletion', async async () => { const transport = await createAndroidScreenRecordingTransport( android, - createLiveTestHostEnvironment(), + preparedVideoValidator, ); await expect(transport.readManifest('/sdcard/manifest.json')).resolves.toEqual({ status: 'unavailable', diff --git a/src/platform-runtime-screen-recording-android-host.ts b/src/platform-runtime-screen-recording-android-host.ts index 0141a07dda..d8f6660d68 100644 --- a/src/platform-runtime-screen-recording-android-host.ts +++ b/src/platform-runtime-screen-recording-android-host.ts @@ -5,9 +5,11 @@ import type { AndroidScreenRecordingTransport, } from '@agent-device/contracts/screen-recording-runtime-host'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { type ShellWord, shellFragment, shellQuote } from '@agent-device/kernel/device-shell'; -import { isPlayableVideo } from '@agent-device/capture-kit/recording-video'; +import { + isPlayableVideo, + type VideoValidatorPreparation, +} from '@agent-device/capture-kit/recording-video'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; const ANDROID_MANIFEST_NAME = 'agent-device-recording-active.json'; @@ -16,7 +18,7 @@ const BIT_RATE = { medium: 8_000_000, high: 20_000_000 } as const; export async function createAndroidScreenRecordingTransport( device: DeviceInfo, - host: HostEnvironment, + validator: VideoValidatorPreparation, ): Promise { const { resolveAndroidAdbExecutor, resolveScopedAndroidAdbBackgroundTransport, runAdbShell } = await loadAndroidMechanics(); @@ -109,7 +111,7 @@ export async function createAndroidScreenRecordingTransport( }); return { ...result, - playable: result.exitCode === 0 && (await isPlayableVideo(outputPath, host)), + playable: result.exitCode === 0 && (await isPlayableVideo(outputPath, validator.read())), }; }, remove: async (remotePath, signal) => diff --git a/src/platform-runtime-screen-recording-finalizer-host-deadline.test.ts b/src/platform-runtime-screen-recording-finalizer-host-deadline.test.ts new file mode 100644 index 0000000000..a698b540bf --- /dev/null +++ b/src/platform-runtime-screen-recording-finalizer-host-deadline.test.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { runCmd } from '@agent-device/host-kit/command'; +import { Deadline } from '@agent-device/host-kit/retry'; +import { createRequestCanceledError } from '@agent-device/kernel/errors'; +import { createVideoValidatorPreparation } from '@agent-device/capture-kit/recording-video'; +import { createScreenRecordingFinalizer } from './platform-runtime-screen-recording-finalizer-host.ts'; + +vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ + ...(await importOriginal()), + runCmd: vi.fn(), +})); +vi.mock(import('@agent-device/capture-kit/recording-overlay'), async (importOriginal) => ({ + ...(await importOriginal()), + startRecordingOverlayBudget: vi + .fn() + .mockImplementationOnce(() => Deadline.fromTimeoutMs(1)) + .mockImplementation(() => Deadline.fromTimeoutMs(10_000)), +})); + +test('a finalizer deadline leaves the compiler running for a later stop', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + const root = mkdtempForTestSync('finalizer-validator-deadline-'); + const outputPath = path.join(root, 'recording.mp4'); + fs.writeFileSync(outputPath, Buffer.from('0000000c6674797069736f6d000000086d6f6f76', 'hex')); + const host = { + cwd: root, + env: { ...process.env, AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(root, 'cache') }, + }; + const preparation = createVideoValidatorPreparation(host); + const finalizer = createScreenRecordingFinalizer(host, preparation); + const input = { outputPath, showTouches: false, gestureEvents: [], targetLabel: 'recording' }; + let finishCompile: (() => void) | undefined; + let compilerSignal: AbortSignal | undefined; + let compiledPath = ''; + const compilerEntered = vi.fn(); + const validatorCalls: string[] = []; + vi.mocked(runCmd).mockImplementation(async (command, args, options) => { + if (command === 'xcrun') { + compilerSignal = options?.signal; + compiledPath = args[args.indexOf('-o') + 1]!; + compilerEntered(); + await new Promise((resolve, reject) => { + const signal = options?.signal; + const abort = () => reject(createRequestCanceledError()); + finishCompile = () => { + signal?.removeEventListener('abort', abort); + resolve(); + }; + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + }); + fs.writeFileSync(compiledPath, 'validator'); + fs.chmodSync(compiledPath, 0o755); + } else { + validatorCalls.push(command); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }); + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + try { + await finalizer.prepare({ container: 'mp4' }); + await vi.waitFor(() => expect(compilerEntered).toHaveBeenCalledOnce(), { timeout: 1_000 }); + await expect(finalizer.complete(input)).rejects.toMatchObject({ + details: { reason: 'video_validator_not_prepared', retriable: true }, + }); + expect(compilerSignal?.aborted).toBe(false); + expect(compilerEntered).toHaveBeenCalledOnce(); + expect(validatorCalls).toEqual([]); + + finishCompile!(); + await expect(finalizer.complete(input)).resolves.toEqual({ telemetryPath: expect.any(String) }); + expect(compilerSignal?.aborted).toBe(false); + expect(compilerEntered).toHaveBeenCalledOnce(); + expect(validatorCalls).toEqual([ + path.join(path.dirname(path.dirname(compiledPath)), path.basename(compiledPath)), + ]); + } finally { + finishCompile?.(); + try { + await preparation.close(); + } finally { + Object.defineProperty(process, 'platform', platform); + } + } +}); diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 9eb0b6f859..e9afca797f 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -6,6 +6,7 @@ import { } from '@agent-device/capture-kit/recording-overlay'; import { Deadline } from '@agent-device/host-kit/retry'; import { AppError } from '@agent-device/kernel/errors'; +import type { PreparedVideoValidator } from '@agent-device/capture-kit/recording-video'; import { createScreenRecordingFinalizer } from './platform-runtime-screen-recording-finalizer-host.ts'; const video = vi.hoisted(() => ({ @@ -18,7 +19,8 @@ const telemetry = vi.hoisted(() => vi.fn(() => '/tmp/capture.telemetry.json')); const overlay = vi.hoisted(() => vi.fn(async () => {})); bindTestCommandHost(); -vi.mock('@agent-device/capture-kit/recording-video', () => ({ +vi.mock('@agent-device/capture-kit/recording-video', async (importOriginal) => ({ + ...(await importOriginal()), waitForStableFile: video.stable, waitForPlayableVideo: video.playable, isPlayableVideo: video.isPlayable, @@ -44,8 +46,42 @@ beforeEach(() => { video.container.mockReset().mockResolvedValue(true); }); +const validatorAuthority: PreparedVideoValidator = Object.freeze({ + kind: 'avfoundation' as const, + executablePath: '/prepared/validator', + env: {}, +}); + +/** Stands in for the runtime host's one preparation owner, at the port the finalizer consumes. */ +function createPreparationStub( + overrides: Partial<{ + prepare: () => Promise; + read: () => Promise | undefined; + close: () => Promise; + }> = {}, +) { + // One promise for every read: the finalizer hands the authority it read straight to validation. + const reading = Promise.resolve(validatorAuthority); + return { + reading, + prepare: vi.fn(overrides.prepare ?? (async () => validatorAuthority)), + read: vi.fn(overrides.read ?? (() => reading)), + close: vi.fn(overrides.close ?? (async () => {})), + }; +} + +function createFinalizer(preparation = createPreparationStub()) { + return { + preparation, + finalizer: createScreenRecordingFinalizer(createLiveTestHostEnvironment(), preparation), + }; +} + test('requires stable playable media before publishing finalization telemetry', async () => { - const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + const { preparation, finalizer } = createFinalizer(); + await finalizer.prepare({ container: 'mp4' }); + expect(preparation.prepare).toHaveBeenCalledOnce(); + const result = await finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: false, gestureEvents: [], @@ -54,17 +90,38 @@ test('requires stable playable media before publishing finalization telemetry', expect(video.stable).toHaveBeenCalledWith('/tmp/capture.mp4'); expect(video.playable).toHaveBeenCalledWith('/tmp/capture.mp4', { - host: expect.objectContaining({ cwd: process.cwd() }), + validator: preparation.reading, deadline: expect.any(Deadline), }); expect(result).toEqual({ telemetryPath: '/tmp/capture.telemetry.json' }); }); +test('background preparation never waits for or fails a start', async () => { + const preparation = createPreparationStub({ + prepare: () => new Promise(() => undefined), + }); + const { finalizer } = createFinalizer(preparation); + + await expect(finalizer.prepare({ container: 'mp4' })).resolves.toBeUndefined(); + expect(preparation.prepare).toHaveBeenCalledOnce(); + await expect(finalizer.prepare({ container: 'webm' })).resolves.toBeUndefined(); + expect(preparation.prepare).toHaveBeenCalledOnce(); +}); + +test('a rejected preparation stays invisible to the start that warmed it', async () => { + const preparation = createPreparationStub({ + prepare: () => Promise.reject(new AppError('TOOL_MISSING', 'no swiftc')), + }); + const { finalizer } = createFinalizer(preparation); + + await expect(finalizer.prepare({ container: 'mp4' })).resolves.toBeUndefined(); +}); + test('names the retry and the escape for a recording that never became playable', async () => { video.playable.mockResolvedValueOnce(false); await expect( - createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + createFinalizer().finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: false, gestureEvents: [], @@ -81,15 +138,15 @@ test('names the retry and the escape for a recording that never became playable' }); }); -test('a validator compile that outlasts the budget reaches the caller as its own retriable error', async () => { - const compileTimeout = new AppError('COMMAND_FAILED', 'The video validator did not compile', { - reason: 'video_validator_compile_timeout', +test('a validator that was not prepared reaches the caller as its own retriable error', async () => { + const compileTimeout = new AppError('COMMAND_FAILED', 'The video validator was not prepared', { + reason: 'video_validator_not_prepared', retriable: true, }); video.playable.mockRejectedValueOnce(compileTimeout); await expect( - createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + createFinalizer().finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: false, gestureEvents: [], @@ -100,7 +157,7 @@ test('a validator compile that outlasts the budget reaches the caller as its own }); test('burns touches into a playable recording without a warning', async () => { - const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + const result = await createFinalizer().finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -112,7 +169,7 @@ test('burns touches into a playable recording without a warning', async () => { }); test('hands the caller fps to the touch overlay', async () => { - await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + await createFinalizer().finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -124,8 +181,6 @@ test('hands the caller fps to the touch overlay', async () => { }); test('the overlay budget covers the playability checks that run before the overlay', async () => { - // A cold host compiles the playability validator before the overlay starts; that time comes out - // of the overlay budget rather than being added to it. vi.useFakeTimers({ toFake: ['Date'] }); try { const playabilityMs = 25_000; @@ -133,7 +188,7 @@ test('the overlay budget covers the playability checks that run before the overl vi.setSystemTime(Date.now() + playabilityMs); }); - await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + await createFinalizer().finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -142,7 +197,6 @@ test('the overlay budget covers the playability checks that run before the overl const budget = overlay.mock.calls[0]![0].deadline; expect(budget.remainingMs()).toBe(OVERLAY_BUDGET_MS - playabilityMs); - // The validator compile those checks may run is bounded by the same budget. expect(video.playable.mock.calls[0]![1].deadline).toBe(budget); } finally { vi.useRealTimers(); @@ -162,7 +216,7 @@ test('a recording confirmed playable as the budget runs out is not refused by a }); await expect( - createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + createFinalizer().finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: false, gestureEvents: [], @@ -187,7 +241,7 @@ test('#2707 reports a dropped overlay and keeps the raw when compositing throws' ), ); - const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + const result = await createFinalizer().finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -200,38 +254,41 @@ test('#2707 reports a dropped overlay and keeps the raw when compositing throws' }); }); -test('#2707 reports a warning when a composited recording turns unplayable after burn-in', async () => { - // Narrower than it looks: overlay.ts confirms the composite playable before renaming, so this only - // models the post-rename TOCTOU where the file the raw was replaced with goes unplayable. The raw - // is already overwritten here, so the finalizer can report but not restore — it must not exit 0 - // silently, which the pre-rename probe and the Swift check already prevent for ordinary failures. - overlay.mockImplementationOnce(async () => { - video.isPlayable.mockResolvedValueOnce(false); - }); - - const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ - outputPath: '/tmp/capture.mp4', - showTouches: true, - gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], - targetLabel: 'test recording', - }); - - expect(result).toMatchObject({ - overlayWarning: expect.stringContaining('failed to overlay recording touches'), - }); -}); +test.each([0, OVERLAY_BUDGET_MS])( + 'a verified overlay is published without another check after %i ms', + async (elapsedMs) => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + overlay.mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + elapsedMs); + }); + video.isPlayable.mockResolvedValueOnce(false); + const result = await createFinalizer().finalizer.complete({ + outputPath: '/tmp/capture.mp4', + showTouches: true, + gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], + targetLabel: 'test recording', + }); + expect(overlay).toHaveBeenCalledOnce(); + expect(result).toEqual({ telemetryPath: '/tmp/capture.telemetry.json' }); + expect(video.isPlayable).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }, +); test('sniffs a collected copy without spawning the validator, and refuses one with no container', async () => { const validatorCalls = video.isPlayable.mock.calls.length + video.playable.mock.calls.length; await expect( - createScreenRecordingFinalizer(createLiveTestHostEnvironment()).sniff({ + createFinalizer().finalizer.sniff({ outputPath: '/tmp/capture.collected.mp4', }), ).resolves.toBeUndefined(); video.container.mockResolvedValueOnce(false); await expect( - createScreenRecordingFinalizer(createLiveTestHostEnvironment()).sniff({ + createFinalizer().finalizer.sniff({ outputPath: '/tmp/capture.collected.mp4', }), ).rejects.toMatchObject({ diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts index d6af20ae47..028d9155ee 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -11,17 +11,24 @@ import { import { persistRecordingTelemetry } from '@agent-device/capture-kit/recording-telemetry'; import { hasVideoContainer, - isPlayableVideo, + type VideoValidator, + type VideoValidatorPreparation, waitForPlayableVideo, waitForStableFile, } from '@agent-device/capture-kit/recording-video'; export function createScreenRecordingFinalizer( host: HostEnvironment, + validator: VideoValidatorPreparation, ): ScreenRecordingRuntimeHost['finalize'] { return Object.freeze({ + prepare: async (input) => { + // Preparation is best effort and outside this request: it never delays a capture, and a stop + // that outlives it keeps the compile running for the host that owns it. + if (input.container === 'mp4') void validator.prepare().catch(() => undefined); + }, sniff: async (input) => await sniffRecording(input), - complete: async (input) => await finalizeScreenRecording(host, input), + complete: async (input) => await finalizeScreenRecording(host, validator, input), }); } @@ -31,18 +38,25 @@ async function sniffRecording(input: Readonly<{ outputPath: string }>): Promise< async function finalizeScreenRecording( host: HostEnvironment, + validator: VideoValidatorPreparation, input: Parameters[0], ) { + const prepared = validator.read(); const finalizeDeadline = startRecordingOverlayBudget(); await waitForStableFile(input.outputPath); - if (!(await waitForPlayableVideo(input.outputPath, { host, deadline: finalizeDeadline }))) { + if ( + !(await waitForPlayableVideo(input.outputPath, { + validator: prepared, + deadline: finalizeDeadline, + })) + ) { throw unplayableRecording(input.outputPath); } const telemetryPath = persistRecordingTelemetry({ recording: { outPath: input.outputPath, gestureEvents: [...input.gestureEvents] }, }); if (!input.showTouches || input.gestureEvents.length === 0) return { telemetryPath }; - return await overlayTouches(host, input, telemetryPath, finalizeDeadline); + return await overlayTouches(host, input, telemetryPath, finalizeDeadline, prepared); } async function overlayTouches( @@ -50,6 +64,7 @@ async function overlayTouches( input: Parameters[0], telemetryPath: string, deadline: Deadline, + validator: VideoValidator | undefined, ) { const warning = getRecordingOverlaySupportWarning(); if (warning) return { telemetryPath, overlayWarning: warning }; @@ -62,10 +77,8 @@ async function overlayTouches( ...(input.fps === undefined ? {} : { fps: input.fps }), targetLabel: input.targetLabel, deadline, + validator, }); - if (!(await isPlayableVideo(input.outputPath, host))) { - throw new Error('recording post-processing produced an unplayable video'); - } return { telemetryPath }; } catch (error) { return { diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts index 27080d469b..d5ba206f10 100644 --- a/src/platform-runtime-screen-recording-host.ts +++ b/src/platform-runtime-screen-recording-host.ts @@ -1,18 +1,52 @@ import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { OwnedProcessRecordWriter } from '@agent-device/contracts/platform-runtime-host'; import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/screen-recording-runtime-host'; +import type { VideoValidatorPreparation } from '@agent-device/capture-kit/recording-video'; + +/** + * The one validator owner this host composes, plus the shutdown its compile needs. A cold compile + * runs outside any request, so the host that started it is the only owner left that can stop it; + * the daemon closes it with the rest of its durable resources. + */ +export type ScreenRecordingVideoValidatorHost = Readonly<{ + /** + * Cancels an unfinished validator compile this host started and awaits its settlement, including + * the compiler's exit, its temporary output and the Swift cache lock. It loads nothing this host + * never loaded, and it never waits for a compile that already settled. + */ + closeValidatorPreparation(): Promise; +}>; /** Lazy host-only composition; platform semantics stay package-owned. */ export function createScreenRecordingRuntimeHost(options: { ownedProcesses?: OwnedProcessRecordWriter; host: HostEnvironment; -}): ScreenRecordingRuntimeHost { +}): ScreenRecordingRuntimeHost & ScreenRecordingVideoValidatorHost { const host = options.host; + // One preparation owner for this host, created when a recording first asks for it. Compilation + // across runtimes and processes stays with the Swift cache lock, not a second lock database. + let validator: Promise | undefined; + let validatorClosed = false; + const loadValidator = async () => { + validator ??= import('@agent-device/capture-kit/recording-video').then((video) => + video.createVideoValidatorPreparation(host), + ); + const preparation = await validator; + // A load that lands after this host closed hands out a closed owner: nothing it is asked for + // afterwards may start a compile no shutdown will reach. + if (validatorClosed) await preparation.close(); + return preparation; + }; + const loadFinalizer = async () => { + const { createScreenRecordingFinalizer } = + await import('./platform-runtime-screen-recording-finalizer-host.ts'); + return createScreenRecordingFinalizer(host, await loadValidator()); + }; const android: ScreenRecordingRuntimeHost['android'] = Object.freeze({ resolve: async (device) => { const { createAndroidScreenRecordingTransport } = await import('./platform-runtime-screen-recording-android-host.ts'); - return await createAndroidScreenRecordingTransport(device, host); + return await createAndroidScreenRecordingTransport(device, await loadValidator()); }, }); const web: ScreenRecordingRuntimeHost['web'] = Object.freeze({ @@ -40,16 +74,9 @@ export function createScreenRecordingRuntimeHost(options: { }, }); const finalize: ScreenRecordingRuntimeHost['finalize'] = Object.freeze({ - sniff: async (input) => { - const { createScreenRecordingFinalizer } = - await import('./platform-runtime-screen-recording-finalizer-host.ts'); - await createScreenRecordingFinalizer(host).sniff(input); - }, - complete: async (input, signal) => { - const { createScreenRecordingFinalizer } = - await import('./platform-runtime-screen-recording-finalizer-host.ts'); - return await createScreenRecordingFinalizer(host).complete(input, signal); - }, + prepare: async (input) => (await loadFinalizer()).prepare(input), + sniff: async (input) => (await loadFinalizer()).sniff(input), + complete: async (input, signal) => (await loadFinalizer()).complete(input, signal), }); const ownedProcesses: OwnedProcessRecordWriter = options.ownedProcesses ?? @@ -65,6 +92,14 @@ export function createScreenRecordingRuntimeHost(options: { outputs, finalize, ownedProcesses, + closeValidatorPreparation: async () => { + // Closed first, so a preparation that is still loading is closed too and nothing that comes + // afterwards can start a compile this shutdown will not reach. + validatorClosed = true; + // Nothing loaded means nothing to close: a host that never recorded never started a compile. + if (!validator) return; + await (await validator).close(); + }, }); } diff --git a/src/platform-runtime-shutdown-error.ts b/src/platform-runtime-shutdown-error.ts index dc82f2f201..1bac0f6132 100644 --- a/src/platform-runtime-shutdown-error.ts +++ b/src/platform-runtime-shutdown-error.ts @@ -1,4 +1,5 @@ import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; +import { redactDiagnosticData } from '@agent-device/kernel/redaction'; import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; type PlatformRuntimeShutdownFailure = Readonly<{ @@ -19,7 +20,10 @@ export function createPlatformRuntimeShutdownError( ): AppError { const details: PlatformRuntimeShutdownErrorDetails = { reason: 'platform-owner-shutdown-failed', - failures: failures.map(({ owner, error }) => ({ owner, error: normalizeError(error) })), + failures: failures.map(({ owner, error }) => ({ + owner, + error: normalizeShutdownFailure(error), + })), }; const message = `Platform runtime owner shutdown failed: ${details.failures .map( @@ -43,3 +47,25 @@ export function createPlatformRuntimeShutdownError( ), ); } + +function normalizeShutdownFailure(error: unknown, ancestors = new Set()): NormalizedError { + const normalized = redactDiagnosticData(normalizeError(error)); + if (ancestors.has(error)) return normalized; + const aggregate = + error instanceof AggregateError + ? error + : error instanceof AppError && error.cause instanceof AggregateError + ? error.cause + : undefined; + if (!aggregate || ancestors.has(aggregate)) return normalized; + const next = new Set(ancestors).add(error).add(aggregate); + return { + ...normalized, + details: { + ...normalized.details, + shutdownFailures: aggregate.errors.map((failure: unknown) => + normalizeShutdownFailure(failure, next), + ), + }, + }; +} diff --git a/test/integration/android-emulator-e2e/live-assertions.ts b/test/integration/android-emulator-e2e/live-assertions.ts index 0e8cd19464..f5d2ea04ff 100644 --- a/test/integration/android-emulator-e2e/live-assertions.ts +++ b/test/integration/android-emulator-e2e/live-assertions.ts @@ -54,18 +54,18 @@ export async function scrollToVisibleSelector( } export function assertDiffLine( - result: CliJsonResult, + result: { lines?: unknown } | undefined, kind: SnapshotDiffLine['kind'], expectedText: string, ): void { - const lines: unknown = result.json?.data?.lines; - assert.ok(Array.isArray(lines), `snapshot diff has no lines: ${JSON.stringify(result.json)}`); + const lines = result?.lines; + assert.ok(Array.isArray(lines), `snapshot diff has no lines: ${JSON.stringify(result)}`); assert.ok( lines.some( (line: unknown) => isSnapshotDiffLine(line) && line.kind === kind && line.text.includes(expectedText), ), - `expected ${kind} snapshot line containing ${expectedText}: ${JSON.stringify(result.json)}`, + `expected ${kind} snapshot line containing ${expectedText}: ${JSON.stringify(result)}`, ); } diff --git a/test/integration/android-emulator-e2e/live-automation-scenario.ts b/test/integration/android-emulator-e2e/live-automation-scenario.ts index 8d9b86ff8d..cce1bfe2d1 100644 --- a/test/integration/android-emulator-e2e/live-automation-scenario.ts +++ b/test/integration/android-emulator-e2e/live-automation-scenario.ts @@ -192,16 +192,14 @@ export async function assertAutomationSystem(context: LiveContext): Promise { JSON.stringify(keyboardStatus.json), ); - await runStep(context, 'fill Unicode full name through test IME', [ + const baselineName = await runStep(context, 'fill the name diff baseline', [ + 'fill', + 'id="field-name"', + 'Grace Hopper', + ]); + assert.equal(baselineName.json?.data?.verification, undefined, JSON.stringify(baselineName.json)); + await runStep(context, 'capture confirmed name diff baseline', ['snapshot', '-i']); + const filledName = await runStep(context, 'fill Unicode full name through test IME', [ 'fill', 'id="field-name"', 'Ada Łovelace', ]); + assert.equal(filledName.json?.data?.verification, undefined, JSON.stringify(filledName.json)); + const nameDiff = await runStep(context, 'observe confirmed Android name replacement diff', [ + 'diff', + 'snapshot', + '-i', + ]); + assertDiffLine(nameDiff.json?.data, 'removed', 'Grace Hopper'); + assertDiffLine(nameDiff.json?.data, 'added', 'Ada Łovelace'); + verifyCommand(context, C.diff, 'snapshot diff reports a confirmed Android name replacement'); await runStep(context, 'fill email field', ['fill', 'id="field-email"', 'ada@example']); const name = await runStep(context, 'read filled full name', ['get', 'text', 'id="field-name"']); assert.equal(name.json?.data?.text, 'Ada Łovelace', JSON.stringify(name.json)); diff --git a/test/integration/android-emulator-e2e/live-observability-scenario.ts b/test/integration/android-emulator-e2e/live-observability-scenario.ts index 8fc85c1cc0..34ba7dd7e0 100644 --- a/test/integration/android-emulator-e2e/live-observability-scenario.ts +++ b/test/integration/android-emulator-e2e/live-observability-scenario.ts @@ -1,3 +1,5 @@ +import { createVideoValidatorPreparation } from '@agent-device/capture-kit/recording-video'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -73,6 +75,9 @@ async function assertTraceAndRecording(context: LiveContext): Promise { ); const recordingPath = path.join(context.artifactDir, 'fixture.mp4'); + const validator = await createVideoValidatorPreparation( + captureHostEnvironment(process), + ).prepare(); await runStep(context, 'start short Android screen recording', [ 'record', 'start', @@ -92,7 +97,7 @@ async function assertTraceAndRecording(context: LiveContext): Promise { await runStep(context, 'record Android visible mutation', ['press', 'id="home-open-settings"']); await assertWaitText(context, 'Settings'); await runStep(context, 'stop short Android screen recording', ['record', 'stop']); - await assertMp4File(recordingPath); + await assertMp4File(recordingPath, validator); verifyCommand(context, C.record, 'short Android recording is a finalized playable MP4'); } diff --git a/test/integration/android-emulator-e2e/scenarios.ts b/test/integration/android-emulator-e2e/scenarios.ts index ae44adc94c..374036685a 100644 --- a/test/integration/android-emulator-e2e/scenarios.ts +++ b/test/integration/android-emulator-e2e/scenarios.ts @@ -53,7 +53,6 @@ export const ANDROID_EMULATOR_LIVE_SCENARIOS = [ C.appState, C.back, C.click, - C.diff, C.find, C.get, C.home, @@ -86,7 +85,7 @@ export const ANDROID_EMULATOR_LIVE_SCENARIOS = [ }, { behaviors: ['test-ime-restoration', 'test-ime-unicode-input'], - commands: [C.fill, C.focus, C.type], + commands: [C.diff, C.fill, C.focus, C.type], id: 'smoke:form-input', run: assertFormInput, start: { diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 1a39b93e49..629ae59241 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -731,8 +731,8 @@ const COMMAND_COVERAGE_DECLARATIONS = { }, [C.diff]: { androidEmulator: androidEmulator.live( - 'smoke:automation-system', - 'snapshot diff observes the Automation-to-Settings transition', + 'smoke:form-input', + 'snapshot diff observes a confirmed Android name replacement', ), iosSimulator: iosSimulator.live( 'smoke:form-input', diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index 5607b85394..3671e7bdee 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -1,3 +1,5 @@ +import { createVideoValidatorPreparation } from '@agent-device/capture-kit/recording-video'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -465,6 +467,9 @@ async function assertCapture(context: LiveContext): Promise { verifyCommand(context, C.screenshot, 'captured fixture file has a valid PNG signature'); const recordingPath = path.join(context.artifactDir, 'fixture.mp4'); + const validator = await createVideoValidatorPreparation( + captureHostEnvironment(process), + ).prepare(); const recording = await runStep(context, 'start screen recording', [ 'record', 'start', @@ -479,7 +484,7 @@ async function assertCapture(context: LiveContext): Promise { await assertWaitText(context, 'Checkout form'); const stopped = await runStep(context, 'stop screen recording', ['record', 'stop']); assert.equal(stopped.json?.data?.recording, 'stopped', JSON.stringify(stopped.json)); - await assertMp4File(recordingPath); + await assertMp4File(recordingPath, validator); verifyCommand( context, C.record, diff --git a/test/integration/live-device-e2e/assertions.ts b/test/integration/live-device-e2e/assertions.ts index fcba5ddba5..f3f0060686 100644 --- a/test/integration/live-device-e2e/assertions.ts +++ b/test/integration/live-device-e2e/assertions.ts @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { assertPngFile } from '../provider-scenarios/assertions.ts'; -import { isPlayableVideo } from '@agent-device/capture-kit/recording-video'; -import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import { + isPlayableVideo, + type PreparedVideoValidator, +} from '@agent-device/capture-kit/recording-video'; import type { CliJsonResult } from '../cli-json.ts'; import type { LiveDeviceContext } from './runtime.ts'; @@ -86,10 +88,13 @@ export function assertNonEmptyFile(filePath: string, name: string): void { assert.ok(fs.statSync(filePath).size > 0, `${name} artifact is empty: ${filePath}`); } -export async function assertMp4File(filePath: string): Promise { +export async function assertMp4File( + filePath: string, + validator: PreparedVideoValidator, +): Promise { assertNonEmptyFile(filePath, 'recording'); assert.equal( - await isPlayableVideo(filePath, captureHostEnvironment(process)), + await isPlayableVideo(filePath, validator), true, `recording is not a finalized playable video: ${filePath}`, ); diff --git a/test/integration/provider-scenarios/android-recording-fixtures.ts b/test/integration/provider-scenarios/android-recording-fixtures.ts index dd598be6bc..1c4f2eb999 100644 --- a/test/integration/provider-scenarios/android-recording-fixtures.ts +++ b/test/integration/provider-scenarios/android-recording-fixtures.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { prepareRecordingValidatorFixture } from '@agent-device/test-kit'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { JsonObject } from '@agent-device/contracts/client'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; @@ -23,7 +24,7 @@ export async function stopAndroidRecording( /** * The daemon's host for an Android recording scenario: no real tool on its PATH, so the Swift - * helpers cannot compile, and a Swift cache of its own. + * helpers use a prepared fixture executable in a Swift cache of its own. */ function androidRecordingHostEnv(tmpDir: string): Readonly> { return { PATH: tmpDir, AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(tmpDir, 'swift-cache') }; @@ -33,9 +34,11 @@ export async function createAndroidRecordingScenarioHarness( deps: Parameters[0], tmpDir: string, ): Promise { + const env = { ...androidRecordingHostEnv(tmpDir), ...deps.env }; + await prepareRecordingValidatorFixture({ env, cwd: tmpDir }); return await createProviderScenarioHarness({ ...deps, - env: { ...androidRecordingHostEnv(tmpDir), ...deps.env }, + env, }); } diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index ae373bb30d..7a2d8471c6 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; +import { prepareRecordingValidatorFixture } from '@agent-device/test-kit'; import { collectedRecordingPath, nativeRecordingPath, @@ -105,9 +106,10 @@ test('iOS simulator recording reports host contention and recovers through the f }); }, }; + const env = { PATH: tmpDir, AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(tmpDir, 'swift-cache') }; + await prepareRecordingValidatorFixture({ env, cwd: tmpDir }); const daemon = await createProviderScenarioHarness({ - // No real tool is on the daemon's PATH, and its Swift helper compiles into the scenario's cache. - env: { PATH: tmpDir, AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(tmpDir, 'swift-cache') }, + env, appleRunnerProvider: () => appleRunnerProvider, appleToolProvider: () => appleTool.provider, appleSimulatorScreenRecordingTransport: () => recordingTransport, diff --git a/test/integration/provider-scenarios/macos-world.ts b/test/integration/provider-scenarios/macos-world.ts index be88cce6d8..656c020c22 100644 --- a/test/integration/provider-scenarios/macos-world.ts +++ b/test/integration/provider-scenarios/macos-world.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { mkdtempForTestSync, prepareRecordingValidatorFixture } from '@agent-device/test-kit'; import type { AppleRunnerProvider } from '@agent-device/apple-runner'; import type { AppleRunnerScreenRecordingTransport } from '../../../src/platform-runtime-screen-recording-apple-runner-transport.ts'; import { PROVIDER_SCENARIO_MACOS } from './fixtures.ts'; @@ -48,8 +49,15 @@ export async function createMacOsDesktopWorld( }, }, }); - // Recording finalization runs the Swift validator; the process's warm cache keeps it from compiling. - const swiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; + const swiftCacheDir = options.appleRunnerScreenRecordingTransport + ? mkdtempForTestSync('agent-device-macos-recording-validator-') + : process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; + if (options.appleRunnerScreenRecordingTransport) { + await prepareRecordingValidatorFixture({ + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: swiftCacheDir }, + cwd: process.cwd(), + }); + } const daemon = await createProviderScenarioHarness({ ...(swiftCacheDir ? { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: swiftCacheDir } } : {}), appleRunnerProvider: options.appleRunnerProvider diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 2d0864ea38..bc75532201 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1065,9 +1065,10 @@ agent-device record stop # Stop active recording - `record start` defaults to app scope and needs a session from `open `. Use `--scope device` or `--scope system` for whole-screen capture (multiple apps, settings, home screen) where the backend supports it. - iOS recording works on simulators and CoreDevice-backed physical devices. Physical devices capture runner screenshots, not a native video stream: no audio, dropped frames are expected, and the default is 15 FPS. `--fps ` (1-120) caps the rate; a busy app or long command yields fewer frames. - `--quality medium|high` (default `medium`) sets export quality on Android and Limrun; Apple export always keeps the captured resolution. Numeric `5`-`7` map to `medium` and `8`-`10` to `high`. -- With touch visualization on, recordings also produce a gesture telemetry sidecar. Burned-in touch overlays export only on macOS hosts; elsewhere `record stop` returns the raw video and telemetry with `overlayWarning`. The overlay renders at most 30 FPS (or `--fps` if lower) within a fixed budget, which also covers the video checks that run before and after it; an export past the budget returns the raw video with `overlayWarning`. On a host that has not run a recording before, part of that budget goes to one-time tool setup, so the first `record stop` of a long recording is more likely to return the raw video. +- On macOS, MP4 `record start` warms the video validator in the background without delaying capture. `agent-device prepare ios-runner --platform ios` also warms it without waiting or failing on build errors. `record stop` waits within its finalization budget. If preparation is still pending, stop returns retriable `video_validator_not_prepared`; preparation continues, so retry stop in the same session. macOS MP4 verification requires Xcode command line tools and AVFoundation; unavailable tooling returns `video_validator_unavailable` only at stop. Other hosts validate the MP4 container without Swift. If preparation is missing or failed, install any missing tooling, then run `agent-device prepare ios-runner --platform ios` on the recording host using the same Swift cache. Preparation runs in the background; once it is ready, retry `record stop` in the original recording session. A daemon shutdown cancels unfinished preparation and clears its unpublished output. The next daemon can reuse an already-published executable from the Swift cache. WebM validation needs no Swift helper. +- With touch visualization on, recordings also produce a gesture telemetry sidecar. Burned-in touch overlays export only on macOS hosts. On other hosts, `record stop` returns the raw video and telemetry with `overlayWarning` when there are touch gestures to overlay. The overlay renders at most 30 FPS (or `--fps` if lower) within a fixed budget, which includes checking the raw video and the exported overlay before saving it; an export past the budget returns the raw video with `overlayWarning`. The first overlay may need one-time tool setup within that budget, so the first `record stop` of a long recording is more likely to return the raw video. - Android uses `adb shell screenrecord` (180s limit per clip). Longer recordings are split into MP4 chunks while the daemon stays alive; after a daemon restart, `record stop` recovers only chunks listed in the device manifest. A clip can end before `record stop` when the screen stopped changing; `durationMs` is wall-clock time, and `capturedDurationMs` plus a warning report how much the video covers. -- Limrun sessions always record the whole screen and refuse `--fps` and `--hide-touches` with `INVALID_ARGS`. A failed download is retried by the next `record stop`; nothing survives a daemon restart. +- Limrun sessions always record the whole screen as MP4 and refuse `.webm` output paths, `--fps`, and `--hide-touches` with `INVALID_ARGS`. A failed download is retried by the next `record stop`; nothing survives a daemon restart. - A busy iOS simulator recording slot returns non-retriable `DEVICE_IN_USE` with `details.reason: apple_simulator_recording_busy`. Run `record stop` in the session that owns the recording; if no recording is active, ask the host operator to restart the CoreSimulator stream service. - `record stop` is safe to repeat. If the request window ends during export, a second `record stop` in the same session returns the finished recording. A recording whose video file is gone reports `no active recording`. - `record start --scope device` on a session that does not exist yet creates one, and `record stop` ends that session. If anything else takes over the session meanwhile — an `open` or an app event that publishes an app, or an audio probe, perf capture, trace, app log, device claim, or lease it holds — `record stop` ends only the recording; the session stays and `close` releases it.