UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

175 lines (174 loc) • 6.49 kB
/** * Shared write-plumbing for the hand-rolled NDJSON debug loggers in clients/. * * One buffered async writer replaces eight drifting copies of append+rotate. * `log()`/`append()` are synchronous-call, async-write: they enqueue a * serialized line and a single in-flight `fs.promises.appendFile` drains the * queue — no `appendFileSync` on the per-edit hot path (latency-logger alone * fired ~10–20 sync appends per edit, #454/#361/#368). * * Errors are swallowed best-effort, matching every current logger. A * best-effort SYNC flush is registered on `process.on("exit")` (appendFileSync * is fine at exit — not the hot path; no child spawning, #234). */ import * as fs from "node:fs"; import * as path from "node:path"; function resolve(v) { return typeof v === "function" ? v() : v; } // One shared exit handler flushes every logger — avoids an EventEmitter // MaxListeners warning once more than ~10 loggers exist (we ship eight, plus // diagnostic + test instances). No child spawning at teardown (#234). const exitFlushers = new Set(); let exitHandlerRegistered = false; /** Test-only view of the registered exit flushers (see ndjson-logger.test.ts). */ export function _exitFlushersForTest() { return exitFlushers; } // Auto-derived retention coverage (clients/log-cleanup.ts): every *static* // filePath a createNdjsonLogger instance is constructed with self-registers // here at module-load time — the moment latency-logger.ts, bus-events-logger.ts, // etc. call createNdjsonLogger(), the sweep in log-cleanup.ts picks the file up // automatically. No second hand-maintained list to forget (the exact mistake // that left actionable-warnings/ast-grep-tools/dead-code, then bus-events.log, // unrotated — see log-cleanup.ts's module doc). // // A *lazy* filePath (a resolver function, e.g. diagnostic-logger's date-keyed // `logs/{date}.jsonl`) is deliberately NOT registered: those already live // under the `logs/` subdirectory and are covered by log-cleanup's separate // `*.jsonl` daily-log sweep, not the single-file rotation list. const registeredLogFiles = new Set(); /** Every absolute path registered by a static-filePath createNdjsonLogger instance. */ export function getRegisteredLogFiles() { return registeredLogFiles; } /** Test-only reset — each test file gets a clean registry (see ndjson-logger.test.ts). */ export function _resetRegisteredLogFilesForTest() { registeredLogFiles.clear(); } function registerExitFlusher(flushSync) { exitFlushers.add(flushSync); if (!exitHandlerRegistered) { exitHandlerRegistered = true; process.on("exit", () => { for (const flush of exitFlushers) { try { flush(); } catch { } } }); } } export function createNdjsonLogger(options) { if (typeof options.filePath === "string") { registeredLogFiles.add(options.filePath); } const queue = []; let drainPromise = null; let ensuredDir = false; function ensureDir(file) { if (ensuredDir) return; try { fs.mkdirSync(path.dirname(file), { recursive: true }); ensuredDir = true; } catch { } } function rotateIfNeeded(file) { if (options.maxBytes === undefined) return; try { const size = fs.statSync(file).size; if (size < options.maxBytes) return; const backup = options.backupPath ? resolve(options.backupPath) : `${file}.1`; try { fs.rmSync(backup, { force: true }); } catch { } fs.renameSync(file, backup); } catch { // no file yet, or rename raced — nothing to rotate } } async function drainLoop() { // Peek, write, then remove — an item stays in the queue until it is on // disk, so a teardown flushSync (which abandons this async loop) never // drops an item this loop had already dequeued but not yet written. while (queue.length > 0) { const item = queue[0]; const file = resolve(options.filePath); ensureDir(file); try { if (item.kind === "truncate") { await fs.promises.writeFile(file, ""); } else { rotateIfNeeded(file); await fs.promises.appendFile(file, item.line); } } catch { // telemetry is best-effort } queue.shift(); } } function drain() { // Serialize: a single in-flight drain owns the queue. flush() awaits this // same promise, so it never resolves before pending writes land. The loop // re-checks queue.length, so items enqueued mid-drain are picked up before // the promise settles — no stranded item, no second concurrent drainer. if (!drainPromise) { drainPromise = drainLoop().finally(() => { drainPromise = null; }); } return drainPromise; } function enqueue(item) { queue.push(item); void drain(); } function flushSync() { // Drain the in-memory queue synchronously — safe at process exit. while (queue.length > 0) { const item = queue.shift(); const file = resolve(options.filePath); ensureDir(file); try { if (item.kind === "truncate") { fs.writeFileSync(file, ""); } else { rotateIfNeeded(file); fs.appendFileSync(file, item.line); } } catch { } } } // Best-effort teardown flush of anything still buffered, via the single // shared exit handler. appendFileSync is fine here — not the hot path. registerExitFlusher(flushSync); return { log(obj) { enqueue({ kind: "line", line: `${JSON.stringify(obj)}\n` }); }, append(line) { enqueue({ kind: "line", line: `${line}\n` }); }, truncate() { enqueue({ kind: "truncate" }); }, async flush() { await drain(); }, flushSync, }; }