@mnemonik/shared 7.154.3 → 7.155.2

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 (46) hide show
  1. package/dist/ast/grammars.d.ts +4 -2
  2. package/dist/ast/grammars.d.ts.map +1 -1
  3. package/dist/ast/grammars.js +45 -3
  4. package/dist/ast/grammars.js.map +1 -1
  5. package/dist/codeScanner.d.ts +26 -329
  6. package/dist/codeScanner.d.ts.map +1 -1
  7. package/dist/codeScanner.js +65 -1141
  8. package/dist/codeScanner.js.map +1 -1
  9. package/dist/fileSelection.d.ts +451 -0
  10. package/dist/fileSelection.d.ts.map +1 -0
  11. package/dist/fileSelection.js +1256 -0
  12. package/dist/fileSelection.js.map +1 -0
  13. package/dist/hookTrace.d.ts +40 -0
  14. package/dist/hookTrace.d.ts.map +1 -1
  15. package/dist/hookTrace.js +126 -0
  16. package/dist/hookTrace.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/runtimeAssets.d.ts +12 -0
  22. package/dist/runtimeAssets.d.ts.map +1 -0
  23. package/dist/runtimeAssets.js +43 -0
  24. package/dist/runtimeAssets.js.map +1 -0
  25. package/dist/sourceRedaction.d.ts +45 -1
  26. package/dist/sourceRedaction.d.ts.map +1 -1
  27. package/dist/sourceRedaction.js +62 -22
  28. package/dist/sourceRedaction.js.map +1 -1
  29. package/dist/sourceRedactionPool.d.ts +198 -0
  30. package/dist/sourceRedactionPool.d.ts.map +1 -0
  31. package/dist/sourceRedactionPool.js +756 -0
  32. package/dist/sourceRedactionPool.js.map +1 -0
  33. package/dist/vueSource.d.ts +0 -2
  34. package/dist/vueSource.d.ts.map +1 -1
  35. package/dist/vueSource.js +8 -19
  36. package/dist/vueSource.js.map +1 -1
  37. package/package.json +5 -1
  38. package/src/ast/grammars.ts +52 -7
  39. package/src/codeScanner.ts +121 -1321
  40. package/src/fileSelection.ts +1440 -0
  41. package/src/hookTrace.ts +143 -0
  42. package/src/index.ts +31 -2
  43. package/src/runtimeAssets.ts +49 -0
  44. package/src/sourceRedaction.ts +85 -20
  45. package/src/sourceRedactionPool.ts +877 -0
  46. package/src/vueSource.ts +8 -23
