Skip to content

Commit 096785b

Browse files
authored
fix: clean up recording stop and snapshot traversal (#640)
1 parent b09e7d3 commit 096785b

9 files changed

Lines changed: 324 additions & 60 deletions

File tree

src/daemon/handlers/__tests__/record-trace.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
overlayRecordingTouches,
6565
} from '../../../recording/overlay.ts';
6666
import { runCmd, runCmdBackground } from '../../../utils/exec.ts';
67+
import { isPlayableVideo, waitForStableFile } from '../../../utils/video.ts';
6768

6869
type RunnerCall = {
6970
command: string;
@@ -81,6 +82,8 @@ const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand);
8182
const mockResizeRecording = vi.mocked(resizeRecording);
8283
const mockTrimRecordingStart = vi.mocked(trimRecordingStart);
8384
const mockOverlayRecordingTouches = vi.mocked(overlayRecordingTouches);
85+
const mockWaitForStableFile = vi.mocked(waitForStableFile);
86+
const mockIsPlayableVideo = vi.mocked(isPlayableVideo);
8487

8588
const overlaySupportWarning = getRecordingOverlaySupportWarning();
8689

@@ -183,6 +186,8 @@ beforeEach(() => {
183186
mockResizeRecording.mockImplementation(async () => {});
184187
mockTrimRecordingStart.mockImplementation(async () => {});
185188
mockOverlayRecordingTouches.mockImplementation(async () => {});
189+
mockWaitForStableFile.mockImplementation(async () => {});
190+
mockIsPlayableVideo.mockImplementation(async () => true);
186191
});
187192

