Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 37 additions & 39 deletions src/daemon/handlers/record-trace-android-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NonNullable<SessionState['recording']>, { platform: 'android' }>;

Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
131 changes: 131 additions & 0 deletions src/daemon/handlers/record-trace-android-liveness.ts
Original file line number Diff line number Diff line change
@@ -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<AndroidAdbExecutorResult> {
return await runAndroidAdb(androidDeviceForSerial(deviceId), args, options);
}

export async function checkRecoverableAndroidScreenrecord(
deviceId: string,
metadata: AndroidRecordingRecoveryMetadata,
): Promise<AndroidScreenrecordLiveness> {
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 <missing-pid>` 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<AndroidScreenrecordLiveness> {
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<AndroidScreenrecordProbe> {
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<typeof match> => match?.remotePath === remotePath);
}

export async function androidRemoteFileExists(
deviceId: string,
remotePath: string,
): Promise<boolean> {
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;
}
93 changes: 6 additions & 87 deletions src/daemon/handlers/record-trace-android-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ 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,
buildAndroidRecoveryManifest,
buildAndroidRecoveryPendingManifest,
buildAndroidRecoveryRotatingManifest,
parseAndroidRecoveryManifest,
parseRecoverableAndroidScreenrecord,
type AndroidRecordingRecoveryChunk,
type AndroidRecordingRecoveryManifest,
type AndroidRecordingRecoveryMetadata,
Expand All @@ -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'];
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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<AndroidRecordingRecoveryMetadata | 'uncertain' | undefined> {
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<typeof match> => match?.remotePath === remotePath);
}

async function androidRemoteFileExists(deviceId: string, remotePath: string): Promise<boolean> {
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,
Expand Down
Loading
Loading