muaddib-scanner 2.11.163 → 2.11.164
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/state.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
const fs = require('fs');
|
|
26
26
|
const path = require('path');
|
|
27
|
+
const readline = require('readline');
|
|
27
28
|
const { isMainThread, threadId } = require('worker_threads');
|
|
28
29
|
const { sanitizePackageName } = require('../shared/download.js');
|
|
29
30
|
|
|
@@ -1054,6 +1055,12 @@ const MAX_SCAN_LEDGER = (() => {
|
|
|
1054
1055
|
})();
|
|
1055
1056
|
const SCAN_LEDGER_COMPACT_INTERVAL = 2000;
|
|
1056
1057
|
let _scanLedgerAppendedSinceCompact = 0;
|
|
1058
|
+
// Async-compaction coordination. `_scanLedgerCompacting` guards re-entrancy (a second
|
|
1059
|
+
// trigger while a compaction is in flight is skipped — the cap is soft, the next
|
|
1060
|
+
// interval retries). The promise lets tests + graceful shutdown await an in-flight
|
|
1061
|
+
// compaction settling. See _compactScanLedgerJsonl (async, streaming, race-safe).
|
|
1062
|
+
let _scanLedgerCompacting = false;
|
|
1063
|
+
let _scanLedgerCompactionPromise = Promise.resolve();
|
|
1057
1064
|
|
|
1058
1065
|
// Terminal outcomes a dequeued package can reach. Unknown values normalize to 'clean'
|
|
1059
1066
|
// so a typo at a call site can never crash the pipeline.
|
|
@@ -1124,7 +1131,10 @@ function appendScanLedger(e) {
|
|
|
1124
1131
|
_scanLedgerAppendedSinceCompact++;
|
|
1125
1132
|
if (_scanLedgerAppendedSinceCompact >= SCAN_LEDGER_COMPACT_INTERVAL) {
|
|
1126
1133
|
_scanLedgerAppendedSinceCompact = 0;
|
|
1127
|
-
|
|
1134
|
+
// Fire the async compaction (non-blocking — keeps the RSS-breaker poll loop
|
|
1135
|
+
// reactive). Guarded + fully caught internally so it never rejects; stored so
|
|
1136
|
+
// tests and graceful shutdown can await an in-flight compaction settling.
|
|
1137
|
+
_scanLedgerCompactionPromise = _compactScanLedgerJsonl();
|
|
1128
1138
|
}
|
|
1129
1139
|
} catch (err) {
|
|
1130
1140
|
if (err.code === 'EROFS' || err.code === 'EACCES' || err.code === 'EPERM') return;
|
|
@@ -1140,31 +1150,99 @@ function appendScanLedger(e) {
|
|
|
1140
1150
|
* Compact the scan-ledger JSONL: keep only the most recent MAX_SCAN_LEDGER entries.
|
|
1141
1151
|
* No-op when already under cap. Streams (never loads the whole file at once).
|
|
1142
1152
|
*/
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1153
|
+
// Stream-count newline-terminated lines in [0, endByte) without loading the file or
|
|
1154
|
+
// parsing JSON. Async — yields between 64KB chunks so it never wedges the poll loop.
|
|
1155
|
+
function _streamCountLines(filePath, endByte) {
|
|
1156
|
+
return new Promise((resolve, reject) => {
|
|
1157
|
+
if (!endByte || endByte <= 0) { resolve(0); return; }
|
|
1158
|
+
let count = 0;
|
|
1159
|
+
const rs = fs.createReadStream(filePath, { start: 0, end: endByte - 1 });
|
|
1160
|
+
rs.on('error', (e) => ((e && e.code === 'ENOENT') ? resolve(0) : reject(e)));
|
|
1161
|
+
rs.on('data', (buf) => { for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) count++; });
|
|
1162
|
+
rs.on('end', () => resolve(count));
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Stream [0, endByte): skip the first `skip` lines, copy the rest VERBATIM (no parse,
|
|
1167
|
+
// no re-stringify) to tmp. Bounded memory: one line at a time + write backpressure
|
|
1168
|
+
// (pause the reader until the write buffer drains). Returns the kept line count.
|
|
1169
|
+
function _streamKeepTailToTmp(filePath, skip, endByte, tmpFile) {
|
|
1170
|
+
return new Promise((resolve, reject) => {
|
|
1171
|
+
let seen = 0, kept = 0, failed = false, draining = false;
|
|
1172
|
+
const rs = fs.createReadStream(filePath, { encoding: 'utf8', start: 0, end: endByte - 1 });
|
|
1173
|
+
const ws = fs.createWriteStream(tmpFile, { encoding: 'utf8' }); // truncates any orphan tmp
|
|
1174
|
+
const rl = readline.createInterface({ input: rs, crlfDelay: Infinity });
|
|
1175
|
+
const fail = (e) => { if (failed) return; failed = true; try { rl.close(); } catch { /* */ } try { rs.destroy(); } catch { /* */ } try { ws.destroy(); } catch { /* */ } reject(e); };
|
|
1176
|
+
rs.on('error', fail); ws.on('error', fail);
|
|
1177
|
+
rl.on('line', (line) => {
|
|
1178
|
+
if (seen++ < skip) return;
|
|
1179
|
+
if (!line) return;
|
|
1180
|
+
const ok = ws.write(line + '\n');
|
|
1181
|
+
kept++;
|
|
1182
|
+
// Backpressure: pause the reader until the write buffer drains. The `draining`
|
|
1183
|
+
// guard is required — readline emits lines ALREADY buffered from the current
|
|
1184
|
+
// chunk synchronously even after pause(), so without it each such line would
|
|
1185
|
+
// stack another 'drain' listener (MaxListenersExceededWarning + redundant resumes).
|
|
1186
|
+
if (!ok && !draining) {
|
|
1187
|
+
draining = true;
|
|
1188
|
+
rl.pause();
|
|
1189
|
+
ws.once('drain', () => { draining = false; rl.resume(); });
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
rl.on('close', () => { if (!failed) ws.end(() => resolve(kept)); });
|
|
1165
1193
|
});
|
|
1166
1194
|
}
|
|
1167
1195
|
|
|
1196
|
+
// Async, streaming, race-safe compaction of the scan-ledger. Replaces the old
|
|
1197
|
+
// SYNCHRONOUS 127MB rewrite that wedged the main-thread poll loop for minutes — and
|
|
1198
|
+
// because the RSS circuit breaker runs on that same loop, a wedged loop is a BLIND
|
|
1199
|
+
// breaker → RSS climbs unchecked → cgroup OOM. Design (why it can't lose appends):
|
|
1200
|
+
// • two async streaming passes over the immutable snapshot [0, preSize): count, then
|
|
1201
|
+
// copy the last MAX lines to a .tmp. Bounded memory (no 500K-entry array / 127MB
|
|
1202
|
+
// join). Non-blocking: appends keep flowing to the LIVE file during these passes.
|
|
1203
|
+
// • the ONLY destructive step — fold the live tail [preSize, nowSize) onto .tmp, then
|
|
1204
|
+
// rename .tmp over the ledger — runs with NO `await` between the tail read and the
|
|
1205
|
+
// rename, so it is ATOMIC w.r.t. the single-threaded event loop: no concurrent
|
|
1206
|
+
// appendScanLedger can interleave, hence the rename can never clobber an un-folded
|
|
1207
|
+
// append. (appendFileSync writes whole lines, so preSize is always line-aligned.)
|
|
1208
|
+
// • crash-safe: the live ledger is untouched until the atomic rename; a crash leaves
|
|
1209
|
+
// it intact plus a stale .tmp (overwritten next run). No sidecar, no recovery hook.
|
|
1210
|
+
// Never throws (fire-and-forget from appendScanLedger).
|
|
1211
|
+
async function _compactScanLedgerJsonl() {
|
|
1212
|
+
if (_scanLedgerCompacting) return; // re-entrancy guard
|
|
1213
|
+
_scanLedgerCompacting = true;
|
|
1214
|
+
const tmpFile = SCAN_LEDGER_FILE + '.tmp';
|
|
1215
|
+
try {
|
|
1216
|
+
if (!fs.existsSync(SCAN_LEDGER_FILE)) return;
|
|
1217
|
+
const preSize = fs.statSync(SCAN_LEDGER_FILE).size; // immutable snapshot boundary
|
|
1218
|
+
if (preSize === 0) return;
|
|
1219
|
+
const total = await _streamCountLines(SCAN_LEDGER_FILE, preSize);
|
|
1220
|
+
if (total <= MAX_SCAN_LEDGER) return;
|
|
1221
|
+
const toDrop = total - MAX_SCAN_LEDGER;
|
|
1222
|
+
const t0 = Date.now();
|
|
1223
|
+
const kept = await _streamKeepTailToTmp(SCAN_LEDGER_FILE, toDrop, preSize, tmpFile);
|
|
1224
|
+
// ── SYNCHRONOUS finalize (atomic w.r.t. the event loop — no await below) ──
|
|
1225
|
+
const nowSize = fs.statSync(SCAN_LEDGER_FILE).size;
|
|
1226
|
+
let foldedBytes = 0;
|
|
1227
|
+
if (nowSize > preSize) { // appends landed during the async passes
|
|
1228
|
+
const fd = fs.openSync(SCAN_LEDGER_FILE, 'r');
|
|
1229
|
+
try {
|
|
1230
|
+
foldedBytes = nowSize - preSize;
|
|
1231
|
+
const tail = Buffer.allocUnsafe(foldedBytes);
|
|
1232
|
+
fs.readSync(fd, tail, 0, foldedBytes, preSize);
|
|
1233
|
+
fs.appendFileSync(tmpFile, tail); // preserve concurrent appends (whole lines)
|
|
1234
|
+
} finally { fs.closeSync(fd); }
|
|
1235
|
+
}
|
|
1236
|
+
fs.renameSync(tmpFile, SCAN_LEDGER_FILE); // atomic swap
|
|
1237
|
+
console.log(`[MONITOR] COMPACT scan-ledger: ${total} -> ${kept} entries (async, ${Date.now() - t0}ms, +${foldedBytes}B live tail)`);
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
console.error(`[MONITOR] Scan-ledger compaction failed: ${err.message}`);
|
|
1240
|
+
try { fs.unlinkSync(tmpFile); } catch { /* nothing to clean */ }
|
|
1241
|
+
} finally {
|
|
1242
|
+
_scanLedgerCompacting = false;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1168
1246
|
/** Stream the scan-ledger into an array (tests + Phase 0b rollup). */
|
|
1169
1247
|
function loadScanLedger() {
|
|
1170
1248
|
const entries = [];
|
|
@@ -1880,6 +1958,7 @@ module.exports = {
|
|
|
1880
1958
|
loadScanLedger,
|
|
1881
1959
|
computeLedgerRollup,
|
|
1882
1960
|
_compactScanLedgerJsonl,
|
|
1961
|
+
_whenScanLedgerCompactionSettles: () => _scanLedgerCompactionPromise,
|
|
1883
1962
|
getDetectionStats,
|
|
1884
1963
|
runStateMigrations,
|
|
1885
1964
|
// Internal — exported for tests and for the daemon hourly housekeeping.
|