Skip to content

Commit fd80c1e

Browse files
authored
fix(ios): recover simulator recorder startup failures (#2447)
Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
1 parent 0feb4e2 commit fd80c1e

5 files changed

Lines changed: 195 additions & 26 deletions

File tree

src/commands/recording/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export const recordCommandFacet = defineCommandFacet({
104104
text: {
105105
summary: 'Start or stop screen recording',
106106
cliDetail:
107-
'The default --scope app requires an active app session from open <app>; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. HarmonyOS supports whole-screen recording on physical devices only: use --scope device/system; --fps, --quality, and --hide-touches are unsupported. Use --quality to choose medium or high export quality on supported backends.',
107+
'The default --scope app requires an active app session from open <app>; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. HarmonyOS supports whole-screen recording on physical devices only: use --scope device/system; --fps, --quality, and --hide-touches are unsupported. Use --quality to choose medium or high export quality on supported backends. An iOS simulator host recording lock returns non-retriable DEVICE_IN_USE with reason apple_simulator_recording_busy. Stop the recording in its owning session; if a dead recorder left the host locked, ask the host operator to restart the CoreSimulator stream service.',
108108
},
109109
metadata: recordCommandMetadata,
110110
run: (client, input) => client.recording.record(input as RecordOptions),

src/platform-runtime-screen-recording-apple-simulator-host.test.ts

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
33
import { beforeEach, expect, test, vi } from 'vitest';
4+
import { normalizeError } from '@agent-device/kernel/errors';
45
import { mkdtempForTestSync } from './__tests__/test-utils/tmp-dir.ts';
56
import { createAppleScreenRecordingHost } from './platform-runtime-screen-recording-apple-host.ts';
67
import { startAppleSimulatorRecording } from './platform-runtime-screen-recording-apple-simulator-host.ts';
@@ -46,6 +47,89 @@ beforeEach(() => {
4647
processes.commands.clear();
4748
});
4849

50+
test.each([
51+
{ exitCode: 16, pid: 43, hasIdentity: true, code: 'DEVICE_IN_USE' },
52+
{ exitCode: 16, pid: 43, hasIdentity: false, code: 'DEVICE_IN_USE' },
53+
{ exitCode: 16, pid: undefined, hasIdentity: false, code: 'DEVICE_IN_USE' },
54+
{ exitCode: 1, pid: 43, hasIdentity: true, code: 'UNKNOWN' },
55+
{ exitCode: 1, pid: 43, hasIdentity: false, code: 'UNKNOWN' },
56+
{ exitCode: 1, pid: undefined, hasIdentity: false, code: 'UNKNOWN' },
57+
])(
58+
'classifies recorder exit $exitCode with pid=$pid and identity=$hasIdentity',
59+
async ({ exitCode, pid, hasIdentity, code }) => {
60+
const root = mkdtempForTestSync('agent-device-recording-busy-');
61+
const failed = background(pid);
62+
const stderr =
63+
'Error starting video recorder: Error Domain=NSPOSIXErrorDomain Code=16 "Resource busy"\nNSLocalizedFailureReason=Host recording is already in progress';
64+
failed.resolveWait({ stdout: '', stderr, exitCode });
65+
if (!hasIdentity) {
66+
processes.starts.clear();
67+
processes.commands.clear();
68+
}
69+
70+
const error = await withTransport(
71+
failed.process,
72+
async () =>
73+
await createAppleScreenRecordingHost().startSimulator(
74+
simulator,
75+
path.join(root, 'failed.mp4'),
76+
),
77+
).then(() => {
78+
throw new Error('unexpected recording start');
79+
}, normalizeError);
80+
81+
expect(error.code).toBe(code);
82+
if (exitCode === 16) {
83+
expect(error).toMatchObject({
84+
retriable: false,
85+
hint: expect.stringContaining('CoreSimulator'),
86+
details: { reason: 'apple_simulator_recording_busy', exitCode, stderr },
87+
});
88+
expect(error.hint).toContain('record stop');
89+
} else {
90+
expect(error.details?.reason).toBeUndefined();
91+
}
92+
},
93+
);
94+
95+
test('classifies a recorder exit that settles during the final identity poll', async () => {
96+
vi.useFakeTimers();
97+
const failed = background(43);
98+
const readStart = vi.spyOn(processes.starts, 'get');
99+
const identityPolls = 2_000 / 25 + 1;
100+
readStart.mockImplementation(() => {
101+
if (readStart.mock.calls.length === identityPolls) {
102+
failed.resolveWait({
103+
stdout: '',
104+
stderr: 'Host recording is already in progress',
105+
exitCode: 16,
106+
});
107+
}
108+
return undefined;
109+
});
110+
try {
111+
const root = mkdtempForTestSync('agent-device-recording-identity-deadline-');
112+
const starting = withTransport(
113+
failed.process,
114+
async () => await startAppleSimulatorRecording(simulator, path.join(root, 'failed.mp4')),
115+
).then(() => {
116+
throw new Error('unexpected recording start');
117+
}, normalizeError);
118+
119+
await vi.waitFor(() => expect(readStart).toHaveBeenCalled());
120+
await vi.runAllTimersAsync();
121+
expect(readStart).toHaveBeenCalledTimes(identityPolls);
122+
expect(await starting).toMatchObject({
123+
code: 'DEVICE_IN_USE',
124+
details: { reason: 'apple_simulator_recording_busy', exitCode: 16 },
125+
});
126+
expect(failed.kill).toHaveBeenCalledWith('SIGINT');
127+
} finally {
128+
readStart.mockRestore();
129+
vi.useRealTimers();
130+
}
131+
});
132+
49133
test('waits for delayed output and rejects an early nonzero exit', async () => {
50134
const root = mkdtempForTestSync('agent-device-recording-ready-');
51135
const outputPath = path.join(root, 'capture.mp4');
@@ -109,7 +193,7 @@ test('late provider acquisition after abort is rolled back exactly once', async
109193
await expect(starting).rejects.toBe(reason);
110194
resolveStart?.(late.process);
111195
await vi.waitFor(() => expect(late.kill).toHaveBeenCalledTimes(1));
112-
expect(late.kill).toHaveBeenCalledWith('SIGKILL');
196+
expect(late.kill).toHaveBeenCalledWith('SIGINT');
113197
});
114198

115199
test('resolved provider acquisition aborted before publication removes partial output and settles', async () => {
@@ -134,7 +218,7 @@ test('resolved provider acquisition aborted before publication removes partial o
134218

135219
await expect(starting).rejects.toBe(reason);
136220
expect(acquired.kill).toHaveBeenCalledTimes(1);
137-
expect(acquired.kill).toHaveBeenCalledWith('SIGKILL');
221+
expect(acquired.kill).toHaveBeenCalledWith('SIGINT');
138222
expect(fs.existsSync(outputPath)).toBe(false);
139223
});
140224

@@ -242,7 +326,30 @@ test('pidless provider process is killed and settled before start fails', async
242326
async () => await startAppleSimulatorRecording(simulator, '/tmp/pidless.mp4'),
243327
),
244328
).rejects.toThrow('complete process identity');
245-
expect(running.kill).toHaveBeenCalledWith('SIGKILL');
329+
expect(running.kill).toHaveBeenCalledWith('SIGINT');
330+
});
331+
332+
test('unpublished recorder cleanup gives SIGINT a grace window before forcing exit', async () => {
333+
vi.useFakeTimers();
334+
try {
335+
const running = background(undefined);
336+
running.kill.mockImplementationOnce(() => true);
337+
const starting = withTransport(
338+
running.process,
339+
async () => await startAppleSimulatorRecording(simulator, '/tmp/stalled-provider.mp4'),
340+
);
341+
const rejected = expect(starting).rejects.toThrow('complete process identity');
342+
343+
await vi.waitFor(() => expect(running.kill).toHaveBeenCalledWith('SIGINT'));
344+
await vi.advanceTimersByTimeAsync(4_000);
345+
expect(running.kill).toHaveBeenCalledTimes(1);
346+
await vi.advanceTimersByTimeAsync(1_000);
347+
await rejected;
348+
expect(running.kill.mock.calls).toEqual([['SIGINT'], ['SIGKILL']]);
349+
expect(vi.getTimerCount()).toBe(0);
350+
} finally {
351+
vi.useRealTimers();
352+
}
246353
});
247354

248355
function background(pid: number | undefined, command?: string) {

src/platform-runtime-screen-recording-apple-simulator-host.ts

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import fs from 'node:fs';
2+
import { execFailureDetails } from '@agent-device/host-kit/command';
3+
import { AppError } from '@agent-device/kernel/errors';
24
import type {
35
HostCommandResult,
46
ManagedProcessIdentity,
@@ -19,6 +21,7 @@ const LIVENESS_GRACE_MS = 50;
1921
const READY_TIMEOUT_MS = 15_000;
2022
const IDENTITY_POLL_MS = 25;
2123
const IDENTITY_TIMEOUT_MS = 2_000;
24+
const UNPUBLISHED_STOP_GRACE_MS = 5_000;
2225

2326
const appleSimulatorRecordingCommandMatches: ManagedProcessCommandMatcher = (
2427
persisted,
@@ -62,13 +65,12 @@ export async function startAppleSimulatorRecording(
6265
if (!background) throw new Error('simctl recordVideo acquisition did not return a process');
6366
let rootMarker: ManagedProcessIdentity | undefined;
6467
try {
65-
rootMarker = await waitForManagedProcessIdentity(background.child.pid, signal);
68+
rootMarker = await waitForManagedProcessIdentity(background.child.pid, background.wait, signal);
6669
if (!rootMarker) {
6770
throw new Error('simctl recordVideo did not expose a complete process identity');
6871
}
6972
} catch (error) {
70-
background.child.kill('SIGKILL');
71-
await background.wait.catch(() => undefined);
73+
await rollbackAcquiredSimulatorProcess(background);
7274
signal?.throwIfAborted();
7375
throw error;
7476
}
@@ -96,15 +98,22 @@ export async function startAppleSimulatorRecording(
9698

9799
async function waitForManagedProcessIdentity(
98100
pid: number | undefined,
101+
wait: Promise<HostCommandResult>,
99102
signal?: AbortSignal,
100103
): Promise<ManagedProcessIdentity | undefined> {
101-
if (pid === undefined) return undefined;
102-
const attempts = Math.ceil(IDENTITY_TIMEOUT_MS / IDENTITY_POLL_MS);
104+
const processExit = observeSimulatorExit(wait);
105+
const attempts = pid === undefined ? 0 : Math.ceil(IDENTITY_TIMEOUT_MS / IDENTITY_POLL_MS);
103106
for (let attempt = 0; attempt <= attempts; attempt += 1) {
104107
signal?.throwIfAborted();
105108
const marker = await resolveManagedProcessIdentity(pid);
106109
if (marker) return marker;
107-
if (attempt < attempts) await delay(IDENTITY_POLL_MS, signal);
110+
const exit = await Promise.race([
111+
processExit,
112+
attempt < attempts
113+
? delay(IDENTITY_POLL_MS, signal).then(() => undefined)
114+
: Promise.resolve(undefined),
115+
]);
116+
if (exit) throw startError(exit);
108117
}
109118
return undefined;
110119
}
@@ -116,7 +125,7 @@ async function acquireSimulatorProcess(
116125
const started = Promise.resolve(acquisition);
117126
if (!signal) return await started;
118127
if (signal.aborted) {
119-
if (isSimulatorProcess(acquisition)) {
128+
if ('child' in acquisition) {
120129
await rollbackAcquiredSimulatorProcess(acquisition);
121130
} else {
122131
void started.then(rollbackAcquiredSimulatorProcess);
@@ -141,17 +150,24 @@ async function acquireSimulatorProcess(
141150
}
142151
}
143152

144-
function isSimulatorProcess(
145-
value: AppleSimulatorScreenRecordingProcess | Promise<AppleSimulatorScreenRecordingProcess>,
146-
): value is AppleSimulatorScreenRecordingProcess {
147-
return 'child' in value;
148-
}
149-
150153
async function rollbackAcquiredSimulatorProcess(
151154
process: AppleSimulatorScreenRecordingProcess,
152155
): Promise<void> {
153-
process.child.kill('SIGKILL');
154-
await process.wait.catch(() => undefined);
156+
// CONSERVATIVE: Give simctl time to detach from CoreSimulator before forcing exit;
157+
// revisit only when the transport can explicitly acknowledge detachment.
158+
process.child.kill('SIGINT');
159+
const grace = new AbortController();
160+
const settled = await Promise.race([
161+
process.wait.then(
162+
() => true,
163+
() => true,
164+
),
165+
delay(UNPUBLISHED_STOP_GRACE_MS, grace.signal).then(() => false),
166+
]).finally(() => grace.abort());
167+
if (!settled) {
168+
process.child.kill('SIGKILL');
169+
await process.wait.catch(() => undefined);
170+
}
155171
}
156172

157173
function createAppleSimulatorProcess(
@@ -203,10 +219,7 @@ async function waitForReadiness(
203219
wait: Promise<HostCommandResult>,
204220
signal?: AbortSignal,
205221
): Promise<void> {
206-
const processExit = wait.then(
207-
(result) => ({ kind: 'exited' as const, result }),
208-
(error: unknown) => ({ kind: 'failed' as const, error }),
209-
);
222+
const processExit = observeSimulatorExit(wait);
210223
let settled: AppleSimulatorExit | undefined;
211224
void processExit.then((outcome) => {
212225
settled = outcome;
@@ -235,11 +248,33 @@ async function waitForReadiness(
235248
}
236249
}
237250

251+
function observeSimulatorExit(wait: Promise<HostCommandResult>): Promise<AppleSimulatorExit> {
252+
return wait.then(
253+
(result) => ({ kind: 'exited' as const, result }),
254+
(error: unknown) => ({ kind: 'failed' as const, error }),
255+
);
256+
}
257+
238258
function startError(outcome: AppleSimulatorExit): Error {
239259
if (outcome.kind === 'failed') {
240260
return outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error));
241261
}
242-
return new Error(`simctl recordVideo exited with code ${outcome.result.exitCode}`);
262+
const { exitCode } = outcome.result;
263+
if (exitCode === 16) {
264+
return new AppError(
265+
'DEVICE_IN_USE',
266+
'CoreSimulator host recording is already in progress',
267+
execFailureDetails(
268+
{ ...outcome.result, exitCode },
269+
{
270+
reason: 'apple_simulator_recording_busy',
271+
retriable: false,
272+
hint: 'Stop the active recording with record stop in its owning session. If a previous recorder died and no recording is active, ask the host operator to restart the CoreSimulator stream service before retrying.',
273+
},
274+
),
275+
);
276+
}
277+
return new Error(`simctl recordVideo exited with code ${exitCode}`);
243278
}
244279

245280
function delay(milliseconds: number, signal?: AbortSignal): Promise<void> {

test/integration/provider-scenarios/ios-record-trace.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import path from 'node:path';
44
import { test } from 'vitest';
5+
import { runCmdBackground } from '@agent-device/host-kit/command';
56
import type { AppleSimulatorScreenRecordingTransport } from '../../../src/platform-runtime-screen-recording-apple-transport.ts';
67
import {
78
assertFlatToolCallStartsWith,
@@ -60,7 +61,7 @@ test('generic scoped iOS physical runner recording fails closed without local fa
6061
);
6162
});
6263

63-
test('Provider-backed integration iOS simulator recording flow uses the focused Apple transport', async () => {
64+
test('iOS simulator recording reports host contention and recovers through the focused Apple transport', async () => {
6465
await withProviderScenarioTempDir(
6566
'agent-device-provider-scenario-ios-sim-record-',
6667
async (tmpDir) => {
@@ -83,6 +84,16 @@ test('Provider-backed integration iOS simulator recording flow uses the focused
8384
start: ({ device, outputPath }) => {
8485
assert.equal(device.id, PROVIDER_SCENARIO_IOS_SIMULATOR.id);
8586
recordingStarts.push(outputPath);
87+
if (recordingStarts.length === 1) {
88+
return runCmdBackground(
89+
process.execPath,
90+
[
91+
'-e',
92+
'process.stderr.write("Host recording is already in progress"); process.exit(16)',
93+
],
94+
{ allowFailure: true },
95+
);
96+
}
8697
return createProviderIosSimulatorRecordingProcess(outputPath, (signal) => {
8798
recordingSignals.push(signal);
8899
});
@@ -108,6 +119,21 @@ test('Provider-backed integration iOS simulator recording flow uses the focused
108119
assert.equal(open.statusCode, 200, JSON.stringify(open.json));
109120
assert.equal(open.json?.error, undefined, JSON.stringify(open.json));
110121

122+
const busyStart = await daemon.callCommand('record', ['start', recordingPath], {
123+
hideTouches: true,
124+
});
125+
assert.equal(busyStart.json?.error?.data?.code, 'DEVICE_IN_USE');
126+
assert.equal(busyStart.json?.error?.data?.retriable, false);
127+
assert.equal(
128+
busyStart.json?.error?.data?.details?.reason,
129+
'apple_simulator_recording_busy',
130+
);
131+
assert.equal(busyStart.json?.error?.data?.details?.exitCode, 16);
132+
assert.match(busyStart.json?.error?.data?.hint ?? '', /record stop/);
133+
assert.match(busyStart.json?.error?.data?.hint ?? '', /CoreSimulator/);
134+
assert.equal(daemon.session()?.screenRecording, undefined);
135+
assert.equal(fs.existsSync(recordingPath), false);
136+
111137
const recordStart = await daemon.callCommand(
112138
'record',
113139
['start', recordingPath],
@@ -143,7 +169,7 @@ test('Provider-backed integration iOS simulator recording flow uses the focused
143169
assert.equal(fs.existsSync(ownedProcessRecordPath), false);
144170

145171
runnerTranscript.assertComplete();
146-
assert.deepEqual(recordingStarts, [recordingPath]);
172+
assert.deepEqual(recordingStarts, [recordingPath, recordingPath]);
147173
assert.deepEqual(recordingSignals, ['SIGINT']);
148174
assertFlatToolCallStartsWith(appleTool.calls, [
149175
'simctl',

website/docs/docs/commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,7 @@ agent-device record stop # Stop active recording
955955
- In `--json` mode, each overlay ref also includes a screenshot-space `center` point for coordinate fallback like `press <x> <y>`.
956956
- Burned-in touch overlays are exported only on macOS hosts, because the overlay pipeline depends on Swift + AVFoundation helpers.
957957
- On Linux or other non-macOS hosts, `record stop` still succeeds and returns the raw video plus telemetry sidecar, and includes `overlayWarning` when burn-in overlays were skipped.
958+
- On iOS simulators, a busy CoreSimulator host recording slot makes `record start` return non-retriable `DEVICE_IN_USE` with `details.reason: apple_simulator_recording_busy`. Use `record stop` in the session that owns the active recording. If a previous recorder died and no recording is active, ask the host operator to restart the CoreSimulator stream service before retrying.
958959
- Android uses `adb shell screenrecord`, which has a 180s platform limit. `record start` publishes a durable device manifest. Longer recordings are split into MP4 chunks while the daemon stays alive; after daemon restart, `record stop` recovers only manifest-owned chunks and warns when gesture overlay telemetry was lost.
959960
960961
**Session app logs (token-efficient debugging):** Logging is off by default in normal flows. Enable it on demand for debugging. Logs are written to a file so agents can grep instead of loading full output into context.

0 commit comments

Comments
 (0)