fix(kernel-cli): log fatal exits from daemon-entry before terminating#966
fix(kernel-cli): log fatal exits from daemon-entry before terminating#966FUDCo wants to merge 3 commits into
Conversation
daemon-entry runs with `stdio: 'ignore'` under the CLI spawner, so Node's default behaviour on uncaughtException / unhandledRejection (print stack to stderr, exit 1) writes to nowhere and the operator sees only that the daemon vanished. Two recent debugging sessions were consumed by silent daemon deaths that left no trace. Install process-level handlers that append a synchronous log line before the process exits: - uncaughtException — captures stack, exits(1) - unhandledRejection — captures reason, exits(1) - SIGHUP — logs, exits(0) (default was silent terminate) - exit — last-ditch record; fires on every exit path Handlers are installed at module load, before main() runs, so early kernel-init failures also leave a fingerprint. Each handler uses only synchronous fs and the fs write is wrapped in try/catch so a log-write failure never masks the original exit cause.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
grypez
left a comment
There was a problem hiding this comment.
I think it would suffice to create the logger at file root scope instead of in the main header. File-scoped logger for entrypoints is already standard convention across the monorepo; seems we just missed this one because it is a command.
| /** | ||
| * Append a fatal-path entry to `daemon.log` synchronously. Used from | ||
| * `process.on('uncaughtException' | 'unhandledRejection' | 'SIGHUP')` | ||
| * handlers where the async logger pipeline can't be trusted to | ||
| * flush before the process exits. Best-effort: if the log file is | ||
| * unwritable we swallow the error rather than throw from a fatal | ||
| * handler. | ||
| * | ||
| * @param logPath - The daemon-log file path. | ||
| * @param message - Short label for the entry. | ||
| * @param detail - Optional extra data (stack, error, etc.) — coerced | ||
| * to string. | ||
| */ | ||
| function logFatalSync( | ||
| logPath: string, | ||
| message: string, | ||
| detail?: string | number, | ||
| ): void { | ||
| try { | ||
| const tail = detail === undefined ? '' : ` ${detail}`; | ||
| const line = `[${new Date().toISOString()}] [error] ${message}${tail}\n`; | ||
| // eslint-disable-next-line n/no-sync -- fatal handler must flush before exit | ||
| appendFileSync(logPath, line); | ||
| } catch { | ||
| // Best-effort — the daemon is dying either way. | ||
| } | ||
| } |
There was a problem hiding this comment.
| /** | |
| * Append a fatal-path entry to `daemon.log` synchronously. Used from | |
| * `process.on('uncaughtException' | 'unhandledRejection' | 'SIGHUP')` | |
| * handlers where the async logger pipeline can't be trusted to | |
| * flush before the process exits. Best-effort: if the log file is | |
| * unwritable we swallow the error rather than throw from a fatal | |
| * handler. | |
| * | |
| * @param logPath - The daemon-log file path. | |
| * @param message - Short label for the entry. | |
| * @param detail - Optional extra data (stack, error, etc.) — coerced | |
| * to string. | |
| */ | |
| function logFatalSync( | |
| logPath: string, | |
| message: string, | |
| detail?: string | number, | |
| ): void { | |
| try { | |
| const tail = detail === undefined ? '' : ` ${detail}`; | |
| const line = `[${new Date().toISOString()}] [error] ${message}${tail}\n`; | |
| // eslint-disable-next-line n/no-sync -- fatal handler must flush before exit | |
| appendFileSync(logPath, line); | |
| } catch { | |
| // Best-effort — the daemon is dying either way. | |
| } | |
| } |
The logger dispatch routine is already synchronous; async methods follow 'spray and pray' semantics, i.e. best effort.
I think the process.on handlers are doing the work here, not this function.
There was a problem hiding this comment.
Good call — done in 70b96eb. Logger dispatch is indeed synchronous (Logger.#dispatch iterates transports via .forEach) and our file transport is appendFileSync, so logger.error(...) from a fatal handler flushes to disk before exit. Dropped the parallel sync helper.
| process.on('uncaughtException', (error: unknown) => { | ||
| const detail = | ||
| error instanceof Error ? (error.stack ?? error.message) : String(error); | ||
| logFatalSync(logPath, 'Uncaught exception (about to exit):', detail); |
There was a problem hiding this comment.
| logFatalSync(logPath, 'Uncaught exception (about to exit):', detail); | |
| logger.error('Uncaught exception', detail); |
There was a problem hiding this comment.
Done — the handler now calls logger.error(...) directly, and the file-scoped logger it references is hoisted to module top level in the same commit.
Applying @grypez review feedback on PR #966: - Move the logger and its path from inside main() to file scope, matching the entrypoint convention already used by app.ts, background.ts, and vat-worker.ts. - Delete logFatalSync — the logger's dispatch routine is synchronous (Logger.#dispatch iterates transports via .forEach) and the file transport itself is appendFileSync, so logger.error(...) from inside a fatal handler flushes to disk before the process exits. - Fatal handlers now call logger.error directly instead of the parallel sync helper. Behaviour is unchanged: every terminating path still writes a line to daemon.log before the process goes away.
Applying @grypez review feedback on PR #966: - Move the logger and its path from inside main() to file scope, matching the entrypoint convention already used by app.ts, background.ts, and vat-worker.ts. - Delete logFatalSync — the logger's dispatch routine is synchronous (Logger.#dispatch iterates transports via .forEach) and the file transport itself is appendFileSync, so logger.error(...) from inside a fatal handler flushes to disk before the process exits. - Fatal handlers now call logger.error directly instead of the parallel sync helper. Behaviour is unchanged: every terminating path still writes a line to daemon.log before the process goes away.
The demo branch's file-scope logger construction hit a temporal-
dead-zone reference because `makeFileTransport(logPath,
resolveMinLogLevel())` runs at module top and `resolveMinLogLevel`
uses `LOG_LEVELS`, which was declared as a `const` further down
the file. Function declarations hoist but `const` bindings stay
in TDZ until execution reaches them, so the daemon child crashed
at module init with:
ReferenceError: Cannot access 'LOG_LEVELS' before initialization
The crash happened before the fatal-handler install ran, and the
child was spawned with stdio: 'ignore', so the failure surfaced
only as a 30-second polling timeout ("Daemon did not start").
Move `LOG_LEVELS`, `LogLevelName`, and `resolveMinLogLevel()`
above the module-scope logger construction. Fixes the demo
branch; PR #966 (main-based) is unaffected because its transport
factory doesn't reference `LOG_LEVELS`.
Summary
daemon-entry.tsforuncaughtException,unhandledRejection,SIGHUP, andexit.daemon.logbefore the process exits.main()runs, so early kernel-init failures also leave a fingerprint.Why
daemon-entryruns withstdio: 'ignore'under the CLI spawner (seedaemon-spawn.ts). Node's default behaviour onuncaughtException/unhandledRejection(print stack to stderr, exit 1) therefore writes to nowhere, and the operator sees only that the daemon vanished — no trace in~/.ocap*/daemon.log.I've hit two silent daemon deaths in the last few weeks (matcher daemon disappearing mid-rehearsal, services daemon disappearing between registration and the next call), and in both cases the log ended cleanly on the last successful message with no shutdown line, no error, no stack trace. Debugging cost real time each time.
With this change, every terminating path now leaves at least one line:
or
Log-write itself is wrapped in try/catch so a broken log file doesn't mask the original exit cause.
Test plan
yarn workspace @metamask/kernel-cli test:dev:quiet --run— all package tests pass.yarn workspace @metamask/kernel-cli lint— clean.🤖 Generated with Claude Code
Note
Low Risk
Observability-only changes to daemon exit paths; SIGHUP now logs and exits 0 explicitly instead of the default silent terminate, with no auth or data-handling impact.
Overview
Daemon fatal-path visibility when the CLI spawns
daemon-entrywithstdio: 'ignore': uncaught errors and signals no longer vanish with no record indaemon.log.daemon-entrynow builds the file logger at module load (beforemain()), registersinstallFatalHandlers()immediately, and logs then exits onuncaughtException,unhandledRejection, andSIGHUP, with anexithandler that always writes a final line. The log transport switches from dynamicrequire('node:fs')toappendFileSyncso fatal handlers flush synchronously. CHANGELOG documents the fix under Unreleased.Reviewed by Cursor Bugbot for commit 70b96eb. Bugbot is set up for automated code reviews on this repo. Configure here.