muaddib-scanner 2.11.98 → 2.11.100
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/package.json
CHANGED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Heavy-lane semaphore (C2, 2026-06-11) — bound the daemon's RSS by limiting
|
|
5
|
+
* how many MEMORY-heavy static scans run concurrently.
|
|
6
|
+
*
|
|
7
|
+
* Measured (worker-mem.jsonl, n=461 workers): per-worker isolate heap peaks
|
|
8
|
+
* are BIMODAL — p50 = 12MB, but 12.6% of scans jump straight to 0.9-2.1GB
|
|
9
|
+
* (giant minified JS bundles; the AST cache accumulates across every parsable
|
|
10
|
+
* file, executor.js only skips files > getMaxFileSize() individually). With
|
|
11
|
+
* 8 concurrent workers a handful of heavies coincide → process RSS > the
|
|
12
|
+
* 8.5GB breaker → EMERGENCY. The heavies are identifiable BEFORE the worker
|
|
13
|
+
* spawns (total parsable-JS bytes on disk), so instead of killing them (a
|
|
14
|
+
* 768MB worker cap would cost 12% of coverage) we serialize them: at most
|
|
15
|
+
* MUADDIB_HEAVY_SCAN_MAX run at once, lights are NEVER blocked.
|
|
16
|
+
* Worst-case RSS ≈ baseline 2GB + 2×2GB heavies + N×12MB lights ≈ 5-6GB.
|
|
17
|
+
*
|
|
18
|
+
* Same {active, queue[]} semaphore pattern as src/shared/http-limiter.js and
|
|
19
|
+
* the sandbox slots (src/sandbox/index.js), plus two extensions those never
|
|
20
|
+
* needed: an abort-aware acquire and a wait-timeout. Both MUST remove their
|
|
21
|
+
* waiter from the queue on the way out — a release would otherwise hand the
|
|
22
|
+
* slot to a dead waiter and leak it permanently.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Max number of HEAVY_LANE_WAIT_TIMEOUT requeues before an item's final pass
|
|
26
|
+
// runs without the wait bound (abort-aware only, still bounded by the outer
|
|
27
|
+
// SCAN_TIMEOUT_MS). Guarantees an item cannot loop in the queue forever.
|
|
28
|
+
const HEAVY_REQUEUE_MAX = 3;
|
|
29
|
+
|
|
30
|
+
// Env knobs (read at call time so tests can flip them around resetHeavyLane()):
|
|
31
|
+
// - MUADDIB_HEAVY_SCAN_MAX: concurrent heavy scans (default 2, 0 = lane off)
|
|
32
|
+
// - MUADDIB_HEAVY_SCAN_BYTES: heavy threshold on total parsable-JS bytes.
|
|
33
|
+
// Default 3 MiB — the measured distribution has a HOLE between light
|
|
34
|
+
// (≤12MB heap ⇔ <~1MB JS) and heavy (≥512MB heap ⇔ ≥~8MB JS); 3 MiB sits
|
|
35
|
+
// in the hole with ~3× margin both ways. A false-heavy costs a short wait;
|
|
36
|
+
// a false-light risks an EMERGENCY — hence the deliberately low default.
|
|
37
|
+
// - MUADDIB_HEAVY_WAIT_MAX_MS: wait bound before requeue (default 120s —
|
|
38
|
+
// ~2.5 slot services of 45s, leaves >150s of the 300s scan budget).
|
|
39
|
+
function heavyScanMax() {
|
|
40
|
+
const v = parseInt(process.env.MUADDIB_HEAVY_SCAN_MAX, 10);
|
|
41
|
+
return Number.isFinite(v) && v >= 0 ? v : 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function heavyScanBytesThreshold() {
|
|
45
|
+
const v = parseInt(process.env.MUADDIB_HEAVY_SCAN_BYTES, 10);
|
|
46
|
+
return Number.isFinite(v) && v > 0 ? v : 3 * 1024 * 1024;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function heavyWaitMaxMs() {
|
|
50
|
+
const v = parseInt(process.env.MUADDIB_HEAVY_WAIT_MAX_MS, 10);
|
|
51
|
+
return Number.isFinite(v) && v >= 0 ? v : 120000;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const _lane = { active: 0, queue: [] };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pure classifier. `truncated` (the bounded measurement walk overflowed its
|
|
58
|
+
* depth/file caps) classifies heavy by default — defensive: an unmeasurable
|
|
59
|
+
* package is exactly the kind that blows a worker. Compares weightedJsBytes
|
|
60
|
+
* (plain + ×12 minified — see measureJsWeight in queue.js: raw bytes alone
|
|
61
|
+
* missed the minified explosions, powerlines 517KB → 1151MB heap) and falls
|
|
62
|
+
* back to totalJsBytes for callers that don't weight.
|
|
63
|
+
* @param {{totalJsBytes: number, weightedJsBytes?: number, truncated: boolean}|null} weight
|
|
64
|
+
* @param {number} [thresholdBytes]
|
|
65
|
+
*/
|
|
66
|
+
function isHeavyScan(weight, thresholdBytes = heavyScanBytesThreshold()) {
|
|
67
|
+
if (!weight) return false;
|
|
68
|
+
if (weight.truncated) return true;
|
|
69
|
+
const effective = Number.isFinite(weight.weightedJsBytes) ? weight.weightedJsBytes : (weight.totalJsBytes || 0);
|
|
70
|
+
return effective >= thresholdBytes;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Acquire a heavy-lane slot. Resolves true when a slot is held, false when
|
|
75
|
+
* the lane is disabled (MUADDIB_HEAVY_SCAN_MAX=0 — nothing to release).
|
|
76
|
+
* FIFO when saturated.
|
|
77
|
+
*
|
|
78
|
+
* @param {Object} [opts]
|
|
79
|
+
* @param {AbortSignal} [opts.signal] - outer scan abort: rejects err.code='ABORT_ERR'
|
|
80
|
+
* @param {number} [opts.maxWaitMs] - wait bound; 0/absent = unbounded.
|
|
81
|
+
* On expiry rejects err.code='HEAVY_LANE_WAIT_TIMEOUT' (caller requeues).
|
|
82
|
+
* @returns {Promise<boolean>}
|
|
83
|
+
*/
|
|
84
|
+
function acquireHeavySlot(opts = {}) {
|
|
85
|
+
const max = heavyScanMax();
|
|
86
|
+
if (max === 0) return Promise.resolve(false);
|
|
87
|
+
if (_lane.active < max) {
|
|
88
|
+
_lane.active++;
|
|
89
|
+
return Promise.resolve(true);
|
|
90
|
+
}
|
|
91
|
+
const { signal, maxWaitMs } = opts;
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
let timer = null;
|
|
94
|
+
const cleanup = () => {
|
|
95
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
96
|
+
if (signal) { try { signal.removeEventListener('abort', onAbort); } catch { /* not added */ } }
|
|
97
|
+
};
|
|
98
|
+
const waiter = () => {
|
|
99
|
+
cleanup();
|
|
100
|
+
resolve(true); // slot transferred by releaseHeavySlot (active unchanged)
|
|
101
|
+
};
|
|
102
|
+
// Leaving the queue WITHOUT being woken: splice the waiter out, or the
|
|
103
|
+
// next release hands the slot to this dead waiter and leaks it (trap #1).
|
|
104
|
+
const bail = (err) => {
|
|
105
|
+
const i = _lane.queue.indexOf(waiter);
|
|
106
|
+
if (i === -1) return; // already woken — the release path owns the slot
|
|
107
|
+
_lane.queue.splice(i, 1);
|
|
108
|
+
cleanup();
|
|
109
|
+
reject(err);
|
|
110
|
+
};
|
|
111
|
+
const onAbort = () => {
|
|
112
|
+
const err = new Error('Heavy-lane wait aborted (outer scan timeout)');
|
|
113
|
+
err.code = 'ABORT_ERR';
|
|
114
|
+
bail(err);
|
|
115
|
+
};
|
|
116
|
+
// Push BEFORE wiring abort/timeout: bail() rejects only when it finds the
|
|
117
|
+
// waiter in the queue (its index check guards the already-woken race) —
|
|
118
|
+
// a pre-aborted signal firing before the push would otherwise bail into
|
|
119
|
+
// the guard and leave the promise forever pending.
|
|
120
|
+
_lane.queue.push(waiter);
|
|
121
|
+
if (signal) {
|
|
122
|
+
if (signal.aborted) { onAbort(); return; }
|
|
123
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
124
|
+
}
|
|
125
|
+
if (Number.isFinite(maxWaitMs) && maxWaitMs > 0) {
|
|
126
|
+
// Deliberately NOT unref'd: a pending acquire is active work (a scan
|
|
127
|
+
// holding tmp disk and a pool slot) — it must keep the process alive.
|
|
128
|
+
timer = setTimeout(() => {
|
|
129
|
+
const err = new Error(`Heavy-lane slot not acquired within ${maxWaitMs}ms`);
|
|
130
|
+
err.code = 'HEAVY_LANE_WAIT_TIMEOUT';
|
|
131
|
+
bail(err);
|
|
132
|
+
}, maxWaitMs);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function releaseHeavySlot() {
|
|
138
|
+
if (_lane.queue.length > 0) {
|
|
139
|
+
const next = _lane.queue.shift();
|
|
140
|
+
next(); // transfers the slot to the next waiter (active count unchanged)
|
|
141
|
+
} else if (_lane.active > 0) {
|
|
142
|
+
_lane.active--;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getHeavyLaneState() {
|
|
147
|
+
return { active: _lane.active, waiting: _lane.queue.length, max: heavyScanMax() };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Test helper — same role as resetSandboxLimiter in src/sandbox/index.js. */
|
|
151
|
+
function resetHeavyLane() {
|
|
152
|
+
_lane.active = 0;
|
|
153
|
+
_lane.queue.length = 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
acquireHeavySlot,
|
|
158
|
+
releaseHeavySlot,
|
|
159
|
+
isHeavyScan,
|
|
160
|
+
getHeavyLaneState,
|
|
161
|
+
resetHeavyLane,
|
|
162
|
+
heavyScanBytesThreshold,
|
|
163
|
+
heavyWaitMaxMs,
|
|
164
|
+
HEAVY_REQUEUE_MAX
|
|
165
|
+
};
|
package/src/monitor/queue.js
CHANGED
|
@@ -13,13 +13,14 @@ const { Worker } = require('worker_threads');
|
|
|
13
13
|
const { runSandbox, tryAcquireSandboxSlot } = require('../sandbox/index.js');
|
|
14
14
|
const { sendWebhook } = require('../webhook.js');
|
|
15
15
|
const { downloadToFile, extractArchive, sanitizePackageName } = require('../shared/download.js');
|
|
16
|
-
const { MAX_TARBALL_SIZE } = require('../shared/constants.js');
|
|
16
|
+
const { MAX_TARBALL_SIZE, getMaxFileSize } = require('../shared/constants.js');
|
|
17
17
|
const { acquireRegistrySlot, releaseRegistrySlot } = require('../shared/http-limiter.js');
|
|
18
18
|
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
19
19
|
const { scanPackageJson } = require('../scanner/package.js');
|
|
20
20
|
const { scanShellScripts } = require('../scanner/shell.js');
|
|
21
21
|
const { buildTrainingRecord } = require('../ml/feature-extractor.js');
|
|
22
22
|
const { appendWorkerMem } = require('./worker-mem.js');
|
|
23
|
+
const { acquireHeavySlot, releaseHeavySlot, isHeavyScan, getHeavyLaneState, heavyWaitMaxMs, HEAVY_REQUEUE_MAX } = require('./heavy-lane.js');
|
|
23
24
|
const { appendRecord: appendTrainingRecord, relabelRecords } = require('../ml/jsonl-writer.js');
|
|
24
25
|
|
|
25
26
|
// From ./state.js
|
|
@@ -305,6 +306,98 @@ function countPackageFiles(dir) {
|
|
|
305
306
|
return { fileCountTotal, hasTests };
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
// C2 heavy-lane measurement bounds. Distinct from countPackageFiles (whose
|
|
310
|
+
// depth cap of 5 is an ML-feature contract — do not touch it).
|
|
311
|
+
const JS_WEIGHT_MAX_DEPTH = 8;
|
|
312
|
+
const JS_WEIGHT_MAX_FILES = 2000;
|
|
313
|
+
const JS_WEIGHT_FILE_PATTERN = /\.(?:[cm]?js|[jt]sx?)$/i;
|
|
314
|
+
// Minified JS expands SUPER-linearly in the worker (live counter-examples
|
|
315
|
+
// from the 16:18 rollout, 2026-06-11: powerlines = 517KB JS of which 449KB
|
|
316
|
+
// minified → 1151MB heap, ~2300×; @lethevimlet/sshift = ~1.9MB minified →
|
|
317
|
+
// 1.38GB — both sailed under the raw-bytes threshold as 'light'). Plain
|
|
318
|
+
// source stays roughly linear (the 12MB-heap mode of the bimodal
|
|
319
|
+
// distribution). So minified bytes count ×12 toward the heavy threshold —
|
|
320
|
+
// ≥~256KB of minified JS crosses the 3MiB default. Detection: average line
|
|
321
|
+
// length over the first 4KB; plain code sits at 40-120 chars, minified
|
|
322
|
+
// bundles at 800+ (often a single line). 250 splits cleanly even when a
|
|
323
|
+
// license header pads the probe window.
|
|
324
|
+
const JS_MINIFIED_WEIGHT = 12;
|
|
325
|
+
const JS_MINIFIED_AVG_LINE = 250;
|
|
326
|
+
const JS_MINIFIED_PROBE_BYTES = 4096;
|
|
327
|
+
|
|
328
|
+
/** Probe the first 4KB of a file (never loads the rest) for minification. */
|
|
329
|
+
function probeIsMinified(filePath) {
|
|
330
|
+
let fd = null;
|
|
331
|
+
try {
|
|
332
|
+
fd = fs.openSync(filePath, 'r');
|
|
333
|
+
const buf = Buffer.alloc(JS_MINIFIED_PROBE_BYTES);
|
|
334
|
+
const n = fs.readSync(fd, buf, 0, JS_MINIFIED_PROBE_BYTES, 0);
|
|
335
|
+
if (n <= 0) return false;
|
|
336
|
+
const head = buf.toString('utf8', 0, n);
|
|
337
|
+
return (head.length / head.split('\n').length) > JS_MINIFIED_AVG_LINE;
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
} finally {
|
|
341
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* best-effort */ } }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Measure how much parsable JS a package carries — the heavy-lane
|
|
347
|
+
* classification signal. The per-worker isolate heap is driven by the SUM of
|
|
348
|
+
* AST-parsed JS bytes (executor.js skips files > getMaxFileSize()
|
|
349
|
+
* individually, but the AST cache accumulates across files), so we sum the
|
|
350
|
+
* on-disk sizes of parsable JS files, skipping the ones the executor will
|
|
351
|
+
* skip anyway. NEVER use meta.unpackedSize for this — it is absent for PyPI
|
|
352
|
+
* and part of npm (the `|| 0` hole that lets giant bundles bypass the C1
|
|
353
|
+
* size-cap in the first place).
|
|
354
|
+
*
|
|
355
|
+
* Bounded walk; an overflow (depth/file caps) returns truncated:true, which
|
|
356
|
+
* isHeavyScan classifies heavy by default.
|
|
357
|
+
*
|
|
358
|
+
* weightedJsBytes = plain bytes + JS_MINIFIED_WEIGHT × minified bytes — the
|
|
359
|
+
* value isHeavyScan compares against the threshold (raw bytes alone missed
|
|
360
|
+
* the minified explosions, see JS_MINIFIED_WEIGHT above).
|
|
361
|
+
*
|
|
362
|
+
* @param {string} dir - extracted package directory
|
|
363
|
+
* @returns {{ totalJsBytes: number, minifiedJsBytes: number, weightedJsBytes: number, maxJsFileBytes: number, truncated: boolean }}
|
|
364
|
+
*/
|
|
365
|
+
function measureJsWeight(dir) {
|
|
366
|
+
let totalJsBytes = 0;
|
|
367
|
+
let minifiedJsBytes = 0;
|
|
368
|
+
let maxJsFileBytes = 0;
|
|
369
|
+
let seen = 0;
|
|
370
|
+
let truncated = false;
|
|
371
|
+
const perFileCap = getMaxFileSize();
|
|
372
|
+
|
|
373
|
+
function walk(current, depth) {
|
|
374
|
+
if (truncated) return;
|
|
375
|
+
if (depth > JS_WEIGHT_MAX_DEPTH) { truncated = true; return; }
|
|
376
|
+
let entries;
|
|
377
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
|
|
378
|
+
for (const entry of entries) {
|
|
379
|
+
if (truncated) return;
|
|
380
|
+
if (entry.isDirectory()) {
|
|
381
|
+
if (ML_EXCLUDED_DIRS.has(entry.name)) continue;
|
|
382
|
+
walk(path.join(current, entry.name), depth + 1);
|
|
383
|
+
} else if (entry.isFile() && JS_WEIGHT_FILE_PATTERN.test(entry.name)) {
|
|
384
|
+
if (++seen > JS_WEIGHT_MAX_FILES) { truncated = true; return; }
|
|
385
|
+
const filePath = path.join(current, entry.name);
|
|
386
|
+
let size;
|
|
387
|
+
try { size = fs.statSync(filePath).size; } catch { continue; }
|
|
388
|
+
if (size > perFileCap) continue; // executor skips these — they never reach the AST
|
|
389
|
+
totalJsBytes += size;
|
|
390
|
+
if (probeIsMinified(filePath)) minifiedJsBytes += size;
|
|
391
|
+
if (size > maxJsFileBytes) maxJsFileBytes = size;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
walk(dir, 0);
|
|
397
|
+
const weightedJsBytes = (totalJsBytes - minifiedJsBytes) + JS_MINIFIED_WEIGHT * minifiedJsBytes;
|
|
398
|
+
return { totalJsBytes, minifiedJsBytes, weightedJsBytes, maxJsFileBytes, truncated };
|
|
399
|
+
}
|
|
400
|
+
|
|
308
401
|
/**
|
|
309
402
|
* Pure classifier: is this a prebuilt native-binary platform shard (the kind that
|
|
310
403
|
* hangs the sandbox install and always times out INCONCLUSIVE)? No I/O — the parsed
|
|
@@ -435,6 +528,7 @@ function runScanInWorker(extractedDir, timeoutMs, scanContext = null, signal = n
|
|
|
435
528
|
appendWorkerMem({
|
|
436
529
|
ev: 'spawn', tid: _wmTid,
|
|
437
530
|
name: _sc.name, version: _sc.version, ecosystem: _sc.ecosystem,
|
|
531
|
+
lane: _sc._lane, jsBytes: _sc._jsBytes, jsMin: _sc._jsMin,
|
|
438
532
|
rss: process.memoryUsage().rss
|
|
439
533
|
});
|
|
440
534
|
|
|
@@ -645,6 +739,18 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
645
739
|
// ML Phase 2a: Count JS files and detect test presence for enriched features
|
|
646
740
|
const { fileCountTotal, hasTests } = countPackageFiles(extractedDir);
|
|
647
741
|
|
|
742
|
+
// C2 heavy-lane classification (see heavy-lane.js header): measured on
|
|
743
|
+
// disk, after extraction — registry metadata is not trustworthy here.
|
|
744
|
+
// Measurement failure falls back to the compressed tarball size
|
|
745
|
+
// (conservative: never silently far under the real JS weight).
|
|
746
|
+
let jsWeight;
|
|
747
|
+
try {
|
|
748
|
+
jsWeight = measureJsWeight(extractedDir);
|
|
749
|
+
} catch {
|
|
750
|
+
jsWeight = { totalJsBytes: fileSize, maxJsFileBytes: 0, truncated: false };
|
|
751
|
+
}
|
|
752
|
+
const lane = isHeavyScan(jsWeight) ? 'heavy' : 'light';
|
|
753
|
+
|
|
648
754
|
// Hoisted before the worker spawn (per-worker 429-storm fix): fetch the npm
|
|
649
755
|
// registry metadata ONCE on the main thread. The shared http-limiter coordinates
|
|
650
756
|
// it and the temporal cache is warm (npm-registry.js reads it first), so only
|
|
@@ -663,6 +769,28 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
663
769
|
}
|
|
664
770
|
}
|
|
665
771
|
|
|
772
|
+
// C2 heavy-lane: serialize the memory-heavy scans. Acquired AFTER the
|
|
773
|
+
// registry fetch above (never hold the slot during network I/O); released
|
|
774
|
+
// in the finally right after the static scan — the slot covers ONLY the
|
|
775
|
+
// worker's lifetime (≤ STATIC_SCAN_TIMEOUT_MS), not the sandbox (which
|
|
776
|
+
// has its own semaphore and runs outside the daemon's heap).
|
|
777
|
+
let heavySlotHeld = false;
|
|
778
|
+
if (lane === 'heavy') {
|
|
779
|
+
stats.heavyScans = (stats.heavyScans || 0) + 1;
|
|
780
|
+
const laneState = getHeavyLaneState();
|
|
781
|
+
if (laneState.max > 0 && laneState.active >= laneState.max) {
|
|
782
|
+
stats.heavyLaneWaits = (stats.heavyLaneWaits || 0) + 1;
|
|
783
|
+
console.log(`[MONITOR] HEAVY_LANE: ${name}@${version} waiting for a slot (${(jsWeight.totalJsBytes / 1024 / 1024).toFixed(1)}MB JS, active=${laneState.active}, waiting=${laneState.waiting})`);
|
|
784
|
+
}
|
|
785
|
+
// After HEAVY_REQUEUE_MAX requeues the final pass waits unbounded
|
|
786
|
+
// (abort-aware only, still under the outer SCAN_TIMEOUT_MS) so an item
|
|
787
|
+
// cannot loop in the queue forever.
|
|
788
|
+
const lastPass = (meta._heavyRetries || 0) >= HEAVY_REQUEUE_MAX;
|
|
789
|
+
const waitStart = Date.now();
|
|
790
|
+
heavySlotHeld = await acquireHeavySlot({ signal, maxWaitMs: lastPass ? 0 : heavyWaitMaxMs() });
|
|
791
|
+
stats.heavyLaneWaitMsTotal = (stats.heavyLaneWaitMsTotal || 0) + (Date.now() - waitStart);
|
|
792
|
+
}
|
|
793
|
+
|
|
666
794
|
let result;
|
|
667
795
|
try {
|
|
668
796
|
// scanContext: feeds monitor-side info (name/version/ecosystem) and the
|
|
@@ -679,7 +807,13 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
679
807
|
// Stage 2: set by processQueueItem when MUADDIB_TRIAGE_MODE=enforce.
|
|
680
808
|
// Defaults to 'full' so any CLI/test caller that bypasses triage gets
|
|
681
809
|
// the full 20-scanner pipeline (unchanged behaviour).
|
|
682
|
-
scanMode: (meta && meta.scanMode) || 'full'
|
|
810
|
+
scanMode: (meta && meta.scanMode) || 'full',
|
|
811
|
+
// C2 observability: lane + JS weight flow into the worker-mem spawn
|
|
812
|
+
// event (runScanInWorker) so lane×heap-peak cross-checks are possible
|
|
813
|
+
// post-rollout (hard criterion: zero 'light' scans peaking >512MB).
|
|
814
|
+
_lane: lane,
|
|
815
|
+
_jsBytes: jsWeight.totalJsBytes,
|
|
816
|
+
_jsMin: jsWeight.minifiedJsBytes || 0
|
|
683
817
|
};
|
|
684
818
|
// Hand the main-thread-fetched metadata to the worker so its processor skips
|
|
685
819
|
// the per-worker getPackageMetadata fetch (429-storm fix). npm only; the key
|
|
@@ -705,6 +839,10 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
705
839
|
return { sandboxResult: null, staticClean: false };
|
|
706
840
|
}
|
|
707
841
|
throw staticErr;
|
|
842
|
+
} finally {
|
|
843
|
+
// Single release point — success, static timeout, EMERGENCY terminate
|
|
844
|
+
// and abort all funnel through here exactly once (heavySlotHeld guard).
|
|
845
|
+
if (heavySlotHeld) { releaseHeavySlot(); heavySlotHeld = false; }
|
|
708
846
|
}
|
|
709
847
|
|
|
710
848
|
// Phase 3 signal — agent-supply-chain lens. Pure observability, no scoring impact.
|
|
@@ -1285,6 +1423,11 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
1285
1423
|
}
|
|
1286
1424
|
}
|
|
1287
1425
|
} catch (err) {
|
|
1426
|
+
// C2 heavy-lane: a wait-timeout is NOT a scan failure — processQueueItem
|
|
1427
|
+
// requeues the item (bounded by HEAVY_REQUEUE_MAX). Re-throw BEFORE any
|
|
1428
|
+
// error accounting: this catch otherwise swallows everything into the
|
|
1429
|
+
// 'scan_error' ledger path and the requeue would never happen.
|
|
1430
|
+
if (err && err.code === 'HEAVY_LANE_WAIT_TIMEOUT') throw err;
|
|
1288
1431
|
recordError(err, stats);
|
|
1289
1432
|
stats.scanned++;
|
|
1290
1433
|
stats.totalTimeMs += Date.now() - startTime;
|
|
@@ -1376,6 +1519,22 @@ async function processQueueItem(item, stats, dailyAlerts, recentlyScanned, downl
|
|
|
1376
1519
|
})
|
|
1377
1520
|
]);
|
|
1378
1521
|
} catch (err) {
|
|
1522
|
+
// C2 heavy-lane: the bounded wait expired while the heavy slots were
|
|
1523
|
+
// saturated (typical under a spill-drain burst). Not a failure — put the
|
|
1524
|
+
// item back at the queue tail (natural backoff) up to HEAVY_REQUEUE_MAX
|
|
1525
|
+
// passes; scanPackage runs the final pass without the wait bound. Note:
|
|
1526
|
+
// _heavyRetries does not survive a spill (spillItems strips non-re-enqueue
|
|
1527
|
+
// fields) — acceptable, the spill drain runs in calm windows anyway.
|
|
1528
|
+
if (err && err.code === 'HEAVY_LANE_WAIT_TIMEOUT') {
|
|
1529
|
+
const decision = computeHeavyRequeue(item);
|
|
1530
|
+
if (decision.requeue) {
|
|
1531
|
+
stats.heavyLaneRequeues = (stats.heavyLaneRequeues || 0) + 1;
|
|
1532
|
+
console.log(`[MONITOR] HEAVY_LANE: requeued ${item.name}@${item.version || '?'} (wait-timeout pass ${decision.retries}/${HEAVY_REQUEUE_MAX})`);
|
|
1533
|
+
enqueueScan(scanQueue, item, stats);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
// Safety net — should be unreachable (the last pass waits unbounded).
|
|
1537
|
+
}
|
|
1379
1538
|
recordError(err, stats);
|
|
1380
1539
|
console.error(`[MONITOR] Queue error for ${item.name}: ${err.message}`);
|
|
1381
1540
|
// IOC fallback: if scan failed for a known malicious package, send P1 alert.
|
|
@@ -1450,6 +1609,18 @@ function computeWorkersToSpawn(targetConcurrency, activeWorkers, queueLength) {
|
|
|
1450
1609
|
return Math.max(0, Math.min(targetConcurrency - activeWorkers, queueLength));
|
|
1451
1610
|
}
|
|
1452
1611
|
|
|
1612
|
+
/**
|
|
1613
|
+
* Pure requeue decision for a heavy-lane wait-timeout (same extraction
|
|
1614
|
+
* rationale as computeWorkersToSpawn). Mutates item._heavyRetries; once the
|
|
1615
|
+
* counter passes HEAVY_REQUEUE_MAX the item is NOT requeued again — its next
|
|
1616
|
+
* pass through scanPackage waits unbounded instead.
|
|
1617
|
+
*/
|
|
1618
|
+
function computeHeavyRequeue(item) {
|
|
1619
|
+
const retries = (item._heavyRetries || 0) + 1;
|
|
1620
|
+
item._heavyRetries = retries;
|
|
1621
|
+
return { requeue: retries <= HEAVY_REQUEUE_MAX, retries };
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1453
1624
|
// ── RSS-aware worker admission (P1 OOM durable fix) ──
|
|
1454
1625
|
// The pressure breaker is reactive: it stops spawning at HIGH, but the workers already in
|
|
1455
1626
|
// flight overshoot RSS by ~2GB (each isolate + gVisor sandbox ~0.55GB, draining up to
|
|
@@ -1795,7 +1966,10 @@ async function resolveTarballAndScan(item, stats, dailyAlerts, recentlyScanned,
|
|
|
1795
1966
|
registryScripts: item.registryScripts || null,
|
|
1796
1967
|
_cacheTrigger: item._cacheTrigger || null,
|
|
1797
1968
|
fastTrack: item.fastTrack || false,
|
|
1798
|
-
scanMode: effectiveScanMode
|
|
1969
|
+
scanMode: effectiveScanMode,
|
|
1970
|
+
// C2 heavy-lane: pass count set by computeHeavyRequeue — at
|
|
1971
|
+
// HEAVY_REQUEUE_MAX the final pass waits for its slot unbounded.
|
|
1972
|
+
_heavyRetries: item._heavyRetries || 0
|
|
1799
1973
|
}, stats, dailyAlerts, recentlyScanned, downloadsCache, scanQueue, sandboxAvailable, signal);
|
|
1800
1974
|
const sandboxResult = scanResult && scanResult.sandboxResult;
|
|
1801
1975
|
const staticClean = scanResult && scanResult.staticClean;
|
|
@@ -1917,6 +2091,8 @@ module.exports = {
|
|
|
1917
2091
|
isBundledToolingOnly,
|
|
1918
2092
|
recordTrainingSample,
|
|
1919
2093
|
countPackageFiles,
|
|
2094
|
+
measureJsWeight,
|
|
2095
|
+
computeHeavyRequeue,
|
|
1920
2096
|
classifyNativeShard,
|
|
1921
2097
|
shouldSkipSandbox,
|
|
1922
2098
|
runScanInWorker,
|