muaddib-scanner 2.11.135 → 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
package/src/monitor/daemon.js
CHANGED
|
@@ -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)
|
package/src/monitor/queue.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
// the on-disk tarball size (reliable, unlike
|
|
193
|
-
//
|
|
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
|
|
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 };
|