@crouton-kit/capture 1.3.7 → 1.3.9
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.
- package/bin/capture +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1894 -0
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +302 -34
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -77
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,764 @@
|
|
|
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';
|
|
44
|
+
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
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
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
|
+
|
|
158
|
+
export interface FinalizedRecording {
|
|
159
|
+
recId: string;
|
|
160
|
+
recDir: string;
|
|
161
|
+
frames: number;
|
|
162
|
+
durationMs: number;
|
|
163
|
+
fps: number;
|
|
164
|
+
state: RecorderLiveState;
|
|
165
|
+
/** Whether a viewport override owned by this recording was restored. */
|
|
166
|
+
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). */
|
|
169
|
+
eventCount: number | null;
|
|
170
|
+
}
|
|
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
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
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
|
+
}
|
|
275
|
+
|
|
276
|
+
export class StartRecorderError extends Error {
|
|
277
|
+
constructor(message: string, readonly viewportRestored: boolean | null) {
|
|
278
|
+
super(message);
|
|
279
|
+
this.name = 'StartRecorderError';
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
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,
|
|
301
|
+
frames,
|
|
302
|
+
durationMs,
|
|
303
|
+
state,
|
|
304
|
+
url,
|
|
305
|
+
fps,
|
|
306
|
+
eventCount,
|
|
307
|
+
viewportRestored,
|
|
308
|
+
};
|
|
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
|
+
}
|
|
438
|
+
|
|
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;
|
|
473
|
+
}
|
|
474
|
+
|
|
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
|
+
}
|
|
488
|
+
|
|
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.
|
|
505
|
+
const operation = await admitSessionOperation(opts.sessionDir);
|
|
506
|
+
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'}).`);
|
|
563
|
+
}
|
|
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);
|
|
643
|
+
}
|
|
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;
|
|
691
|
+
}
|
|
692
|
+
|
|
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);
|
|
764
|
+
}
|