@crouton-kit/capture 1.4.1 → 1.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +178 -0
  2. package/bin/capture +31299 -7322
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +230 -28
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +18 -8
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +11 -6
  44. package/src/cdp/commands/page/shot.ts +68 -33
  45. package/src/cdp/commands/page/type.ts +7 -3
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/leaf-grammar.ts +2 -2
  80. package/src/cdp/measure/check.ts +181 -16
  81. package/src/cdp/measure/collectors/hittest.ts +16 -10
  82. package/src/cdp/measure/collectors/scroll.ts +15 -7
  83. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  84. package/src/cdp/measure/collectors/styles.ts +4 -4
  85. package/src/cdp/measure/explain.ts +14 -2
  86. package/src/cdp/measure/map-layers.ts +52 -8
  87. package/src/cdp/measure/snapshot.ts +5 -3
  88. package/src/cdp/measure/sweep.ts +29 -6
  89. package/src/cdp/measure/text.ts +227 -0
  90. package/src/cdp/motion/jank.ts +29 -83
  91. package/src/cdp/motion/mask.ts +18 -9
  92. package/src/cdp/motion/recorder.ts +86 -731
  93. package/src/cdp/scope-lock.ts +9 -9
  94. package/src/cdp/screenshot.ts +51 -21
  95. package/src/cdp/timing.ts +5 -5
  96. package/src/cdp/trace-analysis.ts +320 -0
  97. package/src/cdp/types.ts +31 -1
  98. package/src/cdp.ts +4 -6
  99. package/src/har-manager.ts +2 -4
  100. package/src/interact.ts +78 -25
  101. package/src/output/artifact.ts +49 -5
  102. package/src/output/render.ts +60 -9
  103. package/src/output/selection.ts +6 -0
  104. package/src/session/artifacts.ts +122 -12
  105. package/src/session/commands.ts +348 -113
  106. package/src/session/coordinator.ts +0 -93
  107. package/src/session/har-redact.ts +29 -23
  108. package/src/session-context.ts +13 -9
  109. package/src/cdp/bridge/protocol.ts +0 -224
  110. package/src/cdp/recorder-client.ts +0 -326
  111. package/src/cdp/tab-lock.ts +0 -132
@@ -1,160 +1,16 @@
1
- /**
2
- * Recorder LIFECYCLE orchestration for the composed `motion rec --start` /
3
- * `--stop` form — spawns/finalizes the recorder-mode held bridge
4
- * (`../recorder-bridge.ts` via `../bridge/spawn.ts`), persists the
5
- * live-recorder handle (`recorder.json`), and writes the finalized
6
- * `markers.json`/`meta.json`. `../commands/motion/rec.ts` is the only
7
- * caller for the command path; `../../session/commands.ts` calls
8
- * `teardownAnyLiveRecorderAtSessionStop` from `session stop`.
9
- *
10
- * This module does NOT drive the browser directly and does NOT talk CDP —
11
- * all of that lives in `../recorder-bridge.ts` (U13, already built/frozen);
12
- * this module only spawns/addresses that process and shapes its
13
- * request/response pairs into the artifact files the design calls for.
14
- *
15
- * One-shot (`rec --do`/`rec <url>`) recording is a separate, simpler path
16
- * (an in-process `RecorderSession`, no detached bridge) that a later unit
17
- * (U24) adds directly to `rec.ts` — out of scope here.
18
- */
19
-
20
- import * as fs from 'fs';
21
- import * as path from 'path';
22
- import * as crypto from 'crypto';
23
- import {
24
- ensurePrivateDir,
25
- writeJsonPrivate,
26
- readPrivateFile,
27
- unlinkPrivateFile,
28
- removeArtifactTree,
29
- processPidBirthProvider,
30
- type PidBirth,
31
- type PidBirthProvider,
32
- type RecMeta,
33
- } from '../../session/artifacts.js';
34
- import {
35
- withSessionLifecycle,
36
- scanRecorderHandles,
37
- recorderProcessAlive,
38
- admitSessionOperation,
39
- RECORDER_NONCE,
40
- type LifecycleSeams,
41
- type ScannedRecorderHandle,
42
- type RecorderHandleRecord,
43
- } from '../../session/coordinator.js';
1
+ import * as crypto from 'node:crypto';
2
+ import * as path from 'node:path';
3
+ import { ensurePrivateDir, readPrivateFile } from '../../session/artifacts.js';
44
4
  import { captureError } from '../../errors.js';
45
- import { startRecorderBridge, recorderSocketPath, stopBridge } from '../bridge/spawn.js';
46
- import { requestRecStart, requestRecStop, RecStopBridgeFailure } from '../recorder-client.js';
47
- import { RECORDER_NONCE_BOOT_FILE } from '../recorder-bridge.js';
48
- import { detectCdpPort } from '../detect.js';
49
- import { findTabByIdAcrossEndpoints } from '../targets.js';
50
- import { CDPClient } from '../client.js';
51
- import { getActiveRecId, setActiveRecId, clearActiveRecId, getActiveSession, updateSessionState, type ActiveSessionState } from '../../session-context.js';
52
-
53
- // ---------------------------------------------------------------------------
54
- // recorder.json — the live-recorder handle. NOT part of the finalized
55
- // recording inventory (removed on finalize/reap); this module is the only
56
- // reader/writer of it.
57
- // ---------------------------------------------------------------------------
5
+ import { getActiveRecId, getActiveSession, setActiveRecId, clearActiveRecId, updateSessionState } from '../../session-context.js';
6
+ import { admitSessionOperation, withSessionLifecycle } from '../../session/coordinator.js';
7
+ import { collectorHostSocketPath, startCollectorHost } from '../bridge/spawn.js';
8
+ import { sendHostRequest } from '../host/client.js';
9
+ import { scanCollectorHost, type CollectorHostHandle } from '../host/handle.js';
10
+ import { stopAndReapCollectorHostAtSessionStop } from '../host/lifecycle.js';
58
11
 
59
12
  export type RecorderLiveState = 'recording' | 'finalized' | 'orphaned-finalized' | 'partial';