@@ -0,0 +1,877 @@
1
+ /**
2
+ * Source redaction in its own processes, without a clock deciding a file's
3
+ * fate. Used by the server (Swift, and every file of a megabyte or more) and
4
+ * by the scanner (every file of a megabyte or more), each supplying how to
5
+ * start its child (`spawn`).
6
+ *
7
+ * Why a separate process at all: Swift's tree-sitter grammar, compiled by
8
+ * TurboFan, allocates 0.5 to 0.7 GB and can take Node down with it, so Swift
9
+ * is parsed in Liftoff-only children (measured 18 October 2026). What this
10
+ * replaces forked one child per upload batch, capped its heap at 128 MB and
11
+ * killed it after 5 seconds (`source_redaction_timeout`). A large Swift file,
12
+ * or any Swift file on a busy task, then failed redaction and its whole
13
+ * upload batch failed with it, every retry, with no line saying why: the same
14
+ * shape as MNEMONIK-CV, where a wall clock and a fixed heap decided a file's
15
+ * fate (stream F2 fixed that one in its parse pool, whose design this
16
+ * mirrors: src/agent/codeConnections/parsePool.ts on fix/scale-f2-code).
17
+ *
18
+ * - A persistent child keeps the grammar loaded between files; uploads queue
19
+ * for it in arrival order.
20
+ * - Its heap is sized from the input: a file of LARGE_FILE_BYTES or more gets
21
+ * its own child with 128 MB plus 16 MB per MB of source (110 MB per MB for
22
+ * Vue and Svelte components, whose compilers build JavaScript syntax trees),
23
+ * and a file that still runs out of memory is tried once more with twice
24
+ * that.
25
+ * - Memory admission: up to MAX_LARGE_CHILDREN large children run at once,
26
+ * each only when the memory it needs fits what the probe reports (the ECS
27
+ * task's limit less the container's memory.current on the server; the
28
+ * cgroup working set, or the scanner's own total budget less its resident
29
+ * memory, on a machine), less a reserve, less what running children may
30
+ * still grow into and what admitted-but-waiting work holds. A child that
31
+ * would not fit even alone is never started, since running the container
32
+ * out of memory ends every request it is serving; the file fails counted
33
+ * (`source_redaction_exceeds_memory`).
34
+ * - No deadline, but a ceiling on work: a file that has used
35
+ * CPU_CEILING_SECONDS of the child's CPU (about ten times the slowest file
36
+ * measured) fails (`source_redaction_cpu_ceiling`) and its child is
37
+ * replaced, so no file holds a child indefinitely. A child that holds a file
38
+ * and has used no CPU for STALL_POLLS polls in a row is presumed hung.
39
+ * CPU time comes from /proc on Linux and `ps` elsewhere; where neither
40
+ * exists (Windows), only an exit ends a file.
41
+ * - Interactive files (an agent's file_push) are served before background
42
+ * ingest, in both queues.
43
+ * - A failure is counted (`source_redaction.failed`), logged with the path's
44
+ * extension and size, and thrown for that file alone: callers store the
45
+ * other files and report the failed one as not uploaded, with its reason.
46
+ * Redacted text is never replaced by unredacted text.
47
+ */
48
+ import { execFile } from 'node:child_process';
49
+ import { EventEmitter } from 'node:events';
50
+ import { parentPort, type Worker } from 'node:worker_threads';
51
+ import { readFileSync } from 'node:fs';
52
+ import { scrubSourceSecretsInProcess } from './sourceRedaction.js';
53
+
54
+ const MB = 1024 * 1024;
55
+ /** At and above this length a file gets a child of its own, sized from it. */
56
+ export const LARGE_FILE_BYTES = 1 * MB;
57
+ /** The shared child's heap: comfortably above any file under LARGE_FILE_BYTES. */
58
+ export const SHARED_HEAP_MB = 256;
59
+ /** A shared child is replaced once its resident memory passes this (WebAssembly memory never shrinks). */
60
+ const SHARED_RECYCLE_RSS_MB = 512;
61
+ export const STALL_POLL_MS = 5_000;
62
+ /** One minute without any CPU while holding a file. */
63
+ export const STALL_POLLS = 12;
64
+ /**
65
+ * CPU a single file may use. Generous: the slowest files measured (26 October
66
+ * 2026, loaded host) were a 3.1 MB Svelte component at 23 s and a 9.9 MB Vue
67
+ * component at 17 s.
68
+ */
69
+ export const CPU_CEILING_SECONDS = 300;
70
+ /**
71
+ * CPU a single file may use in the shared small-file child before it is sent
72
+ * to a child of its own: one tenant's crafted file must not stall small-file
73
+ * redaction for everyone on the task.
74
+ */
75
+ export const SHARED_CPU_CEILING_SECONDS = 30;
76
+ /**
77
+ * Failures that say something about the file itself, which a scanner counts
78
+ * as strikes against that revision: it ran to the CPU ceiling, ran out of
79
+ * memory at twice its heap, or could not be redacted. Not counted: a child
80
+ * that would not fit in memory now, or one that stalled; those are retried.
81
+ */
82
+ export const STRIKE_REASONS: ReadonlySet<string> = new Set([
83
+ 'source_redaction_cpu_ceiling',
84
+ 'source_redaction_out_of_memory',
85
+ 'source_redaction_invalid_result',
86
+ ]);
87
+ /** Large children a pool runs at once, memory permitting. */
88
+ export const MAX_LARGE_CHILDREN = 2;
89
+ /**
90
+ * A scanner's total memory, itself and its redaction children: the systemd
91
+ * unit's MemoryMax, and the budget used where no control group limits it.
92
+ */
93
+ export const SCANNER_TOTAL_MEMORY_MB = 1536;
94
+
95
+ /**
96
+ * The heap a file's child may use: 128 MB plus 16 MB per MB of source (as F2's
97
+ * parse pool), or 110 MB per MB for Vue and Svelte components. Measured 26
98
+ * October 2026 (Liftoff-only): a 9.9 MB Vue component of about 100,000
99
+ * template nodes ran out of memory at 900 MB and completed at 1,228 MB (peak
100
+ * resident 1.13 GB); a 3.1 MB Svelte component completed at 500 MB.
101
+ */
102
+ export function redactionHeapMb(bytes: number, filePath = ''): number {
103
+ const perMb = COMPONENT_SOURCE.test(filePath) ? 110 : 16;
104
+ return Math.ceil(128 + perMb * (bytes / MB));
105
+ }
106
+ const COMPONENT_SOURCE = /\.(?:vue|svelte)$/i;
107
+ /** Resident memory a child uses beyond its heap (code, grammars, buffers). */
108
+ export const CHILD_OVERHEAD_MB = 128;
109
+ /** Memory left free for everything else when a large child is admitted. */
110
+ export const ADMISSION_RESERVE_MB = 256;
111
+
112
+ /** What a memory probe reports, in MB. */
113
+ export interface MemoryReading {
114
+ limitMb: number;
115
+ usedMb: number;
116
+ /** Whether usedMb already includes this pool's running children. */
117
+ includesChildren: boolean;
118
+ source: string;
119
+ }
120
+ export type MemoryProbe = () => MemoryReading | Promise<MemoryReading>;
121
+
122
+ export interface CgroupMemory {
123
+ /** null when the group has no limit. */
124
+ limitMb: number | null;
125
+ /** memory.current: everything charged, page cache included. */
126
+ currentMb: number;
127
+ /** memory.current less inactive file cache: what the kernel cannot easily reclaim. */
128
+ workingSetMb: number;
129
+ }
130
+
131
+ type ReadText = (path: string) => string;
132
+ const readText: ReadText = (path) => readFileSync(path, 'utf8');
133
+
134
+ function numberIn(read: ReadText, path: string): number | null {
135
+ try {
136
+ const text = read(path).trim();
137
+ if (text === 'max') return null;
138
+ const value = Number(text);
139
+ return Number.isFinite(value) ? value : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function statField(read: ReadText, path: string, names: string[]): number {
146
+ try {
147
+ const lines = read(path).split('\n');
148
+ for (const name of names) {
149
+ const line = lines.find((entry) => entry.startsWith(`${name} `));
150
+ if (line) return Number(line.slice(name.length + 1)) || 0;
151
+ }
152
+ } catch {
153
+ // No stat file: nothing reclaimable is subtracted.
154
+ }
155
+ return 0;
156
+ }
157
+
158
+ /** This process's control group memory (v2, else v1), or null where there is none. */
159
+ export function cgroupMemory(read: ReadText = readText): CgroupMemory | null {
160
+ try {
161
+ const line = read('/proc/self/cgroup')
162
+ .split('\n')
163
+ .find((entry) => entry.startsWith('0::'));
164
+ if (line) {
165
+ const path = line.slice(3).trim();
166
+ const dir = `/sys/fs/cgroup${path === '/' ? '' : path}`;
167
+ const current = numberIn(read, `${dir}/memory.current`);
168
+ if (current !== null) {
169
+ const limit = numberIn(read, `${dir}/memory.max`);
170
+ const inactive = statField(read, `${dir}/memory.stat`, ['inactive_file']);
171
+ return {
172
+ limitMb: limit === null ? null : limit / MB,
173
+ currentMb: current / MB,
174
+ workingSetMb: Math.max(0, current - inactive) / MB,
175
+ };
176
+ }
177
+ }
178
+ } catch {
179
+ // Not Linux, or no v2 hierarchy: try v1.
180
+ }
181
+ const usage = numberIn(read, '/sys/fs/cgroup/memory/memory.usage_in_bytes');
182
+ if (usage === null) return null;
183
+ const limit = numberIn(read, '/sys/fs/cgroup/memory/memory.limit_in_bytes');
184
+ const inactive = statField(read, '/sys/fs/cgroup/memory/memory.stat', [
185
+ 'total_inactive_file',
186
+ 'inactive_file',
187
+ ]);
188
+ return {
189
+ limitMb: limit === null || limit >= 2 ** 60 ? null : limit / MB,
190
+ currentMb: usage / MB,
191
+ workingSetMb: Math.max(0, usage - inactive) / MB,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * The API server's probe: the task's memory limit (ECS task metadata, else the
197
+ * cgroup, else the host: the caller supplies it) less the container's working
198
+ * set (memory.current less inactive file cache, which the kernel reclaims
199
+ * first). Without a control group, this process's resident memory.
200
+ */
201
+ export function containerMemoryProbe(
202
+ limitMb: () => number | Promise<number>,
203
+ read: ReadText = readText
204
+ ): MemoryProbe {
205
+ return async () => {
206
+ const group = cgroupMemory(read);
207
+ return {
208
+ limitMb: await limitMb(),
209
+ usedMb: group ? group.workingSetMb : process.memoryUsage().rss / MB,
210
+ includesChildren: group !== null,
211
+ source: group ? 'container_working_set' : 'process_resident',
212
+ };
213
+ };
214
+ }
215
+
216
+ /**
217
+ * A scanner's probe. Under a control group limit (Linux, the systemd unit):
218
+ * the limit less the working set, since the scanner's file reads leave page
219
+ * cache the kernel reclaims before it would kill anything. Elsewhere (macOS,
220
+ * Windows, an unlimited group): the scanner's own total budget less its own
221
+ * resident memory, never the machine's free memory, which on macOS counts
222
+ * compressed and cached pages as used and would refuse almost everything.
223
+ */
224
+ export function scannerMemoryProbe(
225
+ totalBudgetMb = SCANNER_TOTAL_MEMORY_MB,
226
+ read: ReadText = readText,
227
+ /** Children are worker threads, so this process's own resident memory includes them. */
228
+ childrenInProcess = false
229
+ ): MemoryProbe {
230
+ return () => {
231
+ const group = cgroupMemory(read);
232
+ if (group && group.limitMb !== null)
233
+ return {
234
+ limitMb: group.limitMb,
235
+ usedMb: group.workingSetMb,
236
+ includesChildren: true,
237
+ source: 'cgroup_working_set',
238
+ };
239
+ return {
240
+ limitMb: totalBudgetMb,
241
+ usedMb: process.memoryUsage().rss / MB,
242
+ includesChildren: childrenInProcess,
243
+ source: 'own_budget',
244
+ };
245
+ };
246
+ }
247
+
248
+ export type RedactionPriority = 'interactive' | 'background';
249
+
250
+ export interface RedactionInput {
251
+ text: string;
252
+ filePath: string;
253
+ /** An agent waiting on this file (file_push) goes before background ingest. */
254
+ priority?: RedactionPriority;
255
+ }
256
+
257
+ type Reply = { id: number; value: string } | { id: number; error: string; rssMb?: number };
258
+
259
+ const CLOCK_TICKS_PER_SECOND = 100;
260
+
261
+ function parsePsTime(text: string): number | null {
262
+ // [[DD-]HH:]MM:SS[.ss]
263
+ const trimmed = text.trim();
264
+ if (!trimmed) return null;
265
+ const [dayPart, rest] = trimmed.includes('-') ? trimmed.split('-', 2) : ['0', trimmed];
266
+ const parts = (rest ?? '').split(':').map(Number);
267
+ if (parts.some((part) => !Number.isFinite(part))) return null;
268
+ let seconds = 0;
269
+ for (const part of parts) seconds = seconds * 60 + part;
270
+ return seconds + Number(dayPart) * 86_400;
271
+ }
272
+
273
+ /** CPU seconds (user + system) a process has used: /proc on Linux, `ps` elsewhere, null on Windows. */
274
+ export async function processCpuSeconds(pid: number | undefined): Promise<number | null> {
275
+ if (!pid) return null;
276
+ if (process.platform === 'linux') {
277
+ try {
278
+ const fields = readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.split(' ') ?? [];
279
+ const ticks = Number(fields[11]) + Number(fields[12]);
280
+ return Number.isFinite(ticks) ? ticks / CLOCK_TICKS_PER_SECOND : null;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+ if (process.platform === 'win32') return null;
286
+ return new Promise((resolve) => {
287
+ execFile('ps', ['-o', 'time=', '-p', String(pid)], { timeout: 4_000 }, (error, stdout) =>
288
+ resolve(error ? null : parsePsTime(String(stdout)))
289
+ );
290
+ });
291
+ }
292
+ export { parsePsTime as parsePsTimeForTest };
293
+
294
+ function residentMb(pid: number | undefined): number | null {
295
+ if (!pid || process.platform !== 'linux') return null;
296
+ try {
297
+ const match = /VmRSS:\s+(\d+) kB/.exec(readFileSync(`/proc/${pid}/status`, 'utf8'));
298
+ return match ? Number(match[1]) / 1024 : null;
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * What the pool needs from a redaction child: a forked process (the server),
306
+ * or a worker thread (the scanner: redactionWorkerThread).
307
+ */
308
+ export interface RedactionChild {
309
+ readonly pid?: number;
310
+ readonly channel?: { ref?(): void; unref?(): void } | null;
311
+ send(message: unknown, callback?: (error: Error | null) => void): boolean;
312
+ // Listener shapes differ per event (as on ChildProcess and EventEmitter).
313
+ /* eslint-disable @typescript-eslint/no-explicit-any */
314
+ on(event: string, listener: (...args: any[]) => void): unknown;
315
+ once(event: string, listener: (...args: any[]) => void): unknown;
316
+ /* eslint-enable @typescript-eslint/no-explicit-any */
317
+ kill(signal?: NodeJS.Signals): boolean;
318
+ ref?(): void;
319
+ unref?(): void;
320
+ /** CPU seconds this child has used, where it can say so itself (a worker thread, on any platform). */
321
+ cpuSeconds?(): Promise<number | null>;
322
+ /**
323
+ * Memory this child is using now, in MB, where it can say so itself: a
324
+ * worker thread's heap plus its external memory (WebAssembly and Buffers,
325
+ * which resourceLimits does not bound).
326
+ */
327
+ memoryMb?(): Promise<number | null>;
328
+ }
329
+
330
+ /**
331
+ * A worker thread as a redaction child. Its heap is the Worker's
332
+ * resourceLimits (sized per file by the caller), and running out of it ends
333
+ * the worker, not the process, reported as out of memory so the pool retries
334
+ * with twice the heap. Its CPU time comes from the Worker itself, on every
335
+ * platform, even while its own thread is busy (review of 26 Oct 2026: on
336
+ * Windows there was no CPU ceiling or stall detection).
337
+ */
338
+ export function redactionWorkerThread(worker: Worker): RedactionChild {
339
+ const events = new EventEmitter();
340
+ let outOfMemory = false;
341
+ let online = false;
342
+ worker.once('online', () => {
343
+ online = true;
344
+ });
345
+ worker.on('message', (message: unknown) => events.emit('message', message));
346
+ worker.on('error', (error: NodeJS.ErrnoException) => {
347
+ if (error.code === 'ERR_WORKER_OUT_OF_MEMORY') outOfMemory = true;
348
+ else events.emit('error', error);
349
+ });
350
+ worker.once('exit', (code: number) => events.emit('exit', code, outOfMemory ? 'SIGABRT' : null));
351
+ return {
352
+ channel: null,
353
+ send(message, callback) {
354
+ try {
355
+ worker.postMessage(message);
356
+ callback?.(null);
357
+ } catch (error) {
358
+ callback?.(error as Error);
359
+ }
360
+ return true;
361
+ },
362
+ on: (event, listener) => events.on(event, listener),
363
+ once: (event, listener) => events.once(event, listener),
364
+ kill() {
365
+ void worker.terminate();
366
+ return true;
367
+ },
368
+ ref: () => worker.ref(),
369
+ unref: () => worker.unref(),
370
+ async memoryMb() {
371
+ if (!online) return null;
372
+ try {
373
+ // Worker#getHeapStatistics is not in this @types/node yet.
374
+ const stats = await (
375
+ worker as Worker & {
376
+ getHeapStatistics(): Promise<{ total_heap_size: number; external_memory?: number }>;
377
+ }
378
+ ).getHeapStatistics();
379
+ return (stats.total_heap_size + (stats.external_memory ?? 0)) / MB;
380
+ } catch {
381
+ return null;
382
+ }
383
+ },
384
+ async cpuSeconds() {
385
+ if (!online) return null;
386
+ try {
387
+ // Worker#cpuUsage (Node 24.6+) is not in this @types/node yet.
388
+ const usage = await (
389
+ worker as Worker & { cpuUsage(): Promise<{ user: number; system: number }> }
390
+ ).cpuUsage();
391
+ return (usage.user + usage.system) / 1e6;
392
+ } catch {
393
+ return null; // not running (yet or any more)
394
+ }
395
+ },
396
+ };
397
+ }
398
+
399
+ export interface ProcessTimings {
400
+ pollMs: number;
401
+ stallPolls: number;
402
+ cpuCeilingSeconds: number;
403
+ cpuSeconds: (pid: number | undefined) => Promise<number | null>;
404
+ }
405
+
406
+ class RedactionProcess {
407
+ readonly child: RedactionChild;
408
+ private nextId = 1;
409
+ private current: {
410
+ id: number;
411
+ resolve: (value: string) => void;
412
+ reject: (error: Error) => void;
413
+ } | null = null;
414
+ private poll: NodeJS.Timeout | undefined;
415
+ private polling = false;
416
+ private startCpu: number | null = null;
417
+ private lastCpu: number | null = null;
418
+ private stillPolls = 0;
419
+ rssMb = 0;
420
+ /** Memory the child last reported using (MB), or null where it cannot say. */
421
+ usedMb: number | null = null;
422
+ dead = false;
423
+
424
+ constructor(
425
+ readonly heapMb: number,
426
+ spawn: (heapMb: number) => RedactionChild,
427
+ private readonly timings: ProcessTimings,
428
+ /** CPU a file may use here; past it the file fails with ceilingReason. */
429
+ private readonly ceiling: { seconds: number; reason: string },
430
+ /**
431
+ * Memory this child may use in all (MB): its heap and the overhead around
432
+ * it. A worker's WebAssembly and Buffer memory are outside its heap limit,
433
+ * so the pool checks what it reports and ends it past this.
434
+ */
435
+ readonly budgetMb: number,
436
+ private readonly onDeath: (process: RedactionProcess) => void,
437
+ /** Called each time the child reports what it uses (admission reads it). */
438
+ private readonly onUsage: () => void = () => undefined
439
+ ) {
440
+ this.child = spawn(heapMb);
441
+ this.child.on('message', (reply: Reply) => this.receive(reply));
442
+ this.child.once('error', () => this.die('source_redaction_worker_failed'));
443
+ this.child.once('exit', (code, signal) =>
444
+ this.die(
445
+ signal === 'SIGABRT' || code === 134
446
+ ? 'source_redaction_out_of_memory'
447
+ : 'source_redaction_worker_exit'
448
+ )
449
+ );
450
+ this.child.unref?.();
451
+ this.child.channel?.unref?.();
452
+ }
453
+
454
+ get busy(): boolean {
455
+ return this.current !== null;
456
+ }
457
+
458
+ redact(input: RedactionInput): Promise<string> {
459
+ if (this.dead) return Promise.reject(new Error('source_redaction_worker_exit'));
460
+ if (this.current) return Promise.reject(new Error('source_redaction_busy'));
461
+ const id = this.nextId++;
462
+ return new Promise<string>((resolve, reject) => {
463
+ this.current = { id, resolve, reject };
464
+ // Hold the process open while a file is in flight: an idle child is
465
+ // unreferenced so it never keeps its parent alive, but a parent whose
466
+ // only pending work is this reply (a one-shot export) would otherwise
467
+ // exit mid-await with status 0 and nothing written.
468
+ this.child.ref?.();
469
+ this.child.channel?.ref?.();
470
+ this.startCpu = null;
471
+ this.lastCpu = null;
472
+ this.stillPolls = 0;
473
+ void this.sampleCpu();
474
+ this.poll = setInterval(() => void this.sampleCpu(), this.timings.pollMs);
475
+ this.poll.unref();
476
+ try {
477
+ this.child.send({ id, text: input.text, filePath: input.filePath }, (error) => {
478
+ if (error) this.settle(new Error('source_redaction_send_failed'));
479
+ });
480
+ } catch {
481
+ this.settle(new Error('source_redaction_send_failed'));
482
+ }
483
+ });
484
+ }
485
+
486
+ private async sampleCpu(): Promise<void> {
487
+ if (this.polling || !this.current) return;
488
+ this.polling = true;
489
+ const holding = this.current;
490
+ try {
491
+ const used = (await this.child.memoryMb?.()) ?? null;
492
+ if (used !== null && this.current === holding) {
493
+ this.usedMb = used;
494
+ if (used > this.budgetMb) {
495
+ // Past its budget in memory its heap limit does not see: out of
496
+ // memory, as if its heap had run out (retried once with twice the
497
+ // heap, then a strike), before the scanner itself has to restart.
498
+ this.settle(new Error('source_redaction_out_of_memory'));
499
+ this.kill();
500
+ return;
501
+ }
502
+ this.onUsage();
503
+ }
504
+ const cpu = this.child.cpuSeconds
505
+ ? await this.child.cpuSeconds()
506
+ : await this.timings.cpuSeconds(this.child.pid);
507
+ if (cpu === null || this.current !== holding) return; // no CPU accounting: only an exit ends the file
508
+ if (this.startCpu === null) {
509
+ this.startCpu = cpu;
510
+ this.lastCpu = cpu;
511
+ return;
512
+ }
513
+ if (cpu - this.startCpu >= this.ceiling.seconds) {
514
+ this.settle(new Error(this.ceiling.reason));
515
+ this.kill();
516
+ return;
517
+ }
518
+ this.stillPolls = cpu === this.lastCpu ? this.stillPolls + 1 : 0;
519
+ this.lastCpu = cpu;
520
+ if (this.stillPolls >= this.timings.stallPolls) {
521
+ this.settle(new Error('source_redaction_stalled'));
522
+ this.kill();
523
+ }
524
+ } finally {
525
+ this.polling = false;
526
+ }
527
+ }
528
+
529
+ private receive(reply: Reply): void {
530
+ if (!this.current || reply.id !== this.current.id) return;
531
+ if ('value' in reply && typeof reply.value === 'string') this.settle(null, reply.value);
532
+ else this.settle(new Error('source_redaction_invalid_result'));
533
+ if ('rssMb' in reply && typeof reply.rssMb === 'number') this.rssMb = reply.rssMb;
534
+ }
535
+
536
+ private settle(error: Error | null, value?: string): void {
537
+ const current = this.current;
538
+ this.current = null;
539
+ if (this.poll) clearInterval(this.poll);
540
+ this.poll = undefined;
541
+ this.child.unref?.();
542
+ this.child.channel?.unref?.();
543
+ if (!current) return;
544
+ if (error) current.reject(error);
545
+ else current.resolve(value as string);
546
+ }
547
+
548
+ private die(reason: string): void {
549
+ if (this.dead) return;
550
+ this.dead = true;
551
+ this.settle(new Error(reason));
552
+ this.onDeath(this);
553
+ }
554
+
555
+ kill(): void {
556
+ if (this.dead) return;
557
+ this.child.kill('SIGKILL');
558
+ this.dead = true;
559
+ this.settle(new Error('source_redaction_worker_exit'));
560
+ this.onDeath(this);
561
+ }
562
+ }
563
+
564
+ export interface SourceRedactionPoolOptions {
565
+ /** Start a worker child that runs runSourceRedactionWorker, with this heap. */
566
+ spawn: (heapMb: number) => RedactionChild;
567
+ /** Counted, logged and reported failures: never silent. */
568
+ onFailure: (reason: string, details: { bytes: number; extension: string }) => void;
569
+ onRetry?: (reason: string, details: { bytes: number; extension: string }) => void;
570
+ /** Memory a new child is judged against (default: scannerMemoryProbe()). */
571
+ memory?: MemoryProbe;
572
+ /** Memory, in MB, held by admitted work not yet redacted (bodies waiting in this task). */
573
+ heldMb?: () => number;
574
+ maxLargeChildren?: number;
575
+ cpuCeilingSeconds?: number;
576
+ /** CPU a file may use in the shared small-file child before it gets its own. */
577
+ sharedCpuCeilingSeconds?: number;
578
+ pollMs?: number;
579
+ stallPolls?: number;
580
+ cpuSeconds?: (pid: number | undefined) => Promise<number | null>;
581
+ }
582
+
583
+ /** Reasons a second, larger attempt cannot help. */
584
+ const SHARED_CEILING_REASON = 'source_redaction_shared_cpu_ceiling';
585
+
586
+ const FINAL_REASONS = new Set([
587
+ 'source_redaction_invalid_result',
588
+ 'source_redaction_exceeds_memory',
589
+ 'source_redaction_cpu_ceiling',
590
+ ]);
591
+
592
+ interface Job {
593
+ input: RedactionInput;
594
+ bytes: number;
595
+ heapMb: number;
596
+ large: boolean;
597
+ interactive: boolean;
598
+ seq: number;
599
+ resolve: (value: string) => void;
600
+ reject: (error: Error) => void;
601
+ }
602
+
603
+ interface Running {
604
+ process: RedactionProcess;
605
+ reservedMb: number;
606
+ }
607
+
608
+ export class SourceRedactionPool {
609
+ private shared: RedactionProcess | null = null;
610
+ private readonly sharedQueue: Job[] = [];
611
+ private readonly largeQueue: Job[] = [];
612
+ private readonly running = new Set<Running>();
613
+ private seq = 0;
614
+ private admitting = false;
615
+ private admitAgain = false;
616
+ /** Large children finished so far (admission re-reads memory when this moves during a read). */
617
+ private finished = 0;
618
+ private readonly timings: ProcessTimings;
619
+
620
+ constructor(private readonly options: SourceRedactionPoolOptions) {
621
+ this.timings = {
622
+ pollMs: options.pollMs ?? STALL_POLL_MS,
623
+ stallPolls: options.stallPolls ?? STALL_POLLS,
624
+ cpuCeilingSeconds: options.cpuCeilingSeconds ?? CPU_CEILING_SECONDS,
625
+ cpuSeconds: options.cpuSeconds ?? processCpuSeconds,
626
+ };
627
+ }
628
+
629
+ /** Large children running now (tests and diagnostics). */
630
+ get largeRunning(): number {
631
+ return this.running.size;
632
+ }
633
+
634
+ /** Interactive before background; arrival order within each. */
635
+ private enqueue(queue: Job[], job: Job): void {
636
+ const goesBefore = (other: Job) =>
637
+ job.interactive !== other.interactive ? job.interactive : job.seq < other.seq;
638
+ const index = queue.findIndex(goesBefore);
639
+ queue.splice(index < 0 ? queue.length : index, 0, job);
640
+ }
641
+
642
+ private runShared(): void {
643
+ if (this.shared?.busy) return;
644
+ const job = this.sharedQueue.shift();
645
+ if (!job) return;
646
+ if (!this.shared || this.shared.dead || this.shared.rssMb > SHARED_RECYCLE_RSS_MB) {
647
+ this.shared?.kill();
648
+ const created = new RedactionProcess(
649
+ SHARED_HEAP_MB,
650
+ this.options.spawn,
651
+ this.timings,
652
+ {
653
+ seconds: this.options.sharedCpuCeilingSeconds ?? SHARED_CPU_CEILING_SECONDS,
654
+ reason: SHARED_CEILING_REASON,
655
+ },
656
+ SHARED_HEAP_MB + CHILD_OVERHEAD_MB,
657
+ (dead) => {
658
+ if (this.shared === dead) this.shared = null;
659
+ }
660
+ );
661
+ this.shared = created;
662
+ }
663
+ const process = this.shared;
664
+ process.redact(job.input).then(
665
+ (value) => {
666
+ job.resolve(value);
667
+ this.runShared();
668
+ },
669
+ (error: Error) => {
670
+ job.reject(error);
671
+ this.runShared();
672
+ }
673
+ );
674
+ }
675
+
676
+ /**
677
+ * Memory the redaction children use now, in MB, each counted at most up to
678
+ * its budget: what a scanner's own memory ceiling leaves out when its
679
+ * children are worker threads in its process.
680
+ */
681
+ childrenUsedMb(): number {
682
+ let total = 0;
683
+ for (const run of this.running)
684
+ total += Math.min(run.reservedMb, Math.max(0, run.process.usedMb ?? 0));
685
+ if (this.shared && !this.shared.dead)
686
+ total += Math.min(this.shared.budgetMb, Math.max(0, this.shared.usedMb ?? 0));
687
+ return total;
688
+ }
689
+
690
+ private async admitLarge(): Promise<void> {
691
+ // A child that finishes while the probe is read calls this again; it must
692
+ // not be lost, or a waiting file stays queued until the next finish.
693
+ if (this.admitting) {
694
+ this.admitAgain = true;
695
+ return;
696
+ }
697
+ this.admitting = true;
698
+ this.admitAgain = false;
699
+ try {
700
+ const limit = this.options.maxLargeChildren ?? MAX_LARGE_CHILDREN;
701
+ while (this.largeQueue.length > 0 && this.running.size < limit) {
702
+ const job = this.largeQueue[0] as Job;
703
+ const needMb = job.heapMb + CHILD_OVERHEAD_MB;
704
+ const finishedBefore = this.finished;
705
+ const reading = await (this.options.memory ?? scannerMemoryProbe())();
706
+ // A child that finished while memory was read makes the reading stale
707
+ // (it may still count that child): read again before deciding.
708
+ if (this.finished !== finishedBefore) continue;
709
+ let outstanding = 0;
710
+ for (const run of this.running) {
711
+ // What it may still grow into: its reservation less what it uses
712
+ // now, as it reports (a worker thread) or as /proc says (a process).
713
+ const actual = reading.includesChildren
714
+ ? (run.process.usedMb ?? residentMb(run.process.child.pid) ?? 0)
715
+ : 0;
716
+ outstanding += Math.max(0, run.reservedMb - actual);
717
+ }
718
+ const available =
719
+ reading.limitMb -
720
+ reading.usedMb -
721
+ ADMISSION_RESERVE_MB -
722
+ outstanding -
723
+ (this.options.heldMb?.() ?? 0);
724
+ if (needMb > available) {
725
+ // Running children free memory when they finish; with none running,
726
+ // nothing will, so the file can never fit here.
727
+ if (this.running.size > 0) return;
728
+ this.largeQueue.shift();
729
+ job.reject(new Error('source_redaction_exceeds_memory'));
730
+ continue;
731
+ }
732
+ this.largeQueue.shift();
733
+ const run: Running = {
734
+ reservedMb: needMb,
735
+ process: new RedactionProcess(
736
+ job.heapMb,
737
+ this.options.spawn,
738
+ this.timings,
739
+ { seconds: this.timings.cpuCeilingSeconds, reason: 'source_redaction_cpu_ceiling' },
740
+ needMb,
741
+ () => undefined,
742
+ // A file waiting for room may fit once this child says what it
743
+ // uses: until then admission counts its whole reservation.
744
+ () => {
745
+ if (this.largeQueue.length > 0) void this.admitLarge();
746
+ }
747
+ ),
748
+ };
749
+ this.running.add(run);
750
+ run.process
751
+ .redact(job.input)
752
+ .then(job.resolve, job.reject)
753
+ .finally(() => {
754
+ run.process.kill();
755
+ this.running.delete(run);
756
+ this.finished++;
757
+ void this.admitLarge();
758
+ });
759
+ }
760
+ } finally {
761
+ this.admitting = false;
762
+ if (this.admitAgain) {
763
+ this.admitAgain = false;
764
+ void this.admitLarge();
765
+ }
766
+ }
767
+ }
768
+
769
+ private submit(
770
+ input: RedactionInput,
771
+ bytes: number,
772
+ heapMb: number,
773
+ large: boolean,
774
+ seq: number
775
+ ) {
776
+ return new Promise<string>((resolve, reject) => {
777
+ const job: Job = {
778
+ input,
779
+ bytes,
780
+ heapMb,
781
+ large,
782
+ interactive: input.priority === 'interactive',
783
+ seq,
784
+ resolve,
785
+ reject,
786
+ };
787
+ if (large) {
788
+ this.enqueue(this.largeQueue, job);
789
+ void this.admitLarge();
790
+ } else {
791
+ this.enqueue(this.sharedQueue, job);
792
+ this.runShared();
793
+ }
794
+ });
795
+ }
796
+
797
+ /** Redact one source; throws a counted, logged error for this file when it cannot. */
798
+ async redact(input: RedactionInput): Promise<string> {
799
+ const bytes = Buffer.byteLength(input.text);
800
+ const heapMb = Math.max(SHARED_HEAP_MB, redactionHeapMb(bytes, input.filePath));
801
+ const seq = this.seq++;
802
+ try {
803
+ return await this.submit(input, bytes, heapMb, bytes >= LARGE_FILE_BYTES, seq);
804
+ } catch (first) {
805
+ const reason = first instanceof Error ? first.message : String(first);
806
+ if (FINAL_REASONS.has(reason)) return this.fail(input, bytes, reason);
807
+ if (reason === SHARED_CEILING_REASON) {
808
+ // Too much CPU for the shared child: a child of its own, with the full
809
+ // ceiling, so the small files behind it are not held up.
810
+ try {
811
+ return await this.submit(input, bytes, heapMb, true, seq);
812
+ } catch (own) {
813
+ return this.fail(input, bytes, own instanceof Error ? own.message : String(own));
814
+ }
815
+ }
816
+ // Out of memory, an unexpected exit or a hung child: once more, in its
817
+ // own child, with twice the heap, keeping its place in line.
818
+ this.options.onRetry?.(reason, { bytes, extension: extensionOf(input.filePath) });
819
+ try {
820
+ return await this.submit(input, bytes, heapMb * 2, true, seq);
821
+ } catch (second) {
822
+ return this.fail(input, bytes, second instanceof Error ? second.message : String(second));
823
+ }
824
+ }
825
+ }
826
+
827
+ private fail(input: RedactionInput, bytes: number, reason: string): never {
828
+ this.options.onFailure(reason, { bytes, extension: extensionOf(input.filePath) });
829
+ throw new Error(reason);
830
+ }
831
+
832
+ /** Tests and shutdown. */
833
+ close(): void {
834
+ this.shared?.kill();
835
+ this.shared = null;
836
+ for (const run of this.running) run.process.kill();
837
+ }
838
+ }
839
+
840
+ function extensionOf(path: string): string {
841
+ return /\.([A-Za-z0-9]{1,8})$/.exec(path)?.[1]?.toLowerCase() ?? '';
842
+ }
843
+
844
+ /**
845
+ * The child side: redact each file sent over IPC, in this process, and
846
+ * answer with its id and this process's resident memory (so the parent can
847
+ * recycle a child whose WebAssembly memory has grown).
848
+ */
849
+ export function runSourceRedactionWorker(): void {
850
+ // In a worker thread (the scanner) replies go to the parent thread, and
851
+ // the memory reported is this thread's own heap and external memory (its
852
+ // WebAssembly memory included); the process's resident memory is shared.
853
+ const port = parentPort;
854
+ const reply = port
855
+ ? (message: unknown) => port.postMessage(message)
856
+ : (message: unknown) => process.send?.(message);
857
+ if (!port && !process.send) throw new Error('Source redaction worker requires IPC');
858
+ const memoryMb = () => {
859
+ const usage = process.memoryUsage();
860
+ return Math.round((port ? usage.heapTotal + usage.external : usage.rss) / MB);
861
+ };
862
+ const handle = (input: { id: number; text: string; filePath: string }) => {
863
+ void scrubSourceSecretsInProcess(input.text, input.filePath)
864
+ .then((value) => {
865
+ reply({ id: input.id, value, rssMb: memoryMb() });
866
+ })
867
+ .catch(() => {
868
+ reply({ id: input.id, error: 'source_redaction_invalid_result' });
869
+ });
870
+ };
871
+ if (port) {
872
+ port.on('message', handle);
873
+ return;
874
+ }
875
+ process.on('message', handle);
876
+ process.once('disconnect', () => process.exit(0));
877
+ }