diff --git a/src/daemon/handlers/record-trace-android-copy.ts b/src/daemon/handlers/record-trace-android-copy.ts index a12f7922f..d7e124f82 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-liveness.ts b/src/daemon/handlers/record-trace-android-liveness.ts new file mode 100644 index 000000000..d201dc4e5 --- /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 d7173b74c..ef1db85ea 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,89 +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) { - 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 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/src/utils/video.ts b/src/utils/video.ts index 0d9524f71..16c919991 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-fixtures.ts b/test/integration/provider-scenarios/android-recording-fixtures.ts new file mode 100644 index 000000000..a486d43e0 --- /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 26f0e6c09..06f698a3d 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( @@ -774,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: { @@ -1291,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[]; @@ -1472,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, @@ -1596,11 +1472,5 @@ function readLoggedArgs(logPath: string): string[] { .filter(Boolean); } -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()); -} +// A screenrecord capture pulled mid-finalization: the moov space is still a `free` placeholder. +// The same capture after screenrecord patched the reserved slot into a real moov atom. 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 000000000..936fa0b55 --- /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 000000000..6e5f11967 --- /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(); + } + }); +}