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