fix: prevent desktop log floods and bound log retention
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/** Bounded desktop diagnostics. Error paths must never write to a failed pipe. */
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function createDesktopLogger({ directory, stdout, stderr, filesystem = fs, maxFileBytes = MAX_LOG_BYTES }) {
|
||||
let initialized = false;
|
||||
let stdoutEnabled = Boolean(stdout);
|
||||
let writing = false;
|
||||
const location = () => path.join(directory(), "main.log");
|
||||
|
||||
// Read only the retained tail, even when an older release left a huge file.
|
||||
function trim(file) {
|
||||
let handle;
|
||||
try {
|
||||
handle = filesystem.openSync(file, "r+");
|
||||
const size = filesystem.fstatSync(handle).size;
|
||||
if (size <= maxFileBytes) return;
|
||||
const tail = Buffer.alloc(maxFileBytes);
|
||||
let read = 0;
|
||||
while (read < tail.length) {
|
||||
const count = filesystem.readSync(handle, tail, read, tail.length - read, size - tail.length + read);
|
||||
if (!count) break;
|
||||
read += count;
|
||||
}
|
||||
filesystem.writeFileSync(handle, tail.subarray(0, read));
|
||||
filesystem.ftruncateSync(handle, read);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
} finally {
|
||||
if (handle !== undefined) filesystem.closeSync(handle);
|
||||
}
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
try {
|
||||
filesystem.mkdirSync(directory(), { recursive: true });
|
||||
trim(location());
|
||||
trim(`${location()}.1`);
|
||||
initialized = true;
|
||||
} catch {
|
||||
// Diagnostics are best effort; never report a logger failure via itself.
|
||||
}
|
||||
}
|
||||
|
||||
function record(line) {
|
||||
const prefix = Buffer.from(`[${new Date().toISOString()}] `);
|
||||
const body = Buffer.from(String(line));
|
||||
if (prefix.length + body.length + 1 <= maxFileBytes) return Buffer.concat([prefix, body, Buffer.from("\n")]);
|
||||
const marker = Buffer.from("[truncated] ");
|
||||
const tail = body.subarray(body.length - (maxFileBytes - prefix.length - marker.length - 1));
|
||||
// A retained suffix can start inside a UTF-8 character.
|
||||
let start = 0;
|
||||
while (start < tail.length && (tail[start] & 0xc0) === 0x80) start++;
|
||||
return Buffer.concat([prefix, marker, tail.subarray(start), Buffer.from("\n")]);
|
||||
}
|
||||
|
||||
function append(entry) {
|
||||
try {
|
||||
if (!initialized) initialize();
|
||||
if (!initialized) return;
|
||||
const file = location();
|
||||
let size = 0;
|
||||
try { size = filesystem.statSync(file).size; } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
if (size + entry.length > maxFileBytes) {
|
||||
filesystem.rmSync(`${file}.1`, { force: true });
|
||||
filesystem.renameSync(file, `${file}.1`);
|
||||
}
|
||||
filesystem.appendFileSync(file, entry);
|
||||
} catch {
|
||||
// Includes ENOSPC and rotation failures. Do not fall back to a pipe.
|
||||
}
|
||||
}
|
||||
|
||||
function log(line, { fileOnly = false } = {}) {
|
||||
if (writing) return;
|
||||
writing = true;
|
||||
try {
|
||||
const entry = record(line);
|
||||
append(entry);
|
||||
if (!fileOnly && stdoutEnabled && !stdout.destroyed && !stdout.writableEnded) {
|
||||
try {
|
||||
// A false return means backpressure: skip mirroring until drained.
|
||||
if (!stdout.write(entry, (error) => { if (error) disableStdout(error); })) stdoutEnabled = false;
|
||||
} catch (error) {
|
||||
disableStdout(error);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Formatting must not escape into the uncaught-exception handler either.
|
||||
} finally {
|
||||
writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
let stdoutFailed = false;
|
||||
function disableStdout(error) {
|
||||
stdoutEnabled = false;
|
||||
if (stdoutFailed) return;
|
||||
stdoutFailed = true;
|
||||
append(record(`stdout disabled: ${error?.code ?? error?.message ?? "stream error"}`));
|
||||
}
|
||||
stdout?.on("error", disableStdout);
|
||||
stdout?.on("drain", () => { if (!stdoutFailed) stdoutEnabled = true; });
|
||||
// Console / dependency writes to stderr can fail asynchronously as well.
|
||||
let stderrFailed = false;
|
||||
stderr?.on("error", (error) => {
|
||||
if (stderrFailed) return;
|
||||
stderrFailed = true;
|
||||
log(`stderr disabled: ${error?.code ?? error?.message ?? "stream error"}`, { fileOnly: true });
|
||||
});
|
||||
return { log, initialize };
|
||||
}
|
||||
|
||||
module.exports = { createDesktopLogger, MAX_LOG_BYTES };
|
||||
+12
-21
@@ -37,6 +37,7 @@ const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { createDesktopLogger } = require("./desktop-logger.cjs");
|
||||
|
||||
// Mirrors SIDEBAR_AUTOCLOSE in components/sidebar.tsx — keep in sync (the
|
||||
// renderer owns the number; this is the IPC widen target).
|
||||
@@ -143,13 +144,20 @@ let subscriptionChild = null;
|
||||
let desktopAPISecret = null;
|
||||
let reopenWindow = null;
|
||||
const trustedAppWindows = new Set();
|
||||
const desktopLogger = createDesktopLogger({
|
||||
directory: () => path.join(app.getPath("userData"), "logs"),
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
filesystem: fs,
|
||||
});
|
||||
|
||||
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
||||
// in an event handler): the window + child server are the product, and
|
||||
// anything main-side is recoverable by logging it. Log-and-continue.
|
||||
process.on("uncaughtException", (err) => {
|
||||
try {
|
||||
log(`uncaught: ${err?.stack ?? err}`);
|
||||
// File only: writing to stdout here used to turn EPIPE into a tight loop.
|
||||
desktopLogger.log(`uncaught: ${err?.stack ?? err}`, { fileOnly: true });
|
||||
} catch {
|
||||
/* nothing left to write to */
|
||||
}
|
||||
@@ -177,25 +185,7 @@ function freePort() {
|
||||
* still win over the file inside the child (its documented rule). */
|
||||
|
||||
function log(line) {
|
||||
const stamp = new Date().toISOString();
|
||||
// The stdout write is guarded (EPIPE hardening, 2026-10-10): a packaged
|
||||
// app launched with a consumer-owned pipe (a launcher that exits, a
|
||||
// piped run) must not die on its next log line when the pipe breaks.
|
||||
// The file below stays the durable channel.
|
||||
try {
|
||||
console.log(`[lh ${stamp}] ${line}`);
|
||||
} catch {
|
||||
/* stdout gone — userData/logs/main.log still carries it */
|
||||
}
|
||||
// Belt and braces: also append to userData/logs so a misbehaving child
|
||||
// can be diagnosed after the window is gone. Best effort only.
|
||||
try {
|
||||
const dir = path.join(app.getPath("userData"), "logs");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(path.join(dir, "main.log"), `[${stamp}] ${line}\n`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
desktopLogger.log(line);
|
||||
}
|
||||
|
||||
function startServer(port) {
|
||||
@@ -656,6 +646,7 @@ async function main() {
|
||||
process.on("SIGHUP", killServer);
|
||||
|
||||
await app.whenReady();
|
||||
desktopLogger.initialize();
|
||||
|
||||
// Dev mode: point at a running `next dev` server (LH_DEV_URL) — no
|
||||
// spawn, no packaging, full HMR. The rest of this shell still applies
|
||||
@@ -771,4 +762,4 @@ async function main() {
|
||||
checkForUpdates();
|
||||
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user