diff --git a/electron/desktop-logger.cjs b/electron/desktop-logger.cjs new file mode 100644 index 0000000..f15de16 --- /dev/null +++ b/electron/desktop-logger.cjs @@ -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 }; diff --git a/electron/main.cjs b/electron/main.cjs index 4f53c96..e27ec1d 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -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); } -} \ No newline at end of file +} diff --git a/tests/desktop-logger.test.ts b/tests/desktop-logger.test.ts new file mode 100644 index 0000000..896bbce --- /dev/null +++ b/tests/desktop-logger.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { EventEmitter, once } from "node:events"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import type { TestContext } from "node:test"; +import { fileURLToPath } from "node:url"; +import { createContext, runInContext } from "node:vm"; + +const require = createRequire(import.meta.url); +const { createDesktopLogger, MAX_LOG_BYTES } = require("../electron/desktop-logger.cjs"); +const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url)); +const mainRequire = createRequire(mainFile); + +function directory(t: TestContext) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-desktop-logger-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function retained(dir: string, cap: number) { + const names = fs.readdirSync(dir).sort(); + assert.ok(names.length <= 2); + for (const name of names) { + assert.ok(["main.log", "main.log.1"].includes(name)); + assert.ok(fs.statSync(path.join(dir, name)).size <= cap, `${name} exceeds the byte cap`); + } + return ["main.log.1", "main.log"].filter(name => names.includes(name)).map(name => fs.readFileSync(path.join(dir, name), "utf8")).join(""); +} + +class Output extends EventEmitter { + writes = 0; + write() { this.writes++; return true; } +} + +describe("bounded desktop logs", () => { + it("rotates repeated floods within two 5 MiB files and keeps the latest entries", (t) => { + const dir = directory(t); + assert.equal(MAX_LOG_BYTES, 5 * 1024 * 1024); + const logger = createDesktopLogger({ directory: () => dir }); + for (let i = 0; i < 35; i++) logger.log(`${i} ${"x".repeat(1024 * 1024)}`); + logger.log("most recent diagnostic"); + const text = retained(dir, MAX_LOG_BYTES); + assert.match(text, /most recent diagnostic\n$/); + assert.doesNotMatch(text, /\] 0 /); + }); + + it("trims both existing files on startup using a bounded tail read", (t) => { + const dir = directory(t); + for (const name of ["main.log", "main.log.1"]) { + const file = path.join(dir, name); + // Sparse legacy file: reproduces a huge prior log without allocating it. + const fd = fs.openSync(file, "w"); + fs.writeSync(fd, Buffer.from(`recent ${name}\n`), 0, Buffer.byteLength(`recent ${name}\n`), 200 * 1024 ** 3); + fs.closeSync(fd); + } + let largestRead = 0; + const filesystem = { ...fs, readSync: (...args: Parameters) => { + largestRead = Math.max(largestRead, (args[1] as Buffer).byteLength); + return fs.readSync(...args); + } }; + const logger = createDesktopLogger({ directory: () => dir, filesystem }); + logger.initialize(); + assert.ok(largestRead <= MAX_LOG_BYTES); + assert.match(retained(dir, MAX_LOG_BYTES), /recent main.log\n$/); + assert.match(fs.readFileSync(path.join(dir, "main.log.1"), "utf8"), /recent main.log.1\n$/); + logger.log("after restart"); + createDesktopLogger({ directory: () => dir }).log("after second restart"); + assert.match(retained(dir, MAX_LOG_BYTES), /after second restart\n$/); + }); + + it("caps one oversized Unicode entry by bytes, retaining valid UTF-8 and its suffix", (t) => { + const dir = directory(t); + const logger = createDesktopLogger({ directory: () => dir, maxFileBytes: 256 }); + logger.log(`${"🙂".repeat(1000)}latest`); + const text = retained(dir, 256); + assert.match(text, /\[truncated\]/); + assert.match(text, /latest\n$/); + assert.ok(!text.includes("�")); + }); + + it("disables stdout permanently on asynchronous errors, with one file-only diagnostic", (t) => { + const dir = directory(t); + const stdout = new Output(); + const stderr = new Output(); + const logger = createDesktopLogger({ directory: () => dir, stdout, stderr }); + logger.log("before failure"); + const error = Object.assign(new Error("broken pipe"), { code: "EPIPE" }); + stdout.emit("error", error); + stdout.emit("drain"); + stdout.emit("error", error); + stderr.emit("error", error); + stderr.emit("error", error); + logger.log("after failure"); + assert.equal(stdout.writes, 1); + const text = retained(dir, MAX_LOG_BYTES); + assert.equal(text.match(/stdout disabled/g)?.length, 1); + assert.equal(text.match(/stderr disabled/g)?.length, 1); + assert.match(text, /after failure/); + }); + + it("handles synchronous write failures and bounds buffering under backpressure", (t) => { + const dir = directory(t); + const stdout = new Output(); + stdout.write = () => { stdout.writes++; throw Object.assign(new Error("closed"), { code: "EPIPE" }); }; + const logger = createDesktopLogger({ directory: () => dir, stdout }); + assert.doesNotThrow(() => { logger.log("first"); logger.log("second"); }); + assert.equal(stdout.writes, 1); + const slow = new Output(); + slow.write = () => { slow.writes++; return false; }; + const slowLogger = createDesktopLogger({ directory: () => dir, stdout: slow }); + for (let i = 0; i < 100; i++) slowLogger.log(`slow ${i}`); + assert.equal(slow.writes, 1); + slow.emit("drain"); + slowLogger.log("drained"); + assert.equal(slow.writes, 2); + }); + + it("never propagates full-disk or rotation errors or mirrors file-only errors", (t) => { + const dir = directory(t); + const stdout = new Output(); + const logger = createDesktopLogger({ directory: () => dir, stdout, filesystem: { + ...fs, appendFileSync() { throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); }, + } }); + assert.doesNotThrow(() => logger.log("uncaught: diagnostic", { fileOnly: true })); + assert.equal(stdout.writes, 0); + const file = path.join(dir, "main.log"); + fs.writeFileSync(file, "old\n".repeat(60)); + const blockedRotation = createDesktopLogger({ directory: () => dir, maxFileBytes: 256, filesystem: { + ...fs, renameSync() { throw new Error("rotation denied"); }, + } }); + assert.doesNotThrow(() => blockedRotation.log("new".repeat(100))); + assert.equal(fs.statSync(file).size, 240, "a failed rotation must not append beyond the cap"); + }); +}); + +describe("desktop shell logging integration", () => { + it("records uncaught errors without touching stdout", (t) => { + const dir = directory(t); + const handlers = new Map void>(); + const stdout = new Output(); + const context = createContext({ + require: (id: string) => id === "electron" ? { app: { setName() {}, getPath: () => dir, requestSingleInstanceLock: () => false, quit() {} } } : mainRequire(id), + process: { argv: [], env: {}, stdout, stderr: new Output(), on: (name: string, handler: (error: Error) => void) => handlers.set(name, handler) }, + __dirname: path.dirname(mainFile), + }); + runInContext(fs.readFileSync(mainFile, "utf8"), context, { filename: mainFile }); + handlers.get("uncaughtException")!(new Error("original failure")); + assert.equal(stdout.writes, 0); + assert.match(retained(path.join(dir, "logs"), MAX_LOG_BYTES), /uncaught: Error: original failure/); + }); + + it("survives a real closed stdout pipe, records EPIPE once, and exits normally", async (t) => { + const dir = directory(t); + const source = ` +const fs = require('node:fs'); +const vm = require('node:vm'); +const mainFile = ${JSON.stringify(mainFile)}; +const mainRequire = require('node:module').createRequire(mainFile); +const context = vm.createContext({ + require: id => id === 'electron' ? {app: {setName() {}, getPath: () => ${JSON.stringify(dir)}, requestSingleInstanceLock: () => false, quit() {}}} : mainRequire(id), + process, console, __dirname: require('node:path').dirname(mainFile) +}); +vm.runInContext(fs.readFileSync(mainFile, 'utf8'), context, {filename: mainFile}); +process.once('message', () => { + vm.runInContext('log("trigger broken pipe")', context); + setTimeout(() => { + vm.runInContext('log("still alive after broken pipe")', context); + process.disconnect(); + }, 100); +}); +process.send('ready'); +`; + const child = spawn(process.execPath, ["-e", source], { stdio: ["ignore", "pipe", "pipe", "ipc"] }); + t.after(() => { if (child.exitCode === null) child.kill("SIGKILL"); }); + const deadline = setTimeout(() => child.kill("SIGKILL"), 5000); + t.after(() => clearTimeout(deadline)); + let stderr = ""; + child.stderr!.on("data", chunk => { stderr = (stderr + chunk).slice(-4000); }); + const exited = once(child, "exit"); + await Promise.race([once(child, "message"), exited.then(() => { throw new Error(`Fixture exited before ready: ${stderr}`); })]); + child.stdout!.destroy(); + child.send("start"); + const [code, signal] = await exited; + assert.equal(signal, null, stderr); + assert.equal(code, 0, stderr); + const text = retained(path.join(dir, "logs"), MAX_LOG_BYTES); + assert.equal(text.match(/EPIPE/g)?.length, 1); + assert.doesNotMatch(text, /uncaught:/); + assert.match(text, /still alive after broken pipe/); + assert.ok(Buffer.byteLength(text) < 1024, "a closed pipe must not produce a log flood"); + }); +});