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
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ function parseJsonObject(value: string): Record<string, unknown> | undefined {
}

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object';
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function readAndroidRecoveryManifestRequired(
Expand Down
39 changes: 36 additions & 3 deletions src/daemon/handlers/record-trace-android-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,14 @@ async function resolvePendingAndroidRecoveryCandidate(
const pending = await findLiveAndroidScreenrecordByPath(deviceId, pendingMetadata.remotePath);
const adoptedPending = resolveLivePendingScreenrecord(manifest, pending);
if (adoptedPending) return adoptedPending;
if (!manifest.current) return pendingOnlyResolution(pending);
if (!manifest.current) {
return await resolvePendingOnlyAndroidRecoveryCandidate(
deviceId,
manifest,
pendingMetadata.remotePath,
pending,
);
}
return await resolveInterruptedRotationCurrent(deviceId, manifest, manifest.current, pending);
}

Expand Down Expand Up @@ -199,8 +206,34 @@ function resolveLivePendingScreenrecord(
});
}

function pendingOnlyResolution(pending: AndroidScreenrecordProbe): AndroidRecoveryResolution {
return pending === 'uncertain' ? { kind: 'uncertain' } : { kind: 'stale' };
async function resolvePendingOnlyAndroidRecoveryCandidate(
deviceId: string,
manifest: AndroidRecordingRecoveryManifest,
pendingRemotePath: string,
pending: AndroidScreenrecordProbe,
): Promise<AndroidRecoveryResolution> {
if (pending === 'uncertain') {
return { kind: 'uncertain' };
}
// The pending screenrecord process is gone. If it already produced an on-device file,
// recover it as a finished recording rather than discarding a completed capture — the
// same treatment resolveCurrentAndroidRecoveryCandidate gives a finished `current`.
if (await androidRemoteFileExists(deviceId, pendingRemotePath)) {
return liveAndroidRecoveryCandidate({
manifest,
current: {
remotePath: pendingRemotePath,
// A pending chunk never recorded a pid — the manifest is written before the
// screenrecord process starts. The process is confirmed gone, so there is
// nothing to signal; the empty pid tells finishCurrentAndroidRecordingChunk to
// skip the stop signal.
remotePid: '',
startedAt: manifest.startedAt,
},
recoveryWarning: ANDROID_RECOVERY_FINISHED_WARNING,
});
}
return { kind: 'stale' };
}

async function resolveCurrentAndroidRecoveryCandidate(
Expand Down
11 changes: 11 additions & 0 deletions src/daemon/handlers/record-trace-android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,17 @@ async function finishCurrentAndroidRecordingChunk(params: {
remotePid = recording.remotePid,
waitForRemoteFileStability = true,
} = params;
if (!remotePid) {
// A recovered finished recording with no tracked process (a pending chunk whose
// screenrecord already exited): there is nothing to signal, and the on-device file
// is already complete. Skip the kill entirely — probing/signalling an empty pid is
// unsafe (`isAndroidProcessRunning('')` can report a false positive).
appendAndroidRecordingWarning(recording, resolveAndroidScreenrecordLimitWarning(recording));
if (waitForRemoteFileStability) {
await waitForAndroidRemoteFileStability(device.id, remotePath);
}
return undefined;
}
const wasRunningBeforeStop = await isAndroidProcessRunning(device.id, remotePid);
if (!wasRunningBeforeStop) {
appendAndroidRecordingWarning(recording, resolveAndroidScreenrecordLimitWarning(recording));
Expand Down
66 changes: 66 additions & 0 deletions test/integration/provider-scenarios/android-recording.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ test('Provider-backed integration Android record stop recovers pending manifest
);
});

test('Provider-backed integration Android record stop recovers finished pending manifest after process exit', async () => {
await withProviderScenarioTempDir(
'agent-device-provider-scenario-android-record-pending-finished-',
runAndroidPendingFinishedManifestRecoveryScenario,
);
});

test('Provider-backed integration Android record stop recovers rotating manifest after daemon state loss', async () => {
await withProviderScenarioTempDir(
'agent-device-provider-scenario-android-record-rotating-recovery-',
Expand Down Expand Up @@ -637,6 +644,65 @@ async function runAndroidPendingManifestRecoveryScenario(tmpDir: string): Promis
}
}

async function runAndroidPendingFinishedManifestRecoveryScenario(tmpDir: string): Promise<void> {
const adbCalls: string[][] = [];
const pullCalls: PullCall[] = [];
const remotePath = '/sdcard/agent-device-recording-624000001.mp4';
const recordingPath = path.join(tmpDir, 'pending-finished-recovered.mp4');
const manifest = buildAndroidRecordingManifest({
outPath: recordingPath,
remotePath,
sessionName: 'default',
status: 'pending',
});
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 };
}
if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') {
return { stdout: '', stderr: '', exitCode: 1 };
}
// The pending screenrecord process already exited: the full process scan finds no
// match, but the on-device file still exists (default stat returns a non-zero size).
if (command === 'shell ps -A -o pid=,args=') {
return { stdout: '', 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],
});

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), /no longer running/);
// A pending manifest never recorded a pid and the process is confirmed gone, so stop
// sends no signal — it just pulls the completed file instead of discarding it.
assert.equal(
adbCalls.some((args) => args.join(' ').startsWith('shell kill -2')),
false,
);
assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]);
assert.equal(fs.existsSync(recordingPath), true);
} finally {
await daemon.close();
}
}

async function runAndroidRotatingManifestRecoveryScenario(tmpDir: string): Promise<void> {
const adbCalls: string[][] = [];
const pullCalls: PullCall[] = [];
Expand Down
Loading