Skip to content

Commit cd55a6a

Browse files
authored
fix: harden iOS simulator recording cleanup (#575)
1 parent 67aa89a commit cd55a6a

9 files changed

Lines changed: 427 additions & 110 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: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,109 @@ 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+
const recording = sessionStore.get(sessionName)?.recording;
626+
expect(recording?.platform).toBe('ios');
627+
if (recording?.platform === 'ios') {
628+
expect(recording.recorderPid).toBe(5151);
629+
}
630+
});
631+
632+
test('record stop prefers session-owned iOS recorder processes before path fallback', async () => {
633+
vi.useFakeTimers();
634+
const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true);
635+
const sessionStore = makeSessionStore();
636+
const sessionName = 'ios-sim-owned-recorder';
637+
const kill = vi.fn();
638+
const session = makeSession(sessionName, {
639+
platform: 'ios',
640+
id: 'sim-1',
641+
name: 'Simulator',
642+
kind: 'simulator',
643+
booted: true,
644+
});
645+
session.recording = {
646+
platform: 'ios',
647+
outPath: '/tmp/owned-recorder.mp4',
648+
startedAt: Date.now(),
649+
showTouches: true,
650+
gestureEvents: [],
651+
recorderPid: 1111,
652+
child: { kill, pid: 1111 },
653+
wait: new Promise(() => {}),
654+
};
655+
sessionStore.set(sessionName, session);
656+
mockRunCmd.mockImplementation(async (cmd, args) => {
657+
if (cmd === 'pgrep' && args[0] === '-P') {
658+
expect(args).toEqual(['-P', '1111']);
659+
return { stdout: '2222\n', stderr: '', exitCode: 0 };
660+
}
661+
if (cmd === 'pgrep' && args[0] === '-f') {
662+
throw new Error('path fallback should not run when owned recorder cleanup matches');
663+
}
664+
return { stdout: '', stderr: '', exitCode: 0 };
665+
});
666+
667+
try {
668+
const responsePromise = runRecordCommand({
669+
sessionStore,
670+
sessionName,
671+
positionals: ['stop'],
672+
});
673+
674+
await vi.advanceTimersByTimeAsync(12_000);
675+
const response = await responsePromise;
676+
677+
expect(response?.ok).toBe(false);
678+
expect((response as any).error?.message).toMatch(/did not exit/);
679+
expect(kill.mock.calls.map((call) => call[0])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']);
680+
expect(mockRunCmd.mock.calls.map((call) => call[1])).toEqual([
681+
['-P', '1111'],
682+
['-P', '1111'],
683+
['-P', '1111'],
684+
]);
685+
expect(processKill.mock.calls.map((call) => call[0])).toEqual([
686+
1111, 2222, 1111, 2222, 1111, 2222,
687+
]);
688+
expect(processKill.mock.calls.map((call) => call[1])).toEqual([
689+
'SIGINT',
690+
'SIGINT',
691+
'SIGTERM',
692+
'SIGTERM',
693+
'SIGKILL',
694+
'SIGKILL',
695+
]);
696+
} finally {
697+
processKill.mockRestore();
698+
}
699+
});
700+
701+
test('record stop falls back to path matching for stale iOS simulator recordVideo processes', async () => {
600702
vi.useFakeTimers();
601703
const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true);
602704
const sessionStore = makeSessionStore();
@@ -1229,7 +1331,7 @@ test('record stop force-kills Android screenrecord when SIGINT fails but process
12291331
).toBe(true);
12301332
});
12311333

1232-
test('record stop reports invalidated recording after cleanup', async () => {
1334+
test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => {
12331335
const sessionStore = makeSessionStore();
12341336
const sessionName = 'ios-invalidated-recording';
12351337
const session = makeSession(sessionName, {
@@ -1257,10 +1359,12 @@ test('record stop reports invalidated recording after cleanup', async () => {
12571359
positionals: ['stop'],
12581360
});
12591361

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');
1362+
expect(response?.ok).toBe(true);
1363+
if (response?.ok === true) {
1364+
expect(response.data?.outPath).toBe(path.resolve('./invalidated.mp4'));
1365+
expect(response.data?.overlayWarning).toBe(
1366+
'overlay unavailable: iOS runner session exited during recording',
1367+
);
12641368
}
12651369
expect(sessionStore.get(sessionName)?.recording).toBeUndefined();
12661370
});

0 commit comments

Comments
 (0)