Skip to content

Commit 294dc7d

Browse files
authored
fix(record): keep the daemon alive when a record request times out (#3199)
* fix(record): keep the daemon alive when a record request times out A record stop export routinely outlasts the 90s client envelope on long recordings. The default reset-daemon policy SIGKILLed the daemon mid-export, leaving the session's screen-recording manifest open with no owner. A record-only session holds no device claim, so nothing reconciled it, and every later record start on the device refused with cleanup-unconfirmed until that exact session ran record stop. record now preserves the daemon on timeout: the export finishes, a retried record stop serves it, and the next record start is admitted. The local timeout hint now names that retry, as the remote one already did. * fix(record): say a timed-out record stop may still be exporting A record stop that times out while still queued for the device lock is dropped at lock entry, so no export ran. The timeout hint now says the daemon may still be exporting and keeps the retry instruction. The docs no longer call the in-progress export typical only for a remote daemon, since a local daemon now survives the timeout too. * fix(record): keep the Apple runner alive when a record request times out The client's timeout cleanup pkills every Apple runner xcodebuild on the host. On a physical iOS device or macOS the runner is the recorder, so a timed-out record stop could kill the export the preserved daemon was still finishing. record now skips that sweep. The daemon still cancels its own runner work for a timed-out request through the request signal, and the exclusion is keyed on the command, never on the declared platform.
1 parent 84618d1 commit 294dc7d

6 files changed

Lines changed: 80 additions & 19 deletions

File tree

‎packages/command-registry/src/registry.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1234,7 +1234,10 @@ export const RAW_COMMAND_DESCRIPTORS = [
12341234
allowSessionlessDefaultDevice: isRecordingStartRequest,
12351235
},
12361236
platformExecution: { kind: 'device-runtime', uses: screenRecordingRuntimePlanUses },
1237-
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
1237+
// A `record stop` export can outlast the request envelope. Resetting the daemon mid-export
1238+
// left its recording manifest open with no owner, and every later `record start` on the
1239+
// device refused until that exact session ran `record stop`.
1240+
timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY,
12381241
batchable: true,
12391242
},
12401243
{

‎src/__tests__/command-descriptor-timeout-policy.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ test('daemon-preserving timeout commands are a bounded, reviewed set', () => {
7171
// sessions the daemon owns, so a client-side timeout must not SIGKILL the
7272
// daemon mid-create/mid-release and orphan them (and every other provider
7373
// session held).
74+
// record joined because a `record stop` export can outlast the envelope: a
75+
// reset mid-export left an ownerless open manifest that refused every later
76+
// `record start` on the device.
7477
const preserving = commandDescriptors
7578
.filter((descriptor) => descriptor.timeoutPolicy.onTimeout === 'preserve-daemon')
7679
.map((descriptor) => descriptor.name);
@@ -87,6 +90,7 @@ test('daemon-preserving timeout commands are a bounded, reviewed set', () => {
8790
'lease_release',
8891
'longpress',
8992
'press',
93+
'record',
9094
'scroll',
9195
'snapshot',
9296
'type',

‎src/daemon-client/__tests__/daemon-client-timeout-route.test.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
// (the "unknown-session" case) is the common route the original bug misled.
2020
// A design that skips the pkill sweep based on the declared flag alone would
2121
// skip real cleanup in the rebound case — the dangerous direction. This test
22-
// proves the sweep always fires for local timeouts, and that the HINT text
23-
// (not the cleanup) is what carries the platform-evidence gating.
22+
// proves the sweep fires for every local timeout except `record` (excluded by
23+
// command, never by platform), and that the HINT text (not the cleanup) is
24+
// what carries the platform-evidence gating.
2425

2526
import net from 'node:net';
2627
import http from 'node:http';
@@ -308,3 +309,41 @@ test('a refused timeout fallback preserves the timeout without an unhandled reje
308309
server.close();
309310
}
310311
});
312+
313+
test('a local record stop timeout leaves the exporting daemon and runner alive and names the retry', async () => {
314+
// A match would terminate the runner, which is the recorder on a physical iOS device or macOS.
315+
mockRunCmdSync.mockReturnValue({ exitCode: 0, stdout: '', stderr: '' });
316+
mockIsDaemon.mockReturnValue(true);
317+
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
318+
const { server, port } = await startHangingSocketServer();
319+
try {
320+
await assert.rejects(
321+
sendRequest(
322+
{ port, pid: 7, token: 'test-token', processStartTime: 'start' },
323+
{
324+
...buildRequest('ios'),
325+
session: 'e2e-ios-0',
326+
command: 'record',
327+
positionals: ['stop'],
328+
},
329+
'socket',
330+
dummyStatePaths(),
331+
TIMEOUT_MS,
332+
),
333+
(error: unknown) => {
334+
assert.ok(error instanceof AppError);
335+
assert.equal(error.details?.reason, 'daemon_transport_timeout');
336+
assert.match(
337+
error.details?.hint as string,
338+
/^The daemon may still be exporting the recording\. Run agent-device record stop --session e2e-ios-0 again/,
339+
);
340+
return true;
341+
},
342+
);
343+
} finally {
344+
server.close();
345+
}
346+
assert.equal(mockRunCmdSync.mock.calls.length, 0);
347+
assert.equal(kill.mock.calls.length, 0);
348+
assert.equal(mockStop.mock.calls.length, 0);
349+
});

‎src/daemon-client/__tests__/daemon-client-timeout.test.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ test('request timeout hint only names Apple runner cleanup on actual evidence',
9797
);
9898
});
9999

100-
test('a timed-out remote recording names the retry that returns the export', () => {
100+
test('a timed-out record stop on a surviving daemon names the retry that returns the export', () => {
101101
assert.equal(
102102
resolveRequestTimeoutHint({
103103
remote: true,
@@ -107,7 +107,7 @@ test('a timed-out remote recording names the retry that returns the export', ()
107107
action: 'stop',
108108
session: 'recording',
109109
}),
110-
'The remote daemon is still exporting the recording. Run agent-device record stop --session recording again to wait for that export and receive the completed recording.',
110+
'The remote daemon may still be exporting the recording. Run agent-device record stop --session recording again to wait for that export and receive the completed recording.',
111111
);
112112
assert.equal(
113113
resolveRequestTimeoutHint({
@@ -117,9 +117,21 @@ test('a timed-out remote recording names the retry that returns the export', ()
117117
appleCleanupEvidence: false,
118118
action: 'stop',
119119
}),
120-
'The remote daemon is still exporting the recording. Run agent-device record stop again to wait for that export and receive the completed recording.',
120+
'The remote daemon may still be exporting the recording. Run agent-device record stop again to wait for that export and receive the completed recording.',
121121
);
122-
// A local timeout resets the daemon mid-export, so no keep-exporting promise is made.
122+
// A local daemon preserved across the timeout may still be exporting too.
123+
assert.equal(
124+
resolveRequestTimeoutHint({
125+
remote: false,
126+
resetDaemon: false,
127+
command: 'record',
128+
appleCleanupEvidence: true,
129+
action: 'stop',
130+
session: 'recording',
131+
}),
132+
'The daemon may still be exporting the recording. Run agent-device record stop --session recording again to wait for that export and receive the completed recording.',
133+
);
134+
// A reset daemon is no longer exporting, so no keep-exporting promise is made.
123135
assert.equal(
124136
resolveRequestTimeoutHint({
125137
remote: false,

‎src/daemon-client/daemon-client-timeout.ts‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ export function handleRequestTimeout(
5656
): AppError {
5757
const { info, statePaths, remote, timeoutMs, requestId, command, platform, session, action } =
5858
params;
59-
// Cleanup eligibility stays UNCONDITIONAL for every local (non-remote)
60-
// timeout, on purpose: the request's declared --platform is not
59+
// Cleanup eligibility never depends on the declared platform, on purpose:
60+
// the request's declared --platform is not
6161
// authoritative for session-bound execution. An existing session's real
6262
// device platform can silently override a conflicting declared selector
6363
// (`applyStripLockPolicy` in request-lock-policy.ts, reached via
@@ -67,7 +67,10 @@ export function handleRequestTimeout(
6767
// Apple-process-name-specific, so sweeping them on a non-Apple host or
6868
// session matches nothing and costs a few no-op subprocess spawns, never
6969
// a wrong skip.
70-
const cleanup = remote ? { terminated: 0 } : cleanupTimedOutIosRunnerBuilds();
70+
// `record` is excluded by command, which is authoritative: on a physical iOS device or macOS the
71+
// runner is the recorder, and the sweep would kill the export the preserved daemon is finishing.
72+
const sweepRunnerBuilds = !remote && command !== PUBLIC_COMMANDS.record;
73+
const cleanup = sweepRunnerBuilds ? cleanupTimedOutIosRunnerBuilds() : { terminated: 0 };
7174
const resetDaemon = !remote && shouldResetDaemonAfterRequestTimeout(command);
7275
const daemonReset = resetDaemon
7376
? resetDaemonAfterTimeout(info, statePaths)
@@ -136,15 +139,15 @@ export function resolveRequestTimeoutHint(params: {
136139
session?: string;
137140
}): string {
138141
const { remote, resetDaemon, command, appleCleanupEvidence, session, action } = params;
142+
// A daemon that survives this client window may still be exporting a `record stop` that ran out
143+
// of time (a stop still queued for the device lock is dropped before any export starts), and a
144+
// finished file stays retrievable by asking again. A reset daemon makes no such promise.
145+
if (!resetDaemon && command === PUBLIC_COMMANDS.record && action === 'stop') {
146+
return `The ${remote ? 'remote ' : ''}daemon may still be exporting the recording. Run agent-device record stop${
147+
session ? ` --session ${session}` : ''
148+
} again to wait for that export and receive the completed recording.`;
149+
}
139150
if (remote) {
140-
// A remote daemon survives this client window, so a `record stop` that ran out of time is still
141-
// exporting there and its finished file stays retrievable by asking again. A local timeout
142-
// resets the daemon mid-export, where that promise would be false.
143-
if (command === PUBLIC_COMMANDS.record && action === 'stop') {
144-
return `The remote daemon is still exporting the recording. Run agent-device record stop${
145-
session ? ` --session ${session}` : ''
146-
} again to wait for that export and receive the completed recording.`;
147-
}
148151
return 'Retry with --debug and verify the remote daemon URL, auth token, and remote host logs.';
149152
}
150153
if (!resetDaemon) {

‎website/docs/docs/commands.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1093,7 +1093,7 @@ agent-device record stop # Stop active recording
10931093
- 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.
10941094
- Android `screenrecord` encodes a frame only when the screen changes, so a clip ends at the last frame the recorder encoded instead of at `record stop`: a window that ends on an unchanged screen yields a shorter video, while every on-screen change inside the window stays at its real offset in it. `record stop` reports `durationMs` as host wall clock from `record start` until the export finished, and when the video can be measured it also reports `capturedDurationMs` and warns with how much of the window that video covers.
10951095
- Limrun iOS and Android direct sessions record the whole simulator or emulator screen through the provider's server-side recorder, so every `--scope` captures the same frame and `--fps` and `--hide-touches` are refused with `INVALID_ARGS` before any device work. `record stop` asks the instance to stop once and then downloads the served MP4 to the output path; that download is bounded to end inside the request window, so a slow or dropped transfer ends typed, leaves no file behind, and is retried by the next `record stop` from the same URL while the instance lives. Nothing survives a daemon restart: the recording is `unreattachable` and the instance disposes the file when the lease is released.
1096-
- `record stop` is safe to repeat. When its request window ends while the daemon is still exporting — typical for a long touch-overlay burn-in on a remote daemon — the export keeps running there, and a second `record stop` in the same session returns that completed recording, including the caller-side output path, without starting another recording. A finished recording whose video file is already gone reports `no active recording`.
1096+
- `record stop` is safe to repeat. When its request window ends while the daemon is still exporting — typical for a long touch-overlay burn-in, on a local or remote daemon — the export keeps running there, and a second `record stop` in the same session returns that completed recording, including the caller-side output path, without starting another recording. A finished recording whose video file is already gone reports `no active recording`.
10971097
- A recording answers two independent questions, and `record stop` reports both: whether a playable export exists, and whether the recorder stopped. `recorder` is `confirmed` when the recorder exited or acknowledged a stop meant for this recording, and `lost` when the session holding it died — an Apple recording invalidated by a runner restart. `nativePathDisposition` says what became of the artifact path the recorder itself writes to: `retirable` while that file still sits there owed a removal, and `retired` once its removal was verified. Both are optional disclosures — the export is served either way, a replay of a recording stopped before they existed omits them, and so does a backend whose recorder writes the served file itself. The vocabulary is deliberately wider than today's answers: `recorder: unconfirmed` (a probe that could not be read, or no exit inside the stop budget), the identity-mismatch reasons under `lost`, and `nativePathDisposition: pending` are declared by [ADR 0024](https://github.com/callstack/agent-device/blob/main/docs/adr/0024-screen-recording-provable-signal.md) for the steps that gain those probes, and no stop reports them yet.
10981098
10991099
- `record contact-sheet <video.mp4> [--out <sheet.png>]` turns a recording you already exported into one PNG: the frames where the screen visibly changed, laid out in a grid and each labeled with its elapsed time (`HH:MM:SS.mmm`) on the clip timeline. It is how an agent reads a recording it cannot play. It reads the file you pass — no session, no device, no daemon — so it can rebuild an old take and the sheet can only describe screens that export contains. Any backend that exports MP4 works (Apple, Android, HarmonyOS, Limrun); a WebM recording from the web backend is refused with `details.reason: contact_sheet_container_unsupported`. The default output is `<recording>.contact-sheet.png`, and `--out` is refused when it points back at the recording itself, because a sheet is derived from a take and never replaces it. `--json` reports each cell's time and changed-pixel share alongside `sampledFrameCount`, `decodedFrameCount`, and `skippedSampleCount`.

0 commit comments

Comments
 (0)