muaddib-scanner 2.10.46 → 2.10.47
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 +1 -1
- package/src/monitor/daemon.js +106 -2
- package/src/monitor/deferred-sandbox.js +33 -4
- package/src/monitor/state.js +9 -1
package/package.json
CHANGED
package/src/monitor/daemon.js
CHANGED
|
@@ -58,6 +58,15 @@ function persistQueue(scanQueue, state) {
|
|
|
58
58
|
* Skips if file is missing, corrupt, or older than 24h.
|
|
59
59
|
*/
|
|
60
60
|
function restoreQueue(scanQueue) {
|
|
61
|
+
// Cleanup orphan .tmp from previous crash / disk-full (ENOSPC)
|
|
62
|
+
const tmpFile = QUEUE_STATE_FILE + '.tmp';
|
|
63
|
+
try {
|
|
64
|
+
if (fs.existsSync(tmpFile)) {
|
|
65
|
+
console.log(`[MONITOR] Cleaning up orphan ${path.basename(tmpFile)}`);
|
|
66
|
+
fs.unlinkSync(tmpFile);
|
|
67
|
+
}
|
|
68
|
+
} catch { /* best-effort */ }
|
|
69
|
+
|
|
61
70
|
try {
|
|
62
71
|
if (!fs.existsSync(QUEUE_STATE_FILE)) return 0;
|
|
63
72
|
const raw = fs.readFileSync(QUEUE_STATE_FILE, 'utf8');
|
|
@@ -134,6 +143,94 @@ function cleanupOrphanContainers() {
|
|
|
134
143
|
}
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Clean up orphan gVisor runtime directories in /tmp/runsc.
|
|
148
|
+
* runsc creates per-container state dirs that are NOT cleaned up when gVisor or
|
|
149
|
+
* Docker crashes. In production this reached 61GB and filled the disk (ENOSPC),
|
|
150
|
+
* cascading into 0-byte .tmp files and total persistence failure.
|
|
151
|
+
* Removes directories older than maxAgeMs (default: 1h).
|
|
152
|
+
*/
|
|
153
|
+
function cleanupRunscOrphans(maxAgeMs = 3600_000) {
|
|
154
|
+
const runscDir = process.env.MUADDIB_GVISOR_LOG_DIR || '/tmp/runsc';
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(runscDir)) return 0;
|
|
157
|
+
const entries = fs.readdirSync(runscDir);
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
let cleaned = 0;
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const fullPath = path.join(runscDir, entry);
|
|
162
|
+
try {
|
|
163
|
+
const stat = fs.statSync(fullPath);
|
|
164
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
165
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
166
|
+
cleaned++;
|
|
167
|
+
}
|
|
168
|
+
} catch { /* skip unreadable entries */ }
|
|
169
|
+
}
|
|
170
|
+
if (cleaned > 0) {
|
|
171
|
+
console.log(`[MONITOR] Cleaned up ${cleaned} orphan runsc dir(s) in ${runscDir}`);
|
|
172
|
+
}
|
|
173
|
+
return cleaned;
|
|
174
|
+
} catch {
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check disk usage at boot. Warns if root partition > 90% full and logs
|
|
181
|
+
* the largest consumers in /tmp/ and data/ to aid diagnosis.
|
|
182
|
+
* Uses df + du — Linux-only, silently skips on other platforms.
|
|
183
|
+
*/
|
|
184
|
+
function checkDiskSpace() {
|
|
185
|
+
try {
|
|
186
|
+
// df --output=pcent / → "Use%\n 42%\n"
|
|
187
|
+
const dfOutput = execFileSync('df', ['--output=pcent', '/'], {
|
|
188
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000
|
|
189
|
+
});
|
|
190
|
+
const match = dfOutput.match(/(\d+)%/);
|
|
191
|
+
if (!match) return;
|
|
192
|
+
const usagePercent = parseInt(match[1], 10);
|
|
193
|
+
if (usagePercent < 90) return;
|
|
194
|
+
|
|
195
|
+
console.warn(`[MONITOR] WARNING: disk usage at ${usagePercent}% — persistence may fail (ENOSPC)`);
|
|
196
|
+
|
|
197
|
+
// Top consumers in /tmp/
|
|
198
|
+
try {
|
|
199
|
+
const tmpDu = execFileSync('du', ['-sh', '--max-depth=1', '/tmp/'], {
|
|
200
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
|
|
201
|
+
});
|
|
202
|
+
const lines = tmpDu.trim().split('\n')
|
|
203
|
+
.map(l => { const m = l.match(/^([\d.]+[KMGT]?)\s+(.+)/); return m ? { size: m[1], path: m[2] } : null; })
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.sort((a, b) => b.size.localeCompare(a.size))
|
|
206
|
+
.slice(0, 5);
|
|
207
|
+
if (lines.length > 0) {
|
|
208
|
+
console.warn('[MONITOR] Top /tmp/ consumers:');
|
|
209
|
+
for (const l of lines) console.warn(`[MONITOR] ${l.size}\t${l.path}`);
|
|
210
|
+
}
|
|
211
|
+
} catch { /* du failed */ }
|
|
212
|
+
|
|
213
|
+
// Top consumers in data/
|
|
214
|
+
const dataDir = path.join(__dirname, '..', '..', 'data');
|
|
215
|
+
try {
|
|
216
|
+
const dataDu = execFileSync('du', ['-sh', '--max-depth=1', dataDir], {
|
|
217
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
|
|
218
|
+
});
|
|
219
|
+
const lines = dataDu.trim().split('\n')
|
|
220
|
+
.map(l => { const m = l.match(/^([\d.]+[KMGT]?)\s+(.+)/); return m ? { size: m[1], path: m[2] } : null; })
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.sort((a, b) => b.size.localeCompare(a.size))
|
|
223
|
+
.slice(0, 5);
|
|
224
|
+
if (lines.length > 0) {
|
|
225
|
+
console.warn('[MONITOR] Top data/ consumers:');
|
|
226
|
+
for (const l of lines) console.warn(`[MONITOR] ${l.size}\t${l.path}`);
|
|
227
|
+
}
|
|
228
|
+
} catch { /* du failed */ }
|
|
229
|
+
} catch {
|
|
230
|
+
// df not available (non-Linux) — skip silently
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
137
234
|
function reportStats(stats) {
|
|
138
235
|
const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
|
|
139
236
|
const { t1, t1a, t1b, t2, t3 } = stats.suspectByTier;
|
|
@@ -153,7 +250,7 @@ function reportStats(stats) {
|
|
|
153
250
|
if (stats.sandboxDeferred || stats.deferredProcessed) {
|
|
154
251
|
const { getDeferredQueueStats } = require('./deferred-sandbox.js');
|
|
155
252
|
const dq = getDeferredQueueStats();
|
|
156
|
-
console.log(`[MONITOR] Deferred sandbox: ${stats.sandboxDeferred || 0} enqueued, ${stats.deferredProcessed || 0} processed, ${stats.deferredExpired || 0} expired, ${dq.size} pending`);
|
|
253
|
+
console.log(`[MONITOR] Deferred sandbox: ${stats.sandboxDeferred || 0} enqueued, ${stats.deferredProcessed || 0} processed, ${stats.deferredExpired || 0} expired, ${stats.deferredSkipped || 0} skipped, ${dq.size} pending`);
|
|
157
254
|
}
|
|
158
255
|
stats.lastReportTime = Date.now();
|
|
159
256
|
}
|
|
@@ -171,10 +268,14 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
171
268
|
setVerboseMode(true);
|
|
172
269
|
}
|
|
173
270
|
|
|
271
|
+
// Disk space check — early warning before ENOSPC cascading failure
|
|
272
|
+
checkDiskSpace();
|
|
174
273
|
// Cleanup temp dirs from previous runs (SIGTERM/crash may leave orphans)
|
|
175
274
|
cleanupOrphanTmpDirs();
|
|
176
275
|
// Kill orphan sandbox containers from previous crash (npm-audit-* prefix)
|
|
177
276
|
cleanupOrphanContainers();
|
|
277
|
+
// Clean up stale gVisor runtime dirs (runsc leak — caused 61GB disk fill in prod)
|
|
278
|
+
cleanupRunscOrphans();
|
|
178
279
|
// Layer 3: Purge expired cached tarballs on startup
|
|
179
280
|
purgeTarballCache();
|
|
180
281
|
|
|
@@ -364,10 +465,11 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
364
465
|
await processQueue(scanQueue, stats, dailyAlerts, recentlyScanned, downloadsCache, sandboxAvailableRef.value);
|
|
365
466
|
}
|
|
366
467
|
|
|
367
|
-
// Hourly stats report + cache purge
|
|
468
|
+
// Hourly stats report + cache purge + runsc cleanup
|
|
368
469
|
if (Date.now() - stats.lastReportTime >= 3600_000) {
|
|
369
470
|
reportStats(stats);
|
|
370
471
|
purgeTarballCache();
|
|
472
|
+
cleanupRunscOrphans();
|
|
371
473
|
}
|
|
372
474
|
|
|
373
475
|
// Daily webhook report at 08:00 Paris time
|
|
@@ -384,6 +486,8 @@ module.exports = {
|
|
|
384
486
|
startMonitor,
|
|
385
487
|
cleanupOrphanTmpDirs,
|
|
386
488
|
cleanupOrphanContainers,
|
|
489
|
+
cleanupRunscOrphans,
|
|
490
|
+
checkDiskSpace,
|
|
387
491
|
reportStats,
|
|
388
492
|
isDailyReportDue,
|
|
389
493
|
sleep,
|
|
@@ -22,12 +22,15 @@ const DEFERRED_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
|
22
22
|
const DEFERRED_MAX_RETRIES = 2;
|
|
23
23
|
const DEFERRED_WORKER_INTERVAL_MS = 30_000; // 30s
|
|
24
24
|
const DEFERRED_STATE_FILE = path.join(__dirname, '..', '..', 'data', 'deferred-queue.json');
|
|
25
|
+
const SKIP_LOG_INTERVAL = 10; // Log every N skipped ticks (throttle)
|
|
26
|
+
const ANTI_STARVATION_TICKS = 20; // Force processing after N consecutive skips (~10min)
|
|
25
27
|
|
|
26
28
|
// ── Mutable state ──
|
|
27
29
|
const _deferredQueue = [];
|
|
28
30
|
const _deferredSeen = new Set(); // name@version dedup
|
|
29
31
|
let _workerHandle = null;
|
|
30
32
|
let _stats = null; // reference to shared stats object
|
|
33
|
+
let _consecutiveSkips = 0; // Tracks consecutive yield-skips for anti-starvation
|
|
31
34
|
|
|
32
35
|
// ── Queue management ──
|
|
33
36
|
|
|
@@ -126,11 +129,24 @@ async function processDeferredItem(stats) {
|
|
|
126
129
|
|
|
127
130
|
if (_deferredQueue.length === 0) return null;
|
|
128
131
|
|
|
129
|
-
// 2. Yield check: reserve 1 slot for T1a
|
|
132
|
+
// 2. Yield check: reserve 1 slot for T1a (unless anti-starvation kicks in)
|
|
130
133
|
const sem = getSandboxSemaphore();
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
const slotsFullForDeferred = sem.active >= SANDBOX_CONCURRENCY_MAX - 1;
|
|
135
|
+
const forceAntiStarvation = _consecutiveSkips >= ANTI_STARVATION_TICKS && sem.active < SANDBOX_CONCURRENCY_MAX;
|
|
136
|
+
|
|
137
|
+
if (slotsFullForDeferred && !forceAntiStarvation) {
|
|
138
|
+
_consecutiveSkips++;
|
|
139
|
+
if (stats) stats.deferredSkipped = (stats.deferredSkipped || 0) + 1;
|
|
140
|
+
if (_consecutiveSkips % SKIP_LOG_INTERVAL === 0) {
|
|
141
|
+
console.log(`[DEFERRED] YIELD: ${_consecutiveSkips} consecutive skips (slots=${sem.active}/${SANDBOX_CONCURRENCY_MAX}, pending=${_deferredQueue.length})`);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (forceAntiStarvation) {
|
|
147
|
+
console.log(`[DEFERRED] ANTI-STARVATION: forcing processing after ${_consecutiveSkips} skips (slots=${sem.active}/${SANDBOX_CONCURRENCY_MAX}, pending=${_deferredQueue.length})`);
|
|
133
148
|
}
|
|
149
|
+
_consecutiveSkips = 0;
|
|
134
150
|
|
|
135
151
|
// 3. Pick highest-score item
|
|
136
152
|
const item = _deferredQueue.shift();
|
|
@@ -311,6 +327,16 @@ function persistDeferredQueue() {
|
|
|
311
327
|
}
|
|
312
328
|
|
|
313
329
|
function restoreDeferredQueue() {
|
|
330
|
+
// Cleanup orphan .tmp from previous crash / disk-full (ENOSPC)
|
|
331
|
+
const tmpFile = DEFERRED_STATE_FILE + '.tmp';
|
|
332
|
+
try {
|
|
333
|
+
if (fs.existsSync(tmpFile)) {
|
|
334
|
+
const stat = fs.statSync(tmpFile);
|
|
335
|
+
console.log(`[DEFERRED] Cleaning up orphan ${path.basename(tmpFile)} (${stat.size} bytes)`);
|
|
336
|
+
fs.unlinkSync(tmpFile);
|
|
337
|
+
}
|
|
338
|
+
} catch { /* best-effort */ }
|
|
339
|
+
|
|
314
340
|
try {
|
|
315
341
|
if (!fs.existsSync(DEFERRED_STATE_FILE)) return 0;
|
|
316
342
|
const raw = fs.readFileSync(DEFERRED_STATE_FILE, 'utf8');
|
|
@@ -365,6 +391,7 @@ function _resetDeferredQueue() {
|
|
|
365
391
|
_deferredQueue.length = 0;
|
|
366
392
|
_deferredSeen.clear();
|
|
367
393
|
_stats = null;
|
|
394
|
+
_consecutiveSkips = 0;
|
|
368
395
|
stopDeferredWorker();
|
|
369
396
|
}
|
|
370
397
|
|
|
@@ -384,5 +411,7 @@ module.exports = {
|
|
|
384
411
|
DEFERRED_TTL_MS,
|
|
385
412
|
DEFERRED_MAX_RETRIES,
|
|
386
413
|
DEFERRED_WORKER_INTERVAL_MS,
|
|
387
|
-
DEFERRED_STATE_FILE
|
|
414
|
+
DEFERRED_STATE_FILE,
|
|
415
|
+
SKIP_LOG_INTERVAL,
|
|
416
|
+
ANTI_STARVATION_TICKS
|
|
388
417
|
};
|
package/src/monitor/state.js
CHANGED
|
@@ -116,6 +116,10 @@ function atomicWriteFileSync(filePath, data) {
|
|
|
116
116
|
console.warn(`[MONITOR] Cannot create directory ${dir} (${err.code}) — skipping write to ${path.basename(filePath)}`);
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
119
|
+
if (err.code === 'ENOSPC') {
|
|
120
|
+
console.warn(`[MONITOR] WARNING: disk full (ENOSPC) — cannot create directory ${dir}. Free space immediately.`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
119
123
|
throw err;
|
|
120
124
|
}
|
|
121
125
|
const tmpFile = filePath + '.tmp';
|
|
@@ -125,7 +129,11 @@ function atomicWriteFileSync(filePath, data) {
|
|
|
125
129
|
} catch (err) {
|
|
126
130
|
if (err.code === 'EROFS' || err.code === 'EACCES' || err.code === 'EPERM') {
|
|
127
131
|
console.warn(`[MONITOR] Cannot write ${path.basename(filePath)} (${err.code}) — skipping`);
|
|
128
|
-
|
|
132
|
+
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (err.code === 'ENOSPC') {
|
|
136
|
+
console.warn(`[MONITOR] WARNING: disk full (ENOSPC) — cannot write ${path.basename(filePath)}. Free space in /tmp and data/ immediately.`);
|
|
129
137
|
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
|
|
130
138
|
return;
|
|
131
139
|
}
|