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 <[email protected]>
This commit is contained in:
2026-09-12 21:21:03 -04:00
co-authored by Claude Opus 5
parent 3e746cf0cd
commit f2fc66fa92
3 changed files with 128 additions and 3 deletions
+1 -1
Submodule nobilis updated: 9e885a40b1...be400666f4
+6
View File
@@ -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))
+121 -2
View File
@@ -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)
}