From 8a51064771dcdac7944614a66df7637322590a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 6 Aug 2026 16:28:51 +0200 Subject: [PATCH 1/2] fix: make Android record stop survive static screens, slow moov finalization, and dead-pid recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three live-reproduced defects on a loaded Pixel_7_CI emulator shared the "pulled file is not a playable MP4" / "manifest could not be verified" symptom family: - The Swift video validator required duration > 0, permanently rejecting valid single-frame recordings of fully static screens (AVFoundation reports their duration as 0). Whether a run passed depended on whether anything — even the status-bar clock — changed during the window. - screenrecord finalizes by patching a front-reserved moov in place, so the remote file size never changes; the copy path now re-pulls with escalating delays (750/1500/3000ms) and detects finalization from the pulled bytes via a mandatory ftyp+moov container sniff, which also preserves the truncation detection the duration check provided by accident. The local waitForStableFile call is gone: a pull is complete when adb returns. - toybox `ps -p ` exits 1 with empty output — the normal pid-gone signature — but the recovery liveness probe read every non-zero exit as an uncertain adb failure, making a finished recording behind a live-status manifest unrecoverable forever. Empty-output failures now corroborate via the full process list: healthy listing with the pid absent recovers the finished recording; listing failure stays conservatively uncertain (a stale verdict deletes the manifest, so transport health is proven first). Transport failures keep stderr and exec-layer timeouts throw, which is what makes the empty-output signature safe to trust. Provider-scenario coverage: in-place same-size finalization landing past the first retry, and dead-pid recovery on a responsive device — both fail on the previous implementation with the live-observed errors. --- .../handlers/record-trace-android-copy.ts | 76 ++++----- .../handlers/record-trace-android-recovery.ts | 21 +++ src/utils/video.ts | 17 +- .../android-recording.test.ts | 158 ++++++++++++++++++ 4 files changed, 227 insertions(+), 45 deletions(-) diff --git a/src/daemon/handlers/record-trace-android-copy.ts b/src/daemon/handlers/record-trace-android-copy.ts index a12f7922f0..d7e124f82b 100644 --- a/src/daemon/handlers/record-trace-android-copy.ts +++ b/src/daemon/handlers/record-trace-android-copy.ts @@ -7,10 +7,11 @@ import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; import type { SessionState } from '../types.ts'; import type { RecordTraceDeps } from './record-trace-types.ts'; -const ANDROID_REMOTE_FILE_POLL_MS = 250; -const ANDROID_REMOTE_FILE_ATTEMPTS = 20; -const ANDROID_LOCAL_VIDEO_ATTEMPTS = 2; -const ANDROID_LOCAL_VIDEO_RETRY_DELAY_MS = 750; +// After `kill -2`, screenrecord needs 1-3s under load to finalize the MP4, and it does so by +// patching a front-reserved moov in place — the remote file size never changes, so the only way +// to observe finalization is to re-pull and validate. The escalating delays must outlast that +// finalization window with margin. +const ANDROID_LOCAL_VIDEO_RETRY_DELAYS_MS = [750, 1_500, 3_000]; type AndroidRecording = Extract, { platform: 'android' }>; @@ -42,7 +43,11 @@ async function copyAndroidRecordingWithValidation(params: { const { deps, deviceId, remotePath, outPath } = params; let lastCopyError: string | undefined; - for (let attempt = 0; attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS; attempt += 1) { + for (let attempt = 0; attempt <= ANDROID_LOCAL_VIDEO_RETRY_DELAYS_MS.length; attempt += 1) { + const retryDelayMs = ANDROID_LOCAL_VIDEO_RETRY_DELAYS_MS[attempt - 1]; + if (retryDelayMs !== undefined) { + await sleep(retryDelayMs); + } removeLocalRecordingCandidate(outPath); const device = androidDeviceForSerial(deviceId); @@ -52,43 +57,36 @@ async function copyAndroidRecordingWithValidation(params: { }); if (pullResult.exitCode !== 0) { lastCopyError = formatRecordTraceExecFailure(pullResult, 'adb pull'); - } else { - await deps.waitForStableFile(outPath, { - pollMs: ANDROID_REMOTE_FILE_POLL_MS, - attempts: ANDROID_REMOTE_FILE_ATTEMPTS, - }); - const playable = await deps.isPlayableVideo(outPath); - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_pull_validation', - data: { - deviceId, - remotePath, - outPath, - attempt: attempt + 1, - fileSize: readFileSize(outPath), - playable, - }, - }); - if (playable) { - return undefined; - } - - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_android_invalid_video_retry', - data: { - deviceId, - remotePath, - outPath, - attempt: attempt + 1, - }, - }); + continue; } - if (attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS - 1) { - await sleep(ANDROID_LOCAL_VIDEO_RETRY_DELAY_MS); + const playable = await deps.isPlayableVideo(outPath); + emitDiagnostic({ + level: 'debug', + phase: 'record_stop_android_pull_validation', + data: { + deviceId, + remotePath, + outPath, + attempt: attempt + 1, + fileSize: readFileSize(outPath), + playable, + }, + }); + if (playable) { + return undefined; } + + emitDiagnostic({ + level: 'warn', + phase: 'record_stop_android_invalid_video_retry', + data: { + deviceId, + remotePath, + outPath, + attempt: attempt + 1, + }, + }); } if (lastCopyError) { diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts index d7173b74c6..452d14469b 100644 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ b/src/daemon/handlers/record-trace-android-recovery.ts @@ -285,6 +285,13 @@ async function checkRecoverableAndroidScreenrecord( }, ); if (result.exitCode !== 0) { + // toybox `ps -p ` exits non-zero with no output at all — the normal signature + // of an exited process, not an adb failure (transport failures leave stderr and exec-layer + // timeouts throw before this branch). Corroborate with the full process list so a healthy + // device recovers the finished recording while a broken transport stays uncertain. + if (result.stdout.trim().length === 0 && result.stderr.trim().length === 0) { + return await resolveExitedAndroidScreenrecord(deviceId, metadata); + } emitDiagnostic({ level: 'debug', phase: 'record_stop_android_recovery_metadata_probe_uncertain', @@ -317,6 +324,20 @@ async function checkRecoverableAndroidScreenrecord( return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; } +async function resolveExitedAndroidScreenrecord( + deviceId: string, + metadata: AndroidRecordingRecoveryMetadata, +): Promise<'live' | 'stale' | 'uncertain' | 'finished'> { + const listed = await findLiveAndroidScreenrecordByPath(deviceId, metadata.remotePath); + if (listed === 'uncertain') { + return 'uncertain'; + } + if (listed) { + return listed.remotePid === metadata.remotePid ? 'live' : 'uncertain'; + } + return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; +} + async function findLiveAndroidScreenrecordByPath( deviceId: string, remotePath: string, diff --git a/src/utils/video.ts b/src/utils/video.ts index 0d9524f71f..16c919991d 100644 --- a/src/utils/video.ts +++ b/src/utils/video.ts @@ -4,6 +4,8 @@ import { runCmd } from './exec.ts'; import { buildSwiftToolEnv, compileSwiftSourceText } from './swift-cache.ts'; import { sleep } from './timeouts.ts'; +// Duration zero must pass: a recording of a fully static screen legitimately contains a single +// frame (screenrecord only encodes on screen updates), and AVFoundation reports its duration as 0. const VIDEO_VALIDATION_SCRIPT = ` import Foundation import AVFoundation @@ -18,7 +20,7 @@ Task { do { let playable = try await asset.load(.isPlayable) let duration = try await asset.load(.duration) - if playable && duration.isValid && !duration.isIndefinite && CMTimeGetSeconds(duration) > 0 { + if playable && duration.isValid && !duration.isIndefinite { exitCode = 0 } } catch { @@ -64,6 +66,12 @@ export async function waitForStableFile( } export async function isPlayableVideo(filePath: string): Promise { + // The moov sniff is the finalization oracle: screen recorders reserve moov space up front + // and patch it in place on stop, so a capture pulled too early has a `free` placeholder where + // the moov belongs — moov presence, not AVFoundation parseability, tells finalized apart. + if (!hasLikelyPlayableVideoContainer(filePath)) { + return false; + } try { const validatorPath = await getVideoValidatorExecutablePath(); const result = await runCmd(validatorPath, [filePath], { @@ -74,13 +82,10 @@ export async function isPlayableVideo(filePath: string): Promise { if (result.exitCode === 0) { return true; } - if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) { - return hasLikelyPlayableVideoContainer(filePath); - } - return false; + return isSwiftVideoValidatorUnavailable(result.stderr, result.stdout); } catch (error) { if (isSwiftVideoValidatorError(error)) { - return hasLikelyPlayableVideoContainer(filePath); + return true; } throw error; } diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 26f0e6c092..6ffa0b6ce3 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -39,6 +39,13 @@ test('Provider-backed integration Android record stop recovers missing daemon re ); }); +test('Provider-backed integration Android record stop retries the pull until in-place moov finalization lands', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-finalize-race-', + runAndroidRemoteFinalizeRaceScenario, + ); +}, 15_000); + test('Provider-backed integration Android record stop recovers cwd-scoped durable manifest', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-scoped-recovery-', @@ -251,6 +258,13 @@ test('Provider-backed integration Android record stop recovers while another dev ); }); +test('Provider-backed integration Android record stop recovers finished recording after dead-pid probe', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-dead-pid-recovery-', + runAndroidDeadPidRecoveryScenario, + ); +}); + test('Provider-backed integration Android record stop keeps valid metadata on uncertain liveness probe', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-uncertain-metadata-', @@ -303,6 +317,64 @@ async function runAndroidManifestRecoveryScenario(tmpDir: string): Promise }); } +async function runAndroidRemoteFinalizeRaceScenario(tmpDir: string): Promise { + const remotePath = '/sdcard/agent-device-recording-523456789.mp4'; + const recordingPath = path.join(tmpDir, 'finalize-race.mp4'); + const recordingSize = 3232; + const streamingBytes = unfinalizedMp4Container(recordingSize); + const finalizedBytes = finalizedMp4Container(recordingSize); + // screenrecord finalizes by patching a front-reserved moov in place: the size never changes, + // only the content does, so the copy path must detect finalization from re-pulled bytes. + assert.equal(streamingBytes.length, finalizedBytes.length); + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: 'default', + }); + let pullCount = 0; + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell ps -o pid=,args= -p 4321') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCount += 1; + assert.equal(from, remotePath); + // Finalization lands after the second pull, past the first retry delay — inside the + // 1-3s window observed live on a loaded emulator. + fs.writeFileSync(to, pullCount <= 2 ? streamingBytes : finalizedBytes); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + await withAndroidProviderScenarioEnv(tmpDir, async () => { + try { + const recordStop = await stopAndroidRecording(daemon, recordingPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.equal(pullCount, 3); + assert.equal(fs.existsSync(recordingPath), true); + } finally { + await daemon.close(); + } + }); +} + async function runAndroidManifestHostPathScenario(tmpDir: string): Promise { const adbCalls: string[][] = []; const pullCalls: PullCall[] = []; @@ -1159,6 +1231,68 @@ async function runAndroidDeviceMismatchManifestScenario(tmpDir: string): Promise } } +async function runAndroidDeadPidRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-623456789.mp4'; + const recordingPath = path.join(tmpDir, 'dead-pid-recovered.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: 'default', + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => ({ + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + // toybox signature for a pid that no longer exists: exit 1, no output at all. + if (command === 'shell ps -o pid=,args= -p 4321') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + // The device is otherwise responsive: the full listing succeeds and shows no + // screenrecord, and the finalized remote MP4 is present. + if (command === 'shell ps -A -o pid=,args=') { + return { stdout: '1 init\n', stderr: '', exitCode: 0 }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCalls.push({ remotePath: from, localPath: to }); + writePlayableMp4(to); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + await withAndroidProviderScenarioEnv(tmpDir, async () => { + try { + const recordStop = await stopAndroidRecording(daemon, recordingPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( + recordStop, + ); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.match(String(data.warning), /durable device manifest/); + assert.match(String(data.warning), /no longer running/); + assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); + assert.equal(fs.existsSync(recordingPath), true); + assertCommandCall(adbCalls, [ + 'shell', + 'rm', + '-f', + '/sdcard/agent-device-recording-active.json', + ]); + } finally { + await daemon.close(); + } + }); +} + async function runAndroidUncertainMetadataScenario(tmpDir: string): Promise { const adbCalls: string[][] = []; const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; @@ -1596,6 +1730,30 @@ function readLoggedArgs(logPath: string): string[] { .filter(Boolean); } +// A screenrecord capture pulled mid-finalization: the moov space is still a `free` placeholder. +function unfinalizedMp4Container(totalSize: number): Buffer { + return mp4WithMoovSlot('free', totalSize); +} + +// The same capture after screenrecord patched the reserved slot into a real moov atom. +function finalizedMp4Container(totalSize: number): Buffer { + return mp4WithMoovSlot('moov', totalSize); +} + +function mp4WithMoovSlot(slotType: 'free' | 'moov', totalSize: number): Buffer { + const ftyp = mp4Atom('ftyp', Buffer.from('isom0000isom', 'latin1')); + const slot = mp4Atom(slotType, Buffer.alloc(1024)); + const mdat = mp4Atom('mdat', Buffer.alloc(totalSize - ftyp.length - slot.length - 8)); + return Buffer.concat([ftyp, slot, mdat]); +} + +function mp4Atom(type: string, payload: Buffer): Buffer { + const header = Buffer.alloc(8); + header.writeUInt32BE(8 + payload.length, 0); + header.write(type, 4, 4, 'latin1'); + return Buffer.concat([header, payload]); +} + function writePlayableMp4(filePath: string): void { const fixturePath = path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'); if (fs.existsSync(fixturePath)) { From 853bd5d4a1d46fa405035097d15d6c44560a1075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 6 Aug 2026 17:21:54 +0200 Subject: [PATCH 2/2] refactor: extract Android liveness probe and split recording scenarios per file-shape rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record-trace-android-recovery.ts (763 lines) and android-recording.test.ts (1,764 lines) were both past the 500-LOC extraction tripwire. The screenrecord liveness/probe concept this PR modified now lives in record-trace-android-liveness.ts, and the new scenarios moved to test files mirroring the source modules they cover (record-trace-android-copy.test.ts, record-trace-android-liveness.test.ts) with shared scenario plumbing in the android-recording-fixtures.ts sibling. android-recording.test.ts shrinks to 1,476 lines — below its pre-PR size. --- .../handlers/record-trace-android-liveness.ts | 131 ++++++++ .../handlers/record-trace-android-recovery.ts | 114 +------ .../android-recording-fixtures.ts | 148 +++++++++ .../android-recording.test.ts | 310 +----------------- .../record-trace-android-copy.test.ts | 103 ++++++ .../record-trace-android-liveness.test.ts | 84 +++++ 6 files changed, 483 insertions(+), 407 deletions(-) create mode 100644 src/daemon/handlers/record-trace-android-liveness.ts create mode 100644 test/integration/provider-scenarios/android-recording-fixtures.ts create mode 100644 test/integration/provider-scenarios/record-trace-android-copy.test.ts create mode 100644 test/integration/provider-scenarios/record-trace-android-liveness.test.ts diff --git a/src/daemon/handlers/record-trace-android-liveness.ts b/src/daemon/handlers/record-trace-android-liveness.ts new file mode 100644 index 0000000000..d201dc4e5c --- /dev/null +++ b/src/daemon/handlers/record-trace-android-liveness.ts @@ -0,0 +1,131 @@ +import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts'; +import type { + AndroidAdbExecutorOptions, + AndroidAdbExecutorResult, +} from '../../platforms/android/adb-executor.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { + parseRecoverableAndroidScreenrecord, + type AndroidRecordingRecoveryMetadata, +} from './record-trace-android-recovery-manifest.ts'; + +const ANDROID_LIVENESS_PROBE_TIMEOUT_MS = 5_000; +const ANDROID_LIVENESS_STAT_MIN_SIZE_BYTES = 1; + +type AndroidScreenrecordLiveness = 'live' | 'stale' | 'uncertain' | 'finished'; +export type AndroidScreenrecordProbe = AndroidRecordingRecoveryMetadata | 'uncertain' | undefined; + +async function runAndroidLivenessAdb( + deviceId: string, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise { + return await runAndroidAdb(androidDeviceForSerial(deviceId), args, options); +} + +export async function checkRecoverableAndroidScreenrecord( + deviceId: string, + metadata: AndroidRecordingRecoveryMetadata, +): Promise { + const result = await runAndroidLivenessAdb( + deviceId, + ['shell', 'ps', '-o', 'pid=,args=', '-p', metadata.remotePid], + { + allowFailure: true, + timeoutMs: ANDROID_LIVENESS_PROBE_TIMEOUT_MS, + }, + ); + if (result.exitCode !== 0) { + // toybox `ps -p ` exits non-zero with no output at all — the normal signature + // of an exited process, not an adb failure (transport failures leave stderr and exec-layer + // timeouts throw before this branch). Corroborate with the full process list so a healthy + // device recovers the finished recording while a broken transport stays uncertain. + if (result.stdout.trim().length === 0 && result.stderr.trim().length === 0) { + return await resolveExitedAndroidScreenrecord(deviceId, metadata); + } + emitDiagnostic({ + level: 'debug', + phase: 'record_stop_android_recovery_metadata_probe_uncertain', + data: { + deviceId, + remotePid: metadata.remotePid, + remotePath: metadata.remotePath, + exitCode: result.exitCode, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }, + }); + return 'uncertain'; + } + const lines = result.stdout.split(/\r?\n/); + const pidLine = lines + .map((line) => line.trim()) + .find((line) => line.startsWith(metadata.remotePid)); + const matched = lines + .map(parseRecoverableAndroidScreenrecord) + .some( + (candidate) => + candidate?.remotePid === metadata.remotePid && candidate.remotePath === metadata.remotePath, + ); + if (matched) { + return 'live'; + } + if (pidLine?.includes('screenrecord')) return 'uncertain'; + if (pidLine) return 'stale'; + return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; +} + +async function resolveExitedAndroidScreenrecord( + deviceId: string, + metadata: AndroidRecordingRecoveryMetadata, +): Promise { + const listed = await findLiveAndroidScreenrecordByPath(deviceId, metadata.remotePath); + if (listed === 'uncertain') { + return 'uncertain'; + } + if (listed) { + return listed.remotePid === metadata.remotePid ? 'live' : 'uncertain'; + } + return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; +} + +export async function findLiveAndroidScreenrecordByPath( + deviceId: string, + remotePath: string, +): Promise { + const result = await runAndroidLivenessAdb(deviceId, ['shell', 'ps', '-A', '-o', 'pid=,args='], { + allowFailure: true, + timeoutMs: ANDROID_LIVENESS_PROBE_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + emitDiagnostic({ + level: 'debug', + phase: 'record_stop_android_recovery_ps_failed', + data: { + deviceId, + remotePath, + exitCode: result.exitCode, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }, + }); + return 'uncertain'; + } + + return result.stdout + .split(/\r?\n/) + .map(parseRecoverableAndroidScreenrecord) + .find((match): match is NonNullable => match?.remotePath === remotePath); +} + +export async function androidRemoteFileExists( + deviceId: string, + remotePath: string, +): Promise { + const result = await runAndroidLivenessAdb(deviceId, ['shell', 'stat', '-c', '%s', remotePath], { + allowFailure: true, + timeoutMs: ANDROID_LIVENESS_PROBE_TIMEOUT_MS, + }); + const size = result.exitCode === 0 ? Number(result.stdout.trim()) : NaN; + return Number.isFinite(size) && size >= ANDROID_LIVENESS_STAT_MIN_SIZE_BYTES; +} diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts index 452d14469b..ef1db85eaf 100644 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ b/src/daemon/handlers/record-trace-android-recovery.ts @@ -9,6 +9,12 @@ import type { DaemonResponse, SessionState } from '../types.ts'; import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; import { errorResponse } from './response.ts'; import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; +import { + androidRemoteFileExists, + checkRecoverableAndroidScreenrecord, + findLiveAndroidScreenrecordByPath, + type AndroidScreenrecordProbe, +} from './record-trace-android-liveness.ts'; import { androidRecoveryMetadataPathForRemotePath, androidRecoveryMetadataPaths, @@ -16,7 +22,6 @@ import { buildAndroidRecoveryPendingManifest, buildAndroidRecoveryRotatingManifest, parseAndroidRecoveryManifest, - parseRecoverableAndroidScreenrecord, type AndroidRecordingRecoveryChunk, type AndroidRecordingRecoveryManifest, type AndroidRecordingRecoveryMetadata, @@ -30,7 +35,6 @@ const ANDROID_RECOVERY_FINISHED_WARNING = 'Recovered Android recording after daemon restart from durable device manifest; the screenrecord process was no longer running, so the MP4 may be truncated.'; const ANDROID_RECOVERY_ROTATION_WARNING = 'Recovered Android recording from an interrupted chunk rotation; returning chunks known to be safely owned by the durable manifest.'; -const ANDROID_RECOVERY_MANIFEST_STAT_SIZE_BYTES = 1; const ANDROID_RECOVERY_PROBE_TIMEOUT_MS = 5_000; type AndroidDevice = SessionState['device']; @@ -58,8 +62,6 @@ type AndroidRecoveryResolution = | { kind: 'live'; manifest: AndroidRecordingRecoveryCandidate } | { kind: 'stale' } | { kind: 'uncertain' }; -type AndroidScreenrecordProbe = AndroidRecordingRecoveryMetadata | 'uncertain' | undefined; - type AndroidRecoveryManifestScan = { live: AndroidRecordingRecoveryCandidate[]; uncertain: AndroidRecordingRecoveryManifest[]; @@ -272,110 +274,6 @@ function liveAndroidRecoveryCandidate(params: { }; } -async function checkRecoverableAndroidScreenrecord( - deviceId: string, - metadata: AndroidRecordingRecoveryMetadata, -): Promise<'live' | 'stale' | 'uncertain' | 'finished'> { - const result = await runAndroidRecoveryAdb( - deviceId, - ['shell', 'ps', '-o', 'pid=,args=', '-p', metadata.remotePid], - { - allowFailure: true, - timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, - }, - ); - if (result.exitCode !== 0) { - // toybox `ps -p ` exits non-zero with no output at all — the normal signature - // of an exited process, not an adb failure (transport failures leave stderr and exec-layer - // timeouts throw before this branch). Corroborate with the full process list so a healthy - // device recovers the finished recording while a broken transport stays uncertain. - if (result.stdout.trim().length === 0 && result.stderr.trim().length === 0) { - return await resolveExitedAndroidScreenrecord(deviceId, metadata); - } - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_recovery_metadata_probe_uncertain', - data: { - deviceId, - remotePid: metadata.remotePid, - remotePath: metadata.remotePath, - exitCode: result.exitCode, - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - }, - }); - return 'uncertain'; - } - const lines = result.stdout.split(/\r?\n/); - const pidLine = lines - .map((line) => line.trim()) - .find((line) => line.startsWith(metadata.remotePid)); - const matched = lines - .map(parseRecoverableAndroidScreenrecord) - .some( - (candidate) => - candidate?.remotePid === metadata.remotePid && candidate.remotePath === metadata.remotePath, - ); - if (matched) { - return 'live'; - } - if (pidLine?.includes('screenrecord')) return 'uncertain'; - if (pidLine) return 'stale'; - return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; -} - -async function resolveExitedAndroidScreenrecord( - deviceId: string, - metadata: AndroidRecordingRecoveryMetadata, -): Promise<'live' | 'stale' | 'uncertain' | 'finished'> { - const listed = await findLiveAndroidScreenrecordByPath(deviceId, metadata.remotePath); - if (listed === 'uncertain') { - return 'uncertain'; - } - if (listed) { - return listed.remotePid === metadata.remotePid ? 'live' : 'uncertain'; - } - return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; -} - -async function findLiveAndroidScreenrecordByPath( - deviceId: string, - remotePath: string, -): Promise { - const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'ps', '-A', '-o', 'pid=,args='], { - allowFailure: true, - timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_recovery_ps_failed', - data: { - deviceId, - remotePath, - exitCode: result.exitCode, - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - }, - }); - return 'uncertain'; - } - - return result.stdout - .split(/\r?\n/) - .map(parseRecoverableAndroidScreenrecord) - .find((match): match is NonNullable => match?.remotePath === remotePath); -} - -async function androidRemoteFileExists(deviceId: string, remotePath: string): Promise { - const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'stat', '-c', '%s', remotePath], { - allowFailure: true, - timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, - }); - const size = result.exitCode === 0 ? Number(result.stdout.trim()) : NaN; - return Number.isFinite(size) && size >= ANDROID_RECOVERY_MANIFEST_STAT_SIZE_BYTES; -} - function chunksThroughRemotePath( chunks: AndroidRecordingRecoveryChunk[], remotePath: string, diff --git a/test/integration/provider-scenarios/android-recording-fixtures.ts b/test/integration/provider-scenarios/android-recording-fixtures.ts new file mode 100644 index 0000000000..a486d43e02 --- /dev/null +++ b/test/integration/provider-scenarios/android-recording-fixtures.ts @@ -0,0 +1,148 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; +import { + restoreEnv, + createProviderScenarioHarness, + likelyPlayableMp4Container, +} from './harness.ts'; + +export type ProviderScenarioDaemon = Awaited>; +export type ProviderScenarioRpcResult = Awaited>; +export type PullCall = { remotePath: string; localPath: string }; + +export async function stopAndroidRecording( + daemon: ProviderScenarioDaemon, + outPath?: string, +): Promise { + return await daemon.callCommand('record', outPath ? ['stop', outPath] : ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); +} + +// Strips PATH so isPlayableVideo cannot reach swiftc and deterministically validates pulled +// files via the container sniff. +export async function withAndroidProviderScenarioEnv( + tmpDir: string, + runScenario: () => Promise, +): Promise { + const previousPath = process.env.PATH; + const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; + process.env.PATH = tmpDir; + process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); + try { + await runScenario(); + } finally { + restoreEnv('PATH', previousPath); + restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); + } +} + +export type AndroidRecordingManifestFixtureOptions = { + outPath: string; + remotePath: string; + sessionName: string; + sessionScope?: { kind: 'cwd'; id: string }; + status?: 'pending' | 'live' | 'rotating'; + pendingRemotePath?: string; + pendingRemotePid?: string; + remotePid?: string; + startedAt?: number; + chunks?: Array<{ index: number; path: string; remotePath: string }>; +}; + +export function buildAndroidRecordingManifest(options: AndroidRecordingManifestFixtureOptions) { + const startedAt = options.startedAt ?? 123456789; + const status = options.status ?? 'live'; + return { + version: 1, + sessionName: options.sessionName, + sessionScope: options.sessionScope, + recordingId: `recording-${startedAt}`, + deviceId: PROVIDER_SCENARIO_ANDROID.id, + startedAt, + outPath: options.outPath, + showTouches: true, + exportQuality: 'medium', + current: buildAndroidRecordingManifestCurrent(options, startedAt, status), + pending: buildAndroidRecordingManifestPending(options, status), + pendingRemotePid: options.pendingRemotePid ?? (status === 'rotating' ? '4322' : '4321'), + chunks: buildAndroidRecordingManifestChunks(options), + }; +} + +function buildAndroidRecordingManifestCurrent( + options: AndroidRecordingManifestFixtureOptions, + startedAt: number, + status: 'pending' | 'live' | 'rotating', +) { + if (status === 'pending') return undefined; + return { + remotePath: options.remotePath, + remotePid: options.remotePid ?? '4321', + startedAt, + }; +} + +function buildAndroidRecordingManifestPending( + options: AndroidRecordingManifestFixtureOptions, + status: 'pending' | 'live' | 'rotating', +) { + return status === 'pending' || status === 'rotating' + ? { remotePath: options.pendingRemotePath ?? options.remotePath } + : undefined; +} + +function buildAndroidRecordingManifestChunks(options: AndroidRecordingManifestFixtureOptions) { + return ( + options.chunks ?? [ + { + index: 1, + path: options.outPath, + remotePath: options.remotePath, + }, + ] + ); +} + +export function androidAdbResult(args: string[]): { + stdout: string; + stderr: string; + exitCode: number; + stdoutBuffer?: Buffer; +} { + const command = args.join(' '); + if (command === 'shell getprop sys.boot_completed') { + return { stdout: '1\n', stderr: '', exitCode: 0 }; + } + if (isAndroidScreenrecordStartCommand(command)) { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)) { + return { stdout: '2048\n', stderr: '', exitCode: 0 }; + } + if (args[0] === 'pull' && typeof args[2] === 'string') { + writePlayableMp4(args[2]); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (command === 'shell ps -o pid= -p 4321') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; +} + +function isAndroidScreenrecordStartCommand(command: string): boolean { + return /^shell screenrecord --bit-rate (?:8000000|20000000) \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( + command, + ); +} + +export function writePlayableMp4(filePath: string): void { + const fixturePath = path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'); + if (fs.existsSync(fixturePath)) { + fs.copyFileSync(fixturePath, filePath); + return; + } + fs.writeFileSync(filePath, likelyPlayableMp4Container()); +} diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 6ffa0b6ce3..06f698a3d9 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -13,17 +13,18 @@ import { assertRpcOk, } from './assertions.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; -import { - restoreEnv, - createProviderScenarioHarness, - likelyPlayableMp4Container, - withProviderScenarioTempDir, -} from './harness.ts'; +import { createProviderScenarioHarness, withProviderScenarioTempDir } from './harness.ts'; import { ANDROID_RECORDING_CONTRACT_EVIDENCE } from './android-recording.coverage.ts'; - -type ProviderScenarioDaemon = Awaited>; -type ProviderScenarioRpcResult = Awaited>; -type PullCall = { remotePath: string; localPath: string }; +import { + androidAdbResult, + buildAndroidRecordingManifest, + stopAndroidRecording, + withAndroidProviderScenarioEnv, + writePlayableMp4, + type ProviderScenarioDaemon, + type ProviderScenarioRpcResult, + type PullCall, +} from './android-recording-fixtures.ts'; test(ANDROID_RECORDING_CONTRACT_EVIDENCE.testName, async () => { await withProviderScenarioTempDir( @@ -39,13 +40,6 @@ test('Provider-backed integration Android record stop recovers missing daemon re ); }); -test('Provider-backed integration Android record stop retries the pull until in-place moov finalization lands', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-finalize-race-', - runAndroidRemoteFinalizeRaceScenario, - ); -}, 15_000); - test('Provider-backed integration Android record stop recovers cwd-scoped durable manifest', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-scoped-recovery-', @@ -258,13 +252,6 @@ test('Provider-backed integration Android record stop recovers while another dev ); }); -test('Provider-backed integration Android record stop recovers finished recording after dead-pid probe', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-dead-pid-recovery-', - runAndroidDeadPidRecoveryScenario, - ); -}); - test('Provider-backed integration Android record stop keeps valid metadata on uncertain liveness probe', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-uncertain-metadata-', @@ -317,64 +304,6 @@ async function runAndroidManifestRecoveryScenario(tmpDir: string): Promise }); } -async function runAndroidRemoteFinalizeRaceScenario(tmpDir: string): Promise { - const remotePath = '/sdcard/agent-device-recording-523456789.mp4'; - const recordingPath = path.join(tmpDir, 'finalize-race.mp4'); - const recordingSize = 3232; - const streamingBytes = unfinalizedMp4Container(recordingSize); - const finalizedBytes = finalizedMp4Container(recordingSize); - // screenrecord finalizes by patching a front-reserved moov in place: the size never changes, - // only the content does, so the copy path must detect finalization from re-pulled bytes. - assert.equal(streamingBytes.length, finalizedBytes.length); - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: 'default', - }); - let pullCount = 0; - const adbProvider: AndroidAdbProvider = { - exec: async (args) => { - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell ps -o pid=,args= -p 4321') { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - return androidAdbResult(args); - }, - pull: async (from, to) => { - pullCount += 1; - assert.equal(from, remotePath); - // Finalization lands after the second pull, past the first retry delay — inside the - // 1-3s window observed live on a loaded emulator. - fs.writeFileSync(to, pullCount <= 2 ? streamingBytes : finalizedBytes); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => adbProvider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - const recordStop = await stopAndroidRecording(daemon, recordingPath); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.equal(pullCount, 3); - assert.equal(fs.existsSync(recordingPath), true); - } finally { - await daemon.close(); - } - }); -} - async function runAndroidManifestHostPathScenario(tmpDir: string): Promise { const adbCalls: string[][] = []; const pullCalls: PullCall[] = []; @@ -846,16 +775,6 @@ async function createAndroidSingleManifestRecoveryContext(options: { return { adbCalls, pullCalls, daemon }; } -async function stopAndroidRecording( - daemon: ProviderScenarioDaemon, - outPath?: string, -): Promise { - return await daemon.callCommand('record', outPath ? ['stop', outPath] : ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); -} - function assertAndroidManifestRecovery( recordStop: ProviderScenarioRpcResult, context: { @@ -1231,68 +1150,6 @@ async function runAndroidDeviceMismatchManifestScenario(tmpDir: string): Promise } } -async function runAndroidDeadPidRecoveryScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const remotePath = '/sdcard/agent-device-recording-623456789.mp4'; - const recordingPath = path.join(tmpDir, 'dead-pid-recovered.mp4'); - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: 'default', - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => ({ - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - // toybox signature for a pid that no longer exists: exit 1, no output at all. - if (command === 'shell ps -o pid=,args= -p 4321') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - // The device is otherwise responsive: the full listing succeeds and shows no - // screenrecord, and the finalized remote MP4 is present. - if (command === 'shell ps -A -o pid=,args=') { - return { stdout: '1 init\n', stderr: '', exitCode: 0 }; - } - return androidAdbResult(args); - }, - pull: async (from, to) => { - pullCalls.push({ remotePath: from, localPath: to }); - writePlayableMp4(to); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - const recordStop = await stopAndroidRecording(daemon, recordingPath); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( - recordStop, - ); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.match(String(data.warning), /durable device manifest/); - assert.match(String(data.warning), /no longer running/); - assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); - assert.equal(fs.existsSync(recordingPath), true); - assertCommandCall(adbCalls, [ - 'shell', - 'rm', - '-f', - '/sdcard/agent-device-recording-active.json', - ]); - } finally { - await daemon.close(); - } - }); -} - async function runAndroidUncertainMetadataScenario(tmpDir: string): Promise { const adbCalls: string[][] = []; const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; @@ -1425,22 +1282,6 @@ function assertAndroidSessionlessRecording(adbCalls: string[][], logPath: string assert.deepEqual(readLoggedArgs(logPath), []); } -async function withAndroidProviderScenarioEnv( - tmpDir: string, - runScenario: () => Promise, -): Promise { - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - try { - await runScenario(); - } finally { - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); - } -} - function createPullingAndroidProvider(params: { adbCalls: string[][]; pullCalls: PullCall[]; @@ -1606,109 +1447,10 @@ function androidRecoveryAdbResult( return androidAdbResult(args); } -type AndroidRecordingManifestFixtureOptions = { - outPath: string; - remotePath: string; - sessionName: string; - sessionScope?: { kind: 'cwd'; id: string }; - status?: 'pending' | 'live' | 'rotating'; - pendingRemotePath?: string; - pendingRemotePid?: string; - remotePid?: string; - startedAt?: number; - chunks?: Array<{ index: number; path: string; remotePath: string }>; -}; - -function buildAndroidRecordingManifest(options: AndroidRecordingManifestFixtureOptions) { - const startedAt = options.startedAt ?? 123456789; - const status = options.status ?? 'live'; - return { - version: 1, - sessionName: options.sessionName, - sessionScope: options.sessionScope, - recordingId: `recording-${startedAt}`, - deviceId: PROVIDER_SCENARIO_ANDROID.id, - startedAt, - outPath: options.outPath, - showTouches: true, - exportQuality: 'medium', - current: buildAndroidRecordingManifestCurrent(options, startedAt, status), - pending: buildAndroidRecordingManifestPending(options, status), - pendingRemotePid: options.pendingRemotePid ?? (status === 'rotating' ? '4322' : '4321'), - chunks: buildAndroidRecordingManifestChunks(options), - }; -} - -function buildAndroidRecordingManifestCurrent( - options: AndroidRecordingManifestFixtureOptions, - startedAt: number, - status: 'pending' | 'live' | 'rotating', -) { - if (status === 'pending') return undefined; - return { - remotePath: options.remotePath, - remotePid: options.remotePid ?? '4321', - startedAt, - }; -} - -function buildAndroidRecordingManifestPending( - options: AndroidRecordingManifestFixtureOptions, - status: 'pending' | 'live' | 'rotating', -) { - return status === 'pending' || status === 'rotating' - ? { remotePath: options.pendingRemotePath ?? options.remotePath } - : undefined; -} - -function buildAndroidRecordingManifestChunks(options: AndroidRecordingManifestFixtureOptions) { - return ( - options.chunks ?? [ - { - index: 1, - path: options.outPath, - remotePath: options.remotePath, - }, - ] - ); -} - function hashScopeRoot(scopeRoot: string): string { return crypto.createHash('sha256').update(scopeRoot).digest('hex').slice(0, 16); } -function androidAdbResult(args: string[]): { - stdout: string; - stderr: string; - exitCode: number; - stdoutBuffer?: Buffer; -} { - const command = args.join(' '); - if (command === 'shell getprop sys.boot_completed') { - return { stdout: '1\n', stderr: '', exitCode: 0 }; - } - if (isAndroidScreenrecordStartCommand(command)) { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - if (args[0] === 'pull' && typeof args[2] === 'string') { - writePlayableMp4(args[2]); - return { stdout: '', stderr: '', exitCode: 0 }; - } - if (command === 'shell ps -o pid= -p 4321') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; -} - -function isAndroidScreenrecordStartCommand(command: string): boolean { - return /^shell screenrecord --bit-rate (?:8000000|20000000) \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ); -} - function isAndroidHighQualityScreenrecordStartCommand(command: string): boolean { return /^shell screenrecord --bit-rate 20000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( command, @@ -1731,34 +1473,4 @@ function readLoggedArgs(logPath: string): string[] { } // A screenrecord capture pulled mid-finalization: the moov space is still a `free` placeholder. -function unfinalizedMp4Container(totalSize: number): Buffer { - return mp4WithMoovSlot('free', totalSize); -} - // The same capture after screenrecord patched the reserved slot into a real moov atom. -function finalizedMp4Container(totalSize: number): Buffer { - return mp4WithMoovSlot('moov', totalSize); -} - -function mp4WithMoovSlot(slotType: 'free' | 'moov', totalSize: number): Buffer { - const ftyp = mp4Atom('ftyp', Buffer.from('isom0000isom', 'latin1')); - const slot = mp4Atom(slotType, Buffer.alloc(1024)); - const mdat = mp4Atom('mdat', Buffer.alloc(totalSize - ftyp.length - slot.length - 8)); - return Buffer.concat([ftyp, slot, mdat]); -} - -function mp4Atom(type: string, payload: Buffer): Buffer { - const header = Buffer.alloc(8); - header.writeUInt32BE(8 + payload.length, 0); - header.write(type, 4, 4, 'latin1'); - return Buffer.concat([header, payload]); -} - -function writePlayableMp4(filePath: string): void { - const fixturePath = path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'); - if (fs.existsSync(fixturePath)) { - fs.copyFileSync(fixturePath, filePath); - return; - } - fs.writeFileSync(filePath, likelyPlayableMp4Container()); -} diff --git a/test/integration/provider-scenarios/record-trace-android-copy.test.ts b/test/integration/provider-scenarios/record-trace-android-copy.test.ts new file mode 100644 index 0000000000..936fa0b557 --- /dev/null +++ b/test/integration/provider-scenarios/record-trace-android-copy.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; +import { assertRpcOk } from './assertions.ts'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; +import { createProviderScenarioHarness, withProviderScenarioTempDir } from './harness.ts'; +import { + androidAdbResult, + buildAndroidRecordingManifest, + stopAndroidRecording, + withAndroidProviderScenarioEnv, +} from './android-recording-fixtures.ts'; + +test('Provider-backed integration Android record stop retries the pull until in-place moov finalization lands', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-finalize-race-', + runAndroidRemoteFinalizeRaceScenario, + ); +}, 15_000); + +async function runAndroidRemoteFinalizeRaceScenario(tmpDir: string): Promise { + const remotePath = '/sdcard/agent-device-recording-523456789.mp4'; + const recordingPath = path.join(tmpDir, 'finalize-race.mp4'); + const recordingSize = 3232; + const streamingBytes = unfinalizedMp4Container(recordingSize); + const finalizedBytes = finalizedMp4Container(recordingSize); + // screenrecord finalizes by patching a front-reserved moov in place: the size never changes, + // only the content does, so the copy path must detect finalization from re-pulled bytes. + assert.equal(streamingBytes.length, finalizedBytes.length); + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: 'default', + }); + let pullCount = 0; + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell ps -o pid=,args= -p 4321') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCount += 1; + assert.equal(from, remotePath); + // Finalization lands after the second pull, past the first retry delay — inside the + // 1-3s window observed live on a loaded emulator. + fs.writeFileSync(to, pullCount <= 2 ? streamingBytes : finalizedBytes); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + await withAndroidProviderScenarioEnv(tmpDir, async () => { + try { + const recordStop = await stopAndroidRecording(daemon, recordingPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.equal(pullCount, 3); + assert.equal(fs.existsSync(recordingPath), true); + } finally { + await daemon.close(); + } + }); +} + +// A screenrecord capture pulled mid-finalization: the moov space is still a `free` placeholder. +function unfinalizedMp4Container(totalSize: number): Buffer { + return mp4WithMoovSlot('free', totalSize); +} + +// The same capture after screenrecord patched the reserved slot into a real moov atom. +function finalizedMp4Container(totalSize: number): Buffer { + return mp4WithMoovSlot('moov', totalSize); +} + +function mp4WithMoovSlot(slotType: 'free' | 'moov', totalSize: number): Buffer { + const ftyp = mp4Atom('ftyp', Buffer.from('isom0000isom', 'latin1')); + const slot = mp4Atom(slotType, Buffer.alloc(1024)); + const mdat = mp4Atom('mdat', Buffer.alloc(totalSize - ftyp.length - slot.length - 8)); + return Buffer.concat([ftyp, slot, mdat]); +} + +function mp4Atom(type: string, payload: Buffer): Buffer { + const header = Buffer.alloc(8); + header.writeUInt32BE(8 + payload.length, 0); + header.write(type, 4, 4, 'latin1'); + return Buffer.concat([header, payload]); +} diff --git a/test/integration/provider-scenarios/record-trace-android-liveness.test.ts b/test/integration/provider-scenarios/record-trace-android-liveness.test.ts new file mode 100644 index 0000000000..6e5f119679 --- /dev/null +++ b/test/integration/provider-scenarios/record-trace-android-liveness.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { assertCommandCall, assertRpcOk } from './assertions.ts'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; +import { createProviderScenarioHarness, withProviderScenarioTempDir } from './harness.ts'; +import { + androidAdbResult, + buildAndroidRecordingManifest, + stopAndroidRecording, + withAndroidProviderScenarioEnv, + writePlayableMp4, + type PullCall, +} from './android-recording-fixtures.ts'; + +test('Provider-backed integration Android record stop recovers finished recording after dead-pid probe', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-dead-pid-recovery-', + runAndroidDeadPidRecoveryScenario, + ); +}); + +async function runAndroidDeadPidRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-623456789.mp4'; + const recordingPath = path.join(tmpDir, 'dead-pid-recovered.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: 'default', + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => ({ + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + // toybox signature for a pid that no longer exists: exit 1, no output at all. + if (command === 'shell ps -o pid=,args= -p 4321') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + // The device is otherwise responsive: the full listing succeeds and shows no + // screenrecord, and the finalized remote MP4 is present. + if (command === 'shell ps -A -o pid=,args=') { + return { stdout: '1 init\n', stderr: '', exitCode: 0 }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCalls.push({ remotePath: from, localPath: to }); + writePlayableMp4(to); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + await withAndroidProviderScenarioEnv(tmpDir, async () => { + try { + const recordStop = await stopAndroidRecording(daemon, recordingPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( + recordStop, + ); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.match(String(data.warning), /durable device manifest/); + assert.match(String(data.warning), /no longer running/); + assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); + assert.equal(fs.existsSync(recordingPath), true); + assertCommandCall(adbCalls, [ + 'shell', + 'rm', + '-f', + '/sdcard/agent-device-recording-active.json', + ]); + } finally { + await daemon.close(); + } + }); +}