@crouton-kit/capture 1.3.8 → 1.3.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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -0,0 +1,653 @@
1
+ /**
2
+ * Session log tailer ownership.
3
+ *
4
+ * The command process opens the contained destination descriptor, starts this
5
+ * executable's hidden worker route as a detached process-group leader, records
6
+ * its PID birth identity and private control socket, registers that complete
7
+ * handle, and confirms the registration over the control socket before reporting
8
+ * success. The worker invokes `tail` with argv only, timestamps its lines onto
9
+ * the inherited destination descriptor, self-terminates if the registration
10
+ * confirmation never arrives, and drains on a nonce-authenticated control
11
+ * request from `session stop`. A running worker also self-terminates when its
12
+ * recorded control socket stops existing: that socket is the one channel any
13
+ * owner can reach it through, so a worker without it is an unowned orphan.
14
+ */
15
+
16
+ import { spawn, type ChildProcess } from 'node:child_process';
17
+ import * as crypto from 'node:crypto';
18
+ import * as fs from 'node:fs';
19
+ import * as net from 'node:net';
20
+ import * as path from 'node:path';
21
+ import * as readline from 'node:readline';
22
+ import { captureError } from '../errors.js';
23
+ import {
24
+ ensurePrivateDir,
25
+ LOG_TAILER_NONCE,
26
+ LOG_TAILER_SOCKET_DIR,
27
+ LOG_TAILER_SOCKET_TOKEN,
28
+ openPrivateAppendFd,
29
+ parseRegisteredLogTailer,
30
+ processPidBirthProvider,
31
+ sameBirth,
32
+ type PidBirth,
33
+ type PidBirthProvider,
34
+ type RegisteredLogTailer,
35
+ } from './artifacts.js';
36
+
37
+ const READINESS_FD = 3;
38
+ /** Bounded positive milliseconds — the grammar for every worker `--… <ms>` flag. */
39
+ const WORKER_MS = /^[1-9][0-9]{0,6}$/;
40
+ const CONTROL_MESSAGE_LIMIT = 4096;
41
+ const WORKER_CHILD_EXIT_TIMEOUT_MS = 2_000;
42
+
43
+ export interface LogTailWorld {
44
+ /** Node argv before the hidden route token. */
45
+ entryArgv(): string[];
46
+ pidBirthProvider: PidBirthProvider;
47
+ readinessTimeoutMs: number;
48
+ teardownWaitMs: number;
49
+ /** How long a ready worker waits for its registration confirmation before self-terminating. */
50
+ confirmTimeoutMs: number;
51
+ /** How often a running worker re-verifies its recorded control socket still exists. */
52
+ orphanCheckIntervalMs: number;
53
+ /** Test-only seam: fires right before the parent reads its just-readied worker's socket identity. */
54
+ beforeParentSocketIdentity?: (socketPath: string) => void;
55
+ }
56
+
57
+ const productionWorld: LogTailWorld = {
58
+ entryArgv: () => [...process.execArgv, process.argv[1]],
59
+ pidBirthProvider: processPidBirthProvider,
60
+ readinessTimeoutMs: 10_000,
61
+ teardownWaitMs: 5_000,
62
+ confirmTimeoutMs: 10_000,
63
+ orphanCheckIntervalMs: 5_000,
64
+ };
65
+ let world: LogTailWorld = productionWorld;
66
+
67
+ /** Test seam for the executable entry and process-identity provider. */
68
+ export function __setLogTailWorld(next?: Partial<LogTailWorld>): void {
69
+ world = next ? { ...productionWorld, ...next } : productionWorld;
70
+ }
71
+
72
+ function socketDir(create: boolean): string {
73
+ return create ? ensurePrivateDir(LOG_TAILER_SOCKET_DIR) : LOG_TAILER_SOCKET_DIR;
74
+ }
75
+
76
+ function socketPathForToken(token: string, createDir: boolean): string {
77
+ if (!LOG_TAILER_SOCKET_TOKEN.test(token)) throw new Error('invalid log tailer socket token');
78
+ return path.join(socketDir(createDir), `${token}.sock`);
79
+ }
80
+
81
+ /** The throwing point-of-use gate over the neutral strict parser in artifacts. */
82
+ function requireRegisteredLogTailer(value: unknown): RegisteredLogTailer {
83
+ const record = parseRegisteredLogTailer(value);
84
+ if (!record) throw captureError('artifact', 'invalid_log_tailer_record', 'Session metadata contains an invalid log tailer registration.');
85
+ return record;
86
+ }
87
+
88
+ function readRequiredBirth(provider: PidBirthProvider, pid: number): PidBirth {
89
+ const observed = provider.read(pid);
90
+ if (observed.status === 'found') return observed.identity;
91
+ const detail = observed.status === 'unknown' ? observed.reason : 'process absent';
92
+ throw captureError('internal', 'log_tailer_identity_failed', `Could not establish log tailer birth identity: ${detail}.`);
93
+ }
94
+
95
+ type Ownership = 'same' | 'gone';
96
+ function ownership(record: Pick<RegisteredLogTailer, 'pid' | 'birth'>, provider: PidBirthProvider): Ownership {
97
+ const observed = provider.read(record.pid);
98
+ if (observed.status === 'unknown') {
99
+ throw captureError('cleanup', 'log_tailer_identity_unknown', `Could not establish log tailer pid ${record.pid} identity: ${observed.reason}.`);
100
+ }
101
+ return observed.status === 'found' && sameBirth(observed.identity, record.birth) ? 'same' : 'gone';
102
+ }
103
+
104
+ function socketIdentity(socketPath: string): { dev: string; ino: string } {
105
+ const stat = fs.lstatSync(socketPath, { bigint: true });
106
+ if (!stat.isSocket()) throw new Error(`log tailer control path is not a socket: ${socketPath}`);
107
+ return { dev: stat.dev.toString(), ino: stat.ino.toString() };
108
+ }
109
+
110
+ function unlinkRecordedSocket(record: Pick<RegisteredLogTailer, 'socketPath' | 'socketDev' | 'socketIno'>): void {
111
+ let current: fs.BigIntStats;
112
+ try { current = fs.lstatSync(record.socketPath, { bigint: true }); }
113
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw error; }
114
+ if (!current.isSocket()
115
+ || current.dev.toString() !== record.socketDev
116
+ || current.ino.toString() !== record.socketIno) return;
117
+ fs.unlinkSync(record.socketPath);
118
+ }
119
+
120
+ function closeOwnedFd(fd: number | undefined): undefined {
121
+ if (fd !== undefined) fs.closeSync(fd);
122
+ return undefined;
123
+ }
124
+
125
+ function awaitChildExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
126
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
127
+ return new Promise<boolean>((resolve) => {
128
+ let settled = false;
129
+ const finish = (value: boolean): void => {
130
+ if (settled) return;
131
+ settled = true;
132
+ clearTimeout(timer);
133
+ child.removeListener('exit', onGone);
134
+ child.removeListener('error', onGone);
135
+ resolve(value);
136
+ };
137
+ const onGone = (): void => finish(true);
138
+ const timer = setTimeout(() => finish(false), timeoutMs);
139
+ child.once('exit', onGone);
140
+ child.once('error', onGone);
141
+ });
142
+ }
143
+
144
+ async function terminateOwnedGroup(child: ChildProcess, pid: number, birth: PidBirth | undefined, provider: PidBirthProvider): Promise<void> {
145
+ const canSignal = (): boolean => {
146
+ if (!birth) return child.exitCode === null && child.signalCode === null;
147
+ return ownership({ pid, birth }, provider) === 'same';
148
+ };
149
+ // A failed group signal is never proof of a live worker: a group whose only
150
+ // member is already a zombie yields EPERM on macOS. The exit/birth
151
+ // convergence checks below are the authority — a signal error surfaces only
152
+ // if the group never proves gone.
153
+ let signalError: unknown;
154
+ const signalGroup = (signal: NodeJS.Signals): void => {
155
+ if (!canSignal()) return;
156
+ try { process.kill(-pid, signal); }
157
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') signalError = error; }
158
+ };
159
+ signalGroup('SIGTERM');
160
+ if (await awaitChildExit(child, 1_000)) return;
161
+ signalGroup('SIGKILL');
162
+ if (await awaitChildExit(child, 2_000)) return;
163
+ if (birth && ownership({ pid, birth }, provider) === 'gone') return;
164
+ throw captureError('cleanup', 'log_tailer_rollback_failed', `Could not reap log tailer process group ${pid}.`, signalError);
165
+ }
166
+
167
+ function awaitReadiness(child: ChildProcess, timeoutMs: number): Promise<string> {
168
+ return new Promise<string>((resolve, reject) => {
169
+ const pipe = child.stdio[READINESS_FD] as NodeJS.ReadableStream | null;
170
+ if (!pipe) { reject(new Error('log tailer readiness pipe was not created')); return; }
171
+ let input = '';
172
+ let settled = false;
173
+ const finish = (action: () => void): void => {
174
+ if (settled) return;
175
+ settled = true;
176
+ clearTimeout(timer);
177
+ pipe.removeListener('data', onData);
178
+ pipe.removeListener('error', onError);
179
+ pipe.removeListener('end', onEnd);
180
+ child.removeListener('error', onError);
181
+ child.removeListener('exit', onExit);
182
+ try { (pipe as NodeJS.ReadableStream & { destroy?: () => void }).destroy?.(); } catch { /* descriptor is already closing */ }
183
+ action();
184
+ };
185
+ const onData = (chunk: Buffer): void => {
186
+ input += chunk.toString('utf-8');
187
+ if (input.length > CONTROL_MESSAGE_LIMIT) {
188
+ finish(() => reject(new Error('log tailer readiness response exceeded its bound')));
189
+ return;
190
+ }
191
+ const newline = input.indexOf('\n');
192
+ if (newline >= 0) finish(() => resolve(input.slice(0, newline).trim()));
193
+ };
194
+ const onError = (error: Error): void => finish(() => reject(error));
195
+ const onEnd = (): void => finish(() => reject(new Error('log tailer readiness pipe closed without a response')));
196
+ const onExit = (): void => finish(() => reject(new Error('log tailer exited before signalling readiness')));
197
+ const timer = setTimeout(() => finish(() => reject(new Error(`log tailer did not signal readiness within ${timeoutMs}ms`))), timeoutMs);
198
+ pipe.on('data', onData);
199
+ pipe.once('error', onError);
200
+ pipe.once('end', onEnd);
201
+ child.once('error', onError);
202
+ child.once('exit', onExit);
203
+ });
204
+ }
205
+
206
+ export interface StartSessionLogTailerOptions {
207
+ sessionDir: string;
208
+ sourcePath: string;
209
+ name: string;
210
+ register(record: RegisteredLogTailer): Promise<void>;
211
+ /** Removes exactly this registered handle again when startup fails after registration. */
212
+ unregister(record: RegisteredLogTailer): Promise<void>;
213
+ }
214
+
215
+ /** Starts and registers a worker, rolling its owned process group back on failure. */
216
+ export async function startSessionLogTailer(options: StartSessionLogTailerOptions): Promise<{ destPath: string; pid: number }> {
217
+ const logsDir = ensurePrivateDir(path.join(options.sessionDir, 'logs'));
218
+ const destPath = path.join(logsDir, `${options.name}.log`);
219
+ const socketToken = crypto.randomBytes(8).toString('hex');
220
+ const socketPath = socketPathForToken(socketToken, true);
221
+ const nonce = crypto.randomBytes(24).toString('hex');
222
+
223
+ let destFd: number | undefined;
224
+ let child: ChildProcess | undefined;
225
+ let pid: number | undefined;
226
+ let birth: PidBirth | undefined;
227
+ let registered: RegisteredLogTailer | undefined;
228
+ try {
229
+ destFd = openPrivateAppendFd(destPath);
230
+ child = spawn(
231
+ process.execPath,
232
+ [...world.entryArgv(), '__log-tail-serve', '--source', options.sourcePath, '--socket-token', socketToken, '--confirm-timeout', String(world.confirmTimeoutMs), '--orphan-check', String(world.orphanCheckIntervalMs)],
233
+ {
234
+ detached: true,
235
+ shell: false,
236
+ stdio: ['ignore', destFd, 'ignore', 'pipe'],
237
+ env: { ...process.env, CAPTURE_LOG_TAIL_NONCE: nonce },
238
+ },
239
+ );
240
+ child.on('error', () => { /* readiness/rollback owns the failure */ });
241
+ pid = child.pid;
242
+ if (!pid) throw captureError('internal', 'log_tailer_spawn_failed', 'Failed to spawn the session log tailer process.');
243
+
244
+ destFd = closeOwnedFd(destFd);
245
+ birth = readRequiredBirth(world.pidBirthProvider, pid);
246
+
247
+ const response = await awaitReadiness(child, world.readinessTimeoutMs);
248
+ if (response !== 'ready') {
249
+ const detail = response.replace(/^error:\s*/, '');
250
+ throw captureError('internal', 'log_tailer_startup_failed', `Log tailer failed to start: ${detail}`);
251
+ }
252
+ const currentBirth = readRequiredBirth(world.pidBirthProvider, pid);
253
+ if (!sameBirth(birth, currentBirth)) {
254
+ throw captureError('internal', 'log_tailer_identity_changed', 'Log tailer identity changed before registration.');
255
+ }
256
+ world.beforeParentSocketIdentity?.(socketPath);
257
+ // The worker's confirm deadline runs independently of this parent and can
258
+ // already have fired — and the worker already unlinked its own socket in
259
+ // self-teardown — before this read, if the birth re-verification above (a
260
+ // synchronous `sysctl`/`/proc` spawn) stalls under scheduling contention.
261
+ // That is exactly the same "never confirmed" outcome the requestControl()
262
+ // call below already detects and rolls back, just observed one step
263
+ // earlier. A sentinel identity can never match a real socket's inode, so a
264
+ // later unlinkRecordedSocket() stays a safe no-op regardless, and the
265
+ // confirm attempt that follows is what determines and reports the failure.
266
+ let socket: { dev: string; ino: string };
267
+ try {
268
+ socket = socketIdentity(socketPath);
269
+ } catch (error) {
270
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
271
+ socket = { dev: '0', ino: '0' };
272
+ }
273
+ const record: RegisteredLogTailer = {
274
+ pid,
275
+ name: options.name,
276
+ sourcePath: options.sourcePath,
277
+ birth,
278
+ socketPath,
279
+ socketDev: socket.dev,
280
+ socketIno: socket.ino,
281
+ nonce,
282
+ };
283
+ await options.register(record);
284
+ registered = record;
285
+ // Confirm the durable registration to the worker; a worker never confirmed
286
+ // self-terminates at its deadline instead of tailing as an unowned orphan
287
+ // (the shape a parent killed between spawn and registration would leave).
288
+ let confirmed = false;
289
+ try { confirmed = await requestControl(socketPath, nonce, 'confirm', world.readinessTimeoutMs); }
290
+ catch { /* an unconfirmed worker converges via its own deadline; rollback owns the rest */ }
291
+ if (!confirmed) {
292
+ throw captureError('internal', 'log_tailer_confirm_failed', 'Log tailer did not acknowledge its registration confirmation.');
293
+ }
294
+ child.unref();
295
+ return { destPath, pid };
296
+ } catch (primary) {
297
+ const cleanup: unknown[] = [];
298
+ let workerGone = true;
299
+ if (child && pid) {
300
+ try { await terminateOwnedGroup(child, pid, birth, world.pidBirthProvider); }
301
+ catch (error) { workerGone = false; cleanup.push(error); }
302
+ }
303
+ // A handle registered but never confirmed is unpublished with its worker:
304
+ // once the owned group is provably gone, exactly that record is removed so
305
+ // no phantom registration survives the failure. A worker that could not be
306
+ // reaped keeps its record — the handle is how `session stop` drains it.
307
+ if (registered && workerGone) {
308
+ try { await options.unregister(registered); }
309
+ catch (error) { cleanup.push(error); }
310
+ }
311
+ if (destFd !== undefined) {
312
+ try { destFd = closeOwnedFd(destFd); }
313
+ catch (error) { cleanup.push(error); }
314
+ }
315
+ if (cleanup.length) throw new AggregateError([primary, ...cleanup], 'Log tailer startup failed and rollback was incomplete.');
316
+ throw primary;
317
+ }
318
+ }
319
+
320
+ async function waitUntilGone(record: RegisteredLogTailer, provider: PidBirthProvider, timeoutMs: number): Promise<boolean> {
321
+ const deadline = Date.now() + timeoutMs;
322
+ for (;;) {
323
+ if (ownership(record, provider) === 'gone') return true;
324
+ if (Date.now() >= deadline) return false;
325
+ await new Promise(resolve => setTimeout(resolve, 25));
326
+ }
327
+ }
328
+
329
+ async function requestControl(socketPath: string, nonce: string, op: 'drain' | 'confirm', timeoutMs: number): Promise<boolean> {
330
+ return new Promise<boolean>((resolve, reject) => {
331
+ const connection = net.createConnection(socketPath);
332
+ let input = '';
333
+ let settled = false;
334
+ const finish = (action: () => void): void => {
335
+ if (settled) return;
336
+ settled = true;
337
+ clearTimeout(timer);
338
+ connection.destroy();
339
+ action();
340
+ };
341
+ const timer = setTimeout(() => finish(() => reject(new Error(`log tailer ${op} timed out`))), timeoutMs);
342
+ connection.setEncoding('utf-8');
343
+ connection.on('connect', () => connection.write(`${JSON.stringify({ nonce, op })}\n`));
344
+ connection.on('data', (chunk: string) => {
345
+ input += chunk;
346
+ if (input.length > CONTROL_MESSAGE_LIMIT) {
347
+ finish(() => reject(new Error(`log tailer ${op} response exceeded its bound`)));
348
+ return;
349
+ }
350
+ const newline = input.indexOf('\n');
351
+ if (newline < 0) return;
352
+ let ok = false;
353
+ try { ok = (JSON.parse(input.slice(0, newline)) as { ok?: unknown }).ok === true; } catch { /* malformed response */ }
354
+ finish(() => resolve(ok));
355
+ });
356
+ connection.on('error', error => finish(() => reject(error)));
357
+ connection.on('close', () => finish(() => reject(new Error(`log tailer ${op} connection closed without a reply`))));
358
+ });
359
+ }
360
+
361
+ async function stopOne(record: RegisteredLogTailer, provider: PidBirthProvider): Promise<void> {
362
+ if (ownership(record, provider) === 'gone') {
363
+ unlinkRecordedSocket(record);
364
+ return;
365
+ }
366
+
367
+ let acknowledged = false;
368
+ try { acknowledged = await requestControl(record.socketPath, record.nonce, 'drain', Math.min(1_000, world.teardownWaitMs)); }
369
+ catch { /* the identity-verified group signal remains authoritative */ }
370
+ if (acknowledged && await waitUntilGone(record, provider, world.teardownWaitMs)) {
371
+ unlinkRecordedSocket(record);
372
+ return;
373
+ }
374
+
375
+ if (ownership(record, provider) === 'gone') {
376
+ unlinkRecordedSocket(record);
377
+ return;
378
+ }
379
+ try { process.kill(-record.pid, 'SIGTERM'); }
380
+ catch (error) {
381
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
382
+ unlinkRecordedSocket(record);
383
+ return;
384
+ }
385
+ if (!await waitUntilGone(record, provider, world.teardownWaitMs)) {
386
+ throw captureError('cleanup', 'log_tailer_still_alive', `Log tailer pid ${record.pid} (${record.name}) could not be drained or terminated before session stop.`);
387
+ }
388
+ unlinkRecordedSocket(record);
389
+ }
390
+
391
+ /** Drains every strictly validated registered worker before the bundle commit. */
392
+ export async function stopSessionLogTailers(entries: unknown[]): Promise<void> {
393
+ const failures: unknown[] = [];
394
+ for (const value of entries) {
395
+ let record: RegisteredLogTailer;
396
+ try { record = requireRegisteredLogTailer(value); }
397
+ catch (error) { failures.push(error); continue; }
398
+ try { await stopOne(record, world.pidBirthProvider); }
399
+ catch (error) { failures.push(error); }
400
+ }
401
+ if (failures.length) throw new AggregateError(failures, 'Session stop could not drain every registered log tailer.');
402
+ }
403
+
404
+ interface WorkerArgs {
405
+ sourcePath: string;
406
+ socketToken: string;
407
+ confirmTimeoutMs: number;
408
+ orphanCheckIntervalMs: number;
409
+ }
410
+
411
+ function parseWorkerArgs(argv: string[]): WorkerArgs {
412
+ const usage = 'log tailer expects exactly --source <path> --socket-token <token> --confirm-timeout <ms> --orphan-check <ms>';
413
+ let sourcePath: string | undefined;
414
+ let socketToken: string | undefined;
415
+ let confirmTimeout: string | undefined;
416
+ let orphanCheck: string | undefined;
417
+ for (let index = 0; index < argv.length; index += 2) {
418
+ const flag = argv[index];
419
+ const value = argv[index + 1];
420
+ if (value === undefined) throw new Error(usage);
421
+ if (flag === '--source') {
422
+ if (sourcePath !== undefined) throw new Error('duplicate --source');
423
+ sourcePath = value;
424
+ } else if (flag === '--socket-token') {
425
+ if (socketToken !== undefined) throw new Error('duplicate --socket-token');
426
+ socketToken = value;
427
+ } else if (flag === '--confirm-timeout') {
428
+ if (confirmTimeout !== undefined) throw new Error('duplicate --confirm-timeout');
429
+ confirmTimeout = value;
430
+ } else if (flag === '--orphan-check') {
431
+ if (orphanCheck !== undefined) throw new Error('duplicate --orphan-check');
432
+ orphanCheck = value;
433
+ } else {
434
+ throw new Error(usage);
435
+ }
436
+ }
437
+ if (!sourcePath || !socketToken || !confirmTimeout || !orphanCheck || argv.length !== 8
438
+ || !LOG_TAILER_SOCKET_TOKEN.test(socketToken) || !WORKER_MS.test(confirmTimeout) || !WORKER_MS.test(orphanCheck)) {
439
+ throw new Error(usage);
440
+ }
441
+ return { sourcePath, socketToken, confirmTimeoutMs: Number(confirmTimeout), orphanCheckIntervalMs: Number(orphanCheck) };
442
+ }
443
+
444
+ function nonceMatches(expected: string, candidate: unknown): boolean {
445
+ if (typeof candidate !== 'string') return false;
446
+ const left = Buffer.from(expected);
447
+ const right = Buffer.from(candidate);
448
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
449
+ }
450
+
451
+ function writeReadiness(message: string): boolean {
452
+ try { fs.writeSync(READINESS_FD, message); return true; }
453
+ catch { return false; }
454
+ }
455
+
456
+ function readinessError(error: unknown): string {
457
+ const message = error instanceof Error ? error.message : String(error);
458
+ return `error: ${message.replace(/[\r\n]+/g, ' ').slice(0, 1_000)}\n`;
459
+ }
460
+
461
+ function waitForChild(child: ChildProcess): Promise<void> {
462
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
463
+ return new Promise(resolve => {
464
+ const done = (): void => resolve();
465
+ child.once('exit', done);
466
+ child.once('error', done);
467
+ });
468
+ }
469
+
470
+ function resolvesWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> {
471
+ return new Promise(resolve => {
472
+ let settled = false;
473
+ const finish = (value: boolean): void => {
474
+ if (settled) return;
475
+ settled = true;
476
+ clearTimeout(timer);
477
+ resolve(value);
478
+ };
479
+ const timer = setTimeout(() => finish(false), timeoutMs);
480
+ promise.then(() => finish(true), () => finish(true));
481
+ });
482
+ }
483
+
484
+ async function stopWorkerChild(child: ChildProcess, exited: Promise<void>): Promise<void> {
485
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
486
+ if (await resolvesWithin(exited, WORKER_CHILD_EXIT_TIMEOUT_MS)) return;
487
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
488
+ if (!await resolvesWithin(exited, WORKER_CHILD_EXIT_TIMEOUT_MS)) throw new Error('tail subprocess did not exit');
489
+ }
490
+
491
+ function closeServer(server: net.Server | undefined, connections: Set<net.Socket>): Promise<void> {
492
+ for (const connection of connections) connection.destroy();
493
+ if (!server?.listening) return Promise.resolve();
494
+ return new Promise(resolve => server.close(() => resolve()));
495
+ }
496
+
497
+ /** Hidden worker route. All startup diagnostics travel over fd 3, never stdout. */
498
+ export async function runLogTailer(argv: string[]): Promise<void> {
499
+ let server: net.Server | undefined;
500
+ let tail: ChildProcess | undefined;
501
+ let tailExited: Promise<void> | undefined;
502
+ let reader: readline.Interface | undefined;
503
+ let readerClosed = false;
504
+ let socketPath: string | undefined;
505
+ let ownedSocket: { dev: string; ino: string } | undefined;
506
+ let ready = false;
507
+ let exitCode = 0;
508
+ let orphanCheck: NodeJS.Timeout | undefined;
509
+ let confirmDeadline: NodeJS.Timeout | undefined;
510
+ const clearConfirmDeadline = (): void => {
511
+ if (confirmDeadline === undefined) return;
512
+ clearTimeout(confirmDeadline);
513
+ confirmDeadline = undefined;
514
+ };
515
+ const connections = new Set<net.Socket>();
516
+ let requestStop: ((reason: 'drain' | 'tail-exit' | 'fatal') => void) | undefined;
517
+ const onSigterm = (): void => requestStop?.('drain');
518
+
519
+ try {
520
+ const args = parseWorkerArgs(argv);
521
+ const nonce = process.env.CAPTURE_LOG_TAIL_NONCE;
522
+ if (!nonce || !LOG_TAILER_NONCE.test(nonce)) throw new Error('missing or invalid control nonce');
523
+ socketPath = socketPathForToken(args.socketToken, true);
524
+
525
+ let stopReason: 'drain' | 'tail-exit' | 'fatal' | undefined;
526
+ const stopped = new Promise<'drain' | 'tail-exit' | 'fatal'>(resolve => {
527
+ requestStop = reason => {
528
+ if (stopReason !== undefined) return;
529
+ stopReason = reason;
530
+ resolve(reason);
531
+ };
532
+ });
533
+
534
+ server = net.createServer(connection => {
535
+ connections.add(connection);
536
+ connection.setEncoding('utf-8');
537
+ connection.setTimeout(2_000, () => connection.destroy());
538
+ let input = '';
539
+ let handled = false;
540
+ const reply = (accepted: 'drain' | 'confirm' | false): void => {
541
+ if (handled) return;
542
+ handled = true;
543
+ connection.end(`${JSON.stringify({ ok: accepted !== false })}\n`);
544
+ if (accepted === 'drain') requestStop?.('drain');
545
+ else if (accepted === 'confirm') clearConfirmDeadline();
546
+ };
547
+ connection.on('data', (chunk: string) => {
548
+ input += chunk;
549
+ if (input.length > CONTROL_MESSAGE_LIMIT) { reply(false); return; }
550
+ const newline = input.indexOf('\n');
551
+ if (newline < 0) return;
552
+ let accepted: 'drain' | 'confirm' | false = false;
553
+ try {
554
+ const message = JSON.parse(input.slice(0, newline)) as { op?: unknown; nonce?: unknown };
555
+ if (nonceMatches(nonce, message.nonce) && message.op === 'drain') accepted = 'drain';
556
+ // A confirm is meaningful only while the deadline is still armed.
557
+ else if (nonceMatches(nonce, message.nonce) && message.op === 'confirm' && confirmDeadline !== undefined) accepted = 'confirm';
558
+ } catch { /* invalid control message */ }
559
+ reply(accepted);
560
+ });
561
+ connection.on('error', () => { /* one failed client does not affect ownership */ });
562
+ connection.on('close', () => connections.delete(connection));
563
+ });
564
+ await new Promise<void>((resolve, reject) => {
565
+ const onError = (error: Error): void => { server!.removeListener('listening', onListening); reject(error); };
566
+ const onListening = (): void => { server!.removeListener('error', onError); resolve(); };
567
+ server!.once('error', onError);
568
+ server!.once('listening', onListening);
569
+ server!.listen(socketPath);
570
+ });
571
+ ownedSocket = socketIdentity(socketPath);
572
+ server.on('error', () => requestStop?.('fatal'));
573
+
574
+ // Orphan self-check: the recorded control socket is the one channel any
575
+ // owner (a registered session handle) can ever reach this worker through.
576
+ // If that directory entry disappears or is replaced, no `session stop` can
577
+ // drain it, so the worker tears itself down instead of tailing forever as
578
+ // an unowned process. Each probe is one lstat; nothing is held open
579
+ // between checks. Only a provable loss of the entry — ENOENT or a
580
+ // different inode — counts; a transiently failing stat never does.
581
+ const ownedSocketPath = socketPath;
582
+ const owned = ownedSocket;
583
+ orphanCheck = setInterval(() => {
584
+ try {
585
+ const current = fs.lstatSync(ownedSocketPath, { bigint: true });
586
+ if (current.isSocket()
587
+ && current.dev.toString() === owned.dev
588
+ && current.ino.toString() === owned.ino) return;
589
+ } catch (error) {
590
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return;
591
+ }
592
+ requestStop?.('fatal');
593
+ }, args.orphanCheckIntervalMs);
594
+
595
+ const tailEnv = { ...process.env };
596
+ delete tailEnv.CAPTURE_LOG_TAIL_NONCE;
597
+ tail = spawn('tail', ['-f', '--', args.sourcePath], {
598
+ shell: false,
599
+ stdio: ['ignore', 'pipe', 'ignore'],
600
+ env: tailEnv,
601
+ });
602
+ tailExited = waitForChild(tail);
603
+ reader = readline.createInterface({ input: tail.stdout!, crlfDelay: Infinity });
604
+ reader.once('close', () => { readerClosed = true; });
605
+ reader.on('line', line => {
606
+ try { fs.writeSync(1, `${new Date().toISOString()} ${line}\n`); }
607
+ catch { requestStop?.('fatal'); }
608
+ });
609
+ tail.once('exit', () => requestStop?.('tail-exit'));
610
+ tail.once('error', () => requestStop?.('fatal'));
611
+ await new Promise<void>((resolve, reject) => {
612
+ tail!.once('spawn', resolve);
613
+ tail!.once('error', reject);
614
+ });
615
+ if (tail.exitCode !== null || tail.signalCode !== null) throw new Error('tail exited during startup');
616
+
617
+ process.once('SIGTERM', onSigterm);
618
+ if (!writeReadiness('ready\n')) throw new Error('parent closed the readiness channel');
619
+ ready = true;
620
+ try { fs.closeSync(READINESS_FD); } catch { /* the parent owns readiness completion */ }
621
+ // A worker never confirmed was never durably registered — its parent died
622
+ // between spawn and registration — so it tears itself down instead of
623
+ // tailing as an orphan no session record owns.
624
+ confirmDeadline = setTimeout(() => { confirmDeadline = undefined; requestStop?.('fatal'); }, args.confirmTimeoutMs);
625
+
626
+ const reason = await stopped;
627
+ exitCode = reason === 'drain' ? 0 : 1;
628
+ await stopWorkerChild(tail, tailExited);
629
+ if (reader && !readerClosed) {
630
+ const drained = await resolvesWithin(new Promise<void>(resolve => reader!.once('close', () => resolve())), WORKER_CHILD_EXIT_TIMEOUT_MS);
631
+ if (!drained) throw new Error('tail output did not drain');
632
+ }
633
+ } catch (error) {
634
+ exitCode = 1;
635
+ if (!ready) writeReadiness(readinessError(error));
636
+ if (tail && tailExited) {
637
+ try { await stopWorkerChild(tail, tailExited); } catch { /* process exit code preserves the worker failure */ }
638
+ }
639
+ } finally {
640
+ if (orphanCheck !== undefined) clearInterval(orphanCheck);
641
+ clearConfirmDeadline();
642
+ process.removeListener('SIGTERM', onSigterm);
643
+ try { reader?.close(); } catch { /* child output is already closed */ }
644
+ await closeServer(server, connections);
645
+ if (socketPath && ownedSocket) {
646
+ try {
647
+ const current = socketIdentity(socketPath);
648
+ if (current.dev === ownedSocket.dev && current.ino === ownedSocket.ino) fs.unlinkSync(socketPath);
649
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') exitCode = 1; }
650
+ }
651
+ process.exitCode = exitCode;
652
+ }
653
+ }