fix(kernel-cli): log fatal exits from daemon-entry before terminating#966
Open
FUDCo wants to merge 2 commits into
Open
fix(kernel-cli): log fatal exits from daemon-entry before terminating#966FUDCo wants to merge 2 commits into
FUDCo wants to merge 2 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.
Contributor
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
grypez
requested changes
Jul 9, 2026
grypez
left a comment
Member
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.
Comment on lines
+155
to
+181
| /** | ||
| * 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. | ||
| } | ||
| } |
Member
There was a problem hiding this comment.
Suggested change
| /** | |
| * 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.
| process.on('uncaughtException', (error: unknown) => { | ||
| const detail = | ||
| error instanceof Error ? (error.stack ?? error.message) : String(error); | ||
| logFatalSync(logPath, 'Uncaught exception (about to exit):', detail); |
Member
There was a problem hiding this comment.
Suggested change
| logFatalSync(logPath, 'Uncaught exception (about to exit):', detail); | |
| logger.error('Uncaught exception', detail); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 change to daemon exit handling; no auth, data, or RPC behavior changes beyond explicit SIGHUP logging and exit codes.
Overview
Daemon processes spawned with
stdio: 'ignore'no longer die without a trace indaemon.log.daemon-entrynow registers fatal handlers at module load (beforemain()), so early init failures are covered too. OnuncaughtException,unhandledRejection, orSIGHUP, it synchronously appends an[error]line todaemon.log(with stack/detail when available), then exits; anexithandler adds a finalProcess exiting (code=…)fingerprint. Writes are best-effort (try/catch) so a broken log file cannot mask the real failure.The file logger transport is switched to a direct
appendFileSyncimport instead of a syncrequireofnode:fs. The changelog documents the fix under Fixed.Reviewed by Cursor Bugbot for commit 9b9d82b. Bugbot is set up for automated code reviews on this repo. Configure here.