188193
afterEach(() => {
@@ -596,6 +601,138 @@ test('record stop leaves a short visual tail after iOS simulator gestures', asyn
596601
expect(kill).toHaveBeenCalledWith('SIGINT');
597602
});
598603

604+
test('record stop reports too-short iOS simulator recordings without leaving invalid output', async () => {
605+
const sessionStore = makeSessionStore();
606+
const sessionName = 'ios-sim-too-short';
607+
const outPath = path.join(os.tmpdir(), `agent-device-too-short-${Date.now()}.mp4`);
608+
fs.writeFileSync(outPath, 'not-a-video');
609+
const session = makeSession(sessionName, {
610+
platform: 'ios',
611+
id: 'sim-1',
612+
name: 'Simulator',
613+
kind: 'simulator',
614+
booted: true,
615+
});
616+
session.recording = {
617+
platform: 'ios',
618+
outPath,
619+
startedAt: Date.now(),
620+
showTouches: false,
621+
gestureEvents: [],
622+
child: { kill: vi.fn() },
623+
wait: Promise.resolve({ stdout: '', stderr: 'failed to finalize', exitCode: 1 }),
624+
};
625+
sessionStore.set(sessionName, session);
626+
627+
const response = await runRecordCommand({
628+
sessionStore,
629+
sessionName,
630+
positionals: ['stop'],
631+
});
632+
633+
expect(response?.ok).toBe(false);
634+
expect((response as any).error?.message).toMatch(/wait at least 1000ms/i);
635+
expect((response as any).error?.message).toMatch(/failed to finalize/i);
636+
expect(fs.existsSync(outPath)).toBe(false);
637+
});
638+
639+
test('record stop measures too-short iOS simulator recordings from stop request time', async () => {
640+
vi.useFakeTimers();
641+
vi.setSystemTime(10_000);
642+
const sessionStore = makeSessionStore();
643+
const sessionName = 'ios-sim-too-short-delayed-finalize';
644+
const outPath = path.join(os.tmpdir(), `agent-device-too-short-delayed-${Date.now()}.mp4`);
645+
fs.writeFileSync(outPath, 'not-a-video');
646+
const session = makeSession(sessionName, {
647+
platform: 'ios',
648+
id: 'sim-1',
649+
name: 'Simulator',
650+
kind: 'simulator',
651+
booted: true,
652+
});
653+
session.recording = {
654+
platform: 'ios',
655+
outPath,
656+
startedAt: Date.now() - 500,
657+
showTouches: false,
658+
gestureEvents: [],
659+
child: { kill: vi.fn() },
660+
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
661+
};
662+
sessionStore.set(sessionName, session);
663+
mockWaitForStableFile.mockImplementation(async () => {
664+
vi.setSystemTime(11_300);
665+
});
666+
mockIsPlayableVideo.mockImplementation(async () => false);
667+
668+
const response = await runRecordCommand({
669+
sessionStore,
670+
sessionName,
671+
positionals: ['stop'],
672+
});
673+
674+
expect(response?.ok).toBe(false);
675+
expect((response as any).error?.message).toMatch(/Recording stopped after 500ms/i);
676+
expect((response as any).error?.message).toMatch(/wait at least 1000ms/i);
677+
expect(fs.existsSync(outPath)).toBe(false);
678+
});
679+
680+
test('record stop measures too-short Android failures from stop request time', async () => {
681+
vi.useFakeTimers();
682+
vi.setSystemTime(20_000);
683+
const sessionStore = makeSessionStore();
684+
const sessionName = 'android-too-short-delayed-stop';
685+
const session = makeSession(sessionName, {
686+
platform: 'android',
687+
id: 'emulator-5554',
688+
name: 'Android',
689+
kind: 'device',
690+
booted: true,
691+
});
692+
session.recording = {
693+
platform: 'android',
694+
outPath: path.resolve('./android-too-short.mp4'),
695+
startedAt: Date.now() - 500,
696+
showTouches: true,
697+
gestureEvents: [],
698+
remotePath: '/sdcard/agent-device-recording-too-short.mp4',
699+
remotePid: '4322',
700+
chunks: [
701+
{
702+
index: 1,
703+
path: path.resolve('./android-too-short.mp4'),
704+
remotePath: '/sdcard/agent-device-recording-too-short.mp4',
705+
},
706+
],
707+
};
708+
sessionStore.set(sessionName, session);
709+
mockRunCmd.mockImplementation(async (_cmd, args) => {
710+
const command = args.join(' ');
711+
if (command === '-s emulator-5554 shell ps -o pid= -p 4322') {
712+
return { stdout: '4322\n', stderr: '', exitCode: 0 };
713+
}
714+
if (command === '-s emulator-5554 shell kill -2 4322') {
715+
vi.setSystemTime(21_300);
716+
return { stdout: '', stderr: 'failed to stop', exitCode: 1 };
717+
}
718+
if (command === '-s emulator-5554 shell kill -9 4322') {
719+
return { stdout: '', stderr: 'failed to force stop', exitCode: 1 };
720+
}
721+
return { stdout: '', stderr: '', exitCode: 0 };
722+
});
723+
724+
const response = await runRecordCommand({
725+
sessionStore,
726+
sessionName,
727+
positionals: ['stop'],
728+
});
729+
730+
expect(response?.ok).toBe(false);
731+
expect((response as any).error?.message).toMatch(/Recording stopped after 500ms/i);
732+
expect((response as any).error?.message).toMatch(/wait at least 1000ms/i);
733+
expect((response as any).error?.message).toMatch(/failed to stop/i);
734+
});
735+
599736
test('record start stores iOS simulator recorder pid for scoped cleanup', async () => {
600737
const sessionStore = makeSessionStore();
601738
const sessionName = 'ios-sim-recorder-pid';

src/daemon/handlers/record-trace-android-copy.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,7 @@ async function copyAndroidRecordingWithValidation(params: {
4343
let lastCopyError: string | undefined;
4444

4545
for (let attempt = 0; attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS; attempt += 1) {
46-
try {
47-
fs.rmSync(outPath, { force: true });
48-
} catch {
49-
// Ignore stale local file cleanup issues and let adb pull report the real failure.
50-
}
46+
removeLocalRecordingCandidate(outPath);
5147

5248
const device = androidDeviceForSerial(deviceId);
5349
const pullResult = await pullAndroidAdbFile(remotePath, outPath, {
@@ -98,6 +94,7 @@ async function copyAndroidRecordingWithValidation(params: {
9894
if (lastCopyError) {
9995
return `failed to copy recording from device: ${lastCopyError}`;
10096
}
97+
removeLocalRecordingCandidate(outPath);
10198
return 'failed to copy recording from device: pulled file is not a playable MP4';
10299
}
103100

@@ -108,3 +105,11 @@ function readFileSize(filePath: string): number {
108105
return 0;
109106
}
110107
}
108+
109+
function removeLocalRecordingCandidate(filePath: string): void {
110+
try {
111+
fs.rmSync(filePath, { force: true });
112+
} catch {
113+
// Ignore local cleanup issues and let the caller report the validation failure.
114+
}
115+
}

src/daemon/handlers/record-trace-android.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts';
22
import { sleep } from '../../utils/timeouts.ts';
33
import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts';
44
import type { DaemonResponse, SessionState } from '../types.ts';
5-
import { formatRecordTraceExecFailure } from '../record-trace-errors.ts';
5+
import { buildRecordStopFailure, formatRecordTraceExecFailure } from '../record-trace-errors.ts';
66
import type { RecordTraceDeps } from './record-trace-types.ts';
77
import { errorResponse } from './response.ts';
88
import type {
@@ -413,8 +413,9 @@ export async function stopAndroidRecording(params: {
413413
deps: RecordTraceDeps;
414414
device: AndroidDevice;
415415
recording: AndroidRecording;
416+
stopRequestedAt: number;
416417
}): Promise<DaemonResponse | null> {
417-
const { deps, device, recording } = params;
418+
const { deps, device, recording, stopRequestedAt } = params;
418419
emitDiagnostic({
419420
level: 'debug',
420421
phase: 'record_stop_android_enter',
@@ -444,7 +445,10 @@ export async function stopAndroidRecording(params: {
444445
});
445446
if (copyError) {
446447
await cleanupRemoteRecording();
447-
return errorResponse('COMMAND_FAILED', copyError);
448+
return errorResponse(
449+
'COMMAND_FAILED',
450+
formatAndroidStopFailure(copyError, recording, stopRequestedAt),
451+
);
448452
}
449453

450454
await finalizeAndroidRecordingOutput({ recording, deps });
@@ -453,7 +457,10 @@ export async function stopAndroidRecording(params: {
453457
await cleanupRemoteRecording();
454458

455459
if (stopError) {
456-
return errorResponse('COMMAND_FAILED', stopError);
460+
return errorResponse(
461+
'COMMAND_FAILED',
462+
formatAndroidStopFailure(stopError, recording, stopRequestedAt),
463+
);
457464
}
458465

459466
if (cleanupError) {
@@ -488,3 +495,11 @@ export async function stopAndroidRecording(params: {
488495
}
489496
}
490497
}
498+
499+
function formatAndroidStopFailure(
500+
error: string,
501+
recording: AndroidRecording,
502+
stopRequestedAt: number,
503+
): string {
504+
return buildRecordStopFailure(error, recording, stopRequestedAt).message;
505+
}

src/daemon/handlers/record-trace-recording.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
resizeRecording,
1717
trimRecordingStart,
1818
} from '../../recording/overlay.ts';
19-
import { formatRecordTraceError, formatRecordTraceExecFailure } from '../record-trace-errors.ts';
19+
import {
20+
buildRecordStopFailure,
21+
formatRecordTraceError,
22+
formatRecordTraceExecFailure,
23+
} from '../record-trace-errors.ts';
2024
import { resolveRecordingProvider } from '../recording-provider.ts';
2125
import { finalizeRecordingOverlay } from './record-trace-finalize.ts';
2226
import { errorResponse } from './response.ts';
@@ -304,10 +308,11 @@ async function stopNonRunnerRecording(params: {
304308
deps: RecordTraceDeps;
305309
device: SessionState['device'];
306310
recording: Extract<NonNullable<SessionState['recording']>, { platform: 'ios' | 'android' }>;
311+
stopRequestedAt: number;
307312
}): Promise<DaemonResponse | null> {
308-
const { deps, device, recording } = params;
313+
const { deps, device, recording, stopRequestedAt } = params;
309314
if (recording.platform === 'android') {
310-
return await stopAndroidRecording({ deps, device, recording });
315+
return await stopAndroidRecording({ deps, device, recording, stopRequestedAt });
311316
}
312317

313318
await withDiagnosticTimer('record_stop_tail_settle', () => deps.waitForRecordingTail(recording), {
@@ -322,15 +327,17 @@ async function stopNonRunnerRecording(params: {
322327
},
323328
);
324329
if (!stopResult) {
325-
return errorResponse(
326-
'COMMAND_FAILED',
330+
return buildIosSimulatorRecordingStopFailure(
327331
`failed to stop recording: simctl recordVideo did not exit after ${IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS}ms and forced cleanup`,
332+
recording,
333+
stopRequestedAt,
328334
);
329335
}
330336
if (stopResult.exitCode !== 0) {
331-
return errorResponse(
332-
'COMMAND_FAILED',
337+
return buildIosSimulatorRecordingStopFailure(
333338
`failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'simctl recordVideo')}`,
339+
recording,
340+
stopRequestedAt,
334341
);
335342
}
336343

@@ -353,9 +360,10 @@ async function stopNonRunnerRecording(params: {
353360
},
354361
);
355362
if (!playable) {
356-
return errorResponse(
357-
'COMMAND_FAILED',
363+
return buildIosSimulatorRecordingStopFailure(
358364
`failed to stop recording: ${recording.outPath} was not finalized into a playable video`,
365+
recording,
366+
stopRequestedAt,
359367
);
360368
}
361369

@@ -398,6 +406,24 @@ async function stopNonRunnerRecording(params: {
398406
return null;
399407
}
400408

409+
function buildIosSimulatorRecordingStopFailure(
410+
message: string,
411+
recording: Extract<NonNullable<SessionState['recording']>, { platform: 'ios' }>,
412+
stopRequestedAt: number,
413+
): DaemonResponse {
414+
const failure = buildRecordStopFailure(message, recording, stopRequestedAt);
415+
removeInvalidRecordingOutput(recording.outPath);
416+
return errorResponse('COMMAND_FAILED', failure.message);
417+
}
418+
419+
function removeInvalidRecordingOutput(outPath: string): void {
420+
try {
421+
fs.rmSync(outPath, { force: true });
422+
} catch {
423+
// Best effort: the error response still reports the failed finalization.
424+
}
425+
}
426+
401427
async function stopRecording(params: {
402428
req: DaemonRequest;
403429
activeSession: SessionState;
@@ -412,6 +438,7 @@ async function stopRecording(params: {
412438
}
413439

414440
const recording = activeSession.recording;
441+
const stopRequestedAt = Date.now();
415442
const invalidatedReason = recording.invalidatedReason;
416443
activeSession.recording = undefined;
417444

@@ -420,7 +447,7 @@ async function stopRecording(params: {
420447
? await stopIosDeviceRecording({ req, activeSession, device, logPath, deps, recording })
421448
: recording.platform === 'macos-runner'
422449
? await stopMacOsRecording({ req, activeSession, device, logPath, deps, recording })
423-
: await stopNonRunnerRecording({ deps, device, recording });
450+
: await stopNonRunnerRecording({ deps, device, recording, stopRequestedAt });
424451
if (stopError) {
425452
return stopError;
426453
}

src/daemon/record-trace-errors.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@ export function formatRecordTraceError(error: unknown): string {
22
return error instanceof Error ? error.message : String(error);
33
}
44

5+
const MIN_PLAYABLE_RECORDING_DURATION_MS = 1_000;
6+
7+
type RecordingStartedAt = {
8+
startedAt: number;
9+
};
10+
11+
type RecordStopFailure = {
12+
message: string;
13+
tooShort: boolean;
14+
};
15+
516
export function formatRecordTraceExecFailure(
617
result: { stdout: string; stderr: string; exitCode: number },
718
command: string,
@@ -10,3 +21,18 @@ export function formatRecordTraceExecFailure(
1021
result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.exitCode}`
1122
);
1223
}
24+
25+
export function buildRecordStopFailure(
26+
message: string,
27+
recording: RecordingStartedAt,
28+
now = Date.now(),
29+
): RecordStopFailure {
30+
const elapsedMs = Math.max(0, now - recording.startedAt);
31+
if (elapsedMs >= MIN_PLAYABLE_RECORDING_DURATION_MS) {
32+
return { message, tooShort: false };
33+
}
34+
return {
35+
message: `${message}. Recording stopped after ${Math.round(elapsedMs)}ms; wait at least ${MIN_PLAYABLE_RECORDING_DURATION_MS}ms between record start and record stop so the recorder can finalize a playable MP4`,
36+
tooShort: true,
37+
};
38+
}

0 commit comments

Comments
 (0)