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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.98",
3
+ "version": "2.11.100",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-11T15:36:15.399Z",
3
+ "timestamp": "2026-06-11T17:09:48.912Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -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
+ };
@@ -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,