From 8194affef4c4ff22e34924846f3fb410439cf5e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 11:48:40 +0100 Subject: [PATCH 01/28] fix: prepare video validation before recording --- .../__tests__/overlay-script-paths.test.ts | 1 + .../src/recording/__tests__/overlay.test.ts | 32 ++- packages/capture-kit/src/recording/overlay.ts | 9 +- .../src/recording/swift-cache.test.ts | 22 ++ .../capture-kit/src/recording/swift-cache.ts | 27 +- .../capture-kit/src/recording/video.test.ts | 215 +++++++-------- packages/capture-kit/src/recording/video.ts | 247 ++++++++---------- .../src/screen-recording-transport.test.ts | 4 +- .../src/screen-recording-transport.ts | 4 + .../command-surface/src/management/prepare.ts | 2 +- .../command-surface/src/recording/index.ts | 2 +- .../src/screen-recording-runtime-host.ts | 5 + .../src/recording/fixtures.ts | 2 +- .../platform-android/src/recording/runtime.ts | 1 + .../src/recording/runtime.fixtures.ts | 2 + .../src/recording/runtime.test.ts | 47 ++++ .../platform-apple/src/recording/runtime.ts | 2 + .../src/recording/runtime.fixtures.ts | 6 +- .../src/recording/runtime.ts | 1 + .../src/recording/runtime.test.ts | 2 +- packages/platform-web/src/runtime.test.ts | 2 +- .../src/recording-runtime.test.ts | 1 + packages/test-kit/src/index.ts | 2 + packages/test-kit/src/recording-video.ts | 22 ++ ...rm-runtime-apple-application-tools.test.ts | 2 +- ...ple-application-tools-close-alerts.test.ts | 8 +- ...rm-runtime-apple-application-tools.test.ts | 25 +- ...latform-runtime-apple-application-tools.ts | 9 +- src/platform-runtime-operation-host.ts | 11 +- ...time-screen-recording-android-host.test.ts | 56 +--- ...m-runtime-screen-recording-android-host.ts | 10 +- ...me-screen-recording-finalizer-host.test.ts | 14 +- ...runtime-screen-recording-finalizer-host.ts | 23 +- src/platform-runtime-screen-recording-host.ts | 29 +- .../live-observability-scenario.ts | 5 +- .../ios-simulator-e2e/live-runner.ts | 5 +- .../integration/live-device-e2e/assertions.ts | 13 +- .../android-recording-fixtures.ts | 7 +- .../ios-record-trace.test.ts | 6 +- website/docs/docs/commands.md | 3 +- 40 files changed, 513 insertions(+), 373 deletions(-) create mode 100644 packages/test-kit/src/recording-video.ts 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..55e2763675 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -64,6 +64,7 @@ test('overlay burns touches through a cached helper and same-directory temp outp fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -71,6 +72,7 @@ test('overlay burns touches through a cached helper and same-directory temp outp }); fs.writeFileSync(videoPath, 'second original'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -113,6 +115,7 @@ test('overlay preserves Swift helper compile hints', async () => { await expect( overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -134,6 +137,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 +156,7 @@ test('overlay forwards the requested high export preset', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + validator: undefined, host: createTestHost(), videoPath, telemetryPath, @@ -171,6 +176,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 +186,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 +204,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 +233,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 +262,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 +284,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 +298,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 +318,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 +330,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 +350,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 +376,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..624123c4f6 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 PreparedVideoValidator } from './video.ts'; import { DEFAULT_RECORDING_EXPORT_QUALITY, type RecordingExportQuality, @@ -29,7 +29,7 @@ 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 + * before the overlay 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. */ @@ -79,6 +79,7 @@ async function exportProcessedVideo(params: { scriptArgs: string[]; commandDescription: string; deadline: Deadline; + validator: PreparedVideoValidator | 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: PreparedVideoValidator | 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..ff0ab3c169 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); @@ -365,3 +366,24 @@ 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(); + 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(); +}); diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index d2a7872b29..03db648015 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -128,20 +128,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], diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 0c8dd214c1..1c2d288b3d 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -9,7 +9,7 @@ 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 { isPlayableVideo, prepareVideoValidator, waitForPlayableVideo } from './video.ts'; vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -17,12 +17,13 @@ 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 ?? '', + readCompiledSwiftSourceText: vi.fn(() => undefined), compileSwiftSourceText: vi.fn(async () => '/bin/true'), })); beforeEach(() => { - vi.mocked(runCmd).mockClear(); + vi.mocked(runCmd).mockReset().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + vi.mocked(compileSwiftSourceText).mockReset().mockResolvedValue('/bin/true'); }); const directory = mkdtempForTestSync('agent-device-video-webm-'); @@ -30,14 +31,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 +53,100 @@ 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, - }); - vi.mocked(compileSwiftSourceText).mockClear(); - - 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']); +const validator = { executablePath: '/prepared/video-validator', env: {} }; + +test('validation reuses the prepared executable without compiling at completion', async () => { + const mp4 = writeFixture('prepared.mp4', Buffer.concat([ftyp, moov])); + const prepared = await prepareVideoValidator(createTestHost()); + 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('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'), - }, - }); - 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(); - } +test('completion during preparation fails with a typed outcome and does not wait or 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 preparing = prepareVideoValidator(createTestHost()); + await preparationStarted; + await expect( + isPlayableVideo(mp4, undefined, Deadline.fromTimeoutMs(5_000)), + ).rejects.toMatchObject({ + details: { reason: 'video_validator_not_prepared', retriable: true }, + }); + expect(runCmd).not.toHaveBeenCalled(); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(1); + finish('/prepared/validator'); + await expect(isPlayableVideo(mp4, await preparing)).resolves.toBe(true); }); -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.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(prepareVideoValidator(createTestHost())).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(prepareVideoValidator(createTestHost())).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')); - - await expect(isPlayableVideo(mp4, host)).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); - await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); - expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); +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' }, + }); }); 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 +158,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 +166,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 +182,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 +193,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,7 +212,7 @@ 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), }); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index df9c38b350..e5da8d73c6 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,82 @@ 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; - readonly cancel: AbortController; - waiters: number; - settled: boolean; -}; +/** Executable authority prepared before the recorder starts; validation cannot compile. */ +export type PreparedVideoValidator = Readonly<{ + executablePath: string; + env: NodeJS.ProcessEnv; +}>; -// 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(); +/** Reads prepared executable authority from the host cache without compiling. */ +export function readPreparedVideoValidator( + host: HostEnvironment, +): PreparedVideoValidator | undefined { + const executablePath = readCompiledSwiftSourceText({ + host, + source: VIDEO_VALIDATION_SCRIPT, + cacheName: 'video-validator', + }); + return executablePath ? preparedVideoValidator(host, executablePath) : undefined; +} + +function preparedVideoValidator( + host: HostEnvironment, + executablePath: string, +): PreparedVideoValidator { + return Object.freeze({ executablePath, env: buildSwiftToolEnv(host.env) }); +} + +export async function prepareVideoValidator( + host: HostEnvironment, + signal?: AbortSignal, + timeoutMs?: number, +): 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, + timeoutMs, + }); + return preparedVideoValidator(host, executablePath); + } catch (error) { + if ( + error instanceof AppError && + (error.code === 'TOOL_MISSING' || + isSwiftVideoValidatorUnavailable( + String(error.details?.stderr ?? ''), + String(error.details?.stdout ?? ''), + )) + ) { + 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', + { + reason: 'video_validator_unavailable', + retriable: false, + hint: 'Install the Xcode command line tools with AVFoundation on the recording host, then start a new recording.', + }, + cause, + ); +} export async function waitForStableFile( filePath: string, @@ -85,16 +147,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. - */ +/** Validates with a prepared executable, bounded by the caller's finalization deadline. */ export async function isPlayableVideo( filePath: string, - host: HostEnvironment, + validator: PreparedVideoValidator | undefined, deadline?: Deadline, ): Promise { const container = await readVideoContainerKind(filePath); @@ -102,118 +158,34 @@ 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 runTimeoutMs = Math.min( - VALIDATOR_RUN_TIMEOUT_MS, - deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, + if (!validator) { + throw new AppError( + 'COMMAND_FAILED', + 'The video validator was not prepared before finalization', + { + reason: 'video_validator_not_prepared', + retriable: true, + hint: 'Run prepare ios-runner on this host to prepare recording helpers, then retry record stop in this session.', + }, ); - // 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 (!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); - }, + const runTimeoutMs = Math.min( + VALIDATOR_RUN_TIMEOUT_MS, + deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, ); - 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, - ); + if (runTimeoutMs <= 0) return false; + const result = await runCmd(validator.executablePath, [filePath], { + allowFailure: true, + timeoutMs: runTimeoutMs, + env: validator.env, + }).catch((error: unknown) => { + if (isCommandTimeoutError(error)) return undefined; + throw error; }); - try { - return await Promise.race([shared.compiled, expired]); - } 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 (!result) return false; + if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) + throw unavailableVideoValidator(); + return result.exitCode === 0; } /** @@ -222,13 +194,18 @@ function isSwiftVideoValidatorError(error: unknown): boolean { */ export async function waitForPlayableVideo( filePath: string, - options: { host: HostEnvironment; pollMs?: number; attempts?: number; deadline?: Deadline }, + options: { + validator: PreparedVideoValidator | 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 +217,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?["']|xcrun: error: 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..f3105a5ec6 100644 --- a/packages/capture-kit/src/screen-recording-transport.test.ts +++ b/packages/capture-kit/src/screen-recording-transport.test.ts @@ -65,7 +65,7 @@ function harness( host: { screenRecording: { outputs: { prepare, copy: async () => {}, remove: async () => 'removed' as const }, - finalize: { sniff: async () => {}, complete }, + finalize: { prepare: async () => {}, sniff: async () => {}, complete }, }, }, device, @@ -165,7 +165,7 @@ test('unsupported options are refused with a typed error before the output is pr host: { screenRecording: { outputs: { prepare, copy: async () => {}, remove: async () => 'removed' as const }, - finalize: { sniff: async () => {}, complete: async () => ({}) }, + finalize: { prepare: async () => {}, sniff: async () => {}, complete: async () => ({}) }, }, }, device, diff --git a/packages/capture-kit/src/screen-recording-transport.ts b/packages/capture-kit/src/screen-recording-transport.ts index 4d26954667..c1fd8fefe1 100644 --- a/packages/capture-kit/src/screen-recording-transport.ts +++ b/packages/capture-kit/src/screen-recording-transport.ts @@ -99,6 +99,10 @@ export async function startTransportScreenRecording(params: { const { host, device, owner, input, signal, transport } = params; assertScreenRecordingOptionsSupported(input, params.support, params.unsupported); signal.throwIfAborted(); + await host.screenRecording.finalize.prepare( + { container: input.outputPath.toLowerCase().endsWith('.webm') ? 'webm' : 'mp4' }, + signal, + ); 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..c9f8665fbe 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 prepares the MP4 video validator, 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..8995cca1d8 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.', + 'MP4 record start prepares the host video validator before capture; the first start can take longer on a cold Swift cache. record stop never compiles the validator. An unprepared stop returns video_validator_not_prepared; run prepare ios-runner on the host, then retry stop in the same session. MP4 validation requires macOS with the Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable rather than a playable verdict. 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. 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..a339ba5901 100644 --- a/packages/contracts/src/screen-recording-runtime-host.ts +++ b/packages/contracts/src/screen-recording-runtime-host.ts @@ -241,6 +241,11 @@ export type WebScreenRecordingHost = Readonly<{ /** Closed post-processing authority for stable/playable validation, telemetry, and overlays. */ export type ScreenRecordingFinalizer = Readonly<{ + /** Prepares the video validator before capture. */ + prepare( + input: Readonly<{ container: 'mp4' | 'webm'; timeoutMs?: number }>, + signal?: AbortSignal, + ): 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..44902821db 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(); + await host.screenRecording.finalize.prepare({ container: 'mp4' }, signal); const startedAt = Date.now(); const initial = await startInitialTransaction({ transport, 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..7bef04c447 100644 --- a/packages/platform-apple/src/recording/runtime.test.ts +++ b/packages/platform-apple/src/recording/runtime.test.ts @@ -628,3 +628,50 @@ test('compensates a runner finalizer failure without stopping the runner twice', await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); expect(stop).toHaveBeenCalledOnce(); }); + +test('does not start or prepare recorder output while video validation preparation is pending', async () => { + let ready!: () => void; + const preparing = new Promise((resolve) => { + ready = resolve; + }); + let entered!: () => void; + const preparationStarted = new Promise((resolve) => { + entered = resolve; + }); + const host = appleHost({ + prepare: async () => { + entered(); + 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()); + expect( + await Promise.race([ + preparationStarted.then(() => 'preparing'), + starting.then(() => 'started'), + ]), + ).toBe('preparing'); + expect(startRecorder).not.toHaveBeenCalled(); + expect(prepareOutput).not.toHaveBeenCalled(); + expect(clockAnchor).not.toHaveBeenCalled(); + ready(); + const started = await starting; + expect(startRecorder).toHaveBeenCalledOnce(); + 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..9bae1da6ba 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); + await host.screenRecording.finalize.prepare({ container: 'mp4' }, signal); 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'); } + await host.screenRecording.finalize.prepare({ container: 'mp4' }, signal); 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..9eb3e27d04 100644 --- a/packages/platform-harmonyos/src/recording/runtime.fixtures.ts +++ b/packages/platform-harmonyos/src/recording/runtime.fixtures.ts @@ -56,7 +56,11 @@ export function harmonyRecordingHost( ...recordingFileStore().outputs, prepare: overrides.prepare ?? (async () => {}), }, - finalize: { sniff: async () => {}, complete: overrides.complete ?? (async () => ({})) }, + finalize: { + prepare: 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..8e42004fb4 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(); + await host.screenRecording.finalize.prepare({ container: 'mp4' }, signal); 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..8bf3454c63 100644 --- a/packages/provider-limrun/src/recording-runtime.test.ts +++ b/packages/provider-limrun/src/recording-runtime.test.ts @@ -50,6 +50,7 @@ function fakeRecordingHost(calls: string[], finalize?: () => Promise 'removed' as const), }, finalize: { + prepare: async () => {}, sniff: vi.fn(async () => {}), complete: vi.fn(async () => { calls.push('finalize'); 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..aed9aaac6a --- /dev/null +++ b/packages/test-kit/src/recording-video.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import { prepareVideoValidator } 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 a stub semantic validator 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 output = args[args.indexOf('-o') + 1]; + if (!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 prepareVideoValidator(host); + }, + ); +} 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/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..41d543443e 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 fails before startup when the 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, + ), + ).rejects.toBe(failure); + expect(mockPrepareIosRunner).not.toHaveBeenCalled(); +}); diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index af895d1a22..b2662b3397 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(); + await prepareRecording({ container: 'mp4', timeoutMs: input.timeoutMs }, signal); 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-operation-host.ts b/src/platform-runtime-operation-host.ts index 47e3f85a78..ccd8cb8879 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, diff --git a/src/platform-runtime-screen-recording-android-host.test.ts b/src/platform-runtime-screen-recording-android-host.test.ts index a493d2db9d..b3e80e9155 100644 --- a/src/platform-runtime-screen-recording-android-host.test.ts +++ b/src/platform-runtime-screen-recording-android-host.test.ts @@ -2,7 +2,6 @@ import { expect, test, vi } from 'vitest'; import { withAndroidAdbProvider } from '@agent-device/platform-android/mechanics'; import { createAndroidScreenRecordingTransport } from './platform-runtime-screen-recording-android-host.ts'; import './platform-runtime-android-adb-host.ts'; -import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; const adbExecutor = vi.hoisted(() => ({ override: undefined as @@ -51,10 +50,7 @@ test('uses the request-scoped Android ADB executor rather than a host fallback', }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, () => undefined); expect(transport.mode).toBe('transport-composed'); await expect(transport.start({ remotePath: '/sdcard/capture.mp4' })).resolves.toEqual({ process: { pid: '42', remotePath: '/sdcard/capture.mp4', startTime: '42' }, @@ -100,10 +96,7 @@ test('finds only exact screenrecord processes for the canonical remote path', as }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, () => undefined); await expect(transport.probeRunningWriters(remotePath)).resolves.toEqual({ writers: [ { pid: '41', remotePath, startTime: '41' }, @@ -173,10 +166,7 @@ test.each(inconclusiveWriterScans)( }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, () => undefined); await expect(transport.probeRunningWriters(writerScanRemotePath)).resolves.toEqual( expected, ); @@ -202,10 +192,7 @@ test('revalidates start-time and exact argv before SIGINT', async () => { }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, () => undefined); const process = { pid: '51', remotePath: '/sdcard/capture.mp4', @@ -236,10 +223,7 @@ test('distinguishes a missing proc directory from an unavailable identity probe' }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, () => undefined); const expected = { remotePath: '/sdcard/capture.mp4', startTime: '42' }; await expect(transport.inspect({ ...expected, pid: '42' })).resolves.toBe('missing'); await expect(transport.inspect({ ...expected, pid: '43' })).resolves.toBe('uncertain'); @@ -253,10 +237,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, () => undefined); await expect( transport.inspect({ pid: '44', @@ -281,10 +262,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, () => undefined); 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 +279,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, () => undefined); await expect(transport.readManifest('/sdcard/interrupted.json')).resolves.toEqual({ status: 'unavailable', message: 'Android recording manifest probe failed', @@ -339,10 +314,7 @@ test('proves termination from each ownership-lost producer without signalling', }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, () => undefined); for (const replacement of [reassignedPid, otherArtifact, exitedTask]) { identity = replacement; await expect(transport.inspect(recorded)).resolves.toBe('ownership-lost'); @@ -375,10 +347,7 @@ test('classifies a replacement recorder on the same path as a foreign writer and }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, () => undefined); await expect(transport.inspect(recorded)).resolves.toBe('foreign-writer'); await expect(transport.stop(recorded)).resolves.toBe('ownership-lost'); expect(commands.some((command) => command.startsWith('kill '))).toBe(false); @@ -427,10 +396,7 @@ test('retains unavailable manifest reads and confirms manifest deletion', async }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport( - android, - createLiveTestHostEnvironment(), - ); + const transport = await createAndroidScreenRecordingTransport(android, () => undefined); await expect(transport.readManifest('/sdcard/manifest.json')).resolves.toEqual({ status: 'unavailable', message: 'transport unavailable', diff --git a/src/platform-runtime-screen-recording-android-host.ts b/src/platform-runtime-screen-recording-android-host.ts index 0141a07dda..076afec319 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 PreparedVideoValidator, +} 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, + videoValidator: () => PreparedVideoValidator | undefined, ): 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, videoValidator())), }; }, remove: async (remotePath, signal) => diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 9eb0b6f859..5a72f74a22 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -13,6 +13,7 @@ const video = vi.hoisted(() => ({ playable: vi.fn(async (_path: string, _options: { deadline?: Deadline }) => true), isPlayable: vi.fn(async (_path: string, _host: unknown, _deadline?: Deadline) => true), container: vi.fn(async () => true), + prepare: vi.fn(async () => ({ executablePath: '/bin/true', env: {} })), })); const telemetry = vi.hoisted(() => vi.fn(() => '/tmp/capture.telemetry.json')); const overlay = vi.hoisted(() => vi.fn(async () => {})); @@ -23,6 +24,8 @@ vi.mock('@agent-device/capture-kit/recording-video', () => ({ waitForPlayableVideo: video.playable, isPlayableVideo: video.isPlayable, hasVideoContainer: video.container, + prepareVideoValidator: video.prepare, + readPreparedVideoValidator: () => undefined, })); vi.mock('@agent-device/capture-kit/recording-telemetry', () => ({ persistRecordingTelemetry: telemetry, @@ -54,7 +57,7 @@ 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: undefined, deadline: expect.any(Deadline), }); expect(result).toEqual({ telemetryPath: '/tmp/capture.telemetry.json' }); @@ -81,9 +84,9 @@ 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); @@ -124,8 +127,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; @@ -142,7 +143,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(); diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts index d6af20ae47..5c3a444c08 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -11,6 +11,9 @@ import { import { persistRecordingTelemetry } from '@agent-device/capture-kit/recording-telemetry'; import { hasVideoContainer, + prepareVideoValidator, + readPreparedVideoValidator, + type PreparedVideoValidator, isPlayableVideo, waitForPlayableVideo, waitForStableFile, @@ -18,10 +21,17 @@ import { export function createScreenRecordingFinalizer( host: HostEnvironment, -): ScreenRecordingRuntimeHost['finalize'] { +): ScreenRecordingRuntimeHost['finalize'] & + Readonly<{ videoValidator(): PreparedVideoValidator | undefined }> { + let validator = readPreparedVideoValidator(host); return Object.freeze({ + prepare: async (input, signal) => { + if (input.container === 'mp4') + validator = await prepareVideoValidator(host, signal, input.timeoutMs); + }, + videoValidator: () => validator, sniff: async (input) => await sniffRecording(input), - complete: async (input) => await finalizeScreenRecording(host, input), + complete: async (input) => await finalizeScreenRecording(host, input, validator), }); } @@ -32,17 +42,18 @@ async function sniffRecording(input: Readonly<{ outputPath: string }>): Promise< async function finalizeScreenRecording( host: HostEnvironment, input: Parameters[0], + validator: PreparedVideoValidator | undefined, ) { const finalizeDeadline = startRecordingOverlayBudget(); await waitForStableFile(input.outputPath); - if (!(await waitForPlayableVideo(input.outputPath, { host, deadline: finalizeDeadline }))) { + if (!(await waitForPlayableVideo(input.outputPath, { validator, 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, validator); } async function overlayTouches( @@ -50,6 +61,7 @@ async function overlayTouches( input: Parameters[0], telemetryPath: string, deadline: Deadline, + validator: PreparedVideoValidator | undefined, ) { const warning = getRecordingOverlaySupportWarning(); if (warning) return { telemetryPath, overlayWarning: warning }; @@ -62,8 +74,9 @@ async function overlayTouches( ...(input.fps === undefined ? {} : { fps: input.fps }), targetLabel: input.targetLabel, deadline, + validator, }); - if (!(await isPlayableVideo(input.outputPath, host))) { + if (!(await isPlayableVideo(input.outputPath, validator, deadline))) { throw new Error('recording post-processing produced an unplayable video'); } return { telemetryPath }; diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts index 27080d469b..a4b87bf2fd 100644 --- a/src/platform-runtime-screen-recording-host.ts +++ b/src/platform-runtime-screen-recording-host.ts @@ -8,11 +8,25 @@ export function createScreenRecordingRuntimeHost(options: { host: HostEnvironment; }): ScreenRecordingRuntimeHost { const host = options.host; + let finalizer: + | Promise< + ReturnType< + (typeof import('./platform-runtime-screen-recording-finalizer-host.ts'))['createScreenRecordingFinalizer'] + > + > + | undefined; + const loadFinalizer = () => + (finalizer ??= import('./platform-runtime-screen-recording-finalizer-host.ts').then( + ({ createScreenRecordingFinalizer }) => createScreenRecordingFinalizer(host), + )); 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 loadFinalizer()).videoValidator, + ); }, }); const web: ScreenRecordingRuntimeHost['web'] = Object.freeze({ @@ -40,16 +54,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, signal) => (await loadFinalizer()).prepare(input, signal), + sniff: async (input) => (await loadFinalizer()).sniff(input), + complete: async (input, signal) => (await loadFinalizer()).complete(input, signal), }); const ownedProcesses: OwnedProcessRecordWriter = options.ownedProcesses ?? diff --git a/test/integration/android-emulator-e2e/live-observability-scenario.ts b/test/integration/android-emulator-e2e/live-observability-scenario.ts index 8fc85c1cc0..2d77033c8b 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 { prepareVideoValidator } 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,7 @@ async function assertTraceAndRecording(context: LiveContext): Promise { ); const recordingPath = path.join(context.artifactDir, 'fixture.mp4'); + const validator = await prepareVideoValidator(captureHostEnvironment(process)); await runStep(context, 'start short Android screen recording', [ 'record', 'start', @@ -92,7 +95,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/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index 5607b85394..f93f16323b 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -1,3 +1,5 @@ +import { prepareVideoValidator } 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,7 @@ 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 prepareVideoValidator(captureHostEnvironment(process)); const recording = await runStep(context, 'start screen recording', [ 'record', 'start', @@ -479,7 +482,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/website/docs/docs/commands.md b/website/docs/docs/commands.md index 2d0864ea38..640546f051 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1065,7 +1065,8 @@ 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. +- MP4 `record start` prepares the video validator before capture. A cold Swift cache can delay the first start. MP4 validation requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns `video_validator_unavailable`. `record stop` uses the prepared validator within its finalization budget. If it returns `video_validator_not_prepared`, run `prepare ios-runner` on the recording host, then retry stop in the same session. 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. 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. - 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. - 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. From 7e440f19f80576cc14cf8870092363d488c1302a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 13:21:57 +0100 Subject: [PATCH 02/28] fix: warm video validation without delaying capture --- .../src/recording/__tests__/overlay.test.ts | 6 +- packages/capture-kit/src/recording/overlay.ts | 6 +- .../src/recording/swift-cache.test.ts | 1 + .../capture-kit/src/recording/video.test.ts | 52 ++++++++++-- packages/capture-kit/src/recording/video.ts | 84 +++++++++++++------ .../src/screen-recording-transport.test.ts | 15 ++-- .../src/screen-recording-transport.ts | 9 +- .../command-surface/src/management/prepare.ts | 2 +- .../command-surface/src/recording/index.ts | 2 +- .../src/screen-recording-runtime-host.ts | 7 +- .../platform-android/src/recording/runtime.ts | 2 +- .../src/recording/runtime.test.ts | 20 +---- .../platform-apple/src/recording/runtime.ts | 4 +- .../src/recording/runtime.fixtures.ts | 3 +- .../src/recording/runtime.ts | 2 +- .../src/recording-runtime.test.ts | 17 +++- .../provider-limrun/src/recording-runtime.ts | 3 + ...rm-runtime-apple-application-tools.test.ts | 6 +- ...latform-runtime-apple-application-tools.ts | 2 +- ...time-screen-recording-android-host.test.ts | 56 ++++++++++--- ...m-runtime-screen-recording-android-host.ts | 9 +- ...me-screen-recording-finalizer-host.test.ts | 15 +++- ...runtime-screen-recording-finalizer-host.ts | 18 ++-- src/platform-runtime-screen-recording-host.ts | 23 ++--- .../provider-scenarios/macos-world.ts | 12 ++- website/docs/docs/commands.md | 2 +- 26 files changed, 251 insertions(+), 127 deletions(-) diff --git a/packages/capture-kit/src/recording/__tests__/overlay.test.ts b/packages/capture-kit/src/recording/__tests__/overlay.test.ts index 55e2763675..dbb594427e 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -58,13 +58,14 @@ afterEach(() => { }); test('overlay burns touches through a cached helper and same-directory temp output', async () => { + const validator = { 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: undefined, + validator, host: createTestHost(), videoPath, telemetryPath, @@ -72,7 +73,7 @@ test('overlay burns touches through a cached helper and same-directory temp outp }); fs.writeFileSync(videoPath, 'second original'); await overlayRecordingTouches({ - validator: undefined, + validator, host: createTestHost(), videoPath, telemetryPath, @@ -86,6 +87,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]!; diff --git a/packages/capture-kit/src/recording/overlay.ts b/packages/capture-kit/src/recording/overlay.ts index 624123c4f6..e43baea938 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, type PreparedVideoValidator } from './video.ts'; +import { waitForPlayableVideo, type VideoValidator } from './video.ts'; import { DEFAULT_RECORDING_EXPORT_QUALITY, type RecordingExportQuality, @@ -79,7 +79,7 @@ async function exportProcessedVideo(params: { scriptArgs: string[]; commandDescription: string; deadline: Deadline; - validator: PreparedVideoValidator | undefined; + validator: VideoValidator | undefined; }): Promise { const { host, videoPath, scriptPath, scriptArgs, commandDescription, deadline } = params; const outputPath = temporarySiblingVideoPath(videoPath); @@ -159,7 +159,7 @@ function temporarySiblingVideoPath(videoPath: string): string { export async function overlayRecordingTouches(params: { host: HostEnvironment; - validator: PreparedVideoValidator | undefined; + validator: VideoValidator | undefined; videoPath: string; telemetryPath: string; exportQuality?: RecordingExportQuality; diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index ff0ab3c169..d8e13d47da 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -370,6 +370,7 @@ test('the cache root and tool environment come from the host it is given, not th 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); diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 1c2d288b3d..c62d89e318 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -1,7 +1,7 @@ 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'; @@ -17,14 +17,17 @@ 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, readCompiledSwiftSourceText: vi.fn(() => undefined), compileSwiftSourceText: vi.fn(async () => '/bin/true'), })); beforeEach(() => { + 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'); }); +afterEach(() => vi.unstubAllEnvs()); const directory = mkdtempForTestSync('agent-device-video-webm-'); const playableWebm = likelyPlayableWebmContainer(); @@ -67,7 +70,7 @@ test('validation reuses the prepared executable without compiling at completion' ]); }); -test('completion during preparation fails with a typed outcome and does not wait or compile', async () => { +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; @@ -83,15 +86,45 @@ test('completion during preparation fails with a typed outcome and does not wait ); const preparing = prepareVideoValidator(createTestHost()); await preparationStarted; - await expect( - isPlayableVideo(mp4, undefined, Deadline.fromTimeoutMs(5_000)), - ).rejects.toMatchObject({ + 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(prepareVideoValidator(createTestHost())).toBe(preparing); finish('/prepared/validator'); - await expect(isPlayableVideo(mp4, await preparing)).resolves.toBe(true); + await expect(isPlayableVideo(mp4, preparing)).resolves.toBe(true); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(1); +}); + +test('preparation compiles once per Swift cache root', async () => { + const host = createTestHost(); + const first = prepareVideoValidator(host); + const sameRoot = prepareVideoValidator(createTestHost()); + const otherRoot = prepareVideoValidator({ + ...host, + env: { + ...host.env, + AGENT_DEVICE_SWIFT_CACHE_DIR: `${host.env.AGENT_DEVICE_SWIFT_CACHE_DIR}-other`, + }, + }); + await Promise.all([first, sameRoot, otherRoot]); + expect(first).toBe(sameRoot); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); +}); + +test('WebM validation ignores failed background MP4 preparation', async () => { + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce( + new AppError('TOOL_MISSING', 'no swiftc'), + ); + const preparing = prepareVideoValidator(createTestHost()); + 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([ @@ -130,6 +163,13 @@ test('an unavailable validator at execution never makes an MP4 playable', async }); }); +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])); await isPlayableVideo(mp4, validator); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index e5da8d73c6..5d0caad502 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -6,6 +6,7 @@ import { buildSwiftToolEnv, compileSwiftSourceText, readCompiledSwiftSourceText, + getSwiftCacheRoot, } from './swift-cache.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; @@ -41,22 +42,28 @@ exit(exitCode) const VALIDATOR_RUN_TIMEOUT_MS = 10_000; -/** Executable authority prepared before the recorder starts; validation cannot compile. */ +/** Executable authority produced by background preparation; validation cannot compile. */ export type PreparedVideoValidator = Readonly<{ executablePath: string; env: NodeJS.ProcessEnv; }>; +export type VideoValidator = PreparedVideoValidator | Promise; +const videoValidatorPreparations = new Map>(); + /** Reads prepared executable authority from the host cache without compiling. */ export function readPreparedVideoValidator( host: HostEnvironment, -): PreparedVideoValidator | undefined { +): Promise | undefined { + const cacheRoot = getSwiftCacheRoot(host.env); + const preparing = videoValidatorPreparations.get(cacheRoot); + if (preparing) return preparing; const executablePath = readCompiledSwiftSourceText({ host, source: VIDEO_VALIDATION_SCRIPT, cacheName: 'video-validator', }); - return executablePath ? preparedVideoValidator(host, executablePath) : undefined; + return executablePath ? Promise.resolve(preparedVideoValidator(host, executablePath)) : undefined; } function preparedVideoValidator( @@ -66,11 +73,18 @@ function preparedVideoValidator( return Object.freeze({ executablePath, env: buildSwiftToolEnv(host.env) }); } -export async function prepareVideoValidator( - host: HostEnvironment, - signal?: AbortSignal, - timeoutMs?: number, -): Promise { +export function prepareVideoValidator(host: HostEnvironment): Promise { + const cacheRoot = getSwiftCacheRoot(host.env); + let preparing = videoValidatorPreparations.get(cacheRoot); + if (!preparing) { + preparing = compileVideoValidator(host); + videoValidatorPreparations.set(cacheRoot, preparing); + void preparing.catch(() => {}); + } + return preparing; +} + +async function compileVideoValidator(host: HostEnvironment): Promise { const { withDiagnosticTimer } = await import('@agent-device/host-kit/diagnostics'); return await withDiagnosticTimer( 'prepare_video_validator', @@ -80,19 +94,10 @@ export async function prepareVideoValidator( host, source: VIDEO_VALIDATION_SCRIPT, cacheName: 'video-validator', - signal, - timeoutMs, }); return preparedVideoValidator(host, executablePath); } catch (error) { - if ( - error instanceof AppError && - (error.code === 'TOOL_MISSING' || - isSwiftVideoValidatorUnavailable( - String(error.details?.stderr ?? ''), - String(error.details?.stdout ?? ''), - )) - ) { + if (isUnavailableVideoValidatorError(error)) { throw unavailableVideoValidator(error); } throw error; @@ -116,6 +121,17 @@ function unavailableVideoValidator(cause?: Error): AppError { ); } +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, options: { pollMs?: number; attempts?: number } = {}, @@ -150,7 +166,7 @@ export async function waitForStableFile( /** Validates with a prepared executable, bounded by the caller's finalization deadline. */ export async function isPlayableVideo( filePath: string, - validator: PreparedVideoValidator | undefined, + validator: VideoValidator | undefined, deadline?: Deadline, ): Promise { const container = await readVideoContainerKind(filePath); @@ -158,28 +174,44 @@ 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; - if (!validator) { - throw new AppError( + const notPrepared = () => + new AppError( 'COMMAND_FAILED', - 'The video validator was not prepared before finalization', + 'The video validator is not ready within the finalization budget', { reason: 'video_validator_not_prepared', retriable: true, - hint: 'Run prepare ios-runner on this host to prepare recording helpers, then retry record stop in this session.', + hint: 'Preparation continues in the background. Retry record stop in this session once the validator is ready.', }, ); + if (!validator) throw notPrepared(); + let timer: NodeJS.Timeout | undefined; + let prepared: PreparedVideoValidator; + try { + prepared = await Promise.race([ + validator, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(notPrepared()), + deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, + ); + }), + ]); + } finally { + clearTimeout(timer); } const runTimeoutMs = Math.min( VALIDATOR_RUN_TIMEOUT_MS, deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, ); if (runTimeoutMs <= 0) return false; - const result = await runCmd(validator.executablePath, [filePath], { + const result = await runCmd(prepared.executablePath, [filePath], { allowFailure: true, timeoutMs: runTimeoutMs, - env: validator.env, + env: prepared.env, }).catch((error: unknown) => { if (isCommandTimeoutError(error)) return undefined; + if (isUnavailableVideoValidatorError(error)) throw unavailableVideoValidator(error); throw error; }); if (!result) return false; @@ -195,7 +227,7 @@ export async function isPlayableVideo( export async function waitForPlayableVideo( filePath: string, options: { - validator: PreparedVideoValidator | undefined; + validator: VideoValidator | undefined; pollMs?: number; attempts?: number; deadline?: Deadline; diff --git a/packages/capture-kit/src/screen-recording-transport.test.ts b/packages/capture-kit/src/screen-recording-transport.test.ts index f3105a5ec6..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: { prepare: async () => {}, 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: { prepare: async () => {}, 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 c1fd8fefe1..e936bfc77f 100644 --- a/packages/capture-kit/src/screen-recording-transport.ts +++ b/packages/capture-kit/src/screen-recording-transport.ts @@ -99,10 +99,11 @@ export async function startTransportScreenRecording(params: { const { host, device, owner, input, signal, transport } = params; assertScreenRecordingOptionsSupported(input, params.support, params.unsupported); signal.throwIfAborted(); - await host.screenRecording.finalize.prepare( - { container: input.outputPath.toLowerCase().endsWith('.webm') ? 'webm' : 'mp4' }, - signal, - ); + 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 c9f8665fbe..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 prepares the MP4 video validator, 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.', + '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 8995cca1d8..2893355fab 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: - 'MP4 record start prepares the host video validator before capture; the first start can take longer on a cold Swift cache. record stop never compiles the validator. An unprepared stop returns video_validator_not_prepared; run prepare ios-runner on the host, then retry stop in the same session. MP4 validation requires macOS with the Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable rather than a playable verdict. 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. 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.', + '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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable only at stop. 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. 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 a339ba5901..484c726a1f 100644 --- a/packages/contracts/src/screen-recording-runtime-host.ts +++ b/packages/contracts/src/screen-recording-runtime-host.ts @@ -241,11 +241,8 @@ export type WebScreenRecordingHost = Readonly<{ /** Closed post-processing authority for stable/playable validation, telemetry, and overlays. */ export type ScreenRecordingFinalizer = Readonly<{ - /** Prepares the video validator before capture. */ - prepare( - input: Readonly<{ container: 'mp4' | 'webm'; timeoutMs?: number }>, - signal?: AbortSignal, - ): Promise; + /** 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/runtime.ts b/packages/platform-android/src/recording/runtime.ts index 44902821db..fb9b7a0bfe 100644 --- a/packages/platform-android/src/recording/runtime.ts +++ b/packages/platform-android/src/recording/runtime.ts @@ -65,7 +65,7 @@ async function startAndroidRecording(params: { }) { const { host, transport, device, owner, input, signal } = params; signal.throwIfAborted(); - await host.screenRecording.finalize.prepare({ container: 'mp4' }, signal); + void host.screenRecording.finalize.prepare({ container: 'mp4' }).catch(() => {}); const startedAt = Date.now(); const initial = await startInitialTransaction({ transport, diff --git a/packages/platform-apple/src/recording/runtime.test.ts b/packages/platform-apple/src/recording/runtime.test.ts index 7bef04c447..7239f9aefe 100644 --- a/packages/platform-apple/src/recording/runtime.test.ts +++ b/packages/platform-apple/src/recording/runtime.test.ts @@ -629,18 +629,13 @@ test('compensates a runner finalizer failure without stopping the runner twice', expect(stop).toHaveBeenCalledOnce(); }); -test('does not start or prepare recorder output while video validation preparation is pending', async () => { +test('starts capture while video validation preparation is pending', async () => { let ready!: () => void; const preparing = new Promise((resolve) => { ready = resolve; }); - let entered!: () => void; - const preparationStarted = new Promise((resolve) => { - entered = resolve; - }); const host = appleHost({ prepare: async () => { - entered(); await preparing; }, apple: { @@ -661,17 +656,10 @@ test('does not start or prepare recorder output while video validation preparati signal: new AbortController().signal, }); const starting = operations.screenRecordingStart(input()); - expect( - await Promise.race([ - preparationStarted.then(() => 'preparing'), - starting.then(() => 'started'), - ]), - ).toBe('preparing'); - expect(startRecorder).not.toHaveBeenCalled(); - expect(prepareOutput).not.toHaveBeenCalled(); - expect(clockAnchor).not.toHaveBeenCalled(); - ready(); 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 9bae1da6ba..f302d0c82e 100644 --- a/packages/platform-apple/src/recording/runtime.ts +++ b/packages/platform-apple/src/recording/runtime.ts @@ -98,7 +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); - await host.screenRecording.finalize.prepare({ container: 'mp4' }, signal); + void host.screenRecording.finalize.prepare({ container: 'mp4' }).catch(() => {}); const clockAnchor = input.activeSessionApp ? await host.screenRecording.apple.captureClockAnchor( device, @@ -290,7 +290,7 @@ async function startAppleRunnerRecording(params: AppleRecordingStartParams) { if (!appBundleId) { throw new TypeError('Apple runner recording requires an active app session identity'); } - await host.screenRecording.finalize.prepare({ container: 'mp4' }, signal); + 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 9eb3e27d04..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'] = { @@ -57,7 +58,7 @@ export function harmonyRecordingHost( prepare: overrides.prepare ?? (async () => {}), }, finalize: { - prepare: async () => {}, + prepare: overrides.prepareValidator ?? (async () => {}), sniff: async () => {}, complete: overrides.complete ?? (async () => ({})), }, diff --git a/packages/platform-harmonyos/src/recording/runtime.ts b/packages/platform-harmonyos/src/recording/runtime.ts index 8e42004fb4..4650195a98 100644 --- a/packages/platform-harmonyos/src/recording/runtime.ts +++ b/packages/platform-harmonyos/src/recording/runtime.ts @@ -77,7 +77,7 @@ async function startHarmonyRecording(params: { (unsupported) => `HarmonyOS recordings do not support ${unsupported.join(', ')}`, ); signal.throwIfAborted(); - await host.screenRecording.finalize.prepare({ container: 'mp4' }, signal); + 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/provider-limrun/src/recording-runtime.test.ts b/packages/provider-limrun/src/recording-runtime.test.ts index 8bf3454c63..6c6ff4a5d4 100644 --- a/packages/provider-limrun/src/recording-runtime.test.ts +++ b/packages/provider-limrun/src/recording-runtime.test.ts @@ -50,7 +50,9 @@ function fakeRecordingHost(calls: string[], finalize?: () => Promise 'removed' as const), }, finalize: { - prepare: async () => {}, + prepare: vi.fn(async ({ container }: { container: 'mp4' | 'webm' }) => { + calls.push(`prepare-validator:${container}`); + }), sniff: vi.fn(async () => {}), complete: vi.fn(async () => { calls.push('finalize'); @@ -95,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', @@ -123,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', @@ -145,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(); @@ -154,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', @@ -252,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/src/platform-runtime-apple-application-tools.test.ts b/src/platform-runtime-apple-application-tools.test.ts index 41d543443e..0af330ec0b 100644 --- a/src/platform-runtime-apple-application-tools.test.ts +++ b/src/platform-runtime-apple-application-tools.test.ts @@ -113,7 +113,7 @@ test('a macOS frontmost-app open resolves the identity of the frontmost applicat expect(mockResolveFrontmostMacOsApp).toHaveBeenCalledOnce(); }); -test('runner preparation fails before startup when the recording helper cannot be prepared', async () => { +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 () => { @@ -128,6 +128,6 @@ test('runner preparation fails before startup when the recording helper cannot b }, new AbortController().signal, ), - ).rejects.toBe(failure); - expect(mockPrepareIosRunner).not.toHaveBeenCalled(); + ).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 b2662b3397..faac35fef0 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -97,7 +97,7 @@ export function createAppleApplicationTools( }, prepareRunner: async (device, input, signal) => { const startedAtMs = Date.now(); - await prepareRecording({ container: 'mp4', timeoutMs: input.timeoutMs }, signal); + void prepareRecording({ container: 'mp4' }).catch(() => {}); const { Deadline } = await loadRetry(); const { prepareIosRunner } = await loadRunnerOperations(); return await prepareIosRunner(device, { diff --git a/src/platform-runtime-screen-recording-android-host.test.ts b/src/platform-runtime-screen-recording-android-host.test.ts index b3e80e9155..a493d2db9d 100644 --- a/src/platform-runtime-screen-recording-android-host.test.ts +++ b/src/platform-runtime-screen-recording-android-host.test.ts @@ -2,6 +2,7 @@ import { expect, test, vi } from 'vitest'; import { withAndroidAdbProvider } from '@agent-device/platform-android/mechanics'; import { createAndroidScreenRecordingTransport } from './platform-runtime-screen-recording-android-host.ts'; import './platform-runtime-android-adb-host.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; const adbExecutor = vi.hoisted(() => ({ override: undefined as @@ -50,7 +51,10 @@ test('uses the request-scoped Android ADB executor rather than a host fallback', }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); expect(transport.mode).toBe('transport-composed'); await expect(transport.start({ remotePath: '/sdcard/capture.mp4' })).resolves.toEqual({ process: { pid: '42', remotePath: '/sdcard/capture.mp4', startTime: '42' }, @@ -96,7 +100,10 @@ test('finds only exact screenrecord processes for the canonical remote path', as }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.probeRunningWriters(remotePath)).resolves.toEqual({ writers: [ { pid: '41', remotePath, startTime: '41' }, @@ -166,7 +173,10 @@ test.each(inconclusiveWriterScans)( }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.probeRunningWriters(writerScanRemotePath)).resolves.toEqual( expected, ); @@ -192,7 +202,10 @@ test('revalidates start-time and exact argv before SIGINT', async () => { }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); const process = { pid: '51', remotePath: '/sdcard/capture.mp4', @@ -223,7 +236,10 @@ test('distinguishes a missing proc directory from an unavailable identity probe' }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); const expected = { remotePath: '/sdcard/capture.mp4', startTime: '42' }; await expect(transport.inspect({ ...expected, pid: '42' })).resolves.toBe('missing'); await expect(transport.inspect({ ...expected, pid: '43' })).resolves.toBe('uncertain'); @@ -237,7 +253,10 @@ test('retains an interrupted empty-stderr presence probe as uncertain', async () return result('', '', null); }; try { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect( transport.inspect({ pid: '44', @@ -262,7 +281,10 @@ test('credits a path as absent only from a probe that answered, not from one tha return result(''); }; try { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); 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'); @@ -279,7 +301,10 @@ test('retains an interrupted empty-stderr manifest probe as unavailable', async return result('', '', null); }; try { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.readManifest('/sdcard/interrupted.json')).resolves.toEqual({ status: 'unavailable', message: 'Android recording manifest probe failed', @@ -314,7 +339,10 @@ test('proves termination from each ownership-lost producer without signalling', }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); for (const replacement of [reassignedPid, otherArtifact, exitedTask]) { identity = replacement; await expect(transport.inspect(recorded)).resolves.toBe('ownership-lost'); @@ -347,7 +375,10 @@ test('classifies a replacement recorder on the same path as a foreign writer and }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.inspect(recorded)).resolves.toBe('foreign-writer'); await expect(transport.stop(recorded)).resolves.toBe('ownership-lost'); expect(commands.some((command) => command.startsWith('kill '))).toBe(false); @@ -396,7 +427,10 @@ test('retains unavailable manifest reads and confirms manifest deletion', async }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android, () => undefined); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.readManifest('/sdcard/manifest.json')).resolves.toEqual({ status: 'unavailable', message: 'transport unavailable', diff --git a/src/platform-runtime-screen-recording-android-host.ts b/src/platform-runtime-screen-recording-android-host.ts index 076afec319..3210d06804 100644 --- a/src/platform-runtime-screen-recording-android-host.ts +++ b/src/platform-runtime-screen-recording-android-host.ts @@ -5,10 +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, - type PreparedVideoValidator, + readPreparedVideoValidator, } from '@agent-device/capture-kit/recording-video'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; @@ -18,7 +19,7 @@ const BIT_RATE = { medium: 8_000_000, high: 20_000_000 } as const; export async function createAndroidScreenRecordingTransport( device: DeviceInfo, - videoValidator: () => PreparedVideoValidator | undefined, + host: HostEnvironment, ): Promise { const { resolveAndroidAdbExecutor, resolveScopedAndroidAdbBackgroundTransport, runAdbShell } = await loadAndroidMechanics(); @@ -111,7 +112,9 @@ export async function createAndroidScreenRecordingTransport( }); return { ...result, - playable: result.exitCode === 0 && (await isPlayableVideo(outputPath, videoValidator())), + playable: + result.exitCode === 0 && + (await isPlayableVideo(outputPath, readPreparedVideoValidator(host))), }; }, remove: async (remotePath, signal) => diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 5a72f74a22..dd7507f647 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -14,6 +14,7 @@ const video = vi.hoisted(() => ({ isPlayable: vi.fn(async (_path: string, _host: unknown, _deadline?: Deadline) => true), container: vi.fn(async () => true), prepare: vi.fn(async () => ({ executablePath: '/bin/true', env: {} })), + read: vi.fn<() => Promise<{ executablePath: string; env: NodeJS.ProcessEnv }> | undefined>(), })); const telemetry = vi.hoisted(() => vi.fn(() => '/tmp/capture.telemetry.json')); const overlay = vi.hoisted(() => vi.fn(async () => {})); @@ -25,7 +26,7 @@ vi.mock('@agent-device/capture-kit/recording-video', () => ({ isPlayableVideo: video.isPlayable, hasVideoContainer: video.container, prepareVideoValidator: video.prepare, - readPreparedVideoValidator: () => undefined, + readPreparedVideoValidator: video.read, })); vi.mock('@agent-device/capture-kit/recording-telemetry', () => ({ persistRecordingTelemetry: telemetry, @@ -45,10 +46,18 @@ beforeEach(() => { video.playable.mockReset().mockResolvedValue(true); video.isPlayable.mockReset().mockResolvedValue(true); video.container.mockReset().mockResolvedValue(true); + video.prepare.mockReset().mockResolvedValue({ executablePath: '/bin/true', env: {} }); + video.read.mockReset(); }); test('requires stable playable media before publishing finalization telemetry', async () => { - const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + const validator = Promise.resolve({ executablePath: '/prepared/validator', env: {} }); + video.prepare.mockReturnValue(validator); + video.read.mockReturnValue(validator); + const finalizer = createScreenRecordingFinalizer(createLiveTestHostEnvironment()); + await finalizer.prepare({ container: 'mp4' }); + expect(video.prepare).toHaveBeenCalledOnce(); + const result = await finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: false, gestureEvents: [], @@ -57,7 +66,7 @@ test('requires stable playable media before publishing finalization telemetry', expect(video.stable).toHaveBeenCalledWith('/tmp/capture.mp4'); expect(video.playable).toHaveBeenCalledWith('/tmp/capture.mp4', { - validator: undefined, + validator, deadline: expect.any(Deadline), }); expect(result).toEqual({ telemetryPath: '/tmp/capture.telemetry.json' }); diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts index 5c3a444c08..4a2ccffab2 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -13,7 +13,7 @@ import { hasVideoContainer, prepareVideoValidator, readPreparedVideoValidator, - type PreparedVideoValidator, + type VideoValidator, isPlayableVideo, waitForPlayableVideo, waitForStableFile, @@ -21,17 +21,13 @@ import { export function createScreenRecordingFinalizer( host: HostEnvironment, -): ScreenRecordingRuntimeHost['finalize'] & - Readonly<{ videoValidator(): PreparedVideoValidator | undefined }> { - let validator = readPreparedVideoValidator(host); +): ScreenRecordingRuntimeHost['finalize'] { return Object.freeze({ - prepare: async (input, signal) => { - if (input.container === 'mp4') - validator = await prepareVideoValidator(host, signal, input.timeoutMs); + prepare: async (input) => { + if (input.container === 'mp4') void prepareVideoValidator(host); }, - videoValidator: () => validator, sniff: async (input) => await sniffRecording(input), - complete: async (input) => await finalizeScreenRecording(host, input, validator), + complete: async (input) => await finalizeScreenRecording(host, input), }); } @@ -42,8 +38,8 @@ async function sniffRecording(input: Readonly<{ outputPath: string }>): Promise< async function finalizeScreenRecording( host: HostEnvironment, input: Parameters[0], - validator: PreparedVideoValidator | undefined, ) { + const validator = readPreparedVideoValidator(host); const finalizeDeadline = startRecordingOverlayBudget(); await waitForStableFile(input.outputPath); if (!(await waitForPlayableVideo(input.outputPath, { validator, deadline: finalizeDeadline }))) { @@ -61,7 +57,7 @@ async function overlayTouches( input: Parameters[0], telemetryPath: string, deadline: Deadline, - validator: PreparedVideoValidator | undefined, + validator: VideoValidator | undefined, ) { const warning = getRecordingOverlaySupportWarning(); if (warning) return { telemetryPath, overlayWarning: warning }; diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts index a4b87bf2fd..90dd6da302 100644 --- a/src/platform-runtime-screen-recording-host.ts +++ b/src/platform-runtime-screen-recording-host.ts @@ -8,25 +8,16 @@ export function createScreenRecordingRuntimeHost(options: { host: HostEnvironment; }): ScreenRecordingRuntimeHost { const host = options.host; - let finalizer: - | Promise< - ReturnType< - (typeof import('./platform-runtime-screen-recording-finalizer-host.ts'))['createScreenRecordingFinalizer'] - > - > - | undefined; - const loadFinalizer = () => - (finalizer ??= import('./platform-runtime-screen-recording-finalizer-host.ts').then( - ({ createScreenRecordingFinalizer }) => createScreenRecordingFinalizer(host), - )); + const loadFinalizer = async () => { + const { createScreenRecordingFinalizer } = + await import('./platform-runtime-screen-recording-finalizer-host.ts'); + return createScreenRecordingFinalizer(host); + }; const android: ScreenRecordingRuntimeHost['android'] = Object.freeze({ resolve: async (device) => { const { createAndroidScreenRecordingTransport } = await import('./platform-runtime-screen-recording-android-host.ts'); - return await createAndroidScreenRecordingTransport( - device, - (await loadFinalizer()).videoValidator, - ); + return await createAndroidScreenRecordingTransport(device, host); }, }); const web: ScreenRecordingRuntimeHost['web'] = Object.freeze({ @@ -54,7 +45,7 @@ export function createScreenRecordingRuntimeHost(options: { }, }); const finalize: ScreenRecordingRuntimeHost['finalize'] = Object.freeze({ - prepare: async (input, signal) => (await loadFinalizer()).prepare(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), }); 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 640546f051..34d865e1e5 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1065,7 +1065,7 @@ 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`. -- MP4 `record start` prepares the video validator before capture. A cold Swift cache can delay the first start. MP4 validation requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns `video_validator_unavailable`. `record stop` uses the prepared validator within its finalization budget. If it returns `video_validator_not_prepared`, run `prepare ios-runner` on the recording host, then retry stop in the same session. WebM validation needs no Swift helper. +- MP4 `record start` warms the video validator in the background without delaying capture. `prepare ios-runner` 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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns `video_validator_unavailable` only at stop. 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. 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. - 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. From 629a9fb06a4473219862b82887fcf491fae7025c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 13:53:57 +0100 Subject: [PATCH 03/28] fix(recording): retry failed validator preparation --- .../capture-kit/src/recording/video.test.ts | 28 ++++++++++++++++++- packages/capture-kit/src/recording/video.ts | 6 +++- .../command-surface/src/recording/index.ts | 2 +- website/docs/docs/commands.md | 2 +- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index c62d89e318..92be133a30 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -9,7 +9,12 @@ 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 { isPlayableVideo, prepareVideoValidator, waitForPlayableVideo } from './video.ts'; +import { + isPlayableVideo, + prepareVideoValidator, + readPreparedVideoValidator, + waitForPlayableVideo, +} from './video.ts'; vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -113,6 +118,27 @@ test('preparation compiles once per Swift cache root', async () => { expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); }); +test.each([ + new AppError('COMMAND_FAILED', 'Swift cache lock was busy', { reason: 'process_lock_timeout' }), + new AppError('TOOL_MISSING', 'no swiftc'), +])('failed preparation can be retried in the same cache root: %s', async (failure) => { + const host = createTestHost(); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + const failed = prepareVideoValidator(host); + expect(prepareVideoValidator(host)).toBe(failed); + await expect(failed).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + expect(readPreparedVideoValidator(host)).toBeUndefined(); + + const retry = prepareVideoValidator(host); + expect(retry).not.toBe(failed); + await expect(retry).resolves.toMatchObject({ executablePath: '/bin/true' }); + expect(readPreparedVideoValidator(host)).toBe(retry); + await expect( + isPlayableVideo(writeFixture('retried.mp4', Buffer.concat([ftyp, moov])), retry), + ).resolves.toBe(true); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); +}); + test('WebM validation ignores failed background MP4 preparation', async () => { vi.mocked(compileSwiftSourceText).mockRejectedValueOnce( new AppError('TOOL_MISSING', 'no swiftc'), diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index 5d0caad502..568c7ef231 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -79,7 +79,11 @@ export function prepareVideoValidator(host: HostEnvironment): Promise {}); + void preparing.catch(() => { + if (videoValidatorPreparations.get(cacheRoot) === preparing) { + videoValidatorPreparations.delete(cacheRoot); + } + }); } return preparing; } diff --git a/packages/command-surface/src/recording/index.ts b/packages/command-surface/src/recording/index.ts index 2893355fab..adde4571c4 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: - '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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable only at stop. 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. 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.', + '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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable only at stop. 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/website/docs/docs/commands.md b/website/docs/docs/commands.md index 34d865e1e5..3d5dfba2fd 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1068,7 +1068,7 @@ agent-device record stop # Stop active recording - MP4 `record start` warms the video validator in the background without delaying capture. `prepare ios-runner` 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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns `video_validator_unavailable` only at stop. 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. 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. - 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. From 5b87b592fcc9d729101f799baa485a872a6b6e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 13:56:18 +0100 Subject: [PATCH 04/28] fix(recording): retain preparation failures for stop diagnostics --- .../capture-kit/src/recording/video.test.ts | 28 +++++++++++++------ packages/capture-kit/src/recording/video.ts | 20 +++++++------ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 92be133a30..4f6e0a6b87 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -119,23 +119,35 @@ test('preparation compiles once per Swift cache root', async () => { }); test.each([ - new AppError('COMMAND_FAILED', 'Swift cache lock was busy', { reason: 'process_lock_timeout' }), - new AppError('TOOL_MISSING', 'no swiftc'), -])('failed preparation can be retried in the same cache root: %s', async (failure) => { + { + 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', + }, +])('failed preparation can be retried after $name', async ({ failure, reason }) => { const host = createTestHost(); vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); const failed = prepareVideoValidator(host); expect(prepareVideoValidator(host)).toBe(failed); - await expect(failed).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); - expect(readPreparedVideoValidator(host)).toBeUndefined(); + await expect(failed).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason } }); + expect(readPreparedVideoValidator(host)).toBe(failed); + const mp4 = writeFixture('retried.mp4', Buffer.concat([ftyp, moov])); + await expect(isPlayableVideo(mp4, readPreparedVideoValidator(host))).rejects.toMatchObject({ + details: { reason }, + }); const retry = prepareVideoValidator(host); expect(retry).not.toBe(failed); await expect(retry).resolves.toMatchObject({ executablePath: '/bin/true' }); expect(readPreparedVideoValidator(host)).toBe(retry); - await expect( - isPlayableVideo(writeFixture('retried.mp4', Buffer.concat([ftyp, moov])), retry), - ).resolves.toBe(true); + await expect(isPlayableVideo(mp4, retry)).resolves.toBe(true); expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); }); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index 568c7ef231..71512fa234 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -49,7 +49,10 @@ export type PreparedVideoValidator = Readonly<{ }>; export type VideoValidator = PreparedVideoValidator | Promise; -const videoValidatorPreparations = new Map>(); +const videoValidatorPreparations = new Map< + string, + { promise: Promise; failed: boolean } +>(); /** Reads prepared executable authority from the host cache without compiling. */ export function readPreparedVideoValidator( @@ -57,7 +60,7 @@ export function readPreparedVideoValidator( ): Promise | undefined { const cacheRoot = getSwiftCacheRoot(host.env); const preparing = videoValidatorPreparations.get(cacheRoot); - if (preparing) return preparing; + if (preparing) return preparing.promise; const executablePath = readCompiledSwiftSourceText({ host, source: VIDEO_VALIDATION_SCRIPT, @@ -76,16 +79,15 @@ function preparedVideoValidator( export function prepareVideoValidator(host: HostEnvironment): Promise { const cacheRoot = getSwiftCacheRoot(host.env); let preparing = videoValidatorPreparations.get(cacheRoot); - if (!preparing) { - preparing = compileVideoValidator(host); + if (!preparing || preparing.failed) { + const next = { promise: compileVideoValidator(host), failed: false }; + preparing = next; videoValidatorPreparations.set(cacheRoot, preparing); - void preparing.catch(() => { - if (videoValidatorPreparations.get(cacheRoot) === preparing) { - videoValidatorPreparations.delete(cacheRoot); - } + void next.promise.catch(() => { + next.failed = true; }); } - return preparing; + return preparing.promise; } async function compileVideoValidator(host: HostEnvironment): Promise { From a5c82f554ffcead13180ee79352da828c428b275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 13:59:21 +0100 Subject: [PATCH 05/28] fix(recording): distinguish overlay verification budget warnings --- ...me-screen-recording-finalizer-host.test.ts | 20 +++++++++++++++++++ ...runtime-screen-recording-finalizer-host.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index dd7507f647..29adcdd921 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -209,6 +209,26 @@ test('#2707 reports a dropped overlay and keeps the raw when compositing throws' }); }); +test('reports a verification budget warning after overlay', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + overlay.mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + OVERLAY_BUDGET_MS); + }); + 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.overlayWarning).toContain('finalization budget'); + expect(result.overlayWarning).not.toContain('unplayable'); + } finally { + vi.useRealTimers(); + } +}); + 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 diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts index 4a2ccffab2..d1bd953c76 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -73,7 +73,11 @@ async function overlayTouches( validator, }); if (!(await isPlayableVideo(input.outputPath, validator, deadline))) { - throw new Error('recording post-processing produced an unplayable video'); + throw new Error( + deadline.isExpired() + ? 'recording post-processing verification exceeded the finalization budget' + : 'recording post-processing produced an unplayable video', + ); } return { telemetryPath }; } catch (error) { From 49b95be5a1cda52f609c559e410c1ab710eb3ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 14:44:02 +0100 Subject: [PATCH 06/28] fix(recording): recover prepared validator after failed warmup --- packages/capture-kit/src/recording/overlay.ts | 6 +- .../src/recording/swift-cache.test.ts | 64 +++------------ .../capture-kit/src/recording/swift-cache.ts | 9 +-- .../capture-kit/src/recording/video.test.ts | 80 ++++++++++++++----- packages/capture-kit/src/recording/video.ts | 16 +++- .../command-surface/src/recording/index.ts | 2 +- website/docs/docs/commands.md | 2 +- 7 files changed, 93 insertions(+), 86 deletions(-) diff --git a/packages/capture-kit/src/recording/overlay.ts b/packages/capture-kit/src/recording/overlay.ts index e43baea938..f3687f6f12 100644 --- a/packages/capture-kit/src/recording/overlay.ts +++ b/packages/capture-kit/src/recording/overlay.ts @@ -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 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; diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index d8e13d47da..2984b8c212 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -3,7 +3,6 @@ import { createTestHost } from '../host-environment.fixtures.ts'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; -import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors'; vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -172,60 +171,23 @@ test('compileSwiftSourceText resolves a cache name with a long interior dash run expect(elapsedMs).toBeLessThan(1_000); }); -test('a canceled compile kills swiftc through its signal and leaves no temp output', async () => { - const cancel = new AbortController(); - mockRunCmd.mockImplementationOnce( - async (_cmd, _args, options) => - await new Promise((_, reject) => { - options?.signal?.addEventListener('abort', () => reject(createRequestCanceledError())); - }), - ); +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); - const compiled = compileSwiftSourceText({ - host: createTestHost(), - source: 'print(1)', - cacheName: 'canceled', - signal: cancel.signal, - }); - await vi.waitFor(() => expect(mockRunCmd).toHaveBeenCalledOnce()); - cancel.abort(); - - await expect(compiled).rejects.toSatisfy(isRequestCanceledError); - expect(mockRunCmd.mock.calls[0]![2]).toMatchObject({ - signal: cancel.signal, - kill: { signal: 'SIGINT' }, - }); - expect(fs.readdirSync(path.join(tmpDir, 'swift-cache', 'bin'))).toEqual([]); -}); - -test('a canceled wait on another process compile stops waiting and leaves its lock alone', async () => { - const compile = (signal?: AbortSignal) => + await expect( compileSwiftSourceText({ host: createTestHost(), source: 'print(1)', - cacheName: 'held-elsewhere', - signal, - }); - const executablePath = await compile(); - fs.rmSync(executablePath); - const lockDir = `${executablePath}.lock`; - fs.mkdirSync(lockDir); - vi.clearAllMocks(); - // The parent process stands in for another live process that holds the lock while it compiles. - fs.writeFileSync( - path.join(lockDir, 'owner.json'), - JSON.stringify({ pid: process.ppid, startTime: null, acquiredAtMs: Date.now() }), - ); - const cancel = new AbortController(); - - const compiled = compile(cancel.signal); - // Let the wait poll the held lock a few times before it is canceled. - await new Promise((resolve) => setTimeout(resolve, 100)); - cancel.abort(); - - await expect(compiled).rejects.toSatisfy(isRequestCanceledError); - expect(mockRunCmd).not.toHaveBeenCalled(); - expect(fs.existsSync(path.join(lockDir, 'owner.json'))).toBe(true); + 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('compileSwiftSourceText falls back to swift-helper when the cache name sanitizes to nothing', async () => { diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index 03db648015..ef7369ed37 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -153,8 +153,6 @@ export function readCompiledSwiftSourceText(params: SwiftSourceTextInput): strin 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); @@ -165,7 +163,6 @@ export async function compileSwiftSourceText( executablePath, sourceText: params.source, timeoutMs: params.timeoutMs, - signal: params.signal, }); return executablePath; } @@ -185,7 +182,6 @@ async function ensureSwiftExecutable(params: { executablePath: string; sourceText?: string; timeoutMs?: number; - signal?: AbortSignal; }): Promise { if (isExecutableFile(params.executablePath)) { return; @@ -195,7 +191,7 @@ async function ensureSwiftExecutable(params: { fs.mkdirSync(executableDir, { recursive: true }); const timeoutMs = params.timeoutMs ?? 120_000; await withProcessLock({ - acquire: () => acquireSwiftCacheLock(`${params.executablePath}.lock`, timeoutMs, params.signal), + acquire: () => acquireSwiftCacheLock(`${params.executablePath}.lock`, timeoutMs), task: async () => { // Another process may have published the executable while this one waited for the lock. if (isExecutableFile(params.executablePath)) { @@ -217,7 +213,6 @@ async function ensureSwiftExecutable(params: { } await runCmd('xcrun', ['swiftc', ...params.sourcePaths, '-o', tempExecutablePath], { timeoutMs, - signal: params.signal, kill: SWIFTC_STOP, env: buildSwiftToolEnv(params.host.env), }); @@ -232,7 +227,6 @@ async function ensureSwiftExecutable(params: { async function acquireSwiftCacheLock( lockDir: string, timeoutMs: number, - signal: AbortSignal | undefined, ): Promise { try { return await acquireProcessLock({ @@ -244,7 +238,6 @@ async function acquireSwiftCacheLock( }, timeoutMs, pollMs: LOCK_RETRY_DELAY_MS, - signal, description: `Swift cache lock: ${lockDir} (${timeoutMs}ms)`, }); } catch (error) { diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 4f6e0a6b87..bcfcbb757a 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -8,7 +8,7 @@ import { likelyPlayableWebmContainer } from '../__tests__/test-utils/video-fixtu 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 { compileSwiftSourceText, readCompiledSwiftSourceText } from './swift-cache.ts'; import { isPlayableVideo, prepareVideoValidator, @@ -31,6 +31,7 @@ beforeEach(() => { 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()); @@ -118,7 +119,7 @@ test('preparation compiles once per Swift cache root', async () => { expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); }); -test.each([ +const preparationFailures = [ { name: 'a contended cache lock', failure: new AppError('COMMAND_FAILED', 'Swift cache lock was busy', { @@ -131,24 +132,67 @@ test.each([ failure: new AppError('TOOL_MISSING', 'no swiftc'), reason: 'video_validator_unavailable', }, -])('failed preparation can be retried after $name', async ({ failure, reason }) => { +] as const; + +test.each(preparationFailures)( + 'failed preparation can be retried after $name', + async ({ failure, reason }) => { + const host = createTestHost(); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + const failed = prepareVideoValidator(host); + expect(prepareVideoValidator(host)).toBe(failed); + await expect(failed).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason } }); + expect(readPreparedVideoValidator(host)).toBe(failed); + const originalError = await failed.catch((error: unknown) => error); + const mp4 = writeFixture('retried.mp4', Buffer.concat([ftyp, moov])); + await expect(isPlayableVideo(mp4, readPreparedVideoValidator(host))).rejects.toBe( + originalError, + ); + + const retry = prepareVideoValidator(host); + expect(retry).not.toBe(failed); + await expect(retry).resolves.toMatchObject({ executablePath: '/bin/true' }); + expect(readPreparedVideoValidator(host)).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 host = createTestHost(); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + const failed = prepareVideoValidator(host); + await expect(failed).rejects.toMatchObject({ details: { reason } }); + expect(readPreparedVideoValidator(host)).toBe(failed); + + vi.mocked(readCompiledSwiftSourceText).mockReturnValue('/published/video-validator'); + const recovered = readPreparedVideoValidator(host); + await expect(recovered).resolves.toMatchObject({ + executablePath: '/published/video-validator', + }); + 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 host = createTestHost(); - vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); - const failed = prepareVideoValidator(host); - expect(prepareVideoValidator(host)).toBe(failed); - await expect(failed).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason } }); - expect(readPreparedVideoValidator(host)).toBe(failed); - const mp4 = writeFixture('retried.mp4', Buffer.concat([ftyp, moov])); - await expect(isPlayableVideo(mp4, readPreparedVideoValidator(host))).rejects.toMatchObject({ - details: { reason }, + expect(readPreparedVideoValidator(host)).toBeUndefined(); + await expect( + isPlayableVideo( + writeFixture('never-prepared.mp4', Buffer.concat([ftyp, moov])), + readPreparedVideoValidator(host), + ), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'video_validator_not_prepared', retriable: true }, }); - - const retry = prepareVideoValidator(host); - expect(retry).not.toBe(failed); - await expect(retry).resolves.toMatchObject({ executablePath: '/bin/true' }); - expect(readPreparedVideoValidator(host)).toBe(retry); - await expect(isPlayableVideo(mp4, retry)).resolves.toBe(true); - expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); + expect(compileSwiftSourceText).not.toHaveBeenCalled(); + expect(runCmd).not.toHaveBeenCalled(); }); test('WebM validation ignores failed background MP4 preparation', async () => { diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index 71512fa234..c624b324ad 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -60,13 +60,15 @@ export function readPreparedVideoValidator( ): Promise | undefined { const cacheRoot = getSwiftCacheRoot(host.env); const preparing = videoValidatorPreparations.get(cacheRoot); - if (preparing) return preparing.promise; + if (preparing && !preparing.failed) return preparing.promise; const executablePath = readCompiledSwiftSourceText({ host, source: VIDEO_VALIDATION_SCRIPT, cacheName: 'video-validator', }); - return executablePath ? Promise.resolve(preparedVideoValidator(host, executablePath)) : undefined; + return executablePath + ? Promise.resolve(preparedVideoValidator(host, executablePath)) + : preparing?.promise; } function preparedVideoValidator( @@ -121,7 +123,7 @@ function unavailableVideoValidator(cause?: Error): AppError { { reason: 'video_validator_unavailable', retriable: false, - hint: 'Install the Xcode command line tools with AVFoundation on the recording host, then start a new recording.', + 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, ); @@ -190,7 +192,13 @@ export async function isPlayableVideo( hint: 'Preparation continues in the background. Retry record stop in this session once the validator is ready.', }, ); - if (!validator) throw notPrepared(); + 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.', + }); + } let timer: NodeJS.Timeout | undefined; let prepared: PreparedVideoValidator; try { diff --git a/packages/command-surface/src/recording/index.ts b/packages/command-surface/src/recording/index.ts index adde4571c4..ae4bb81670 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: - '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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable only at stop. 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.', + '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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable only at stop. 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/website/docs/docs/commands.md b/website/docs/docs/commands.md index 3d5dfba2fd..5ee272d809 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1065,7 +1065,7 @@ 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`. -- MP4 `record start` warms the video validator in the background without delaying capture. `prepare ios-runner` 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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns `video_validator_unavailable` only at stop. WebM validation needs no Swift helper. +- 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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns `video_validator_unavailable` only at stop. 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. 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. 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. - 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 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. From d853858237ac62569045a50dc115b895edb4f710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 15:55:18 +0100 Subject: [PATCH 07/28] fix(recording): preserve non-macOS MP4 validation --- .../src/recording/__tests__/overlay.test.ts | 6 ++- .../capture-kit/src/recording/video.test.ts | 45 ++++++++++++++++++- packages/capture-kit/src/recording/video.ts | 19 ++++---- .../command-surface/src/recording/index.ts | 2 +- packages/test-kit/src/recording-video.ts | 2 +- ...me-screen-recording-finalizer-host.test.ts | 19 ++++++-- website/docs/docs/commands.md | 2 +- 7 files changed, 77 insertions(+), 18 deletions(-) diff --git a/packages/capture-kit/src/recording/__tests__/overlay.test.ts b/packages/capture-kit/src/recording/__tests__/overlay.test.ts index dbb594427e..47780964d3 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -58,7 +58,11 @@ afterEach(() => { }); test('overlay burns touches through a cached helper and same-directory temp output', async () => { - const validator = { executablePath: '/prepared/video-validator', env: {} }; + 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'); diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index bcfcbb757a..12b7f0a6eb 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -27,13 +27,19 @@ vi.mock('./swift-cache.ts', () => ({ compileSwiftSourceText: vi.fn(async () => '/bin/true'), })); +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; + beforeEach(() => { + 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()); +afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, 'platform', originalPlatform); +}); const directory = mkdtempForTestSync('agent-device-video-webm-'); const playableWebm = likelyPlayableWebmContainer(); @@ -62,7 +68,42 @@ const moov = mp4Atom( mp4Atom('mvhd', mp4MovieHeader({ version: 0, timescale: 1_000, duration: 2_000 })), ); -const validator = { executablePath: '/prepared/video-validator', env: {} }; +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 }); + const host = createTestHost(); + const prepared = await prepareVideoValidator(host); + expect(prepared).toEqual({ kind: 'container' }); + expect(await readPreparedVideoValidator(host)).toBe(prepared); + 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])); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index c624b324ad..ca0108cc9b 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -42,22 +42,23 @@ exit(exitCode) const VALIDATOR_RUN_TIMEOUT_MS = 10_000; -/** Executable authority produced by background preparation; validation cannot compile. */ -export type PreparedVideoValidator = Readonly<{ - executablePath: string; - env: NodeJS.ProcessEnv; -}>; +/** 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); const videoValidatorPreparations = new Map< string, { promise: Promise; failed: boolean } >(); -/** Reads prepared executable authority from the host cache without compiling. */ +/** Selects container validation or reads prepared executable authority without compiling. */ export function readPreparedVideoValidator( host: HostEnvironment, ): Promise | undefined { + if (process.platform !== 'darwin') return Promise.resolve(containerVideoValidator); const cacheRoot = getSwiftCacheRoot(host.env); const preparing = videoValidatorPreparations.get(cacheRoot); if (preparing && !preparing.failed) return preparing.promise; @@ -75,10 +76,11 @@ function preparedVideoValidator( host: HostEnvironment, executablePath: string, ): PreparedVideoValidator { - return Object.freeze({ executablePath, env: buildSwiftToolEnv(host.env) }); + return Object.freeze({ kind: 'avfoundation', executablePath, env: buildSwiftToolEnv(host.env) }); } export function prepareVideoValidator(host: HostEnvironment): Promise { + if (process.platform !== 'darwin') return Promise.resolve(containerVideoValidator); const cacheRoot = getSwiftCacheRoot(host.env); let preparing = videoValidatorPreparations.get(cacheRoot); if (!preparing || preparing.failed) { @@ -171,7 +173,7 @@ export async function waitForStableFile( } } -/** Validates with a prepared executable, bounded by the caller's finalization deadline. */ +/** Uses prepared validation authority; executable runs stay within the finalization deadline. */ export async function isPlayableVideo( filePath: string, validator: VideoValidator | undefined, @@ -214,6 +216,7 @@ export async function isPlayableVideo( } finally { clearTimeout(timer); } + if (prepared.kind === 'container') return true; const runTimeoutMs = Math.min( VALIDATOR_RUN_TIMEOUT_MS, deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, diff --git a/packages/command-surface/src/recording/index.ts b/packages/command-surface/src/recording/index.ts index ae4bb81670..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: - '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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns video_validator_unavailable only at stop. 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.', + '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/test-kit/src/recording-video.ts b/packages/test-kit/src/recording-video.ts index aed9aaac6a..8dbf0de493 100644 --- a/packages/test-kit/src/recording-video.ts +++ b/packages/test-kit/src/recording-video.ts @@ -3,7 +3,7 @@ import { prepareVideoValidator } 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 a stub semantic validator for synthetic recording fixtures, without host Swift tools. */ +/** Prepares validation for synthetic recording fixtures without host Swift tools. */ export async function prepareRecordingValidatorFixture(host: HostEnvironment): Promise { await withCommandExecutorOverride( (command, args) => { diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 29adcdd921..38733a47ff 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(() => ({ @@ -13,8 +14,12 @@ const video = vi.hoisted(() => ({ playable: vi.fn(async (_path: string, _options: { deadline?: Deadline }) => true), isPlayable: vi.fn(async (_path: string, _host: unknown, _deadline?: Deadline) => true), container: vi.fn(async () => true), - prepare: vi.fn(async () => ({ executablePath: '/bin/true', env: {} })), - read: vi.fn<() => Promise<{ executablePath: string; env: NodeJS.ProcessEnv }> | undefined>(), + prepare: vi.fn(async () => ({ + kind: 'avfoundation' as const, + executablePath: '/bin/true', + env: {}, + })), + read: vi.fn<() => Promise | undefined>(), })); const telemetry = vi.hoisted(() => vi.fn(() => '/tmp/capture.telemetry.json')); const overlay = vi.hoisted(() => vi.fn(async () => {})); @@ -46,12 +51,18 @@ beforeEach(() => { video.playable.mockReset().mockResolvedValue(true); video.isPlayable.mockReset().mockResolvedValue(true); video.container.mockReset().mockResolvedValue(true); - video.prepare.mockReset().mockResolvedValue({ executablePath: '/bin/true', env: {} }); + video.prepare + .mockReset() + .mockResolvedValue({ kind: 'avfoundation', executablePath: '/bin/true', env: {} }); video.read.mockReset(); }); test('requires stable playable media before publishing finalization telemetry', async () => { - const validator = Promise.resolve({ executablePath: '/prepared/validator', env: {} }); + const validator = Promise.resolve({ + kind: 'avfoundation' as const, + executablePath: '/prepared/validator', + env: {}, + }); video.prepare.mockReturnValue(validator); video.read.mockReturnValue(validator); const finalizer = createScreenRecordingFinalizer(createLiveTestHostEnvironment()); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 5ee272d809..5b110bd16b 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1065,7 +1065,7 @@ 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`. -- 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. MP4 verification requires macOS with Xcode command line tools and AVFoundation; unavailable tooling returns `video_validator_unavailable` only at stop. 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. WebM validation needs no Swift helper. +- 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. 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. 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. - 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 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. From c2e2d2701fe9d33f15406e5ede1902180ebdd2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 15:48:20 +0100 Subject: [PATCH 08/28] test(android): observe settled Back transition in smoke --- .../android-emulator-e2e/live-assertions.ts | 8 +++---- .../live-automation-scenario.ts | 24 ++++++++++++------- .../command-coverage/declarations.ts | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) 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..b0631275ad 100644 --- a/test/integration/android-emulator-e2e/live-automation-scenario.ts +++ b/test/integration/android-emulator-e2e/live-automation-scenario.ts @@ -137,7 +137,17 @@ export async function assertAutomationSystem(context: LiveContext): Promise Date: Tue, 3 Nov 2026 16:05:00 +0100 Subject: [PATCH 09/28] test(recording): reject Swift use on non-macOS hosts --- packages/capture-kit/src/recording/video.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 12b7f0a6eb..5fec0162cb 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -78,6 +78,7 @@ 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 prepared = await prepareVideoValidator(host); expect(prepared).toEqual({ kind: 'container' }); From 776aab8492c636dafba14eebe7f39bfe3e2d910c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 16:05:00 +0100 Subject: [PATCH 10/28] test(android): diff confirmed field replacements --- .../live-automation-scenario.ts | 10 ---------- .../live-form-scenario.ts | 19 ++++++++++++++++++- .../android-emulator-e2e/scenarios.ts | 3 +-- .../command-coverage/declarations.ts | 4 ++-- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/test/integration/android-emulator-e2e/live-automation-scenario.ts b/test/integration/android-emulator-e2e/live-automation-scenario.ts index b0631275ad..cce1bfe2d1 100644 --- a/test/integration/android-emulator-e2e/live-automation-scenario.ts +++ b/test/integration/android-emulator-e2e/live-automation-scenario.ts @@ -137,17 +137,7 @@ 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/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 929d57afa1..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 removed and added Android input canary text', + 'smoke:form-input', + 'snapshot diff observes a confirmed Android name replacement', ), iosSimulator: iosSimulator.live( 'smoke:form-input', From 6829cbf8892bd9161f0b77c958f5822312a56d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 16:46:11 +0100 Subject: [PATCH 11/28] fix(recording): trust verified overlay publication --- .../capture-kit/src/recording/video.test.ts | 10 ++- packages/capture-kit/src/recording/video.ts | 10 ++- ...me-screen-recording-finalizer-host.test.ts | 64 +++++++------------ ...runtime-screen-recording-finalizer-host.ts | 8 --- 4 files changed, 40 insertions(+), 52 deletions(-) diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 5fec0162cb..9fb55d8f48 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -283,7 +283,15 @@ test('an unavailable validator at execution never makes an MP4 playable', async stderr: `error: no such module 'AVFoundation'`, }); await expect(isPlayableVideo(mp4, validator)).rejects.toMatchObject({ - details: { reason: 'video_validator_unavailable' }, + details: { + reason: 'video_validator_unavailable', + exitCode: 1, + stdout: '', + stderr: `error: no such module 'AVFoundation'`, + }, + cause: { + details: { stderr: `error: no such module 'AVFoundation'` }, + }, }); }); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index ca0108cc9b..5331ef5dde 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -123,6 +123,7 @@ function unavailableVideoValidator(cause?: Error): 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.', @@ -232,8 +233,11 @@ export async function isPlayableVideo( throw error; }); if (!result) return false; - if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) - throw unavailableVideoValidator(); + if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) { + throw unavailableVideoValidator( + new AppError('COMMAND_FAILED', 'The prepared video validator could not run', result), + ); + } return result.exitCode === 0; } @@ -266,7 +270,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?["'])/i.test( + return /\b(no such module ['"]AVFoundation['"]|unable to find utility ["']swiftc?["'])/i.test( combined, ); } diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 38733a47ff..3b20beb21a 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -220,46 +220,30 @@ test('#2707 reports a dropped overlay and keeps the raw when compositing throws' }); }); -test('reports a verification budget warning after overlay', async () => { - vi.useFakeTimers({ toFake: ['Date'] }); - try { - overlay.mockImplementationOnce(async () => { - vi.setSystemTime(Date.now() + OVERLAY_BUDGET_MS); - }); - 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.overlayWarning).toContain('finalization budget'); - expect(result.overlayWarning).not.toContain('unplayable'); - } finally { - vi.useRealTimers(); - } -}); - -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 createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete( + { + outputPath: '/tmp/capture.mp4', + showTouches: true, + gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], + targetLabel: 'test recording', + }, + ); + 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; diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts index d1bd953c76..581720bcc7 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -14,7 +14,6 @@ import { prepareVideoValidator, readPreparedVideoValidator, type VideoValidator, - isPlayableVideo, waitForPlayableVideo, waitForStableFile, } from '@agent-device/capture-kit/recording-video'; @@ -72,13 +71,6 @@ async function overlayTouches( deadline, validator, }); - if (!(await isPlayableVideo(input.outputPath, validator, deadline))) { - throw new Error( - deadline.isExpired() - ? 'recording post-processing verification exceeded the finalization budget' - : 'recording post-processing produced an unplayable video', - ); - } return { telemetryPath }; } catch (error) { return { From d8bc80e0163ef0942a9d34ff03917dc71b6ebb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 17:15:44 +0100 Subject: [PATCH 12/28] test(recording): require overlay invocation at budget boundary --- src/platform-runtime-screen-recording-finalizer-host.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 3b20beb21a..5dee992fec 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -237,6 +237,7 @@ test.each([0, OVERLAY_BUDGET_MS])( targetLabel: 'test recording', }, ); + expect(overlay).toHaveBeenCalledOnce(); expect(result).toEqual({ telemetryPath: '/tmp/capture.telemetry.json' }); expect(video.isPlayable).not.toHaveBeenCalled(); } finally { From 3e988a8c4cab6ca88bab8eb67a8570f96477eafc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 17:40:24 +0100 Subject: [PATCH 13/28] test(apple): await launch lookup spawn explicitly --- .../src/launch-confirmation.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 343e1c2316..467c0741c9 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -214,17 +214,20 @@ test.each([ */ function wedgedCoreSimulator( answers: Readonly> = {}, + onSpawn?: (count: number) => void, ): AppleToolProvider & { spawns: () => number } { let spawns = 0; const answered = (key: string): ExecResult | undefined => { const stdout = answers[key]; if (stdout === undefined) return undefined; spawns += 1; + onSpawn?.(spawns); return { stdout, stderr: '', exitCode: 0 } as ExecResult; }; const hang = async (options?: ExecOptions): Promise => await new Promise((_resolve, reject) => { spawns += 1; + onSpawn?.(spawns); if (options?.signal?.aborted) { reject(options.signal.reason); return; @@ -251,14 +254,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', @@ -282,7 +277,10 @@ test.each([ vi.mocked(emitDiagnostic).mockClear(); try { const { interactor, acceptAlert } = confirmationRunner(); - const coreSimulator = wedgedCoreSimulator(answers); + const secondSpawned = Promise.withResolvers(); + const coreSimulator = wedgedCoreSimulator(answers, (count) => { + if (count === 2) secondSpawned.resolve(); + }); let answered: unknown = 'pending'; const answer = withAppleToolProvider(coreSimulator, async () => answerSimulatorLaunchConfirmation( @@ -294,7 +292,8 @@ test.each([ ).then((value) => (answered = value)), ); - await untilSpawned(coreSimulator, 2); + await secondSpawned.promise; + expect(coreSimulator.spawns()).toBe(2); await vi.advanceTimersByTimeAsync(URL_OWNER_LOOKUP_TIMEOUT_MS + 1); expect(answered).toEqual({ outcome: 'unreadable', step: 'url-owner' }); await answer; From e6b8b205087372975bc4a319b45d20eaba0b84c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 17:40:49 +0100 Subject: [PATCH 14/28] test(apple): use supported spawn signal promise --- packages/platform-apple/src/launch-confirmation.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 467c0741c9..5a046a7e20 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -277,9 +277,12 @@ test.each([ vi.mocked(emitDiagnostic).mockClear(); try { const { interactor, acceptAlert } = confirmationRunner(); - const secondSpawned = Promise.withResolvers(); + let observeSecondSpawn!: () => void; + const secondSpawned = new Promise((resolve) => { + observeSecondSpawn = resolve; + }); const coreSimulator = wedgedCoreSimulator(answers, (count) => { - if (count === 2) secondSpawned.resolve(); + if (count === 2) observeSecondSpawn(); }); let answered: unknown = 'pending'; const answer = withAppleToolProvider(coreSimulator, async () => @@ -292,7 +295,7 @@ test.each([ ).then((value) => (answered = value)), ); - await secondSpawned.promise; + await secondSpawned; expect(coreSimulator.spawns()).toBe(2); await vi.advanceTimersByTimeAsync(URL_OWNER_LOOKUP_TIMEOUT_MS + 1); expect(answered).toEqual({ outcome: 'unreadable', step: 'url-owner' }); From 9bf650575c73cdefdb9db8af6fe8a1c66126a946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 17:49:13 +0100 Subject: [PATCH 15/28] fix(recording): clarify overlay budget and fixture output --- packages/test-kit/src/recording-video.ts | 5 +++-- website/docs/docs/commands.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/test-kit/src/recording-video.ts b/packages/test-kit/src/recording-video.ts index 8dbf0de493..07d8bf5137 100644 --- a/packages/test-kit/src/recording-video.ts +++ b/packages/test-kit/src/recording-video.ts @@ -10,8 +10,9 @@ export async function prepareRecordingValidatorFixture(host: HostEnvironment): P if (command !== 'xcrun' || args[0] !== 'swiftc') { throw new Error(`Unexpected validator preparation command: ${command}`); } - const output = args[args.indexOf('-o') + 1]; - if (!output) throw new Error('Swift compiler fixture received no executable output'); + 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: '' }); }, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 5b110bd16b..d826e32cee 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1066,7 +1066,7 @@ agent-device record stop # Stop active recording - 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`. - 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. 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. 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. +- With touch visualization on, recordings also produce a gesture telemetry sidecar. Burned-in touch overlays export only on macOS hosts. 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 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. From b92d87df82dfa3a1499b51b7db7307ad1f849de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 18:02:44 +0100 Subject: [PATCH 16/28] test(apple): bound launch lookup spawn assertion --- .../platform-apple/src/launch-confirmation.test.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 5a046a7e20..f07e274f95 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -214,20 +214,17 @@ test.each([ */ function wedgedCoreSimulator( answers: Readonly> = {}, - onSpawn?: (count: number) => void, ): AppleToolProvider & { spawns: () => number } { let spawns = 0; const answered = (key: string): ExecResult | undefined => { const stdout = answers[key]; if (stdout === undefined) return undefined; spawns += 1; - onSpawn?.(spawns); return { stdout, stderr: '', exitCode: 0 } as ExecResult; }; const hang = async (options?: ExecOptions): Promise => await new Promise((_resolve, reject) => { spawns += 1; - onSpawn?.(spawns); if (options?.signal?.aborted) { reject(options.signal.reason); return; @@ -277,13 +274,7 @@ test.each([ vi.mocked(emitDiagnostic).mockClear(); try { const { interactor, acceptAlert } = confirmationRunner(); - let observeSecondSpawn!: () => void; - const secondSpawned = new Promise((resolve) => { - observeSecondSpawn = resolve; - }); - const coreSimulator = wedgedCoreSimulator(answers, (count) => { - if (count === 2) observeSecondSpawn(); - }); + const coreSimulator = wedgedCoreSimulator(answers); let answered: unknown = 'pending'; const answer = withAppleToolProvider(coreSimulator, async () => answerSimulatorLaunchConfirmation( @@ -295,8 +286,7 @@ test.each([ ).then((value) => (answered = value)), ); - await secondSpawned; - expect(coreSimulator.spawns()).toBe(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; From e5cde023d17ed17656af2fe5d9f5a955e052f085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 18:17:02 +0100 Subject: [PATCH 17/28] docs(recording): state unsupported overlay artifacts --- website/docs/docs/commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d826e32cee..12d427229c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1066,7 +1066,7 @@ agent-device record stop # Stop active recording - 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`. - 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. 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. 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. +- 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 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. From 0ad30c8fe82082ab5a945bd4e9b3d47f9409b51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 20:17:16 +0100 Subject: [PATCH 18/28] fix(recording): close the validator compile on daemon shutdown A background video-validator compile kept running after the daemon exited. process.exit() ended the supervisor while swiftc was still a live child, so the daemon left an orphaned compiler and a held cache lock. The shutdown path had no owner for the preparation because the module-level prepare/read API did not track the running promise. Give the preparation one owner per runtime host. createVideoValidatorPreparation() holds the promise and an AbortController, so close() can cancel it and await the settlement. The host closes that owner while it shuts down, before the Apple runner sweep. Late host imports close the owner the import produced, so a warm-up that starts after shutdown compiles nothing. Restore signal propagation from compileSwiftSourceText through ensureSwiftExecutable into the cache lock and runCmd, so cancellation terminates the compiler child and removes the lock and temp paths. Cross-process dedup stays with the filesystem cache lock. Stop no longer looks like a cancelled compile, and one host closing does not cancel another host's compiler. --- .../src/recording/swift-cache.test.ts | 57 ++++++ .../capture-kit/src/recording/swift-cache.ts | 9 +- .../capture-kit/src/recording/video.test.ts | 193 ++++++++++++++---- packages/capture-kit/src/recording/video.ts | 123 ++++++++--- packages/test-kit/src/recording-video.ts | 4 +- src/owned-fake-compiler.fixtures.ts | 88 ++++++++ ...form-runtime-application-resources.test.ts | 56 +++++ src/platform-runtime-application-resources.ts | 19 +- ...-operation-host-validator-shutdown.test.ts | 158 ++++++++++++++ src/platform-runtime-operation-host.ts | 1 + ...time-screen-recording-android-host.test.ts | 40 ++-- ...m-runtime-screen-recording-android-host.ts | 9 +- ...me-screen-recording-finalizer-host.test.ts | 112 ++++++---- ...runtime-screen-recording-finalizer-host.ts | 22 +- src/platform-runtime-screen-recording-host.ts | 43 +++- .../live-observability-scenario.ts | 6 +- .../ios-simulator-e2e/live-runner.ts | 6 +- website/docs/docs/commands.md | 2 +- 18 files changed, 789 insertions(+), 159 deletions(-) create mode 100644 src/owned-fake-compiler.fixtures.ts create mode 100644 src/platform-runtime-operation-host-validator-shutdown.test.ts diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index 2984b8c212..855077e417 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -3,6 +3,7 @@ import { createTestHost } from '../host-environment.fixtures.ts'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; +import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors'; vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -190,6 +191,62 @@ test('a compile timeout preserves the error and cleans unpublished output and it 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( + async (_cmd, _args, options) => + await new Promise((_, reject) => { + options?.signal?.addEventListener('abort', () => reject(createRequestCanceledError())); + }), + ); + + const compiled = compileSwiftSourceText({ + host: createTestHost(), + source: 'print(1)', + cacheName: 'canceled', + signal: cancel.signal, + }); + await vi.waitFor(() => expect(mockRunCmd).toHaveBeenCalledOnce()); + cancel.abort(); + + await expect(compiled).rejects.toSatisfy(isRequestCanceledError); + expect(mockRunCmd.mock.calls[0]![2]).toMatchObject({ + signal: cancel.signal, + kill: { signal: 'SIGINT' }, + }); + expect(fs.readdirSync(path.join(tmpDir, 'swift-cache', 'bin'))).toEqual([]); +}); + +test('a canceled wait on another process compile stops waiting and leaves its lock alone', async () => { + const compile = (signal?: AbortSignal) => + compileSwiftSourceText({ + host: createTestHost(), + source: 'print(1)', + cacheName: 'held-elsewhere', + signal, + }); + const executablePath = await compile(); + fs.rmSync(executablePath); + const lockDir = `${executablePath}.lock`; + fs.mkdirSync(lockDir); + vi.clearAllMocks(); + // The parent process stands in for another live process that holds the lock while it compiles. + fs.writeFileSync( + path.join(lockDir, 'owner.json'), + JSON.stringify({ pid: process.ppid, startTime: null, acquiredAtMs: Date.now() }), + ); + const cancel = new AbortController(); + + const compiled = compile(cancel.signal); + // Let the wait poll the held lock a few times before it is canceled. + await new Promise((resolve) => setTimeout(resolve, 100)); + cancel.abort(); + + await expect(compiled).rejects.toSatisfy(isRequestCanceledError); + expect(mockRunCmd).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(lockDir, 'owner.json'))).toBe(true); +}); + test('compileSwiftSourceText falls back to swift-helper when the cache name sanitizes to nothing', async () => { const executablePath = await compileSwiftSourceText({ host: createTestHost(), diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index ef7369ed37..8bd17da73e 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -153,6 +153,8 @@ export function readCompiledSwiftSourceText(params: SwiftSourceTextInput): strin 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); @@ -163,6 +165,7 @@ export async function compileSwiftSourceText( executablePath, sourceText: params.source, timeoutMs: params.timeoutMs, + signal: params.signal, }); return executablePath; } @@ -182,6 +185,7 @@ async function ensureSwiftExecutable(params: { executablePath: string; sourceText?: string; timeoutMs?: number; + signal?: AbortSignal; }): Promise { if (isExecutableFile(params.executablePath)) { return; @@ -191,7 +195,7 @@ async function ensureSwiftExecutable(params: { fs.mkdirSync(executableDir, { recursive: true }); const timeoutMs = params.timeoutMs ?? 120_000; await withProcessLock({ - acquire: () => acquireSwiftCacheLock(`${params.executablePath}.lock`, timeoutMs), + acquire: () => acquireSwiftCacheLock(`${params.executablePath}.lock`, timeoutMs, params.signal), task: async () => { // Another process may have published the executable while this one waited for the lock. if (isExecutableFile(params.executablePath)) { @@ -213,6 +217,7 @@ async function ensureSwiftExecutable(params: { } await runCmd('xcrun', ['swiftc', ...params.sourcePaths, '-o', tempExecutablePath], { timeoutMs, + signal: params.signal, kill: SWIFTC_STOP, env: buildSwiftToolEnv(params.host.env), }); @@ -227,6 +232,7 @@ async function ensureSwiftExecutable(params: { async function acquireSwiftCacheLock( lockDir: string, timeoutMs: number, + signal: AbortSignal | undefined, ): Promise { try { return await acquireProcessLock({ @@ -238,6 +244,7 @@ async function acquireSwiftCacheLock( }, timeoutMs, pollMs: LOCK_RETRY_DELAY_MS, + signal, description: `Swift cache lock: ${lockDir} (${timeoutMs}ms)`, }); } catch (error) { diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 9fb55d8f48..ee88d073fb 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -9,12 +9,7 @@ import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { Deadline } from '@agent-device/host-kit/retry'; import { compileSwiftSourceText, readCompiledSwiftSourceText } from './swift-cache.ts'; -import { - isPlayableVideo, - prepareVideoValidator, - readPreparedVideoValidator, - waitForPlayableVideo, -} from './video.ts'; +import { createVideoValidatorPreparation, isPlayableVideo, waitForPlayableVideo } from './video.ts'; vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -80,9 +75,11 @@ test.each(['linux', 'win32'] as const)( Object.defineProperty(process, 'platform', { value: platform, configurable: true }); vi.mocked(compileSwiftSourceText).mockRejectedValue(new AppError('TOOL_MISSING', 'no xcrun')); const host = createTestHost(); - const prepared = await prepareVideoValidator(host); + const preparation = createVideoValidatorPreparation(host); + const prepared = await preparation.prepare(); expect(prepared).toEqual({ kind: 'container' }); - expect(await readPreparedVideoValidator(host)).toBe(prepared); + 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( @@ -108,7 +105,8 @@ test('semantic authority still runs when the current platform differs from its c test('validation reuses the prepared executable without compiling at completion', async () => { const mp4 = writeFixture('prepared.mp4', Buffer.concat([ftyp, moov])); - const prepared = await prepareVideoValidator(createTestHost()); + 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); @@ -132,33 +130,150 @@ test('a stop deadline leaves preparation running and a later stop uses the same entered(); }), ); - const preparing = prepareVideoValidator(createTestHost()); + 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(prepareVideoValidator(createTestHost())).toBe(preparing); + expect(preparation.prepare()).toBe(preparing); finish('/prepared/validator'); await expect(isPlayableVideo(mp4, preparing)).resolves.toBe(true); expect(compileSwiftSourceText).toHaveBeenCalledTimes(1); }); -test('preparation compiles once per Swift cache root', async () => { +test('each runtime owner compiles for itself instead of sharing a module-global preparation', async () => { const host = createTestHost(); - const first = prepareVideoValidator(host); - const sameRoot = prepareVideoValidator(createTestHost()); - const otherRoot = prepareVideoValidator({ + 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`, }, }); - await Promise.all([first, sameRoot, otherRoot]); - expect(first).toBe(sameRoot); - expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); + 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()); + const preparing = 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(); +}); + +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 = [ @@ -179,22 +294,20 @@ const preparationFailures = [ test.each(preparationFailures)( 'failed preparation can be retried after $name', async ({ failure, reason }) => { - const host = createTestHost(); + const preparation = createVideoValidatorPreparation(createTestHost()); vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); - const failed = prepareVideoValidator(host); - expect(prepareVideoValidator(host)).toBe(failed); + const failed = preparation.prepare(); + expect(preparation.prepare()).toBe(failed); await expect(failed).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason } }); - expect(readPreparedVideoValidator(host)).toBe(failed); + 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, readPreparedVideoValidator(host))).rejects.toBe( - originalError, - ); + await expect(isPlayableVideo(mp4, preparation.read())).rejects.toBe(originalError); - const retry = prepareVideoValidator(host); + const retry = preparation.prepare(); expect(retry).not.toBe(failed); await expect(retry).resolves.toMatchObject({ executablePath: '/bin/true' }); - expect(readPreparedVideoValidator(host)).toBe(retry); + expect(preparation.read()).toBe(retry); await expect(isPlayableVideo(mp4, retry)).resolves.toBe(true); expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); }, @@ -203,14 +316,14 @@ test.each(preparationFailures)( test.each(preparationFailures)( 'a cache-only stop observes an executable published after $name', async ({ failure, reason }) => { - const host = createTestHost(); + const preparation = createVideoValidatorPreparation(createTestHost()); vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); - const failed = prepareVideoValidator(host); + const failed = preparation.prepare(); await expect(failed).rejects.toMatchObject({ details: { reason } }); - expect(readPreparedVideoValidator(host)).toBe(failed); + expect(preparation.read()).toBe(failed); vi.mocked(readCompiledSwiftSourceText).mockReturnValue('/published/video-validator'); - const recovered = readPreparedVideoValidator(host); + const recovered = preparation.read(); await expect(recovered).resolves.toMatchObject({ executablePath: '/published/video-validator', }); @@ -222,12 +335,12 @@ test.each(preparationFailures)( ); test('an MP4 stop without prior preparation or cache reports not-prepared without compiling', async () => { - const host = createTestHost(); - expect(readPreparedVideoValidator(host)).toBeUndefined(); + const preparation = createVideoValidatorPreparation(createTestHost()); + expect(preparation.read()).toBeUndefined(); await expect( isPlayableVideo( writeFixture('never-prepared.mp4', Buffer.concat([ftyp, moov])), - readPreparedVideoValidator(host), + preparation.read(), ), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', @@ -241,7 +354,7 @@ test('WebM validation ignores failed background MP4 preparation', async () => { vi.mocked(compileSwiftSourceText).mockRejectedValueOnce( new AppError('TOOL_MISSING', 'no swiftc'), ); - const preparing = prepareVideoValidator(createTestHost()); + const preparing = createVideoValidatorPreparation(createTestHost()).prepare(); await expect(preparing).rejects.toMatchObject({ details: { reason: 'video_validator_unavailable' }, }); @@ -260,9 +373,11 @@ test.each([ vi.mocked(compileSwiftSourceText).mockRejectedValueOnce( new AppError('COMMAND_FAILED', 'compile failed', { stderr }), ); - await expect(prepareVideoValidator(createTestHost())).rejects.toMatchObject({ - details: { reason: 'video_validator_unavailable', retriable: false }, - }); + await expect(createVideoValidatorPreparation(createTestHost()).prepare()).rejects.toMatchObject( + { + details: { reason: 'video_validator_unavailable', retriable: false }, + }, + ); expect(runCmd).not.toHaveBeenCalled(); }, ); @@ -272,7 +387,7 @@ test('does not classify a different missing module as unavailable AVFoundation', stderr: "error: no such module 'AVFoundationExtras'", }); vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(error); - await expect(prepareVideoValidator(createTestHost())).rejects.toBe(error); + await expect(createVideoValidatorPreparation(createTestHost()).prepare()).rejects.toBe(error); }); test('an unavailable validator at execution never makes an MP4 playable', async () => { diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index 5331ef5dde..d1f7c8726f 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -6,7 +6,6 @@ import { buildSwiftToolEnv, compileSwiftSourceText, readCompiledSwiftSourceText, - getSwiftCacheRoot, } from './swift-cache.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; @@ -49,27 +48,81 @@ export type PreparedVideoValidator = export type VideoValidator = PreparedVideoValidator | Promise; const containerVideoValidator = Object.freeze({ kind: 'container' } as const); -const videoValidatorPreparations = new Map< - string, - { promise: Promise; failed: boolean } ->(); -/** Selects container validation or reads prepared executable authority without compiling. */ -export function readPreparedVideoValidator( - host: HostEnvironment, -): Promise | undefined { - if (process.platform !== 'darwin') return Promise.resolve(containerVideoValidator); - const cacheRoot = getSwiftCacheRoot(host.env); - const preparing = videoValidatorPreparations.get(cacheRoot); - if (preparing && !preparing.failed) return preparing.promise; - const executablePath = readCompiledSwiftSourceText({ - host, - source: VIDEO_VALIDATION_SCRIPT, - cacheName: 'video-validator', +/** + * 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; + failed: boolean; +}; + +/** 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; + 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(() => { + next.failed = true; + }); + } + 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); + }, }); - return executablePath - ? Promise.resolve(preparedVideoValidator(host, executablePath)) - : preparing?.promise; } function preparedVideoValidator( @@ -79,22 +132,23 @@ function preparedVideoValidator( return Object.freeze({ kind: 'avfoundation', executablePath, env: buildSwiftToolEnv(host.env) }); } -export function prepareVideoValidator(host: HostEnvironment): Promise { - if (process.platform !== 'darwin') return Promise.resolve(containerVideoValidator); - const cacheRoot = getSwiftCacheRoot(host.env); - let preparing = videoValidatorPreparations.get(cacheRoot); - if (!preparing || preparing.failed) { - const next = { promise: compileVideoValidator(host), failed: false }; - preparing = next; - videoValidatorPreparations.set(cacheRoot, preparing); - void next.promise.catch(() => { - next.failed = true; - }); - } - return preparing.promise; +/** 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): Promise { +async function compileVideoValidator( + host: HostEnvironment, + signal: AbortSignal, +): Promise { const { withDiagnosticTimer } = await import('@agent-device/host-kit/diagnostics'); return await withDiagnosticTimer( 'prepare_video_validator', @@ -104,6 +158,7 @@ async function compileVideoValidator(host: HostEnvironment): Promise { - await prepareVideoValidator(host); + await createVideoValidatorPreparation(host).prepare(); }, ); } diff --git a/src/owned-fake-compiler.fixtures.ts b/src/owned-fake-compiler.fixtures.ts new file mode 100644 index 0000000000..e4680aa8e3 --- /dev/null +++ b/src/owned-fake-compiler.fixtures.ts @@ -0,0 +1,88 @@ +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};`, + 'fs.mkdirSync(pidDir, { recursive: true });', + 'fs.writeFileSync(`${pidDir}/${process.pid}`, process.argv.slice(2).join(" "));', + "process.on('SIGINT', () => process.exit(0));", + "process.on('SIGTERM', () => process.exit(0));", + // 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((entry) => Number(entry)) + .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-application-resources.test.ts b/src/platform-runtime-application-resources.test.ts index ea728b5cdf..79c45c9498 100644 --- a/src/platform-runtime-application-resources.test.ts +++ b/src/platform-runtime-application-resources.test.ts @@ -10,6 +10,7 @@ function lifecycle(overrides: { recoverTestImeStartup: AndroidApplicationTools['recoverTestImeStartup']; detachRunnerSessionsForShutdown?: AppleApplicationTools['detachRunnerSessionsForShutdown']; finalizeRunnerSessionsForShutdown?: AppleApplicationTools['finalizeRunnerSessionsForShutdown']; + closeValidatorPreparation?: () => Promise; }) { return createApplicationResourceLifecycle({ android: { @@ -22,6 +23,9 @@ function lifecycle(overrides: { finalizeRunnerSessionsForShutdown: overrides.finalizeRunnerSessionsForShutdown ?? (async () => {}), } as unknown as AppleApplicationTools, + recording: { + closeValidatorPreparation: overrides.closeValidatorPreparation ?? (async () => {}), + }, }); } @@ -45,11 +49,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 +63,54 @@ 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); }); diff --git a/src/platform-runtime-application-resources.ts b/src/platform-runtime-application-resources.ts index 7936439422..8e723fd5bd 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,21 @@ 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. The + // sweep stays the failure this phase reports when both owners failed. + const validatorFailure = await tools.recording + .closeValidatorPreparation() + .catch((error: unknown) => error); + const sweep = await tools.apple + .finalizeRunnerSessionsForShutdown() + .then((devices) => ({ failed: false, devices }) as const) + .catch((error: unknown) => ({ failed: true, error }) as const); + if (sweep.failed) throw sweep.error; + if (validatorFailure !== undefined) throw validatorFailure; + return sweep.devices; + }, }); } 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 ccd8cb8879..cc7db95dd3 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -135,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 3210d06804..d8f6660d68 100644 --- a/src/platform-runtime-screen-recording-android-host.ts +++ b/src/platform-runtime-screen-recording-android-host.ts @@ -5,11 +5,10 @@ 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, - readPreparedVideoValidator, + type VideoValidatorPreparation, } from '@agent-device/capture-kit/recording-video'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; @@ -19,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(); @@ -112,9 +111,7 @@ export async function createAndroidScreenRecordingTransport( }); return { ...result, - playable: - result.exitCode === 0 && - (await isPlayableVideo(outputPath, readPreparedVideoValidator(host))), + playable: result.exitCode === 0 && (await isPlayableVideo(outputPath, validator.read())), }; }, remove: async (remotePath, signal) => diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 5dee992fec..e9afca797f 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -14,24 +14,17 @@ const video = vi.hoisted(() => ({ playable: vi.fn(async (_path: string, _options: { deadline?: Deadline }) => true), isPlayable: vi.fn(async (_path: string, _host: unknown, _deadline?: Deadline) => true), container: vi.fn(async () => true), - prepare: vi.fn(async () => ({ - kind: 'avfoundation' as const, - executablePath: '/bin/true', - env: {}, - })), - read: vi.fn<() => Promise | undefined>(), })); 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, hasVideoContainer: video.container, - prepareVideoValidator: video.prepare, - readPreparedVideoValidator: video.read, })); vi.mock('@agent-device/capture-kit/recording-telemetry', () => ({ persistRecordingTelemetry: telemetry, @@ -51,23 +44,43 @@ beforeEach(() => { video.playable.mockReset().mockResolvedValue(true); video.isPlayable.mockReset().mockResolvedValue(true); video.container.mockReset().mockResolvedValue(true); - video.prepare - .mockReset() - .mockResolvedValue({ kind: 'avfoundation', executablePath: '/bin/true', env: {} }); - video.read.mockReset(); }); +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 validator = Promise.resolve({ - kind: 'avfoundation' as const, - executablePath: '/prepared/validator', - env: {}, - }); - video.prepare.mockReturnValue(validator); - video.read.mockReturnValue(validator); - const finalizer = createScreenRecordingFinalizer(createLiveTestHostEnvironment()); + const { preparation, finalizer } = createFinalizer(); await finalizer.prepare({ container: 'mp4' }); - expect(video.prepare).toHaveBeenCalledOnce(); + expect(preparation.prepare).toHaveBeenCalledOnce(); const result = await finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: false, @@ -77,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', { - validator, + 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: [], @@ -112,7 +146,7 @@ test('a validator that was not prepared reaches the caller as its own retriable video.playable.mockRejectedValueOnce(compileTimeout); await expect( - createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ + createFinalizer().finalizer.complete({ outputPath: '/tmp/capture.mp4', showTouches: false, gestureEvents: [], @@ -123,7 +157,7 @@ test('a validator that was not prepared reaches the caller as its own retriable }); 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 }], @@ -135,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 }], @@ -154,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 }], @@ -182,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: [], @@ -207,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 }], @@ -229,14 +263,12 @@ test.each([0, OVERLAY_BUDGET_MS])( vi.setSystemTime(Date.now() + elapsedMs); }); 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', - }, - ); + 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(); @@ -249,14 +281,14 @@ test.each([0, OVERLAY_BUDGET_MS])( 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 581720bcc7..028d9155ee 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -11,22 +11,24 @@ import { import { persistRecordingTelemetry } from '@agent-device/capture-kit/recording-telemetry'; import { hasVideoContainer, - prepareVideoValidator, - readPreparedVideoValidator, 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) => { - if (input.container === 'mp4') void prepareVideoValidator(host); + // 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), }); } @@ -36,19 +38,25 @@ async function sniffRecording(input: Readonly<{ outputPath: string }>): Promise< async function finalizeScreenRecording( host: HostEnvironment, + validator: VideoValidatorPreparation, input: Parameters[0], ) { - const validator = readPreparedVideoValidator(host); + const prepared = validator.read(); const finalizeDeadline = startRecordingOverlayBudget(); await waitForStableFile(input.outputPath); - if (!(await waitForPlayableVideo(input.outputPath, { validator, 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, validator); + return await overlayTouches(host, input, telemetryPath, finalizeDeadline, prepared); } async function overlayTouches( diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts index 90dd6da302..d5ba206f10 100644 --- a/src/platform-runtime-screen-recording-host.ts +++ b/src/platform-runtime-screen-recording-host.ts @@ -1,23 +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); + 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({ @@ -63,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/test/integration/android-emulator-e2e/live-observability-scenario.ts b/test/integration/android-emulator-e2e/live-observability-scenario.ts index 2d77033c8b..34ba7dd7e0 100644 --- a/test/integration/android-emulator-e2e/live-observability-scenario.ts +++ b/test/integration/android-emulator-e2e/live-observability-scenario.ts @@ -1,4 +1,4 @@ -import { prepareVideoValidator } from '@agent-device/capture-kit/recording-video'; +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'; @@ -75,7 +75,9 @@ async function assertTraceAndRecording(context: LiveContext): Promise { ); const recordingPath = path.join(context.artifactDir, 'fixture.mp4'); - const validator = await prepareVideoValidator(captureHostEnvironment(process)); + const validator = await createVideoValidatorPreparation( + captureHostEnvironment(process), + ).prepare(); await runStep(context, 'start short Android screen recording', [ 'record', 'start', diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index f93f16323b..3671e7bdee 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -1,4 +1,4 @@ -import { prepareVideoValidator } from '@agent-device/capture-kit/recording-video'; +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'; @@ -467,7 +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 prepareVideoValidator(captureHostEnvironment(process)); + const validator = await createVideoValidatorPreparation( + captureHostEnvironment(process), + ).prepare(); const recording = await runStep(context, 'start screen recording', [ 'record', 'start', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 12d427229c..325f675dd8 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1065,7 +1065,7 @@ 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`. -- 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. WebM validation needs no Swift helper. +- 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 stops a background compile that has not finished and clears its unpublished output, so the next daemon prepares from scratch. 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 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. From 5a8515fdbd52812badb3f3ddff8a5070ea589e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:09:27 +0100 Subject: [PATCH 19/28] fix(recording): preserve shutdown cleanup failures --- .../src/recording/swift-cache.test.ts | 41 +++++++++++++++-- .../capture-kit/src/recording/swift-cache.ts | 46 ++++++++++++++----- .../capture-kit/src/recording/video.test.ts | 23 +++++++++- packages/capture-kit/src/recording/video.ts | 7 ++- src/owned-fake-compiler.fixtures.ts | 2 +- ...form-runtime-application-resources.test.ts | 19 ++++++++ src/platform-runtime-application-resources.ts | 19 ++++++-- 7 files changed, 134 insertions(+), 23 deletions(-) diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index 855077e417..a6f6ef94cf 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -258,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 = ''; @@ -277,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); }); @@ -407,3 +415,28 @@ test('reads only a published executable for the exact source and cache, without 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, + 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 8bd17da73e..bff9a2e6b9 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; @@ -194,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; @@ -223,10 +226,31 @@ 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(error); + } } - }, - }); + })(); + } catch (error) { + taskFailed = true; + taskFailure = error; + } + try { + await release(); + } catch (error) { + cleanupFailures.push(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.test.ts b/packages/capture-kit/src/recording/video.test.ts index ee88d073fb..ed6d5af26d 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -223,7 +223,7 @@ test('closing the owner cancels the compile it started and awaits its settlement test('closing marks the owner closed synchronously and later preparation starts nothing', async () => { const compile = pendingCompile(); const preparation = createVideoValidatorPreparation(createTestHost()); - const preparing = preparation.prepare(); + void preparation.prepare(); await compile.enter; const closing = preparation.close(); @@ -505,3 +505,24 @@ test('waiting for a playable video stops polling once its deadline passes', asyn }); 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); +}); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index d1f7c8726f..9c92457910 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -80,6 +80,7 @@ type VideoValidatorCompile = { export function createVideoValidatorPreparation(host: HostEnvironment): VideoValidatorPreparation { let compile: VideoValidatorCompile | undefined; let closed = false; + let cleanupFailure: AppError | undefined; return Object.freeze({ prepare: () => { if (closed) return Promise.reject(videoValidatorPreparationClosed()); @@ -92,8 +93,11 @@ export function createVideoValidatorPreparation(host: HostEnvironment): VideoVal promise: compileVideoValidator(host, cancel.signal), }; compile = next; - void next.promise.catch(() => { + void next.promise.catch((error: unknown) => { next.failed = true; + if (error instanceof AppError && error.details?.reason === 'swift_cache_cleanup_failed') { + cleanupFailure = error; + } }); } return compile.promise; @@ -121,6 +125,7 @@ export function createVideoValidatorPreparation(host: HostEnvironment): VideoVal // temporary output and gives the cache lock back before this settles. pending.cancel.abort(); await pending.promise.catch(() => undefined); + if (cleanupFailure) throw cleanupFailure; }, }); } diff --git a/src/owned-fake-compiler.fixtures.ts b/src/owned-fake-compiler.fixtures.ts index e4680aa8e3..7a9f0bfc32 100644 --- a/src/owned-fake-compiler.fixtures.ts +++ b/src/owned-fake-compiler.fixtures.ts @@ -70,7 +70,7 @@ function readPids(pidDir: string): number[] { try { return fs .readdirSync(pidDir) - .map((entry) => Number(entry)) + .map(Number) .filter((pid) => Number.isSafeInteger(pid) && pid > 0); } catch { return []; diff --git a/src/platform-runtime-application-resources.test.ts b/src/platform-runtime-application-resources.test.ts index 79c45c9498..895d4ead35 100644 --- a/src/platform-runtime-application-resources.test.ts +++ b/src/platform-runtime-application-resources.test.ts @@ -114,3 +114,22 @@ test('a failed validator close is reported when the runner sweep succeeded', asy 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); +}); diff --git a/src/platform-runtime-application-resources.ts b/src/platform-runtime-application-resources.ts index 8e723fd5bd..da9f47d838 100644 --- a/src/platform-runtime-application-resources.ts +++ b/src/platform-runtime-application-resources.ts @@ -27,17 +27,26 @@ export function createApplicationResourceLifecycle(tools: { 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. The - // sweep stays the failure this phase reports when both owners failed. + // 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() - .catch((error: unknown) => error); + .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) throw sweep.error; - if (validatorFailure !== undefined) throw validatorFailure; + 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; }, }); From 4394ea2c5b74b1fb1008c89d0280af52fa870438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:14:00 +0100 Subject: [PATCH 20/28] fix(shutdown): retain cleanup failure evidence --- .../src/recording/swift-cache.test.ts | 68 +++++++++- .../capture-kit/src/recording/swift-cache.ts | 18 ++- .../capture-kit/src/recording/video.test.ts | 23 ++++ packages/capture-kit/src/recording/video.ts | 12 +- ...form-runtime-application-resources.test.ts | 126 ++++++++++++++++++ src/platform-runtime-shutdown-error.ts | 28 +++- 6 files changed, 268 insertions(+), 7 deletions(-) diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index a6f6ef94cf..53bb1fc84c 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -434,7 +434,73 @@ test('a temp removal failure survives a canceled compile', async () => { expect(error).toMatchObject({ details: { reason: 'swift_cache_cleanup_failed' } }); expect(((error as Error).cause as AggregateError).errors).toEqual([ cancelFailure, - removalFailure, + 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 bff9a2e6b9..3359d4f803 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -229,7 +229,14 @@ async function ensureSwiftExecutable(params: { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (error) { - cleanupFailures.push(error); + cleanupFailures.push( + new AppError( + 'COMMAND_FAILED', + 'Swift temporary output removal failed', + { reason: 'swift_cache_temp_removal_failed' }, + error, + ), + ); } } })(); @@ -240,7 +247,14 @@ async function ensureSwiftExecutable(params: { try { await release(); } catch (error) { - cleanupFailures.push(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( diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index ed6d5af26d..645149c6a3 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -526,3 +526,26 @@ test('a retry does not erase an earlier cleanup failure from close', async () => 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 9c92457910..b50c3b5cdd 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -80,7 +80,7 @@ type VideoValidatorCompile = { export function createVideoValidatorPreparation(host: HostEnvironment): VideoValidatorPreparation { let compile: VideoValidatorCompile | undefined; let closed = false; - let cleanupFailure: AppError | undefined; + const cleanupFailures: AppError[] = []; return Object.freeze({ prepare: () => { if (closed) return Promise.reject(videoValidatorPreparationClosed()); @@ -96,7 +96,7 @@ export function createVideoValidatorPreparation(host: HostEnvironment): VideoVal void next.promise.catch((error: unknown) => { next.failed = true; if (error instanceof AppError && error.details?.reason === 'swift_cache_cleanup_failed') { - cleanupFailure = error; + cleanupFailures.push(error); } }); } @@ -125,7 +125,13 @@ export function createVideoValidatorPreparation(host: HostEnvironment): VideoVal // temporary output and gives the cache lock back before this settles. pending.cancel.abort(); await pending.promise.catch(() => undefined); - if (cleanupFailure) throw cleanupFailure; + if (cleanupFailures.length === 1) throw cleanupFailures[0]; + if (cleanupFailures.length > 1) { + throw new AggregateError( + cleanupFailures, + 'Video validator cleanup failed on multiple attempts', + ); + } }, }); } diff --git a/src/platform-runtime-application-resources.test.ts b/src/platform-runtime-application-resources.test.ts index 895d4ead35..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, @@ -133,3 +135,127 @@ test('shutdown retains both runner and validator cleanup failures', async () => 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-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), + ), + }, + }; +} From 7ff691ca6cf6d8473c32f3ce5531f42e084faf38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:25:28 +0100 Subject: [PATCH 21/28] test(recording): cover finalizer deadline during preparation --- ...-recording-finalizer-host-deadline.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/platform-runtime-screen-recording-finalizer-host-deadline.test.ts 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); + } + } +}); From 7f15773ed12cfbfd597f403dc0ae70f94ce1d729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:45:57 +0100 Subject: [PATCH 22/28] test: constrain fake Swift compiler invocation --- src/owned-fake-compiler.fixtures.ts | 9 ++++-- src/owned-fake-compiler.test.ts | 49 +++++++++++++++++++++++++++++ website/docs/docs/commands.md | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/owned-fake-compiler.test.ts diff --git a/src/owned-fake-compiler.fixtures.ts b/src/owned-fake-compiler.fixtures.ts index 7a9f0bfc32..cb228cff40 100644 --- a/src/owned-fake-compiler.fixtures.ts +++ b/src/owned-fake-compiler.fixtures.ts @@ -39,10 +39,15 @@ export function createOwnedFakeCompiler(name: string): OwnedFakeCompiler { "const fs = require('node:fs');", `const pidDir = process.env[${JSON.stringify(PID_DIR_ENV)}];`, `const selfBoundMs = ${SELF_BOUND_MS};`, - 'fs.mkdirSync(pidDir, { recursive: true });', - 'fs.writeFileSync(`${pidDir}/${process.pid}`, process.argv.slice(2).join(" "));', + '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);', diff --git a/src/owned-fake-compiler.test.ts b/src/owned-fake-compiler.test.ts new file mode 100644 index 0000000000..997c87aaeb --- /dev/null +++ b/src/owned-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/website/docs/docs/commands.md b/website/docs/docs/commands.md index 325f675dd8..bc75532201 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1065,7 +1065,7 @@ 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`. -- 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 stops a background compile that has not finished and clears its unpublished output, so the next daemon prepares from scratch. WebM validation needs no Swift helper. +- 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 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. From 837491fa12713fa1563d62791c567f17d33f9e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:47:23 +0100 Subject: [PATCH 23/28] test: cover concurrent validator cache owners --- .../recording/video-cache-isolation.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/capture-kit/src/recording/video-cache-isolation.test.ts 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..f9f0ba325b --- /dev/null +++ b/packages/capture-kit/src/recording/video-cache-isolation.test.ts @@ -0,0 +1,90 @@ +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(); +}); + +test('closing a cache lock holder lets another pending owner compile and publish', async () => { + 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; + vi.mocked(runCmd).mockImplementation(async (_command, args, options) => { + const signal = options!.signal!; + signals.push(signal); + if (signals.length === 1) { + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('compiler cancelled')), { + once: true, + }); + }); + } else { + await new Promise((resolve) => { + finishSecond = resolve; + }); + 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; + 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); + 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 { + finishSecond?.(); + await first.close(); + await second.close(); + await secondResult?.catch(() => undefined); + fs.rmSync(cacheDir, { recursive: true, force: true }); + } +}, 15_000); From 8566ffdc445c0a8771de5f4eff8006edf3541502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:50:50 +0100 Subject: [PATCH 24/28] test: bound validator isolation cleanup --- .../recording/video-cache-isolation.test.ts | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/packages/capture-kit/src/recording/video-cache-isolation.test.ts b/packages/capture-kit/src/recording/video-cache-isolation.test.ts index f9f0ba325b..c9f92d6c92 100644 --- a/packages/capture-kit/src/recording/video-cache-isolation.test.ts +++ b/packages/capture-kit/src/recording/video-cache-isolation.test.ts @@ -17,7 +17,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -test('closing a cache lock holder lets another pending owner compile and publish', async () => { +async function runIsolationScenario(failBeforeClose: boolean): Promise { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); const cacheDir = mkdtempForTestSync('video-cache-isolation-'); const host = { @@ -31,25 +31,31 @@ test('closing a cache lock holder lets another pending owner compile and publish vi.mocked(runCmd).mockImplementation(async (_command, args, options) => { const signal = options!.signal!; signals.push(signal); - if (signals.length === 1) { - await new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(new Error('compiler cancelled')), { - once: true, - }); - }); - } else { - await new Promise((resolve) => { - finishSecond = resolve; - }); - const output = args[args.indexOf('-o') + 1]!; - fs.writeFileSync(output, 'compiled'); - fs.chmodSync(output, 0o755); - } + 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; + } + }); + 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'); @@ -64,6 +70,10 @@ test('closing a cache lock holder lets another pending owner compile and publish }) .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'); @@ -83,8 +93,26 @@ test('closing a cache lock holder lets another pending owner compile and publish } finally { finishSecond?.(); await first.close(); - await second.close(); - await secondResult?.catch(() => undefined); - fs.rmSync(cacheDir, { recursive: true, force: true }); + try { + if (earlyFailureReached) await expect.poll(() => signals.length, { timeout: 5_000 }).toBe(2); + } finally { + await second.close(); + await secondResult?.catch(() => undefined); + const bin = path.join(cacheDir, 'bin'); + if (fs.existsSync(bin)) { + expect( + fs.readdirSync(bin).some((entry) => entry.endsWith('.lock') || entry.startsWith('.')), + ).toBe(false); + } + 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); From 693d5fdde9dd8085fe8d39696efe02b2d3d7c74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 22:57:46 +0100 Subject: [PATCH 25/28] test: bound cache isolation failure cleanup --- .../recording/video-cache-isolation.test.ts | 18 +++++++++++++++++- ...ntime-operation-host-fake-compiler.test.ts} | 0 2 files changed, 17 insertions(+), 1 deletion(-) rename src/{owned-fake-compiler.test.ts => platform-runtime-operation-host-fake-compiler.test.ts} (100%) diff --git a/packages/capture-kit/src/recording/video-cache-isolation.test.ts b/packages/capture-kit/src/recording/video-cache-isolation.test.ts index c9f92d6c92..5218371b22 100644 --- a/packages/capture-kit/src/recording/video-cache-isolation.test.ts +++ b/packages/capture-kit/src/recording/video-cache-isolation.test.ts @@ -96,7 +96,23 @@ async function runIsolationScenario(failBeforeClose: boolean): Promise { try { if (earlyFailureReached) await expect.poll(() => signals.length, { timeout: 5_000 }).toBe(2); } finally { - await second.close(); + const closing = second.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); + finishSecond?.(); + await closing; + } await secondResult?.catch(() => undefined); const bin = path.join(cacheDir, 'bin'); if (fs.existsSync(bin)) { diff --git a/src/owned-fake-compiler.test.ts b/src/platform-runtime-operation-host-fake-compiler.test.ts similarity index 100% rename from src/owned-fake-compiler.test.ts rename to src/platform-runtime-operation-host-fake-compiler.test.ts From f026759d6f4a06d460a7a9e566e03f5836a62e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 23:00:49 +0100 Subject: [PATCH 26/28] test: always remove validator isolation cache --- .../recording/video-cache-isolation.test.ts | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/capture-kit/src/recording/video-cache-isolation.test.ts b/packages/capture-kit/src/recording/video-cache-isolation.test.ts index 5218371b22..a55b5c6a5a 100644 --- a/packages/capture-kit/src/recording/video-cache-isolation.test.ts +++ b/packages/capture-kit/src/recording/video-cache-isolation.test.ts @@ -91,36 +91,42 @@ async function runIsolationScenario(failBeforeClose: boolean): Promise { fs.readdirSync(bin).some((entry) => entry.endsWith('.lock') || entry.startsWith('.')), ).toBe(false); } finally { - finishSecond?.(); - await first.close(); try { + finishSecond?.(); + await first.close(); if (earlyFailureReached) await expect.poll(() => signals.length, { timeout: 5_000 }).toBe(2); } finally { - const closing = second.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, - ); - }), - ]); + const closing = second.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); + finishSecond?.(); + await closing; + } } finally { - clearTimeout(timeout); - finishSecond?.(); - await closing; - } - await secondResult?.catch(() => undefined); - const bin = path.join(cacheDir, 'bin'); - if (fs.existsSync(bin)) { - expect( - fs.readdirSync(bin).some((entry) => entry.endsWith('.lock') || entry.startsWith('.')), - ).toBe(false); + 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 }); + } } - fs.rmSync(cacheDir, { recursive: true, force: true }); } } } From e83a5939c5e581f19f8889139c8d51213e34cf04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 23:28:38 +0100 Subject: [PATCH 27/28] test: bound stalled validator owner close --- .../recording/video-cache-isolation.test.ts | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/capture-kit/src/recording/video-cache-isolation.test.ts b/packages/capture-kit/src/recording/video-cache-isolation.test.ts index a55b5c6a5a..a9f43e8d1e 100644 --- a/packages/capture-kit/src/recording/video-cache-isolation.test.ts +++ b/packages/capture-kit/src/recording/video-cache-isolation.test.ts @@ -17,7 +17,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -async function runIsolationScenario(failBeforeClose: boolean): Promise { +async function runIsolationScenario(failBeforeClose: boolean, stallClose = false): Promise { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); const cacheDir = mkdtempForTestSync('video-cache-isolation-'); const host = { @@ -28,6 +28,7 @@ async function runIsolationScenario(failBeforeClose: boolean): Promise { 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); @@ -44,7 +45,10 @@ async function runIsolationScenario(failBeforeClose: boolean): Promise { abort(); } else { signal.addEventListener('abort', abort, { once: true }); - if (signals.length > 1) finishSecond = finish; + if (signals.length > 1) { + finishSecond = finish; + if (releaseSecond) finish(); + } } }); const output = args[args.indexOf('-o') + 1]!; @@ -97,7 +101,8 @@ async function runIsolationScenario(failBeforeClose: boolean): Promise { if (earlyFailureReached) await expect.poll(() => signals.length, { timeout: 5_000 }).toBe(2); } finally { try { - const closing = second.close(); + const close = second.close(); + const closing = stallClose ? close.then(() => new Promise(() => {})) : close; let timeout: ReturnType | undefined; try { await Promise.race([ @@ -111,8 +116,8 @@ async function runIsolationScenario(failBeforeClose: boolean): Promise { ]); } finally { clearTimeout(timeout); + releaseSecond = true; finishSecond?.(); - await closing; } } finally { await Promise.allSettled([firstResult, ...(secondResult ? [secondResult] : [])]); @@ -138,3 +143,15 @@ test('closing a cache lock holder lets another pending owner compile and publish 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'); + await expect(runIsolationScenario(true, true)).rejects.toThrow( + '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); From b1150a8715055777d9f9788c521f4374cd313c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 3 Nov 2026 23:41:28 +0100 Subject: [PATCH 28/28] test: bound stalled cleanup assertion --- .../src/recording/video-cache-isolation.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/capture-kit/src/recording/video-cache-isolation.test.ts b/packages/capture-kit/src/recording/video-cache-isolation.test.ts index a9f43e8d1e..c620b4c3f2 100644 --- a/packages/capture-kit/src/recording/video-cache-isolation.test.ts +++ b/packages/capture-kit/src/recording/video-cache-isolation.test.ts @@ -146,9 +146,22 @@ test('cleanup cancels a compiler that starts after an early failure', async () = test('cleanup reports a close that never settles and removes its cache', async () => { const remove = vi.spyOn(fs, 'rmSync'); - await expect(runIsolationScenario(true, true)).rejects.toThrow( - 'second owner cleanup did not settle', + 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-'));