Skip to content

Commit d3760ce

Browse files
committed
fix(kernel-cli): log fatal exits from daemon-entry before terminating
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.
1 parent 03b6a62 commit d3760ce

2 files changed

Lines changed: 92 additions & 3 deletions

File tree

packages/kernel-cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
### Fixed
2121

2222
- `kernel daemon start` refuses to start when another daemon is already listening on the same Unix socket, instead of unlinking the socket and orphaning the running process ([#952](https://github.com/MetaMask/ocap-kernel/pull/952))
23+
- Daemon fatal-path visibility: `daemon-entry` now installs handlers for `uncaughtException`, `unhandledRejection`, `SIGHUP`, and `exit` that append a synchronous fingerprint line to `daemon.log` before terminating. Without these, silent daemon deaths under `stdio: 'ignore'` (the CLI's default spawn mode) left no trace in the log; the operator saw only that the daemon was gone. Every terminating path now leaves at least one line.
2324

2425
## [0.1.0]
2526

packages/kernel-cli/src/commands/daemon-entry.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@ import { startDaemon } from '@metamask/kernel-node-runtime/daemon';
44
import type { DaemonHandle } from '@metamask/kernel-node-runtime/daemon';
55
import type { LogEntry } from '@metamask/logger';
66
import { Logger } from '@metamask/logger';
7+
import { appendFileSync } from 'node:fs';
78
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
89
import { join } from 'node:path';
910

1011
import { getOcapHome } from '../ocap-home.ts';
1112
import { isProcessAlive } from '../utils.ts';
1213

14+
// Install exit-cause handlers at module load, before main() runs, so
15+
// failures during kernel init also leave a fingerprint. daemon-entry
16+
// runs with `stdio: 'ignore'` under the CLI spawner (see
17+
// `daemon-spawn.ts`); without these, an uncaught exception, an
18+
// unhandled rejection, or a SIGHUP terminates the process silently
19+
// with no record in `daemon.log`. Silent deaths cost real debugging
20+
// time — see the run-notes for two past cases where a daemon
21+
// disappeared with no trace. Every terminating path now writes at
22+
// least one line before the process goes away.
23+
installFatalHandlers(join(getOcapHome(), 'daemon.log'));
24+
1325
main().catch((error) => {
1426
process.stderr.write(`Daemon fatal: ${String(error)}\n`);
1527
process.exitCode = 1;
@@ -133,11 +145,87 @@ async function readDaemonPid(pidPath: string): Promise<number | undefined> {
133145
* @returns A log transport function.
134146
*/
135147
function makeFileTransport(logPath: string) {
136-
// eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require -- need sync fs for log transport
137-
const fs = require('node:fs') as typeof import('node:fs');
138148
return (entry: LogEntry): void => {
139149
const line = `[${new Date().toISOString()}] [${entry.level}] ${entry.message ?? ''} ${(entry.data ?? []).map(String).join(' ')}\n`;
140150
// eslint-disable-next-line n/no-sync -- synchronous write needed for log transport reliability
141-
fs.appendFileSync(logPath, line);
151+
appendFileSync(logPath, line);
142152
};
143153
}
154+
155+
/**
156+
* Append a fatal-path entry to `daemon.log` synchronously. Used from
157+
* `process.on('uncaughtException' | 'unhandledRejection' | 'SIGHUP')`
158+
* handlers where the async logger pipeline can't be trusted to
159+
* flush before the process exits. Best-effort: if the log file is
160+
* unwritable we swallow the error rather than throw from a fatal
161+
* handler.
162+
*
163+
* @param logPath - The daemon-log file path.
164+
* @param message - Short label for the entry.
165+
* @param detail - Optional extra data (stack, error, etc.) — coerced
166+
* to string.
167+
*/
168+
function logFatalSync(
169+
logPath: string,
170+
message: string,
171+
detail?: string | number,
172+
): void {
173+
try {
174+
const tail = detail === undefined ? '' : ` ${detail}`;
175+
const line = `[${new Date().toISOString()}] [error] ${message}${tail}\n`;
176+
// eslint-disable-next-line n/no-sync -- fatal handler must flush before exit
177+
appendFileSync(logPath, line);
178+
} catch {
179+
// Best-effort — the daemon is dying either way.
180+
}
181+
}
182+
183+
/**
184+
* Install process-level handlers that guarantee a log line is
185+
* written for every terminating event before the daemon exits.
186+
*
187+
* Handlers registered:
188+
*
189+
* - `uncaughtException` — the classic silent-death path. Node's
190+
* default is to print the stack to stderr and exit with code 1;
191+
* under `stdio: 'ignore'` (how the daemon is spawned) that
192+
* default writes nowhere.
193+
* - `unhandledRejection` — currently defaults to a warning in
194+
* Node, but future Node versions treat it as uncaughtException;
195+
* either way we want a fingerprint.
196+
* - `SIGHUP` — sent when the controlling terminal disappears
197+
* (ssh session closed, laptop lid closed while the daemon was
198+
* under an interactive shell). Default action terminates the
199+
* process; installing a handler lets us log the fact before
200+
* exiting.
201+
* - `exit` — last-ditch record. Fires during every exit, including
202+
* the ones already logged by the handlers above. Sync-safe: only
203+
* sync APIs are usable here.
204+
*
205+
* @param logPath - The daemon-log file path.
206+
*/
207+
function installFatalHandlers(logPath: string): void {
208+
/* eslint-disable n/no-sync, n/no-process-exit -- fatal handlers must flush synchronously and terminate deterministically */
209+
process.on('uncaughtException', (error: unknown) => {
210+
const detail =
211+
error instanceof Error ? (error.stack ?? error.message) : String(error);
212+
logFatalSync(logPath, 'Uncaught exception (about to exit):', detail);
213+
process.exit(1);
214+
});
215+
process.on('unhandledRejection', (reason: unknown) => {
216+
const detail =
217+
reason instanceof Error
218+
? (reason.stack ?? reason.message)
219+
: String(reason);
220+
logFatalSync(logPath, 'Unhandled rejection (about to exit):', detail);
221+
process.exit(1);
222+
});
223+
process.on('SIGHUP', () => {
224+
logFatalSync(logPath, 'SIGHUP received; exiting.');
225+
process.exit(0);
226+
});
227+
process.on('exit', (code) => {
228+
logFatalSync(logPath, `Process exiting (code=${code}).`);
229+
});
230+
/* eslint-enable n/no-sync, n/no-process-exit */
231+
}

0 commit comments

Comments
 (0)