From f2fc66fa922fdd8c42f9e2fb7b1b563ea197b506 Mon Sep 17 00:00:00 2001 From: Salastil Date: Sat, 12 Sep 2026 21:21:03 -0400 Subject: [PATCH] The daemon's log lands somewhere it can be read Everything nobilis says is piped through `log`, and `log` writes with console.log. Started from a desktop entry, a launcher or tray autostart there is no terminal on the other end, so in every normally-launched build the daemon's entire output went to /dev/null - and essentially all of this program's behaviour lives in the daemon. A backend reconnecting, an auth failure, a prune that did not run: no record, and nothing a user could send anybody. So the same lines also go to a file: 4MB, one previous generation kept, written synchronously because the line most worth having is the last one before a crash and a queued write is the one that does not make it. The stdout latch this module was written around stays exactly as it was, and the file copy is written before it is consulted - a console that throws must not take the file down with it. Not in the daemon's data directory, which is where the ticket suggested. The client does not know that path: it spawns nobilis without --data-dir and lets the daemon resolve its own, XDG rules and legacy fallbacks included, so writing there would mean reimplementing somebody else's path logic and being wrong the day it moves. It goes in this app's own userData directory, which main knows for free. The module still imports no electron - the directory is passed in at startup - so its rotation can be exercised without standing up an app. Checked: daemon lines land, levels and timestamps are recorded, an object argument and an Error's stack both survive, it rotates at the cap keeping one generation, and after stdout is made to throw EIO the latch trips and the file still gets the line. Also picks up nobilis be40066: the scrollback prune runs on its own fifteen-minute clock instead of the media caches' six-hourly one, and the incremental vacuum hands back what is actually free - it had been giving back a single 4KB page per pass. Closes #210. Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- src/main/index.ts | 6 +++ src/main/log.ts | 123 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/nobilis b/nobilis index 9e885a4..be40066 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 9e885a40b1367ac51cb4498b4eb64377b492bc5b +Subproject commit be400666f484c2a411fb6bfb5ebf7d5cf0ce5e8e diff --git a/src/main/index.ts b/src/main/index.ts index 3495de2..ba497ab 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -778,6 +778,12 @@ app.whenReady().then(() => { // claim a tray icon or register a hotkey on the way. if (!isPrimaryInstance) return + // Before anything worth logging happens. Everything the daemon says is + // piped through `log`, and in a launched build stdout is /dev/null - so + // without this there is no record of a backend failing, anywhere, ever. + log.toDirectory(app.getPath('userData')) + log.info('moho starting, logging to', log.file() ?? '(nowhere)') + electronApp.setAppUserModelId('com.salastil.moho') app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window)) diff --git a/src/main/log.ts b/src/main/log.ts index cfa4db6..f8ccf2d 100644 --- a/src/main/log.ts +++ b/src/main/log.ts @@ -1,5 +1,9 @@ +import fs from 'fs' +import path from 'path' + /** - * Logging in the main process that cannot take the app down with it. + * Logging in the main process that cannot take the app down with it, and that + * still exists after the app has closed. * * `console.log` writes to stdout, and when nothing is attached to stdout that * write throws EIO *synchronously* - not as an 'error' event a listener could @@ -13,11 +17,19 @@ * * Once a write has failed there is nowhere for logs to go, so the rest are * dropped rather than re-attempted per line. + * + * The consequence of surviving that way was that everything the daemon + * reported went nowhere: stdout is /dev/null in a launched build, and + * essentially all of this program's behaviour lives in the daemon. A backend + * reconnecting, an auth failure, a prune that did not run - none of it left a + * trace anybody could read afterwards or send to anybody else. So the same + * lines also go to a file, which is the copy that outlives the process. */ let broken = false function write(method: 'log' | 'warn' | 'error', args: unknown[]): void { + toFile(method, args) if (broken) return try { console[method](...args) @@ -28,10 +40,117 @@ function write(method: 'log' | 'warn' | 'error', args: unknown[]): void { } } +/** + * How much log to keep, and how many generations of it. + * + * Two files of 4MB: enough that a session's worth of daemon chatter survives a + * restart and can be sent to somebody, small enough that it can be read and + * that nothing here quietly fills a disk. A log that needs managing is a log + * people delete. + */ +const MAX_BYTES = 4 * 1024 * 1024 +const FILE = 'moho.log' +const PREVIOUS = 'moho.log.1' + +let handle: number | null = null +let written = 0 +let directory: string | null = null +/** A separate latch from `broken`: a failing file says nothing about stdout. */ +let fileBroken = false + +/** + * Where the log file goes. Called once at startup, from the one place that + * knows - `app.getPath('userData')`. + * + * Passed in rather than read here so this module never imports electron: it is + * the lowest thing in the main process and everything else imports it, and a + * log that cannot be exercised without standing up an app is a log whose + * rotation nobody checks. + * + * Deliberately not the daemon's data directory, which is where the ticket + * suggested it. The client does not know that path - it spawns nobilis without + * `--data-dir` and lets the daemon resolve its own, XDG rules and legacy + * fallbacks included - so writing there would mean reimplementing somebody + * else's path logic and being wrong the day it changes. + */ +function toDirectory(dir: string): void { + directory = dir + openFile() +} + +function openFile(): void { + if (fileBroken || !directory) return + try { + const file = path.join(directory, FILE) + // Pick up where the last run left off, so a restart does not lose the + // lines that explain why it restarted. + written = fs.existsSync(file) ? fs.statSync(file).size : 0 + if (written >= MAX_BYTES) { + rotate() + return + } + handle = fs.openSync(file, 'a') + } catch { + fileBroken = true + } +} + +function rotate(): void { + if (!directory) return + try { + if (handle !== null) { + fs.closeSync(handle) + handle = null + } + // One generation, replaced. Two files of a known size beats an unbounded + // pile of dated ones that nobody prunes. + fs.renameSync(path.join(directory, FILE), path.join(directory, PREVIOUS)) + } catch { + // A rotation that fails must not stop logging - worst case the file grows + // past the cap until the next attempt. + } + try { + written = 0 + handle = fs.openSync(path.join(directory, FILE), 'a') + } catch { + fileBroken = true + } +} + +function toFile(method: 'log' | 'warn' | 'error', args: unknown[]): void { + if (fileBroken || handle === null) return + try { + const line = `${new Date().toISOString()} ${method === 'log' ? 'info' : method} ${args + .map((a) => (typeof a === 'string' ? a : inspect(a))) + .join(' ')}\n` + // Synchronous on purpose: the thing most worth having in this file is the + // last line before a crash, and a queued async write is the line that does + // not make it. + fs.writeSync(handle, line) + written += Buffer.byteLength(line) + if (written >= MAX_BYTES) rotate() + } catch { + fileBroken = true + } +} + +/** Whatever an argument is, without throwing on a circular one. */ +function inspect(value: unknown): string { + if (value instanceof Error) return value.stack || `${value.name}: ${value.message}` + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} + export const log = { info: (...args: unknown[]): void => write('log', args), warn: (...args: unknown[]): void => write('warn', args), error: (...args: unknown[]): void => write('error', args), /** Test seam: lets a check assert the latch without a broken stdout. */ - isBroken: (): boolean => broken + isBroken: (): boolean => broken, + toDirectory, + /** Where the log is being written, for anything that wants to say so. */ + file: (): string | null => (directory && !fileBroken ? path.join(directory, FILE) : null) }