60
-
61
- /** Canonical persisted recorder-handle shape — owned by `session/coordinator.ts`
62
- * (one authority for the shape and its liveness classification); aliased here
63
- * for the motion callers and tests. `birth` is REQUIRED: recorder liveness is
64
- * a pid-birth-identity match, never a bare numeric-pid check. */
65
- export type RecorderJson = RecorderHandleRecord;
66
-
67
- export function recDirFor(sessionDir: string, recId: string): string {
68
- return path.join(sessionDir, 'motion', 'recs', recId);
69
- }
70
-
71
- function recsRootFor(sessionDir: string): string {
72
- return path.join(sessionDir, 'motion', 'recs');
73
- }
74
-
75
- const SAFE_REC_ID = /^[A-Za-z0-9_-]+$/;
76
-
77
- /** Validates an explicit (CLI `--rec-id`) recId as a safe path basename and
78
- * asserts the directory it resolves to stays under this session's
79
- * `motion/recs` root — defense against a recId containing `..` or an
80
- * absolute-path escape. Throws a factual error; no lenient sanitizing. Only
81
- * ever called on the external/explicit `opts.recId` — internally-generated
82
- * ids (`crypto.randomBytes`, or `readdirSync` entries already confined to
83
- * `recsRoot`) never need it. */
84
- function validateRecId(sessionDir: string, recId: string): string {
85
- if (!SAFE_REC_ID.test(recId)) {
86
- throw new Error(`Invalid --rec-id "${recId}": must be a plain basename (letters, digits, "-", "_" only).`);
87
- }
88
- const recsRoot = path.resolve(recsRootFor(sessionDir));
89
- const resolved = path.resolve(recDirFor(sessionDir, recId));
90
- if (resolved !== recsRoot && !resolved.startsWith(recsRoot + path.sep)) {
91
- throw new Error(`Invalid --rec-id "${recId}": resolves outside the session's recording directory.`);
92
- }
93
- return recId;
94
- }
95
-
96
- /** The active-session pointer (`session-context.ts`, scoped per-caller by
97
- * `CRTR_NODE_ID`) is a SINGLE global pointer, not one per session id — so
98
- * before trusting/clearing its `activeRecId`, every reap/teardown path here
99
- * must first confirm that pointer actually names THIS session's dir, not
100
- * some other concurrently-active session. Returns `null` if it doesn't. */
101
- function activeSessionMatching(sessionDir: string): ActiveSessionState | null {
102
- const active = getActiveSession();
103
- return active && active.dir === sessionDir ? active : null;
104
- }
105
-
106
- function recorderJsonPath(recDir: string): string {
107
- return path.join(recDir, 'recorder.json');
108
- }
109
-
110
- function markersPath(recDir: string): string {
111
- return path.join(recDir, 'markers.json');
112
- }
113
-
114
- function metaPath(recDir: string): string {
115
- return path.join(recDir, 'meta.json');
116
- }
117
-
118
- /** Reads `recorder.json`, or `null` if absent/unparseable (already reaped, or never existed). */
119
- export function readRecorderJson(recDir: string): RecorderJson | null {
120
- try {
121
- return JSON.parse(fs.readFileSync(recorderJsonPath(recDir), 'utf-8')) as RecorderJson;
122
- } catch {
123
- return null;
124
- }
125
- }
126
-
127
- /** Best-effort session url, read directly from `.session.json` rather than
128
- * threading `session/commands.ts`'s internal `Session` type through here —
129
- * keeps this module decoupled from that file (which itself calls INTO this
130
- * module at session-stop time; an import back the other way would cycle).
131
- * This remains verbatim because session URLs are browser evidence. */
132
- function readSessionUrl(sessionDir: string): string | null {
133
- try {
134
- const meta = JSON.parse(fs.readFileSync(path.join(sessionDir, '.session.json'), 'utf-8')) as { url?: string | null };
135
- const url = meta.url ?? null;
136
- return url === null ? null : url;
137
- } catch {
138
- return null;
139
- }
140
- }
141
-
142
- function round1(n: number): number {
143
- return Math.round(n * 10) / 10;
144
- }
145
-
146
- function countFrames(recDir: string): number {
147
- const framesDir = path.join(recDir, 'frames');
148
- if (!fs.existsSync(framesDir)) return 0;
149
- return fs.readdirSync(framesDir).filter((f) => f.endsWith('.png')).length;
150
- }
151
-
152
- // ---------------------------------------------------------------------------
153
- // Finalize — shared by a graceful `rec-stop`, an orphaned (dead-pid) reap,
154
- // and session-stop teardown. Writes markers.json + meta.json, removes
155
- // recorder.json, clears activeRecId. Never re-derives baselines itself.
156
- // ---------------------------------------------------------------------------
157
-
13
+ export interface RecordingViewport { width: number; height: number; }
158
14
  export interface FinalizedRecording {
159
15
  recId: string;
160
16
  recDir: string;
@@ -162,603 +18,102 @@ export interface FinalizedRecording {
162
18
  durationMs: number;
163
19
  fps: number;
164
20
  state: RecorderLiveState;
165
- /** Whether a viewport override owned by this recording was restored. */
166
21
  viewportRestored: boolean | null;
167
- /** Total events.jsonl record count observed at graceful `rec-stop` time;
168
- * `null` when the recorder was orphaned/best-effort finalized (no live socket to ask). */
22
+ viewportRetained: boolean;
169
23
  eventCount: number | null;
170
24
  }
171
-
172
- /** Clears the (single, global, per-caller-scoped) `activeRecId` pointer
173
- * ONLY if it currently names this exact `(sessionDir, recId)` — i.e. only
174
- * when the pointer is actually OURS to clear. A teardown/reap running
175
- * against session A must never blow away session B's pointer just because
176
- * B happens to be the currently-active one for this caller scope. */
177
- async function clearActiveRecIdIfOwned(sessionDir: string, recId: string): Promise<void> {
178
- const active = getActiveSession();
179
- if (active && active.dir === sessionDir && active.activeRecId === recId) {
180
- await clearActiveRecId();
181
- }
182
- }
183
-
184
- const VIEWPORT_STATE_FILE = 'viewport-override.json';
185
-
186
- let viewportDeps = {
187
- findTarget: findTabByIdAcrossEndpoints,
188
- createClient: (url: string) => new CDPClient(url),
189
- };
190
-
191
- /** Focused lifecycle tests inject the target/CDP boundary; production always
192
- * uses the normal target resolver and client. */
193
- export function __setViewportLifecycleDepsForTest(overrides: Partial<typeof viewportDeps>): () => void {
194
- const previous = viewportDeps;
195
- viewportDeps = { ...viewportDeps, ...overrides };
196
- return () => { viewportDeps = previous; };
197
- }
198
-
199
- /** Persist the lifecycle-owned restoration obligation only after the recorder
200
- * is live, so every finalization route (stop, reap, session-stop) sees it. */
201
- export function recordViewportOverride(recDir: string): void {
202
- writeJsonPrivate(path.join(recDir, VIEWPORT_STATE_FILE), { phase: 'applied' });
203
- }
204
-
205
- export interface RecordingViewport {
206
- width: number;
207
- height: number;
208
- }
209
-
210
- async function applyViewportOverride(recDir: string, targetId: string, viewport: RecordingViewport | undefined): Promise<boolean> {
211
- if (!viewport) return false;
212
- const tab = await viewportDeps.findTarget(targetId);
213
- if (!tab?.tab.webSocketDebuggerUrl) throw new Error('recording target is unavailable');
214
- const client = viewportDeps.createClient(tab.tab.webSocketDebuggerUrl);
215
- try {
216
- await client.waitReady();
217
- // From this write onward the set request may reach Chrome even if its
218
- // response is lost, so teardown owns a compensating clear.
219
- writeJsonPrivate(path.join(recDir, VIEWPORT_STATE_FILE), { phase: 'attempting', targetId });
220
- await client.send('Emulation.setDeviceMetricsOverride', {
221
- width: viewport.width,
222
- height: viewport.height,
223
- deviceScaleFactor: 1,
224
- mobile: false,
225
- });
226
- writeJsonPrivate(path.join(recDir, VIEWPORT_STATE_FILE), { phase: 'applied', targetId });
227
- return true;
228
- } finally {
229
- client.close();
230
- }
231
- }
232
-
233
- interface ViewportOverrideState {
234
- phase?: unknown;
235
- applied?: unknown;
236
- targetId?: unknown;
237
- }
238
-
239
- function readViewportOverrideState(statePath: string): ViewportOverrideState | null {
240
- try {
241
- return JSON.parse(fs.readFileSync(statePath, 'utf8')) as ViewportOverrideState;
242
- } catch {
243
- return null;
244
- }
25
+ export interface StartRecorderResult { recId: string; recDir: string; state: 'recording'; reapedStale: FinalizedRecording | null; }
26
+ export class StartRecorderError extends Error {
27
+ constructor(message: string, readonly viewportRestored: boolean | null = null) { super(message); this.name = 'StartRecorderError'; }
245
28
  }
246
29
 
247
- async function restoreViewportOverride(recDir: string, targetId: string): Promise<boolean | null> {
248
- const statePath = path.join(recDir, VIEWPORT_STATE_FILE);
249
- if (!fs.existsSync(statePath)) return null;
250
- const state = readViewportOverrideState(statePath);
251
- // Only an in-flight or acknowledged set can have mutated Chrome. A
252
- // prepared obligation is discarded rather than clearing another owner.
253
- const mayHaveApplied = state?.phase === 'attempting' || state?.phase === 'applied' || state?.applied === true;
254
- if (!mayHaveApplied) {
255
- removeArtifactTree(statePath);
256
- return null;
257
- }
258
- try {
259
- const tab = await viewportDeps.findTarget(targetId);
260
- if (!tab?.tab.webSocketDebuggerUrl) throw new Error('recording target is unavailable');
261
- const client = viewportDeps.createClient(tab.tab.webSocketDebuggerUrl);
262
- try {
263
- await client.waitReady();
264
- await client.send('Emulation.clearDeviceMetricsOverride');
265
- } finally {
266
- client.close();
267
- }
268
- removeArtifactTree(statePath);
269
- return true;
270
- } catch {
271
- // Preserve the state file as the factual failed-restoration evidence.
272
- return false;
273
- }
274
- }
30
+ export function recDirFor(sessionDir: string, recId: string): string { return path.join(sessionDir, 'motion', 'recs', recId); }
275
31
 
276
- export class StartRecorderError extends Error {
277
- constructor(message: string, readonly viewportRestored: boolean | null) {
278
- super(message);
279
- this.name = 'StartRecorderError';
280
- }
32
+ function motionHandle(handle: CollectorHostHandle, recId: string) {
33
+ return handle.collectors.find(collector => collector.id === recId && collector.kind === 'motion') ?? null;
281
34
  }
282
35
 
283
- async function writeFinalizedArtifacts(
284
- sessionDir: string,
285
- recDir: string,
286
- recId: string,
287
- url: string | null,
288
- frames: number,
289
- durationMs: number,
290
- state: RecorderLiveState,
291
- markers: unknown,
292
- eventCount: number | null,
293
- targetId: string,
294
- ): Promise<FinalizedRecording> {
295
- const fps = durationMs > 0 ? round1(frames / (durationMs / 1000)) : 0;
296
- const viewportRestored = await restoreViewportOverride(recDir, targetId);
297
- writeJsonPrivate(markersPath(recDir), markers);
298
- const meta: RecMeta & { url: string | null; fps: number; eventCount: number | null; viewportRestored: boolean | null } = {
299
- id: recId,
300
- action: null,
36
+ function finalFromMeta(recDir: string, recId: string): FinalizedRecording {
37
+ const meta = JSON.parse(readPrivateFile(path.join(recDir, 'meta.json')).toString('utf8')) as Record<string, unknown>;
38
+ const frames = typeof meta.frames === 'number' ? meta.frames : 0;
39
+ const durationMs = typeof meta.durationMs === 'number' ? meta.durationMs : 0;
40
+ return {
41
+ recId,
42
+ recDir,
301
43
  frames,
302
44
  durationMs,
303
- state,
304
- url,
305
- fps,
306
- eventCount,
307
- viewportRestored,
45
+ fps: durationMs > 0 ? Math.round((frames / (durationMs / 1000)) * 10) / 10 : 0,
46
+ state: meta.state === 'partial' ? 'partial' : meta.completion === 'orphaned' ? 'orphaned-finalized' : 'finalized',
47
+ eventCount: typeof meta.eventCount === 'number' ? meta.eventCount : null,
48
+ viewportRestored: typeof meta.viewportRestored === 'boolean' ? meta.viewportRestored : null,
49
+ viewportRetained: meta.viewportRetained === true,
308
50
  };
309
- writeJsonPrivate(metaPath(recDir), meta);
310
- removeArtifactTree(recorderJsonPath(recDir));
311
- await clearActiveRecIdIfOwned(sessionDir, recId);
312
- return { recId, recDir, frames, durationMs, fps, state, eventCount, viewportRestored };
313
- }
314
-
315
- /** Every SIGTERM this module ever sends goes through here: re-reads the
316
- * process's birth identity immediately before signaling and only signals when
317
- * it still matches the recorded identity. A recycled/foreign pid gets NO
318
- * signal — the socket file is cleaned up either way. */
319
- function killIfIdentityMatches(record: Pick<RecorderHandleRecord, 'pid' | 'socketPath' | 'birth'>, provider: PidBirthProvider): void {
320
- if (recorderProcessAlive(record, provider)) stopBridge(record.pid, record.socketPath);
321
- else stopBridge(null, record.socketPath); // gone/foreign/mismatch: clean socket only, NEVER SIGTERM
322
- }
323
-
324
- /** After a successful authenticated `rec-stop`, the bridge process exits
325
- * itself once its response has flushed. The graceful stop paths wait for that
326
- * VERIFIED exit (birth-identity check — an absent or recycled/foreign pid
327
- * counts as gone and is never signaled) instead of racing it with an
328
- * immediate SIGTERM; only a timeout with a STILL-MATCHING identity escalates
329
- * to the identity-checked kill. Socket-file hygiene happens either way. */
330
- async function waitForRecorderExit(
331
- record: Pick<RecorderHandleRecord, 'pid' | 'socketPath' | 'birth'>,
332
- provider: PidBirthProvider,
333
- deps: LifecycleSeams,
334
- ): Promise<void> {
335
- const timeoutMs = deps.stopExitTimeoutMs ?? 2000;
336
- const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
337
- const deadline = Date.now() + timeoutMs;
338
- for (;;) {
339
- if (!recorderProcessAlive(record, provider)) {
340
- stopBridge(null, record.socketPath); // gone or identity mismatch: clean socket only, NEVER signal
341
- return;
342
- }
343
- if (Date.now() >= deadline) {
344
- killIfIdentityMatches(record, provider); // still the same process after the grace window: escalate
345
- return;
346
- }
347
- await sleep(25);
348
- }
349
- }
350
-
351
- /**
352
- * Shared tail for a TERMINAL `rec-stop` failure — the bridge authenticated
353
- * the request and explicitly answered `ok:false` (`RecStopBridgeFailure`,
354
- * e.g. a fatal HAR drain: sink/store rejection or assembly/validation
355
- * failure), as opposed to a wedged/unreachable bridge. That fatal bridge
356
- * self-exits non-zero once its response has flushed (see
357
- * `../recorder-bridge.ts`'s terminal rec-stop latch), so waiting through the
358
- * SAME identity-verified `waitForRecorderExit` the success path uses
359
- * resolves rather than timing out — there is no wedged process to escalate
360
- * against. This path takes NO identity-kill first resort and produces NO
361
- * orphan-finalize success: it always re-throws the primary bridge failure,
362
- * never a finalized recording. `waitForRecorderExit` itself does not throw
363
- * in its ordinary outcomes (gone/mismatched identity resolves normally; a
364
- * still-matching identity after timeout escalates to a kill and still
365
- * resolves) — if it somehow does throw, that fact is folded in alongside the
366
- * primary error via `AggregateError` (same precedent as
367
- * `../../session/artifacts.ts`'s `combineFailure`) rather than masking the
368
- * primary with a generic fallback.
369
- */
370
- async function propagateTerminalRecStopFailure(
371
- record: Pick<RecorderHandleRecord, 'pid' | 'socketPath' | 'birth'>,
372
- provider: PidBirthProvider,
373
- deps: LifecycleSeams,
374
- primary: RecStopBridgeFailure,
375
- ): Promise<never> {
376
- try {
377
- await waitForRecorderExit(record, provider, deps);
378
- } catch (cleanupErr) {
379
- throw new AggregateError([primary, cleanupErr], primary.message);
380
- }
381
- throw primary;
382
- }
383
-
384
- /**
385
- * True for the designated fatal teardown error `../../session/commands.ts`'s
386
- * `session stop` must let escape its warn-and-continue recorder-teardown
387
- * catch and route to the outer `stop_failed` lane instead — a terminal
388
- * `rec-stop` failure thrown by `propagateTerminalRecStopFailure` above,
389
- * whether that's the bare `RecStopBridgeFailure` or one wrapped alongside a
390
- * cleanup fact in an `AggregateError`. Any OTHER teardown failure (dead-handle
391
- * noise, liveness-unknown, a malformed handle) keeps the existing
392
- * warn-and-continue policy — only an authenticated, explicit bridge refusal
393
- * is fatal to the whole session stop.
394
- */
395
- export function isTerminalRecStopFailure(err: unknown): boolean {
396
- if (err instanceof RecStopBridgeFailure) return true;
397
- return err instanceof AggregateError && err.errors.some((e) => e instanceof RecStopBridgeFailure);
398
- }
399
-
400
- /** Reads, validates, and CONSUMES the nonce boot file the recorder bridge
401
- * wrote into `recDir` before binding its socket (see `recorder-bridge.ts`'s
402
- * `RECORDER_NONCE_BOOT_FILE`). The nonce is persisted into `recorder.json`
403
- * by the caller — one persistent authority — so the boot file is deleted here
404
- * as soon as it is read. Throws factually on a missing/malformed file: there
405
- * is no unauthenticated fallback lane. */
406
- function consumeRecorderNonceBootFile(recDir: string): string {
407
- const bootPath = path.join(recDir, RECORDER_NONCE_BOOT_FILE);
408
- let nonce: unknown;
409
- try {
410
- nonce = (JSON.parse(readPrivateFile(bootPath).toString('utf-8')) as { nonce?: unknown }).nonce;
411
- } catch (err) {
412
- throw new Error(`Recorder bridge did not hand back a control nonce (${err instanceof Error ? err.message : String(err)}).`);
413
- }
414
- if (typeof nonce !== 'string' || !RECORDER_NONCE.test(nonce)) {
415
- throw new Error('Recorder bridge handed back a malformed control nonce.');
416
- }
417
- unlinkPrivateFile(bootPath);
418
- return nonce;
419
- }
420
-
421
- /** Best-effort finalize when the recorder process is dead, or known-alive but
422
- * unresponsive on its socket: counts whatever `frames/`/lack thereof already
423
- * made it to disk, computes duration from `recorder.json`'s own `startedAt`,
424
- * and reuses whichever baseline triple `rec-start` last persisted (it cannot
425
- * get a fresher one — nothing is listening on the socket anymore).
426
- *
427
- * `attemptKill` lets a caller that classified the handle live (but whose
428
- * socket round trip failed — a wedged bridge) still stop the process, via an
429
- * identity-verified kill. Callers finalizing an already-dead handle pass
430
- * `false`: the socket file is cleaned up, nothing is ever signaled. */
431
- async function finalizeOrphaned(record: RecorderHandleRecord, recDir: string, sessionDir: string, provider: PidBirthProvider, attemptKill: boolean): Promise<FinalizedRecording> {
432
- const frames = countFrames(recDir);
433
- const durationMs = Math.max(0, Date.now() - Date.parse(record.startedAt));
434
- if (attemptKill) killIfIdentityMatches(record, provider);
435
- else stopBridge(null, record.socketPath); // dead handle: clean socket, never signal
436
- return writeFinalizedArtifacts(sessionDir, recDir, record.recId, record.url, frames, durationMs, 'orphaned-finalized', record.markers, null, record.targetId);
437
51
  }
438
52
 
439
- // ---------------------------------------------------------------------------
440
- // Dead-handle reap — a `recorder.json` whose birth-identity no longer matches
441
- // a live process. Called (under the lifecycle lock) at the top of
442
- // `startComposedRecorder` — never resume, always reap-then-proceed.
443
- // ---------------------------------------------------------------------------
444
-
445
- /** Orphan-finalizes every `dead`-classified handle in `scan` (motion
446
- * consequence: meta/markers/viewport restore — reset does none of this; it
447
- * only calls coordinator's neutral `clearDanglingRecorderPointer` and leaves
448
- * dead handles intact for this finalizer to reap on the next start/stop).
449
- * Clears a dangling `activeRecId` pointer owned by this session. Returns the
450
- * handle the pointer named if it was reaped, else the first one reaped, else `null`. */
451
- async function reapDeadHandles(sessionDir: string, scan: ScannedRecorderHandle[], provider: PidBirthProvider): Promise<FinalizedRecording | null> {
452
- const active = activeSessionMatching(sessionDir);
453
- let primary: FinalizedRecording | null = null;
454
- for (const h of scan) {
455
- if (h.classification !== 'dead') continue;
456
- const finalized = await finalizeOrphaned(h.record!, h.recDir, sessionDir, provider, false);
457
- if (h.recId === active?.activeRecId || primary === null) primary = finalized;
458
- }
459
- if (active?.activeRecId && !scan.some(h => h.recId === active.activeRecId)) await clearActiveRecId();
460
- return primary;
461
- }
462
-
463
- // ---------------------------------------------------------------------------
464
- // Start
465
- // ---------------------------------------------------------------------------
466
-
467
- export interface StartRecorderResult {
468
- recId: string;
469
- recDir: string;
470
- state: 'recording';
471
- /** Set when a stale prior recording was reaped before this one armed. */
472
- reapedStale: FinalizedRecording | null;
53
+ async function liveHost(sessionDir: string): Promise<CollectorHostHandle> {
54
+ const scanned = scanCollectorHost(sessionDir);
55
+ if (scanned.classification !== 'live' || !scanned.handle) throw captureError('precondition', 'recorder_unavailable', `This session has no live collector host (${scanned.classification}).`);
56
+ return scanned.handle;
473
57
  }
474
58
 
475
- /**
476
- * Injectable seams for `startComposedRecorder`'s two external effects (CDP
477
- * port detection and spawning the detached recorder-bridge process) —
478
- * lets tests exercise the full start path (recorder.json shape, stale-reap,
479
- * activeRecId bookkeeping) against a fake NDJSON socket server instead of a
480
- * real spawned `__bridge-serve` child (which only works against the built
481
- * bin, not a `tsx`-run test — see `../bridge/spawn.ts`'s own doc comment).
482
- * Omit both in production; only tests pass `deps`.
483
- */
484
- export interface StartRecorderDeps extends LifecycleSeams {
485
- detectPort?: () => Promise<number>;
486
- spawnRecorderBridge?: typeof startRecorderBridge;
487
- }
59
+ export interface StartRecorderDeps { detectPort?: () => Promise<number>; spawnCollectorHost?: typeof startCollectorHost; }
488
60
 
489
- export async function startComposedRecorder(
490
- opts: {
491
- sessionDir: string;
492
- viewport?: RecordingViewport;
493
- },
494
- deps: StartRecorderDeps = {},
495
- ): Promise<StartRecorderResult> {
496
- // Admission into the session's operation coordinator comes FIRST — before
497
- // the lifecycle lock and before any session-bound effect. A `session stop`
498
- // that has already marked `.operations.json` stopping rejects this start
499
- // with zero side effects; a start admitted first holds its token until the
500
- // recorder handle is atomically published (or the start fully rolled back),
501
- // so a concurrent stop's drain barrier cannot finalize the bundle around a
502
- // half-armed recorder. Deadlock-free ordering: stop drains tokens BEFORE
503
- // taking `.lifecycle.lock`, while this start holds its token WHILE waiting
504
- // on that lock — the two never wait on each other in a cycle.
61
+ export async function startComposedRecorder(opts: { sessionDir: string; viewport?: RecordingViewport }, deps: StartRecorderDeps = {}): Promise<StartRecorderResult> {
505
62
  const operation = await admitSessionOperation(opts.sessionDir);
506
63
  try {
507
- return await startComposedRecorderAdmitted(opts, deps);
508
- } finally {
509
- await operation.release();
510
- }
511
- }
512
-
513
- async function startComposedRecorderAdmitted(
514
- opts: {
515
- sessionDir: string;
516
- viewport?: RecordingViewport;
517
- },
518
- deps: StartRecorderDeps = {},
519
- ): Promise<StartRecorderResult> {
520
- // The lifecycle lock covers the scan, dead-handle reap, endpoint
521
- // resolution, viewport mutation, and bridge startup. A rejected start
522
- // therefore cannot touch a live recording's viewport, a stale reap
523
- // completes before a new override, and the recording target is the one
524
- // the session names UNDER the lock — never a stale pre-lock snapshot.
525
- return withSessionLifecycle(opts.sessionDir, async () => {
526
- const provider = deps.pidBirthProvider ?? processPidBirthProvider;
527
- const scan = scanRecorderHandles(opts.sessionDir, deps);
528
- if (scan.some(h => h.classification === 'unknown')) throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to start.');
529
- if (scan.some(h => h.classification === 'malformed')) throw captureError('precondition', 'recorder_unavailable', 'A malformed recorder handle exists on this session; resolve it before starting.');
530
- if (scan.some(h => h.classification === 'live')) throw new Error('A recording is already active on this session. Stop it first: `capture motion rec --stop`.');
531
- const reapedStale = await reapDeadHandles(opts.sessionDir, scan, provider);
532
- const session = getActiveSession();
533
- if (!session || session.dir !== opts.sessionDir) throw new Error('The active capture session is no longer available.');
534
- if (!session.targetId) throw new Error('The active session has no attached tab to record. Start it with a URL: `capture session start --url <url>`.');
535
- const targetId = session.targetId;
536
- // The owning session's live HAR store — REQUIRED: a session-backed
537
- // recorder always has one (session start creates it), and the recorder
538
- // bridge streams its network evidence there. No fallback lane.
539
- const harId = session.harId;
540
- if (typeof harId !== 'string' || harId.length === 0) {
541
- throw new Error('The active session has no live HAR recording id (harId); cannot arm a recorder without its HAR evidence store.');
542
- }
543
- const port = session.port ?? await (deps.detectPort ?? detectCdpPort)();
544
- await updateSessionState(opts.sessionDir, { targetId, port });
545
-
546
- const recId = `rec-${crypto.randomBytes(2).toString('hex')}`;
547
- const recDir = recDirFor(opts.sessionDir, recId);
548
- ensurePrivateDir(recDir);
549
- ensurePrivateDir(path.join(recDir, 'frames'));
550
- let viewportAttempted = false;
551
- let socketPath: string | null = null;
552
- let established: { pid: number; socketPath: string; birth: PidBirth } | null = null;
553
- try {
554
- viewportAttempted = await applyViewportOverride(recDir, targetId, opts.viewport);
555
- socketPath = recorderSocketPath(recDir);
556
- const spawnRecorderBridge = deps.spawnRecorderBridge ?? startRecorderBridge;
557
- const { pid } = await spawnRecorderBridge(socketPath, port, targetId, recDir, harId);
558
- const born = provider.read(pid);
559
- if (born.status !== 'found') {
560
- // Identity unestablished — never signal a pid we cannot prove is ours.
561
- stopBridge(null, socketPath);
562
- throw new Error(`Could not establish recorder process identity (${born.status === 'unknown' ? born.reason : 'process absent'}).`);
64
+ return await withSessionLifecycle(opts.sessionDir, async () => {
65
+ const session = getActiveSession();
66
+ if (!session || session.dir !== opts.sessionDir) throw new Error('The active capture session is no longer available.');
67
+ if (!session.targetId) throw new Error('The active session has no attached tab to record. Start it with a URL: `capture session start --url <url>`.');
68
+ if (!session.harId) throw new Error('The active session has no live HAR recording id (harId); cannot arm a recorder without its HAR evidence store.');
69
+ const port = session.port ?? await (deps.detectPort?.() ?? Promise.reject(new Error('The active session has no CDP port.')));
70
+ await updateSessionState(opts.sessionDir, { targetId: session.targetId, port });
71
+ let scanned = scanCollectorHost(opts.sessionDir);
72
+ if (scanned.classification === 'unknown' || scanned.classification === 'malformed') throw captureError('precondition', 'recorder_unavailable', `This session's collector host is ${scanned.classification}.`);
73
+ if (scanned.classification === 'dead') {
74
+ const reaped = await stopAndReapCollectorHostAtSessionStop(opts.sessionDir);
75
+ if (reaped.status === 'terminal') throw new Error(reaped.error);
76
+ scanned = scanCollectorHost(opts.sessionDir);
563
77
  }
564
- established = { pid, socketPath, birth: born.identity };
565
- // The bridge wrote its server-generated nonce into recDir before binding
566
- // the socket; spawn's readiness poll guarantees it exists here. Read,
567
- // validate, and consume it — recorder.json below becomes the one
568
- // persistent authority for it.
569
- const nonce = consumeRecorderNonceBootFile(recDir);
570
- const startResp = await requestRecStart(socketPath, nonce);
571
-
572
- const recorderJson: RecorderJson = {
573
- recId,
574
- pid,
575
- socketPath,
576
- targetId,
577
- url: readSessionUrl(opts.sessionDir),
578
- startedAt: new Date().toISOString(),
579
- state: 'recording',
580
- birth: born.identity,
581
- markers: startResp.markers,
582
- nonce,
583
- };
584
- writeJsonPrivate(recorderJsonPath(recDir), recorderJson);
585
- await setActiveRecId(recId);
586
- return { recId, recDir, state: 'recording' as const, reapedStale };
587
- } catch (err) {
588
- if (established) killIfIdentityMatches(established, provider);
589
- else if (socketPath) stopBridge(null, socketPath);
590
- const restored = viewportAttempted || fs.existsSync(path.join(recDir, VIEWPORT_STATE_FILE))
591
- ? await restoreViewportOverride(recDir, targetId)
592
- : null;
593
- // A failed restoration is a live lifecycle obligation, not disposable
594
- // partial output. Leave it for the next session teardown to retry.
595
- if (restored !== false) removeArtifactTree(recDir);
596
- throw err instanceof StartRecorderError ? err : new StartRecorderError(err instanceof Error ? err.message : String(err), restored);
597
- }
598
- }, deps);
599
- }
600
-
601
- // ---------------------------------------------------------------------------
602
- // Stop
603
- // ---------------------------------------------------------------------------
604
-
605
- /** Finalizes the session's active composed recording (or an explicit `recId`).
606
- * Gracefully stops a live recorder over its socket; best-effort finalizes an
607
- * already-dead one instead of failing. Throws if there is nothing to stop. */
608
- export async function stopComposedRecorder(opts: {
609
- sessionDir: string;
610
- recId?: string;
611
- }, deps: LifecycleSeams = {}): Promise<FinalizedRecording> {
612
- // validateRecId throws BEFORE the lock — a traversal-shaped --rec-id is an
613
- // invocation defect, not a lifecycle transition; it never touches the lock.
614
- const recId = opts.recId ? validateRecId(opts.sessionDir, opts.recId) : getActiveRecId();
615
- if (!recId) {
616
- throw new Error('No active recording on this session. Start one first: `capture motion rec --start`.');
617
- }
618
- return withSessionLifecycle(opts.sessionDir, async () => {
619
- const provider = deps.pidBirthProvider ?? processPidBirthProvider;
620
- const handle = scanRecorderHandles(opts.sessionDir, deps).find(h => h.recId === recId);
621
- if (!handle) {
622
- throw new Error(`No live-recorder state found for "${recId}" (already finalized, or never started).`);
623
- }
624
- if (handle.classification === 'unknown') throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to stop.');
625
- if (handle.classification === 'malformed') throw captureError('precondition', 'recorder_unavailable', `The recorder handle for "${recId}" is malformed.`);
626
- const record = handle.record!;
627
- if (handle.classification === 'dead') {
628
- return await finalizeOrphaned(record, handle.recDir, opts.sessionDir, provider, false);
629
- }
630
- try {
631
- const stopResp = await requestRecStop(record.socketPath, record.nonce);
632
- // The bridge self-exits once its authenticated rec-stop response has
633
- // flushed — wait for that verified exit; escalate to an identity-checked
634
- // SIGTERM only if the same process is still alive after the grace window.
635
- await waitForRecorderExit(record, provider, deps);
636
- return await writeFinalizedArtifacts(opts.sessionDir, handle.recDir, recId, record.url, stopResp.frameCount, stopResp.durationMs, 'finalized', stopResp.markers, stopResp.eventCount, record.targetId);
637
- } catch (err) {
638
- if (err instanceof RecStopBridgeFailure) {
639
- // The bridge authenticated the request and explicitly refused — a
640
- // terminal recorder failure, NOT a wedged bridge. No identity-kill,
641
- // no orphan-finalize success: propagate the primary error.
642
- await propagateTerminalRecStopFailure(record, provider, deps, err);
78
+ if (scanned.classification === 'absent') {
79
+ const spawn = deps.spawnCollectorHost ?? startCollectorHost;
80
+ await spawn(collectorHostSocketPath(opts.sessionDir), port, session.targetId, opts.sessionDir);
81
+ scanned = scanCollectorHost(opts.sessionDir);
643
82
  }
644
- // Known-live handle, but the socket round trip failed (wedged/missing
645
- // bridge) — identity-verified kill and best-effort finalize from
646
- // whatever made it to disk, same fallback teardown uses.
647
- return await finalizeOrphaned(record, handle.recDir, opts.sessionDir, provider, true);
648
- }
649
- }, deps);
650
- }
651
-
652
- // ---------------------------------------------------------------------------
653
- // Session-stop teardown
654
- // ---------------------------------------------------------------------------
655
-
656
- /**
657
- * Called by `session stop` before bundle collection: finalizes/tears down
658
- * EVERY live recorder found on this session, directory-authoritatively —
659
- * it scans `motion/recs/{recId}/recorder.json` rather than trusting the single
660
- * global `activeRecId` pointer, because that pointer can be absent (a
661
- * `recorder.json` left behind with no pointer naming it) or can currently
662
- * name a *different* session entirely (this caller scope's one active
663
- * pointer, pointing elsewhere while THIS session still has its own live
664
- * recorder on disk). Each handle found gets a graceful `rec-stop` if its
665
- * pid answers, otherwise a best-effort orphaned finalize; reap never
666
- * resumes a stale one. The pointer is cleared only for handles this
667
- * session actually owns (`clearActiveRecIdIfOwned`) — a pointer naming
668
- * some other session is left completely untouched. Returns the finalized
669
- * recording, if any, plus every pending failed-start viewport retry outcome. */
670
- export interface PendingViewportRestoration {
671
- recId: string;
672
- viewportRestored: boolean | null;
673
- }
674
-
675
- async function restorePendingStartViewportOverrides(sessionDir: string): Promise<PendingViewportRestoration[]> {
676
- const recsRoot = recsRootFor(sessionDir);
677
- if (!fs.existsSync(recsRoot)) return [];
678
- const outcomes: PendingViewportRestoration[] = [];
679
- for (const entry of fs.readdirSync(recsRoot, { withFileTypes: true })) {
680
- if (!entry.isDirectory()) continue;
681
- const recDir = path.join(recsRoot, entry.name);
682
- if (readRecorderJson(recDir)) continue;
683
- const statePath = path.join(recDir, VIEWPORT_STATE_FILE);
684
- if (!fs.existsSync(statePath)) continue;
685
- const state = readViewportOverrideState(statePath);
686
- const targetId = typeof state?.targetId === 'string' ? state.targetId : null;
687
- const viewportRestored = targetId ? await restoreViewportOverride(recDir, targetId) : null;
688
- outcomes.push({ recId: entry.name, viewportRestored });
689
- }
690
- return outcomes;
83
+ if (scanned.classification !== 'live' || !scanned.handle) throw new Error('Collector host did not publish a live handle.');
84
+ if (scanned.handle.targetId !== session.targetId) throw new Error('A collector host is bound to a different session target. Stop the session before changing tabs.');
85
+ if (scanned.handle.collectors.some(collector => collector.kind === 'motion')) throw new Error('A recording is already active on this session. Stop it first: `capture motion rec --stop`.');
86
+ const response = await sendHostRequest(scanned.handle.socketPath, { type: 'collector-start', nonce: scanned.handle.nonce, kind: 'motion', config: { harId: session.harId, viewport: opts.viewport } }, 30_000);
87
+ if (!response.ok || !response.collector || typeof response.collector !== 'object') throw new StartRecorderError(response.error ?? 'collector host refused to start motion recording');
88
+ const collector = response.collector as { id?: unknown; dir?: unknown };
89
+ if (typeof collector.id !== 'string' || typeof collector.dir !== 'string') throw new StartRecorderError('collector host returned a malformed motion collector');
90
+ await setActiveRecId(collector.id);
91
+ return { recId: collector.id, recDir: collector.dir, state: 'recording', reapedStale: null };
92
+ });
93
+ } finally { await operation.release(); }
691
94
  }
692
95
 
693
- export type SessionRecorderTeardown = (FinalizedRecording & {
694
- pendingViewportRestorations: PendingViewportRestoration[];
695
- }) | {
696
- recording: null;
697
- pendingViewportRestorations: PendingViewportRestoration[];
698
- } | null;
699
-
700
- export async function teardownAnyLiveRecorderAtSessionStop(sessionDir: string, deps: LifecycleSeams = {}): Promise<SessionRecorderTeardown> {
701
- return withSessionLifecycle(sessionDir, async () => {
702
- const provider = deps.pidBirthProvider ?? processPidBirthProvider;
703
- const scan = scanRecorderHandles(sessionDir, deps);
704
- const activeHere = activeSessionMatching(sessionDir);
705
-
706
- let primary: FinalizedRecording | null = null;
707
- for (const h of scan) {
708
- if (h.classification === 'unknown') throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness during session stop.');
709
- if (h.classification === 'malformed') continue; // no readable handle to finalize; leave the dir as-is
710
- const record = h.record!;
711
- let finalized: FinalizedRecording;
712
- if (h.classification === 'dead') {
713
- finalized = await finalizeOrphaned(record, h.recDir, sessionDir, provider, false);
714
- } else {
715
- try {
716
- const stopResp = await requestRecStop(record.socketPath, record.nonce);
717
- // Same verified-exit wait as stopComposedRecorder's graceful path —
718
- // never race the bridge's own post-response self-exit with a signal.
719
- await waitForRecorderExit(record, provider, deps);
720
- finalized = await writeFinalizedArtifacts(
721
- sessionDir,
722
- h.recDir,
723
- record.recId,
724
- record.url,
725
- stopResp.frameCount,
726
- stopResp.durationMs,
727
- 'finalized',
728
- stopResp.markers,
729
- stopResp.eventCount,
730
- record.targetId,
731
- );
732
- } catch (err) {
733
- if (err instanceof RecStopBridgeFailure) {
734
- // Terminal recorder-stop failure during whole-session teardown —
735
- // the bridge authenticated the request and explicitly refused
736
- // (e.g. fatal HAR drain). This must escape the caller's
737
- // warn-and-continue teardown catch (`../../session/commands.ts`)
738
- // and reach the outer `stop_failed` lane: no bundle commit, no
739
- // live-HAR deletion, no handle deletion-as-finalized. Propagate
740
- // rather than falling into the dead-handle fallback below.
741
- await propagateTerminalRecStopFailure(record, provider, deps, err);
742
- }
743
- // The handle classified live but the socket round trip itself
744
- // failed (e.g. mid-teardown race) — identity-verified kill plus the
745
- // same best-effort path a dead handle takes, rather than leaving
746
- // the session stuck with a live-looking recorder it can never stop.
747
- finalized = await finalizeOrphaned(record, h.recDir, sessionDir, provider, true);
748
- }
749
- }
750
- if (record.recId === activeHere?.activeRecId || primary === null) primary = finalized;
751
- }
752
-
753
- // A dangling pointer for THIS session with no matching on-disk handle left
754
- // to tear down (already reaped, or the pointer never named a real one).
755
- if (activeHere?.activeRecId && !scan.some((h) => h.recId === activeHere.activeRecId)) {
756
- await clearActiveRecId();
757
- }
758
-
759
- const pendingViewportRestorations = await restorePendingStartViewportOverrides(sessionDir);
760
- if (primary) return { ...primary, pendingViewportRestorations };
761
- if (pendingViewportRestorations.length) return { recording: null, pendingViewportRestorations };
762
- return null;
763
- }, deps);
96
+ export async function stopComposedRecorder(opts: { sessionDir: string; recId?: string }): Promise<FinalizedRecording> {
97
+ return withSessionLifecycle(opts.sessionDir, async () => {
98
+ const recId = opts.recId ?? getActiveRecId();
99
+ if (!recId) throw new Error('No active recording on this session. Start one first: `capture motion rec --start`.');
100
+ const host = await liveHost(opts.sessionDir);
101
+ const row = motionHandle(host, recId);
102
+ if (!row) throw new Error(`No live motion collector named "${recId}".`);
103
+ const response = await sendHostRequest(host.socketPath, { type: 'collector-stop', nonce: host.nonce, id: recId }, 30_000);
104
+ if (!response.ok) throw new Error(response.error ?? 'collector host refused to stop motion recording');
105
+ if (getActiveSession()?.dir === opts.sessionDir && getActiveRecId() === recId) await clearActiveRecId();
106
+ return finalFromMeta(row.dir, recId);
107
+ });
108
+ }
109
+
110
+ /** Session stop owns host teardown. Kept as the motion-facing explicit call for command code. */
111
+ export async function teardownAnyLiveRecorderAtSessionStop(sessionDir: string): Promise<FinalizedRecording | null> {
112
+ const host = scanCollectorHost(sessionDir);
113
+ const motion = host.handle?.collectors.find(collector => collector.kind === 'motion');
114
+ const result = await stopAndReapCollectorHostAtSessionStop(sessionDir);
115
+ if (result.status === 'terminal') throw new Error(result.error);
116
+ if (!motion) return null;
117
+ if (getActiveSession()?.dir === sessionDir && getActiveRecId() === motion.id) await clearActiveRecId();
118
+ return finalFromMeta(motion.dir, motion.id);
764
119
  }