mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-09 15:52:14 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c020796bf | ||
|
|
6d2fda616a | ||
|
|
fba20f605a | ||
|
|
42832d2b6a | ||
|
|
232c51978d | ||
|
|
1316d3e6a8 |
No files matched your search
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* Log files are stored as JSONL (one JSON object per line) under
|
||||
* {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and
|
||||
* atomic. Files older than 30 days are pruned on startup.
|
||||
* atomic. Files older than 30 days are pruned on startup and every file is
|
||||
* capped at 16 MB: once today's file is full, further entries are dropped,
|
||||
* and oversized files left by previous runs are trimmed to their tail.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
@@ -24,6 +26,15 @@ let electronShell = null;
|
||||
let sessionsMap = null;
|
||||
|
||||
const LOG_RETENTION_DAYS = 30;
|
||||
// Hard caps so a recurring error can never fill the disk (see issue #3626:
|
||||
// a single crash-YYYY-MM-DD.log grew to ~150 GB). The per-day file stops
|
||||
// accepting entries once it reaches MAX_LOG_FILE_BYTES and existing oversized
|
||||
// files are trimmed down to their tail at startup.
|
||||
const MAX_LOG_FILE_BYTES = 16 * 1024 * 1024;
|
||||
// Cap the size of a single serialized entry before it is appended.
|
||||
const MAX_LOG_ENTRY_BYTES = 128 * 1024;
|
||||
const MAX_MESSAGE_CHARS = 4096;
|
||||
const MAX_STACK_CHARS = 16384;
|
||||
const TERMINAL_PERF_DEBUG_ENV_KEYS = [
|
||||
"NETCATTY_TERMINAL_PERF_DEBUG",
|
||||
"NETCATTY_TERMINAL_DEBUG",
|
||||
@@ -74,6 +85,11 @@ function todayFileName() {
|
||||
return `crash-${ymd}.log`;
|
||||
}
|
||||
|
||||
function boundedText(text, maxChars) {
|
||||
if (typeof text !== "string" || text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)} ... [truncated]`;
|
||||
}
|
||||
|
||||
function buildEntry(source, err, extra) {
|
||||
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
|
||||
|
||||
@@ -100,8 +116,8 @@ function buildEntry(source, err, extra) {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
source,
|
||||
message: error.message || String(err),
|
||||
stack: error.stack || undefined,
|
||||
message: boundedText(error.message || String(err), MAX_MESSAGE_CHARS),
|
||||
stack: boundedText(error.stack, MAX_STACK_CHARS) || undefined,
|
||||
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
|
||||
extra: extra || undefined,
|
||||
pid: process.pid,
|
||||
@@ -120,6 +136,70 @@ function buildEntry(source, err, extra) {
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Append one JSONL line to filePath as long as the file stays under
|
||||
* MAX_LOG_FILE_BYTES. Oversized entries are rejected; once a day's file is
|
||||
* full, later entries are dropped instead of growing the file further.
|
||||
* Returns true when the line was written.
|
||||
*/
|
||||
function appendEntryLine(filePath, line) {
|
||||
const lineBytes = Buffer.byteLength(line, "utf-8");
|
||||
if (lineBytes + 1 > MAX_LOG_ENTRY_BYTES) return false;
|
||||
|
||||
let size = 0;
|
||||
try {
|
||||
size = fs.statSync(filePath).size;
|
||||
} catch {
|
||||
size = 0; // file does not exist yet
|
||||
}
|
||||
if (size + lineBytes + 1 > MAX_LOG_FILE_BYTES) return false;
|
||||
|
||||
try {
|
||||
fs.appendFileSync(filePath, line + "\n", "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `line` (plus its trailing newline) fits inside the per-entry byte
|
||||
* cap. JSON.stringify measures UTF-16 code units, but the file stores UTF-8
|
||||
* bytes, so multibyte text must be measured with Buffer.byteLength here to
|
||||
* match what appendEntryLine actually enforces.
|
||||
*/
|
||||
function fitsEntryByteBudget(line) {
|
||||
return Buffer.byteLength(line, "utf-8") + 1 <= MAX_LOG_ENTRY_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a crash entry into a bounded JSONL line. If the caller-supplied
|
||||
* extra payload (or anything else) blows past MAX_LOG_ENTRY_BYTES, retry with
|
||||
* progressively less detail so the line length is always bounded.
|
||||
*/
|
||||
function serializeEntry(entry) {
|
||||
let line = JSON.stringify(entry);
|
||||
if (fitsEntryByteBudget(line)) return line;
|
||||
|
||||
const leanEntry = {
|
||||
...entry,
|
||||
message: boundedText(entry.message, 1024),
|
||||
stack: boundedText(entry.stack, MAX_STACK_CHARS),
|
||||
extra: undefined,
|
||||
};
|
||||
line = JSON.stringify(leanEntry);
|
||||
if (fitsEntryByteBudget(line)) return line;
|
||||
|
||||
const minimalEntry = {
|
||||
timestamp: entry.timestamp,
|
||||
source: boundedText(entry.source, 256),
|
||||
message: boundedText(entry.message, 512),
|
||||
pid: entry.pid,
|
||||
version: entry.version,
|
||||
};
|
||||
return JSON.stringify(minimalEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a crash/error entry to today's log file (sync, safe for use in
|
||||
* uncaughtException handlers).
|
||||
@@ -130,8 +210,9 @@ function captureError(source, err, extra) {
|
||||
if (!dir) return;
|
||||
|
||||
const entry = buildEntry(source, err, extra);
|
||||
const line = serializeEntry(entry);
|
||||
const filePath = path.join(dir, todayFileName());
|
||||
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
|
||||
appendEntryLine(filePath, line);
|
||||
} catch {
|
||||
// Never throw from the crash logger itself.
|
||||
}
|
||||
@@ -174,6 +255,89 @@ function pruneOldLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an oversized log file in place so only its last MAX_LOG_FILE_BYTES
|
||||
* (aligned to a line boundary so every remaining line parses) is kept. This
|
||||
* reads and writes at most MAX_LOG_FILE_BYTES, so it is safe even for a
|
||||
* multi-gigabyte file. Returns true when the file was trimmed.
|
||||
*/
|
||||
function truncateFileToTail(filePath, maxBytes) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!stat.isFile() || stat.size <= maxBytes) return false;
|
||||
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(filePath, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0));
|
||||
// Validate the opened object before reading or truncating it. O_NOFOLLOW
|
||||
// is not available on every platform, and the path may have changed.
|
||||
const openedStat = fs.fstatSync(fd);
|
||||
if (!openedStat.isFile() || openedStat.dev !== stat.dev || openedStat.ino !== stat.ino) {
|
||||
return false;
|
||||
}
|
||||
if (openedStat.size <= maxBytes) return false;
|
||||
const buf = Buffer.alloc(maxBytes);
|
||||
const read = fs.readSync(fd, buf, 0, buf.length, openedStat.size - buf.length);
|
||||
const tail = buf.subarray(0, read);
|
||||
// Drop the first (possibly partial) line of the tail so every remaining
|
||||
// line is a complete JSONL entry. Also trim anything after the last
|
||||
// newline: a final write interrupted before its trailing newline would
|
||||
// otherwise leave a partial object that the next appendEntryLine appends
|
||||
// to, corrupting both lines. If the tail contains no newline at all it
|
||||
// is one giant partial entry (e.g. from an interrupted write), so
|
||||
// discard it entirely rather than keeping an unappendable file: the file
|
||||
// would sit exactly at the cap and reject every later report for the day.
|
||||
const newline = tail.indexOf("\n");
|
||||
let keep = Buffer.alloc(0);
|
||||
if (newline >= 0) {
|
||||
const lastNewline = tail.lastIndexOf("\n");
|
||||
keep = tail.subarray(newline + 1, lastNewline + 1);
|
||||
}
|
||||
fs.ftruncateSync(fd, keep.length);
|
||||
if (keep.length > 0) {
|
||||
fs.writeSync(fd, keep, 0, keep.length, 0);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap every existing crash log file at MAX_LOG_FILE_BYTES. Runs at startup
|
||||
* before any new entries are appended, so a bloated file from a previous run
|
||||
* (e.g. issue #3626) is shrunk instead of filling the disk for another day.
|
||||
*/
|
||||
function trimOversizedLogs() {
|
||||
try {
|
||||
const dir = ensureLogDir();
|
||||
if (!dir) return;
|
||||
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
|
||||
try {
|
||||
if (truncateFileToTail(path.join(dir, file), MAX_LOG_FILE_BYTES)) {
|
||||
console.log(`[CrashLog] Trimmed oversized log: ${file}`);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -331,6 +495,7 @@ function init(deps) {
|
||||
// ignore
|
||||
}
|
||||
pruneOldLogs();
|
||||
trimOversizedLogs();
|
||||
|
||||
console.log(`[CrashLog] Crash log directory: ${logDir}`);
|
||||
}
|
||||
@@ -359,9 +524,22 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
}
|
||||
|
||||
// Internals exposed for tests only; not part of the public bridge API.
|
||||
const __internals = {
|
||||
MAX_LOG_FILE_BYTES,
|
||||
MAX_LOG_ENTRY_BYTES,
|
||||
MAX_MESSAGE_CHARS,
|
||||
MAX_STACK_CHARS,
|
||||
appendEntryLine,
|
||||
boundedText,
|
||||
serializeEntry,
|
||||
truncateFileToTail,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
captureError,
|
||||
captureDiagnostic,
|
||||
registerHandlers,
|
||||
__internals,
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
MAX_LOG_FILE_BYTES,
|
||||
MAX_LOG_ENTRY_BYTES,
|
||||
appendEntryLine,
|
||||
boundedText,
|
||||
serializeEntry,
|
||||
truncateFileToTail,
|
||||
} = require("./crashLogBridge.cjs").__internals;
|
||||
|
||||
// Creates a unique temp directory for one test and registers teardown on the
|
||||
// test context so fixture files never outlive the run.
|
||||
function tmpFile(t, name = "crash-2026-10-10.log") {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-crashlog-"));
|
||||
t.after(() => {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best effort cleanup
|
||||
}
|
||||
});
|
||||
return { dir, filePath: path.join(dir, name) };
|
||||
}
|
||||
|
||||
function entryLine(padLen = 0) {
|
||||
return JSON.stringify({
|
||||
timestamp: "2026-10-10T00:00:00.000Z",
|
||||
source: "test",
|
||||
message: "boom",
|
||||
...(padLen > 0 ? { pad: "p".repeat(padLen) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
test("appendEntryLine stops accepting entries once the per-day file reaches the size cap", (t) => {
|
||||
const { filePath } = tmpFile(t);
|
||||
|
||||
assert.equal(appendEntryLine(filePath, entryLine()), true, "first entry written");
|
||||
assert.equal(
|
||||
appendEntryLine(filePath, "x".repeat(MAX_LOG_ENTRY_BYTES)),
|
||||
false,
|
||||
"oversized entry rejected",
|
||||
);
|
||||
|
||||
// Model the boundary with a small real file, without allocating a full log.
|
||||
const filler = JSON.stringify({ message: "\u4e2d" });
|
||||
const lineBytes = Buffer.byteLength(filler, "utf-8");
|
||||
const before = fs.readFileSync(filePath, "utf-8");
|
||||
const stat = t.mock.method(fs, "statSync", () => ({ size: MAX_LOG_FILE_BYTES - lineBytes }));
|
||||
assert.equal(appendEntryLine(filePath, filler), false, "newline would exceed cap by one byte");
|
||||
assert.equal(fs.readFileSync(filePath, "utf-8"), before);
|
||||
stat.mock.mockImplementation(() => ({ size: MAX_LOG_FILE_BYTES - lineBytes - 1 }));
|
||||
assert.equal(appendEntryLine(filePath, filler), true, "entry plus newline fits exactly");
|
||||
assert.equal(fs.readFileSync(filePath, "utf-8"), before + filler + "\n");
|
||||
});
|
||||
|
||||
test("serializeEntry keeps serialized lines bounded even for huge extras", () => {
|
||||
const huge = { blob: "z".repeat(MAX_LOG_ENTRY_BYTES + 1) };
|
||||
const line = serializeEntry({
|
||||
timestamp: "2026-10-10T00:00:00.000Z",
|
||||
source: "test",
|
||||
message: "boom",
|
||||
stack: new Error("boom").stack,
|
||||
extra: huge,
|
||||
});
|
||||
assert.ok(line.length <= MAX_LOG_ENTRY_BYTES, `line length ${line.length} exceeds cap`);
|
||||
const entry = JSON.parse(line);
|
||||
assert.equal(entry.source, "test");
|
||||
assert.equal(entry.message, "boom");
|
||||
});
|
||||
|
||||
test("boundedText truncates long strings and leaves short ones alone", () => {
|
||||
assert.equal(boundedText("short", 100), "short");
|
||||
const cut = boundedText("a".repeat(5000), 4096);
|
||||
assert.equal(cut.length, 4096 + " ... [truncated]".length);
|
||||
assert.ok(cut.startsWith("a".repeat(4096)));
|
||||
assert.ok(cut.endsWith("[truncated]"));
|
||||
});
|
||||
|
||||
test("truncateFileToTail trims oversized files to their last lines", (t) => {
|
||||
const { filePath } = tmpFile(t);
|
||||
|
||||
const maxBytes = 1024;
|
||||
const line = entryLine(128);
|
||||
const content = (line + "\n").repeat(8);
|
||||
fs.writeFileSync(filePath, content);
|
||||
assert.ok(fs.statSync(filePath).size > maxBytes);
|
||||
|
||||
assert.equal(truncateFileToTail(filePath, maxBytes), true);
|
||||
const newSize = fs.statSync(filePath).size;
|
||||
assert.ok(newSize <= maxBytes, `trimmed size ${newSize} exceeds cap`);
|
||||
assert.equal(appendEntryLine(filePath, entryLine()), true, "trimmed log remains appendable");
|
||||
// Every remaining line must be a complete, parseable JSONL entry.
|
||||
const remaining = fs.readFileSync(filePath, "utf-8").split("\n").filter(Boolean);
|
||||
assert.ok(remaining.length > 0);
|
||||
for (const l of remaining) assert.doesNotThrow(() => JSON.parse(l));
|
||||
|
||||
// Files under the cap are left untouched.
|
||||
assert.equal(truncateFileToTail(filePath, maxBytes), false);
|
||||
});
|
||||
|
||||
test("truncateFileToTail tolerates a tail with no newline and missing files", (t) => {
|
||||
const { dir, filePath } = tmpFile(t);
|
||||
|
||||
// Missing file
|
||||
assert.equal(truncateFileToTail(path.join(dir, "crash-2026-01-01.log"), MAX_LOG_FILE_BYTES), false);
|
||||
|
||||
// Discard an oversized unterminated line so later entries can be appended.
|
||||
fs.appendFileSync(filePath, "g".repeat(8192));
|
||||
assert.equal(truncateFileToTail(filePath, 4096), true);
|
||||
assert.equal(fs.statSync(filePath).size, 0);
|
||||
|
||||
// Oversized tail whose final write was interrupted before its trailing
|
||||
// newline: three terminated entries followed by one entry whose trailing
|
||||
// newline was never written. The partial final line must be dropped so the
|
||||
// rewritten file ends on a complete JSONL boundary and the next append does
|
||||
// not merge with a partial object.
|
||||
fs.appendFileSync(
|
||||
filePath,
|
||||
Array.from({ length: 3 }, () => entryLine(1200) + "\n").join("") +
|
||||
entryLine(1200),
|
||||
);
|
||||
assert.ok(fs.statSync(filePath).size > 4096);
|
||||
assert.equal(truncateFileToTail(filePath, 4096), true);
|
||||
const trimmed = fs.readFileSync(filePath, "utf-8");
|
||||
assert.ok(trimmed.endsWith("\n"), "trimmed file must end on a line boundary");
|
||||
for (const l of trimmed.split("\n").filter(Boolean)) {
|
||||
assert.doesNotThrow(() => JSON.parse(l));
|
||||
}
|
||||
});
|
||||
|
||||
test("truncateFileToTail leaves symlink targets and non-regular files untouched", (t) => {
|
||||
const { dir, filePath } = tmpFile(t);
|
||||
const target = path.join(dir, "unrelated.txt");
|
||||
const content = (entryLine() + "\n").repeat(4);
|
||||
fs.writeFileSync(target, content);
|
||||
fs.symlinkSync(target, filePath);
|
||||
assert.equal(truncateFileToTail(filePath, 128), false);
|
||||
assert.equal(fs.readFileSync(target, "utf-8"), content);
|
||||
assert.ok(fs.lstatSync(filePath).isSymbolicLink());
|
||||
assert.equal(truncateFileToTail(dir, 1), false);
|
||||
});
|
||||
|
||||
test("truncateFileToTail verifies the opened file before modifying it", (t) => {
|
||||
const { dir, filePath } = tmpFile(t);
|
||||
const replacement = path.join(dir, "replacement.log");
|
||||
const content = (entryLine() + "\n").repeat(4);
|
||||
fs.writeFileSync(filePath, content);
|
||||
fs.writeFileSync(replacement, content);
|
||||
const openSync = fs.openSync;
|
||||
const open = t.mock.method(fs, "openSync", (_filePath, flags) => openSync(replacement, flags));
|
||||
assert.equal(truncateFileToTail(filePath, 128), false);
|
||||
open.mock.restore();
|
||||
assert.equal(fs.readFileSync(filePath, "utf-8"), content);
|
||||
assert.equal(fs.readFileSync(replacement, "utf-8"), content);
|
||||
});
|
||||
Reference in new issue
Block a user