muaddib-scanner 2.11.134 → 2.11.136

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.134",
3
+ "version": "2.11.136",
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-28T13:13:44.321Z",
3
+ "timestamp": "2026-06-30T19:55:27.666Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -1040,6 +1040,8 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
1040
1040
  } catch (e) {
1041
1041
  console.error(`[MONITOR] Shutdown in-flight spill failed: ${e.message}`);
1042
1042
  }
1043
+ // Terminate the off-thread extraction worker pool (bounded-resource teardown).
1044
+ try { await require('../shared/extract-pool.js').destroyExtractPool(); } catch { /* best-effort */ }
1043
1045
  // Persist remaining queue items so they survive the restart
1044
1046
  persistQueue(scanQueue, state);
1045
1047
  saveRecentlyScanned(recentlyScanned); // Persist dedup set too (avoid re-scan storm on restart)
@@ -622,6 +622,100 @@ function isPrereleaseVersion(v) {
622
622
  return /-/.test(String(v == null ? '' : v).split('+')[0]);
623
623
  }
624
624
 
625
+ // Derive the capture-at-publish trigger for the fast-takedown class (Leo/Miasma)
626
+ // from a registry packument. Shared by preResolveNpmBatch (Stage 2.1) and the
627
+ // real-time capture lane so both compute the SAME signal: ATO = published version
628
+ // != dist-tags.latest, excluding prereleases (isPrereleaseVersion); burst =
629
+ // >= BURST_MIN_VERSIONS_PREFETCH versions in the recent window. Returns the cache
630
+ // trigger object (shouldCache:true) or null. `name` is passed through so a package
631
+ // that is ALSO an ioc/typosquat match still gets the right (higher-priority) reason.
632
+ function evaluateBurstAtoTrigger(name, npmInfo, version) {
633
+ if (!npmInfo) return null;
634
+ const atoSignal = !!(npmInfo.latestTagVersion && version &&
635
+ version !== npmInfo.latestTagVersion && !isPrereleaseVersion(version));
636
+ const burstCount = Number.isFinite(npmInfo.recentWindowCount)
637
+ ? npmInfo.recentWindowCount
638
+ : ((Array.isArray(npmInfo.recentVersions) ? npmInfo.recentVersions.length : 0) + 1);
639
+ const isBurst = burstCount >= BURST_MIN_VERSIONS_PREFETCH;
640
+ if (!atoSignal && !isBurst) return null;
641
+ const trig = evaluateCacheTrigger(name, null, null, { atoSignal, isBurst });
642
+ return trig.shouldCache ? trig : null;
643
+ }
644
+
645
+ // Fix C — real-time capture lane. preResolveNpmBatch sheds the packument prefetch
646
+ // when the scan queue is deep (queue > PRE_RESOLVE_SHED_QUEUE — the chronic backlog
647
+ // state). Under shed nothing is prefetched AT ALL: schedulePrefetch needs a resolved
648
+ // tarballUrl, which the shed never fetched — so even ioc/typosquat/first_publish items
649
+ // (flagged name-only at ingestion) are not captured, and burst/ATO is never even
650
+ // computed. The fast-takedown tarballs then 404 before the backlogged scan reaches
651
+ // them (the Leo/Miasma gap Fix A meant to close). This lane resolves a BOUNDED subset
652
+ // of the still-unresolved items REGARDLESS of the shed — prioritizing the already
653
+ // name-flagged (known high-risk, FP-free), then filling the budget to DISCOVER
654
+ // burst/ATO — and enriches each item (version/tarballUrl/_npmInfo/_cacheTrigger) IN
655
+ // PLACE so (a) the schedulePrefetch pass that immediately follows captures the tarball
656
+ // at publish, and (b) the already-enqueued item (enqueueScan pushes by reference)
657
+ // carries the real version so scanPackage's cache-hit (keyed by name@version) finds it.
658
+ //
659
+ // Bounded + rate-limited + flag-gated so it can be turned on only once throughput /
660
+ // OOM headroom allows: OFF unless MUADDIB_REALTIME_PREFETCH=1; at most
661
+ // MUADDIB_REALTIME_PREFETCH_MAX items/cycle (default 30); registry calls go through
662
+ // getNpmLatestTarball's shared semaphore (honors the 429 brain). npm-only for now.
663
+ const REALTIME_PREFETCH_CONCURRENCY = 4;
664
+ function realtimePrefetchEnabled() {
665
+ return process.env.MUADDIB_REALTIME_PREFETCH === '1';
666
+ }
667
+ function realtimePrefetchMax() {
668
+ const n = parseInt(process.env.MUADDIB_REALTIME_PREFETCH_MAX, 10);
669
+ return Number.isFinite(n) && n >= 0 ? n : 30;
670
+ }
671
+ async function realtimePrefetchHighRisk(items, stats) {
672
+ if (!realtimePrefetchEnabled()) return;
673
+ const cap = realtimePrefetchMax();
674
+ if (!cap || !Array.isArray(items) || items.length === 0) return;
675
+ // Act only on items the shed (or a resolve failure) left UNRESOLVED (no _npmInfo).
676
+ // When preResolveNpmBatch did NOT shed, every item already has _npmInfo and this
677
+ // selects nothing (no duplicate fetch). Flagged first (ioc/typo/first_publish —
678
+ // known high-risk, just needs its tarballUrl resolved to be prefetched), then
679
+ // unflagged (candidates for burst/ATO discovery).
680
+ const flagged = [];
681
+ const unflagged = [];
682
+ for (const it of items) {
683
+ if (!it || it.ecosystem !== 'npm' || !it.name || it._npmInfo) continue;
684
+ (it._cacheTrigger ? flagged : unflagged).push(it);
685
+ }
686
+ const selected = flagged.concat(unflagged).slice(0, cap);
687
+ if (selected.length === 0) return;
688
+ const overflow = (flagged.length + unflagged.length) - selected.length;
689
+ if (overflow > 0) {
690
+ // No silent caps (CLAUDE.md): a burst wider than the budget is only partially captured.
691
+ console.warn(`[MONITOR] REALTIME PREFETCH: ${flagged.length + unflagged.length} unresolved high-risk candidates, capped at ${cap} this cycle (${overflow} deferred to lazy scan)`);
692
+ if (stats) stats.realtimePrefetchCapped = (stats.realtimePrefetchCapped || 0) + overflow;
693
+ }
694
+ let idx = 0;
695
+ const worker = async () => {
696
+ while (idx < selected.length) {
697
+ const it = selected[idx++];
698
+ try {
699
+ const npmInfo = await getNpmLatestTarball(it.name);
700
+ if (!npmInfo || !npmInfo.tarball) continue;
701
+ // Enrich in place — the item is already enqueued by reference (shed path), so
702
+ // this also lets the eventual scan reuse the URL + hit the prefetch cache.
703
+ it.tarballUrl = it.tarballUrl || npmInfo.tarball;
704
+ if (!it.version) it.version = npmInfo.version || '';
705
+ it._npmInfo = npmInfo;
706
+ if (!it._cacheTrigger) {
707
+ const trig = evaluateBurstAtoTrigger(it.name, npmInfo, it.version);
708
+ if (trig) it._cacheTrigger = trig;
709
+ }
710
+ if (it._cacheTrigger && stats) stats.realtimePrefetchFlagged = (stats.realtimePrefetchFlagged || 0) + 1;
711
+ } catch {
712
+ if (stats) stats.realtimePrefetchFailed = (stats.realtimePrefetchFailed || 0) + 1;
713
+ }
714
+ }
715
+ };
716
+ await Promise.all(Array.from({ length: Math.min(REALTIME_PREFETCH_CONCURRENCY, selected.length) }, worker));
717
+ }
718
+
625
719
  async function preResolveNpmBatch(items, stats, scanQueue) {
626
720
  if (!items || items.length === 0) return;
627
721
  const start = Date.now();
@@ -676,17 +770,8 @@ async function preResolveNpmBatch(items, stats, scanQueue) {
676
770
  // higher-priority (ioc/typosquat) trigger already fired. queue.js still computes
677
771
  // the same signals later for scan-queue protection + extras enqueue (idempotent).
678
772
  if (!item._cacheTrigger) {
679
- const atoSignal = !!(npmInfo.latestTagVersion && item.version &&
680
- item.version !== npmInfo.latestTagVersion &&
681
- !isPrereleaseVersion(item.version));
682
- const burstCount = Number.isFinite(npmInfo.recentWindowCount)
683
- ? npmInfo.recentWindowCount
684
- : ((Array.isArray(npmInfo.recentVersions) ? npmInfo.recentVersions.length : 0) + 1);
685
- const isBurst = burstCount >= BURST_MIN_VERSIONS_PREFETCH;
686
- if (atoSignal || isBurst) {
687
- const trig = evaluateCacheTrigger(item.name, null, null, { atoSignal, isBurst });
688
- if (trig.shouldCache) item._cacheTrigger = trig;
689
- }
773
+ const trig = evaluateBurstAtoTrigger(item.name, npmInfo, item.version);
774
+ if (trig) item._cacheTrigger = trig;
690
775
  }
691
776
  resolved++;
692
777
  } else {
@@ -962,6 +1047,12 @@ async function pollNpmChanges(state, scanQueue, stats) {
962
1047
  // resolveTarballAndScan() picks up the slack — zero scan loss.
963
1048
  await preResolveNpmBatch(newItems, stats, scanQueue);
964
1049
 
1050
+ // Fix C — real-time capture lane: when preResolveNpmBatch shed under backlog,
1051
+ // resolve+flag a bounded high-risk subset anyway so the schedulePrefetch below
1052
+ // still captures them. No-op when not shedding or when MUADDIB_REALTIME_PREFETCH
1053
+ // is off (default). See realtimePrefetchHighRisk.
1054
+ await realtimePrefetchHighRisk(newItems, stats);
1055
+
965
1056
  // Capture-at-publish (Miasma / Leo, June 2026): prefetch the high-value
966
1057
  // subset's tarballs into the local cache NOW — before the (often backlogged)
967
1058
  // scan downloads them — so we win the race against fast-takedown unpublishes.
@@ -1604,6 +1695,8 @@ module.exports = {
1604
1695
  preResolvePyPIBatch,
1605
1696
  preResolveShouldShed,
1606
1697
  isPrereleaseVersion,
1698
+ evaluateBurstAtoTrigger,
1699
+ realtimePrefetchHighRisk,
1607
1700
 
1608
1701
  // RSS parsing
1609
1702
  parseNpmRss,
@@ -12,7 +12,8 @@ const os = require('os');
12
12
  const { Worker } = require('worker_threads');
13
13
  const { runSandbox, tryAcquireSandboxSlot } = require('../sandbox/index.js');
14
14
  const { sendWebhook } = require('../webhook.js');
15
- const { downloadToFile, extractArchive, extractArchiveOffThread, sanitizePackageName } = require('../shared/download.js');
15
+ const { downloadToFile, extractArchive, sanitizePackageName } = require('../shared/download.js');
16
+ const { extractInPool } = require('../shared/extract-pool.js');
16
17
  const { MAX_TARBALL_SIZE, getMaxFileSize } = require('../shared/constants.js');
17
18
  const { acquireRegistrySlot, releaseRegistrySlot, awaitRateToken: awaitRateTokenForWorker, signal429: signal429ForWorker } = require('../shared/http-limiter.js');
18
19
  const { loadCachedIOCs } = require('../ioc/updater.js');
@@ -179,21 +180,25 @@ const STATIC_SCAN_TIMEOUT_MS = 45_000; // 45s for static analysis only
179
180
  const LARGE_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB
180
181
  const RECENTLY_SCANNED_MAX = 50_000; // FIFO cap for the dedup Set (P0c — bounded resource)
181
182
 
182
- // OOM fix (2026-06-19): archives larger than this (COMPRESSED, on-disk size) are
183
- // extracted off the main thread in a worker, so the synchronous extractor
184
- // (adm-zip extractAllTo / execFileSync tar) can no longer wedge the event loop and
185
- // starve the RSS breaker / memory governor / EMERGENCY purge (all main-thread
186
- // timers) cgroup OOM. Confirmed culprit: data/loop-stalls.jsonl (extract:* up to
187
- // 148s). Small archives extract inline a worker spawn costs more than their
188
- // sub-100ms extraction. Env-tunable via MUADDIB_INLINE_EXTRACT_MB.
189
- const INLINE_EXTRACT_MAX_BYTES = (parseInt(process.env.MUADDIB_INLINE_EXTRACT_MB, 10) || 4) * 1024 * 1024;
190
-
191
- // Extract inline for small archives, off-thread for large ones. compressedSize is
192
- // the on-disk tarball size (reliable, unlike the registry unpackedSize metadata).
193
- // Always returns a Promise so the call sites can uniformly `await`.
183
+ // Event-loop-stall fix (2026-06-30): ALL archive extraction runs off the main
184
+ // thread via a persistent worker pool (src/shared/extract-pool.js). The previous
185
+ // OOM fix only offloaded archives > 4MB (spawn-per-call) and extracted everything
186
+ // smaller INLINE but adm-zip extractAllTo is fully synchronous and slow on
187
+ // many-file trees regardless of size, so those inline extractions still wedged the
188
+ // loop for seconds-to-minutes (data/loop-stalls.jsonl: extract:prework up to 229s;
189
+ // a 2MB many-file package blocked 5s+), starving the RSS breaker / memory governor
190
+ // / EMERGENCY purge (all main-thread timers) restart. The pool's reusable workers
191
+ // pay no per-package spawn cost, so there is no longer a reason to extract inline.
192
+ // MUADDIB_INLINE_EXTRACT_MB keeps a small inline escape hatch (default 0 = always
193
+ // pool). compressedSize is the on-disk tarball size (reliable, unlike registry
194
+ // unpackedSize metadata).
195
+ const INLINE_EXTRACT_MAX_BYTES = (parseInt(process.env.MUADDIB_INLINE_EXTRACT_MB, 10) || 0) * 1024 * 1024;
196
+
197
+ // Always returns a Promise so call sites uniformly `await`. Pool for anything above
198
+ // the (default 0) inline window; the tiny inline path stays available for operators.
194
199
  function extractGated(archivePath, destDir, compressedSize) {
195
200
  if (compressedSize > INLINE_EXTRACT_MAX_BYTES) {
196
- return extractArchiveOffThread(archivePath, destDir);
201
+ return extractInPool(archivePath, destDir);
197
202
  }
198
203
  return Promise.resolve(extractArchive(archivePath, destDir));
199
204
  }
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Persistent worker for the extraction pool (src/shared/extract-pool.js).
5
+ *
6
+ * Unlike extract-worker.js (one-shot, used by download.js extractArchiveOffThread),
7
+ * this worker stays alive and serves a request/response loop: one extraction per
8
+ * message, reusing the loaded extractArchive + adm-zip across packages so the pool
9
+ * pays no per-package Worker-spawn cost. All extraction hardening (zip-slip,
10
+ * zip-bomb uncompressed-size cap) lives in extractArchive and runs HERE, off the
11
+ * parent's event loop. Each reply echoes the job id so the pool matches it to the
12
+ * caller. Never throws to the thread — failures come back as { ok:false, error }.
13
+ *
14
+ * Message in: { id, archivePath, destDir, format? }
15
+ * Message out: { id, ok:true, dir } | { id, ok:false, error }
16
+ */
17
+
18
+ const { parentPort } = require('worker_threads');
19
+ const { extractArchive } = require('./download.js');
20
+
21
+ parentPort.on('message', (job) => {
22
+ if (!job || typeof job.id === 'undefined') return;
23
+ try {
24
+ const opts = job.format ? { format: job.format } : {};
25
+ const dir = extractArchive(job.archivePath, job.destDir, opts);
26
+ parentPort.postMessage({ id: job.id, ok: true, dir });
27
+ } catch (err) {
28
+ parentPort.postMessage({ id: job.id, ok: false, error: err && err.message ? err.message : String(err) });
29
+ }
30
+ });
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Persistent worker-thread pool for off-main-thread archive extraction.
5
+ *
6
+ * Why a pool, not download.js extractArchiveOffThread (spawn-per-call): the
7
+ * monitor extracts thousands of small packages on its main thread. The
8
+ * spawn-per-call offloader pays a fresh Worker (V8 isolate + module load,
9
+ * ~10-40ms) every time, which is why queue.js gated it to archives > 4MB and
10
+ * ran everything smaller INLINE (synchronous extractArchive). Those inline
11
+ * extractions are exactly what wedge the event loop: adm-zip `extractAllTo` is
12
+ * fully synchronous and slow on many-file trees regardless of total size
13
+ * (data/loop-stalls.jsonl: `extract:prework` up to 229s; a 2MB many-file
14
+ * package blocks 5s+). A wedged loop starves the RSS breaker / memory governor /
15
+ * EMERGENCY purge (all main-thread timers) → restart.
16
+ *
17
+ * A pool of N reusable workers removes BOTH costs: no extraction ever runs on
18
+ * the main thread (at any size), and there is no per-package spawn. The size
19
+ * gate in queue.js then becomes unnecessary (its inline window defaults to 0).
20
+ *
21
+ * Each worker runs extract-pool-worker.js — a request/response loop that calls
22
+ * the SAME extractArchive, so all hardening (zip-slip, zip-bomb uncompressed
23
+ * cap) stays centralized there and runs in the worker.
24
+ *
25
+ * Bounded + crash-resilient (CLAUDE.md "Production Engineering"):
26
+ * - POOL_SIZE workers max, spawned lazily on demand; idle workers are `unref`'d
27
+ * so the pool never keeps the process alive on its own.
28
+ * - One job per worker; excess jobs wait in a FIFO bounded to MAX_PENDING —
29
+ * past the cap a job rejects immediately (the caller treats it as an extract
30
+ * failure → size_skip, ledgered) so a slow extractor cannot grow memory
31
+ * without bound.
32
+ * - Per-job timeout terminates and drops a wedged worker and rejects that job —
33
+ * a pathological archive cannot pin a slot forever.
34
+ * - A worker that errors or exits mid-job rejects its in-flight job and is
35
+ * removed; the next dispatch lazily respawns up to POOL_SIZE.
36
+ */
37
+
38
+ const path = require('path');
39
+
40
+ const POOL_SIZE = (() => {
41
+ const v = parseInt(process.env.MUADDIB_EXTRACT_POOL_SIZE, 10);
42
+ if (Number.isFinite(v) && v >= 1) return v;
43
+ let cores = 4;
44
+ try { cores = require('os').cpus().length || 4; } catch { /* default 4 */ }
45
+ return Math.max(2, Math.min(4, cores - 2));
46
+ })();
47
+
48
+ const JOB_TIMEOUT_MS = (() => {
49
+ const v = parseInt(process.env.MUADDIB_EXTRACT_POOL_TIMEOUT_MS, 10);
50
+ return Number.isFinite(v) && v > 0 ? v : 120_000;
51
+ })();
52
+
53
+ const MAX_PENDING = (() => {
54
+ const v = parseInt(process.env.MUADDIB_EXTRACT_POOL_MAX_PENDING, 10);
55
+ return Number.isFinite(v) && v > 0 ? v : 512;
56
+ })();
57
+
58
+ const WORKER_FILE = path.join(__dirname, 'extract-pool-worker.js');
59
+
60
+ // Pool state (lazy-init on first extractInPool). Each worker entry:
61
+ // { worker, busy, job }. `pending` is the FIFO of jobs waiting for a free slot.
62
+ let workers = [];
63
+ let pending = [];
64
+ let jobSeq = 0;
65
+ let destroyed = false;
66
+
67
+ function _settle(job, err, dir) {
68
+ if (job.settled) return;
69
+ job.settled = true;
70
+ if (job.timer) { clearTimeout(job.timer); job.timer = null; }
71
+ if (err) job.reject(err);
72
+ else job.resolve(dir);
73
+ }
74
+
75
+ function _removeWorker(entry) {
76
+ const i = workers.indexOf(entry);
77
+ if (i !== -1) workers.splice(i, 1);
78
+ }
79
+
80
+ function _spawnWorker() {
81
+ const { Worker } = require('worker_threads');
82
+ const entry = { worker: null, busy: false, job: null };
83
+ const w = new Worker(WORKER_FILE);
84
+ entry.worker = w;
85
+ // Idle workers must not keep the process alive; an in-flight job is kept alive
86
+ // by the caller's awaited Promise, not by the worker handle.
87
+ if (typeof w.unref === 'function') w.unref();
88
+ w.on('message', (msg) => _onMessage(entry, msg));
89
+ w.on('error', (err) => _onWorkerError(entry, err));
90
+ w.on('exit', (code) => _onWorkerExit(entry, code));
91
+ workers.push(entry);
92
+ return entry;
93
+ }
94
+
95
+ function _onMessage(entry, msg) {
96
+ const job = entry.job;
97
+ entry.busy = false;
98
+ entry.job = null;
99
+ if (job && msg && msg.id === job.id) {
100
+ if (msg.ok) _settle(job, null, msg.dir);
101
+ else _settle(job, new Error(msg.error || 'extract-pool: worker reported failure'));
102
+ }
103
+ _dispatch();
104
+ }
105
+
106
+ function _onWorkerError(entry, err) {
107
+ const job = entry.job;
108
+ entry.busy = false;
109
+ entry.job = null;
110
+ _removeWorker(entry);
111
+ if (job) _settle(job, err instanceof Error ? err : new Error(String(err)));
112
+ _dispatch();
113
+ }
114
+
115
+ function _onWorkerExit(entry, code) {
116
+ const job = entry.job;
117
+ entry.busy = false;
118
+ entry.job = null;
119
+ _removeWorker(entry);
120
+ if (job) _settle(job, new Error(`extract-pool: worker exited (${code}) mid-job`));
121
+ _dispatch();
122
+ }
123
+
124
+ function _assign(entry, job) {
125
+ entry.busy = true;
126
+ entry.job = job;
127
+ job.timer = setTimeout(() => {
128
+ // Worker is wedged on a pathological archive: terminate it (best-effort) and
129
+ // drop it from the pool, reject the job, let _dispatch respawn lazily.
130
+ entry.busy = false;
131
+ entry.job = null;
132
+ _removeWorker(entry);
133
+ try { entry.worker.terminate(); } catch { /* already gone */ }
134
+ _settle(job, new Error(`extract-pool: timeout after ${JOB_TIMEOUT_MS}ms: ${path.basename(job.archivePath)}`));
135
+ _dispatch();
136
+ }, JOB_TIMEOUT_MS);
137
+ if (job.timer && typeof job.timer.unref === 'function') job.timer.unref();
138
+ entry.worker.postMessage({ id: job.id, archivePath: job.archivePath, destDir: job.destDir, format: job.format });
139
+ }
140
+
141
+ function _dispatch() {
142
+ if (destroyed) return;
143
+ while (pending.length > 0) {
144
+ let entry = workers.find((e) => !e.busy);
145
+ if (!entry) {
146
+ if (workers.length < POOL_SIZE) entry = _spawnWorker();
147
+ else break; // all workers busy and at cap — wait for a completion
148
+ }
149
+ _assign(entry, pending.shift());
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Extract an archive off the main thread via the pool. Same contract as
155
+ * download.js extractArchive (resolves to the extracted package root) but never
156
+ * blocks the caller's event loop.
157
+ *
158
+ * @param {string} archivePath
159
+ * @param {string} destDir - must already exist
160
+ * @param {Object} [options]
161
+ * @param {'targz'|'zip'} [options.format] - override auto-detection
162
+ * @returns {Promise<string>} extracted package root
163
+ */
164
+ function extractInPool(archivePath, destDir, options = {}) {
165
+ return new Promise((resolve, reject) => {
166
+ if (destroyed) { reject(new Error('extract-pool: pool destroyed')); return; }
167
+ if (pending.length >= MAX_PENDING) {
168
+ reject(new Error(`extract-pool: pending queue full (${MAX_PENDING}) — extraction is not keeping up`));
169
+ return;
170
+ }
171
+ const job = {
172
+ id: ++jobSeq,
173
+ archivePath,
174
+ destDir,
175
+ format: (options && options.format) || null,
176
+ resolve,
177
+ reject,
178
+ settled: false,
179
+ timer: null,
180
+ };
181
+ pending.push(job);
182
+ _dispatch();
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Terminate all workers and reject any in-flight / pending jobs. Idempotent and
188
+ * reusable: after it resolves the pool lazily re-inits on the next extractInPool
189
+ * (the daemon calls this once during gracefulShutdown; tests call it between
190
+ * cases).
191
+ * @returns {Promise<void>}
192
+ */
193
+ async function destroyExtractPool() {
194
+ destroyed = true;
195
+ const inflight = workers.slice();
196
+ const queued = pending.slice();
197
+ workers = [];
198
+ pending = [];
199
+ for (const job of queued) _settle(job, new Error('extract-pool: pool destroyed'));
200
+ await Promise.all(inflight.map((entry) => {
201
+ if (entry.job) _settle(entry.job, new Error('extract-pool: pool destroyed'));
202
+ try { return entry.worker.terminate(); } catch { return Promise.resolve(); }
203
+ }));
204
+ destroyed = false;
205
+ }
206
+
207
+ /** Observability snapshot (live workers / busy / pending), all bounded. */
208
+ function getPoolStats() {
209
+ return {
210
+ size: workers.length,
211
+ max: POOL_SIZE,
212
+ busy: workers.filter((e) => e.busy).length,
213
+ pending: pending.length,
214
+ };
215
+ }
216
+
217
+ module.exports = { extractInPool, destroyExtractPool, getPoolStats, POOL_SIZE };