Skip to content

Commit f5da6d1

Browse files
committed
fix: harden iOS simulator recording cleanup
1 parent dfd5c71 commit f5da6d1

8 files changed

Lines changed: 312 additions & 14 deletions

src/daemon/__tests__/recording-provider.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts';
44

55
const { runCmdBackgroundMock } = vi.hoisted(() => ({
66
runCmdBackgroundMock: vi.fn(() => ({
7-
child: { kill: () => true },
7+
child: { kill: () => true, pid: 1234 },
88
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
99
})),
1010
}));
@@ -31,6 +31,7 @@ test('local recording provider starts iOS simulator recordVideo through simctl',
3131
});
3232

3333
assert.equal(result.child.kill('SIGINT'), true);
34+
assert.equal(result.child.pid, 1234);
3435
assert.deepEqual(mockRunCmdBackground.mock.calls, [
3536
[
3637
'xcrun',

src/daemon/__tests__/request-recording-health.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ test('raw iOS simulator recordings do not depend on runner health', () => {
5353
expect(session.recording?.invalidatedReason).toBeUndefined();
5454
});
5555

56-
test('touch-overlay iOS simulator recordings are invalidated by runner restarts', () => {
56+
test('touch-overlay iOS simulator recordings tolerate runner restarts', () => {
5757
const session = makeIosSimulatorSession(true);
5858
mockGetRunnerSessionSnapshot.mockReturnValue({
5959
alive: true,
@@ -62,6 +62,30 @@ test('touch-overlay iOS simulator recordings are invalidated by runner restarts'
6262

6363
refreshRecordingHealth(session);
6464

65+
expect(mockGetRunnerSessionSnapshot).not.toHaveBeenCalled();
66+
expect(session.recording?.runnerSessionId).toBe('runner-before');
67+
expect(session.recording?.invalidatedReason).toBeUndefined();
68+
});
69+
70+
test('runner-backed iOS recordings still invalidate on runner restarts', () => {
71+
const session = makeIosSimulatorSession(true);
72+
session.device.kind = 'device';
73+
session.recording = {
74+
platform: 'ios-device-runner',
75+
outPath: '/tmp/demo.mp4',
76+
remotePath: '/tmp/demo.mp4',
77+
startedAt: Date.now() - 1_000,
78+
showTouches: true,
79+
gestureEvents: [],
80+
runnerSessionId: 'runner-before',
81+
};
82+
mockGetRunnerSessionSnapshot.mockReturnValue({
83+
alive: true,
84+
sessionId: 'runner-after',
85+
});
86+
87+
refreshRecordingHealth(session);
88+
6589
expect(mockGetRunnerSessionSnapshot).toHaveBeenCalledWith('sim-1');
6690
expect(session.recording?.invalidatedReason).toBe(
6791
'iOS runner session restarted during recording',

src/daemon/__tests__/request-router-recording-health.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,24 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
77
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
88
});
99

10+
vi.mock('../../platforms/ios/runner-client.ts', () => ({
11+
getRunnerSessionSnapshot: vi.fn(),
12+
}));
13+
1014
import { dispatchCommand } from '../../core/dispatch.ts';
15+
import { getRunnerSessionSnapshot } from '../../platforms/ios/runner-client.ts';
1116
import { createRequestHandler } from '../request-router.ts';
1217
import type { SessionState } from '../types.ts';
1318
import { LeaseRegistry } from '../lease-registry.ts';
1419
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
1520

1621
const mockDispatch = vi.mocked(dispatchCommand);
22+
const mockGetRunnerSessionSnapshot = vi.mocked(getRunnerSessionSnapshot);
1723

1824
beforeEach(() => {
1925
mockDispatch.mockReset();
2026
mockDispatch.mockResolvedValue({});
27+
mockGetRunnerSessionSnapshot.mockReset();
2128
});
2229

2330
test('router blocks non-record commands when recording was invalidated', async () => {
@@ -72,3 +79,67 @@ test('router blocks non-record commands when recording was invalidated', async (
7279
expect(response.error.message).toBe('iOS runner session restarted during recording');
7380
expect(mockDispatch).not.toHaveBeenCalled();
7481
});
82+
83+
test('router allows iOS simulator gestures during overlay recording after runner restart', async () => {
84+
const sessionStore = makeSessionStore('agent-device-router-recording-health-');
85+
const session: SessionState = {
86+
name: 'default',
87+
createdAt: Date.now(),
88+
actions: [],
89+
appBundleId: 'com.apple.Preferences',
90+
device: {
91+
platform: 'ios',
92+
target: 'mobile',
93+
id: 'sim-1',
94+
name: 'iPhone 17 Pro',
95+
kind: 'simulator',
96+
booted: true,
97+
},
98+
recording: {
99+
platform: 'ios',
100+
outPath: '/tmp/demo.mp4',
101+
startedAt: Date.now() - 1_000,
102+
showTouches: true,
103+
gestureEvents: [],
104+
runnerSessionId: 'runner-before',
105+
child: { kill: () => {} } as any,
106+
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
107+
},
108+
};
109+
sessionStore.set('default', session);
110+
mockGetRunnerSessionSnapshot.mockReturnValue({
111+
alive: true,
112+
sessionId: 'runner-after',
113+
});
114+
mockDispatch.mockResolvedValue({
115+
action: 'pinch',
116+
scale: 1.2,
117+
x: 100,
118+
y: 200,
119+
durationMs: 280,
120+
});
121+
122+
const handler = createRequestHandler({
123+
logPath: path.join(os.tmpdir(), 'daemon.log'),
124+
token: 'test-token',
125+
sessionStore,
126+
leaseRegistry: new LeaseRegistry(),
127+
trackDownloadableArtifact: () => 'artifact-id',
128+
});
129+
130+
const response = await handler({
131+
token: 'test-token',
132+
session: 'default',
133+
command: 'pinch',
134+
positionals: ['1.2', '100', '200'],
135+
meta: { requestId: 'req-simulator-runner-restart' },
136+
});
137+
138+
expect(response.ok).toBe(true);
139+
expect(mockGetRunnerSessionSnapshot).not.toHaveBeenCalled();
140+
expect(mockDispatch).toHaveBeenCalled();
141+
const recording = sessionStore.get('default')?.recording;
142+
expect(recording?.invalidatedReason).toBeUndefined();
143+
expect(recording?.gestureEvents).toHaveLength(1);
144+
expect(recording?.gestureEvents[0]?.kind).toBe('pinch');
145+
});

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

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,105 @@ test('record stop leaves a short visual tail after iOS simulator gestures', asyn
596596
expect(kill).toHaveBeenCalledWith('SIGINT');
597597
});
598598

599-
test('record stop escalates stale iOS simulator recordVideo processes', async () => {
599+
test('record start stores iOS simulator recorder pid for scoped cleanup', async () => {
600+
const sessionStore = makeSessionStore();
601+
const sessionName = 'ios-sim-recorder-pid';
602+
sessionStore.set(
603+
sessionName,
604+
makeSession(sessionName, {
605+
platform: 'ios',
606+
id: 'sim-1',
607+
name: 'Simulator',
608+
kind: 'simulator',
609+
booted: true,
610+
}),
611+
);
612+
mockRunCmdBackground.mockImplementation(() => ({
613+
child: { kill: () => {}, pid: 5151 } as any,
614+
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
615+
}));
616+
617+
const response = await runRecordCommand({
618+
sessionStore,
619+
sessionName,
620+
positionals: ['start', './sim-recorder-pid.mp4'],
621+
flags: { hideTouches: true },
622+
});
623+
624+
expect(response?.ok).toBe(true);
625+
expect(sessionStore.get(sessionName)?.recording?.recorderPid).toBe(5151);
626+
});
627+
628+
test('record stop prefers session-owned iOS recorder processes before path fallback', async () => {
629+
vi.useFakeTimers();
630+
const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true);
631+
const sessionStore = makeSessionStore();
632+
const sessionName = 'ios-sim-owned-recorder';
633+
const kill = vi.fn();
634+
const session = makeSession(sessionName, {
635+
platform: 'ios',
636+
id: 'sim-1',
637+
name: 'Simulator',
638+
kind: 'simulator',
639+
booted: true,
640+
});
641+
session.recording = {
642+
platform: 'ios',
643+
outPath: '/tmp/owned-recorder.mp4',
644+
startedAt: Date.now(),
645+
showTouches: true,
646+
gestureEvents: [],
647+
recorderPid: 1111,
648+
child: { kill, pid: 1111 },
649+
wait: new Promise(() => {}),
650+
};
651+
sessionStore.set(sessionName, session);
652+
mockRunCmd.mockImplementation(async (cmd, args) => {
653+
if (cmd === 'pgrep' && args[0] === '-P') {
654+
expect(args).toEqual(['-P', '1111']);
655+
return { stdout: '2222\n', stderr: '', exitCode: 0 };
656+
}
657+
if (cmd === 'pgrep' && args[0] === '-f') {
658+
return { stdout: '3333\n', stderr: '', exitCode: 0 };
659+
}
660+
return { stdout: '', stderr: '', exitCode: 0 };
661+
});
662+
663+
try {
664+
const responsePromise = runRecordCommand({
665+
sessionStore,
666+
sessionName,
667+
positionals: ['stop'],
668+
});
669+
670+
await vi.advanceTimersByTimeAsync(12_000);
671+
const response = await responsePromise;
672+
673+
expect(response?.ok).toBe(false);
674+
expect((response as any).error?.message).toMatch(/did not exit/);
675+
expect(kill.mock.calls.map((call) => call[0])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']);
676+
expect(mockRunCmd.mock.calls.map((call) => call[1])).toEqual([
677+
['-P', '1111'],
678+
['-P', '1111'],
679+
['-P', '1111'],
680+
]);
681+
expect(processKill.mock.calls.map((call) => call[0])).toEqual([
682+
1111, 2222, 1111, 2222, 1111, 2222,
683+
]);
684+
expect(processKill.mock.calls.map((call) => call[1])).toEqual([
685+
'SIGINT',
686+
'SIGINT',
687+
'SIGTERM',
688+
'SIGTERM',
689+
'SIGKILL',
690+
'SIGKILL',
691+
]);
692+
} finally {
693+
processKill.mockRestore();
694+
}
695+
});
696+
697+
test('record stop falls back to path matching for stale iOS simulator recordVideo processes', async () => {
600698
vi.useFakeTimers();
601699
const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true);
602700
const sessionStore = makeSessionStore();
@@ -1229,7 +1327,7 @@ test('record stop force-kills Android screenrecord when SIGINT fails but process
12291327
).toBe(true);
12301328
});
12311329

1232-
test('record stop reports invalidated recording after cleanup', async () => {
1330+
test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => {
12331331
const sessionStore = makeSessionStore();
12341332
const sessionName = 'ios-invalidated-recording';
12351333
const session = makeSession(sessionName, {
@@ -1257,10 +1355,12 @@ test('record stop reports invalidated recording after cleanup', async () => {
12571355
positionals: ['stop'],
12581356
});
12591357

1260-
expect(response?.ok).toBe(false);
1261-
if (response?.ok === false) {
1262-
expect(response.error.code).toBe('COMMAND_FAILED');
1263-
expect(response.error.message).toBe('iOS runner session exited during recording');
1358+
expect(response?.ok).toBe(true);
1359+
if (response?.ok === true) {
1360+
expect(response.data?.outPath).toBe(path.resolve('./invalidated.mp4'));
1361+
expect(response.data?.overlayWarning).toBe(
1362+
'overlay unavailable: iOS runner session exited during recording',
1363+
);
12641364
}
12651365
expect(sessionStore.get(sessionName)?.recording).toBeUndefined();
12661366
});

0 commit comments

Comments
 (0)