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:
+1
-1
Submodule nobilis updated: 9e885a40b1...be400666f4
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user