Files
NetMesh/electron/bridges/crashLogBridge.cjs

368 lines
10 KiB
JavaScript
Raw Normal View History

/**
* Crash Log Bridge - Captures main-process errors and writes them to local log files.
*
* 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.
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const {
appendTerminalPerfLogLine,
configureTerminalPerformanceDiagnostics,
} = require("./terminalPerformanceDiagnostics.cjs");
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let logDir = null;
let electronApp = null;
let electronShell = null;
let sessionsMap = null;
const LOG_RETENTION_DAYS = 30;
const TERMINAL_PERF_DEBUG_ENV_KEYS = [
"NETCATTY_TERMINAL_PERF_DEBUG",
"NETCATTY_TERMINAL_DEBUG",
];
const TERMINAL_PERF_LOG_PREFIX = "[Netcatty Terminal Perf]";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function ensureLogDir() {
if (logDir) return logDir;
try {
// Try the stored app reference first, then fall back to requiring electron
// directly so crash logging works even before init() is called.
let userDataPath = null;
if (electronApp) {
userDataPath = electronApp.getPath("userData");
} else {
try {
const { app } = require("node:electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
try {
const { app } = require("electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
// Electron not available yet
}
}
}
if (!userDataPath) return null;
logDir = path.join(userDataPath, "crash-logs");
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
return logDir;
} catch {
return null;
}
}
function todayFileName() {
const d = new Date();
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return `crash-${ymd}.log`;
}
function buildEntry(source, err, extra) {
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
let mem;
try {
const m = process.memoryUsage();
mem = {
rss: Math.round(m.rss / 1048576),
heapUsed: Math.round(m.heapUsed / 1048576),
heapTotal: Math.round(m.heapTotal / 1048576),
};
} catch {
// ignore
}
// Extract extra properties from the error object (code, errno, syscall, etc.)
const errorMeta = {};
for (const key of ["code", "errno", "syscall", "hostname", "port", "signal", "level"]) {
if (error[key] !== undefined) {
errorMeta[key] = error[key];
}
}
return {
timestamp: new Date().toISOString(),
source,
message: error.message || String(err),
stack: error.stack || undefined,
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
extra: extra || undefined,
pid: process.pid,
platform: process.platform,
arch: process.arch,
version: electronApp?.getVersion?.() ?? "unknown",
electronVersion: process.versions?.electron ?? "unknown",
osVersion: os.release(),
memoryMB: mem,
activeSessionCount: sessionsMap?.size ?? -1,
uptimeSeconds: Math.round(process.uptime()),
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Write a crash/error entry to today's log file (sync, safe for use in
* uncaughtException handlers).
*/
function captureError(source, err, extra) {
try {
const dir = ensureLogDir();
if (!dir) return;
const entry = buildEntry(source, err, extra);
const filePath = path.join(dir, todayFileName());
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
} catch {
// Never throw from the crash logger itself.
}
}
function captureDiagnostic(source, message, extra) {
captureError(source, new Error(String(message || "diagnostic")), extra);
}
function shouldMirrorTerminalPerfDiagnostics() {
return TERMINAL_PERF_DEBUG_ENV_KEYS.some((key) => process.env[key] === "1");
}
/**
* Delete log files older than LOG_RETENTION_DAYS.
*/
function pruneOldLogs() {
try {
const dir = ensureLogDir();
if (!dir) return;
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
const files = fs.readdirSync(dir);
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log(`[CrashLog] Pruned old log: ${file}`);
}
} catch {
// skip
}
}
} catch {
// skip
}
}
// ---------------------------------------------------------------------------
// IPC handlers
// ---------------------------------------------------------------------------
/**
* Count newlines in a file by streaming instead of reading entire content.
*/
async function countLines(filePath) {
return new Promise((resolve) => {
let count = 0;
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
stream.on("data", (chunk) => {
for (let i = 0; i < chunk.length; i++) {
if (chunk[i] === "\n") count++;
}
});
stream.on("end", () => resolve(count));
stream.on("error", () => resolve(0));
});
}
async function listLogs() {
const dir = ensureLogDir();
if (!dir) return [];
try {
const files = await fs.promises.readdir(dir);
const results = [];
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
const filePath = path.join(dir, file);
const stat = await fs.promises.stat(filePath);
const entryCount = await countLines(filePath);
results.push({
fileName: file,
date: file.replace("crash-", "").replace(".log", ""),
size: stat.size,
entryCount,
});
} catch {
// skip unreadable files
}
}
// Sort newest first
results.sort((a, b) => b.date.localeCompare(a.date));
return results;
} catch {
return [];
}
}
const MAX_READ_ENTRIES = 500;
// Read up to ~256KB from the tail of the file to cap memory/CPU usage
const MAX_TAIL_BYTES = 256 * 1024;
async function readLog(fileName) {
const dir = ensureLogDir();
if (!dir) return [];
// Validate fileName to prevent path traversal
if (!/^crash-\d{4}-\d{2}-\d{2}\.log$/.test(fileName)) return [];
try {
const filePath = path.join(dir, fileName);
const stat = await fs.promises.stat(filePath);
let content;
if (stat.size > MAX_TAIL_BYTES) {
// Only read the tail of the file
const buf = Buffer.alloc(MAX_TAIL_BYTES);
const fd = await fs.promises.open(filePath, "r");
try {
await fd.read(buf, 0, MAX_TAIL_BYTES, stat.size - MAX_TAIL_BYTES);
} finally {
await fd.close();
}
const raw = buf.toString("utf-8");
// Drop the first partial line
const firstNewline = raw.indexOf("\n");
content = firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw;
} else {
content = await fs.promises.readFile(filePath, "utf-8");
}
const lines = content.split("\n").filter(Boolean);
// Only parse the last MAX_READ_ENTRIES lines
const tail = lines.slice(-MAX_READ_ENTRIES);
const entries = [];
for (const line of tail) {
try {
entries.push(JSON.parse(line));
} catch {
// skip malformed lines
}
}
return entries;
} catch {
return [];
}
}
async function clearLogs() {
const dir = ensureLogDir();
if (!dir) return { deletedCount: 0 };
let deletedCount = 0;
try {
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
await fs.promises.unlink(path.join(dir, file));
deletedCount++;
} catch {
// skip
}
}
} catch {
// skip
}
return { deletedCount };
}
async function openDir() {
const dir = ensureLogDir();
if (!dir || !electronShell?.openPath) return { success: false };
try {
const errorMessage = await electronShell.openPath(dir);
// shell.openPath resolves to an error string on failure, empty string on success
return { success: !errorMessage };
} catch {
return { success: false };
}
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function init(deps) {
const { electronModule, sessions } = deps;
const { app, shell } = electronModule || {};
electronApp = app;
electronShell = shell;
sessionsMap = sessions || null;
ensureLogDir();
try {
const userDataPath = electronApp?.getPath?.("userData");
configureTerminalPerformanceDiagnostics({ userDataPath });
} catch {
// ignore
}
pruneOldLogs();
console.log(`[CrashLog] Crash log directory: ${logDir}`);
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:crashLogs:list", async () => listLogs());
ipcMain.handle("netcatty:crashLogs:read", async (_event, { fileName }) => readLog(fileName));
ipcMain.handle("netcatty:crashLogs:clear", async () => clearLogs());
ipcMain.handle("netcatty:crashLogs:openDir", async () => openDir());
ipcMain.handle("netcatty:diagnostics:log", async (_event, payload) => {
const source = typeof payload?.source === "string" && payload.source.trim()
? payload.source.trim()
: "renderer-diagnostic";
const message = typeof payload?.message === "string" && payload.message.trim()
? payload.message.trim()
: "diagnostic";
if (source === "terminal-perf" && message.startsWith(TERMINAL_PERF_LOG_PREFIX)) {
appendTerminalPerfLogLine(message);
if (shouldMirrorTerminalPerfDiagnostics()) {
console.info(message);
}
return { success: true };
}
captureDiagnostic(source, message, payload?.extra);
return { success: true };
});
}
module.exports = {
init,
captureError,
captureDiagnostic,
registerHandlers,
};