Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/kernel-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- `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))
- 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 ([#966](https://github.com/MetaMask/ocap-kernel/pull/966))
- 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.

## [0.1.0]

Expand Down
87 changes: 75 additions & 12 deletions packages/kernel-cli/src/commands/daemon-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,31 @@ import { startDaemon } from '@metamask/kernel-node-runtime/daemon';
import type { DaemonHandle } from '@metamask/kernel-node-runtime/daemon';
import type { LogEntry } from '@metamask/logger';
import { Logger } from '@metamask/logger';
import { appendFileSync } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

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

const ocapDir = getOcapHome();
const logPath = join(ocapDir, 'daemon.log');
const logger = new Logger({
tags: ['daemon'],
transports: [makeFileTransport(logPath)],
});

// Install exit-cause handlers at module load, before main() runs, so
// failures during kernel init also leave a fingerprint. daemon-entry
// runs with `stdio: 'ignore'` under the CLI spawner (see
// `daemon-spawn.ts`); without these, an uncaught exception, an
// unhandled rejection, or a SIGHUP terminates the process silently
// with no record in `daemon.log`. Silent deaths cost real debugging
// time — see the run-notes for two past cases where a daemon
// disappeared with no trace. Every terminating path now writes at
// least one line before the process goes away.
installFatalHandlers();

main().catch((error) => {
process.stderr.write(`Daemon fatal: ${String(error)}\n`);
process.exitCode = 1;
Expand All @@ -19,15 +38,8 @@ main().catch((error) => {
* Main daemon entry point. Starts the daemon process and keeps it running.
*/
async function main(): Promise<void> {
const ocapDir = getOcapHome();
await mkdir(ocapDir, { recursive: true });

const logPath = join(ocapDir, 'daemon.log');
const logger = new Logger({
tags: ['daemon'],
transports: [makeFileTransport(logPath)],
});

const socketPath =
process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'daemon.sock');

Expand Down Expand Up @@ -129,15 +141,66 @@ async function readDaemonPid(pidPath: string): Promise<number | undefined> {
/**
* Create a file transport that writes logs to a file.
*
* @param logPath - The log file path.
* @param logFilePath - The log file path.
* @returns A log transport function.
*/
function makeFileTransport(logPath: string) {
// eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require -- need sync fs for log transport
const fs = require('node:fs') as typeof import('node:fs');
function makeFileTransport(logFilePath: string) {
return (entry: LogEntry): void => {
const line = `[${new Date().toISOString()}] [${entry.level}] ${entry.message ?? ''} ${(entry.data ?? []).map(String).join(' ')}\n`;
// eslint-disable-next-line n/no-sync -- synchronous write needed for log transport reliability
fs.appendFileSync(logPath, line);
appendFileSync(logFilePath, line);
};
}

/**
* Install process-level handlers that guarantee a log line is
* written for every terminating event before the daemon exits.
*
* The `@metamask/logger` dispatch routine is synchronous and the
* file transport we're using here is `appendFileSync` under the
* hood, so `logger.error(...)` from inside a fatal handler flushes
* to disk before the process exits — no separate sync-write path
* is required.
*
* Handlers registered:
*
* - `uncaughtException` — the classic silent-death path. Node's
* default is to print the stack to stderr and exit with code 1;
* under `stdio: 'ignore'` (how the daemon is spawned) that
* default writes nowhere.
* - `unhandledRejection` — currently defaults to a warning in
* Node, but future Node versions treat it as uncaughtException;
* either way we want a fingerprint.
* - `SIGHUP` — sent when the controlling terminal disappears
* (ssh session closed, laptop lid closed while the daemon was
* under an interactive shell). Default action terminates the
* process; installing a handler lets us log the fact before
* exiting.
* - `exit` — last-ditch record. Fires during every exit, including
* the ones already logged by the handlers above.
*/
function installFatalHandlers(): void {
/* eslint-disable n/no-process-exit -- fatal handlers must terminate deterministically */
process.on('uncaughtException', (error: unknown) => {
const detail =
error instanceof Error ? (error.stack ?? error.message) : String(error);
logger.error('Uncaught exception', detail);
process.exit(1);
});
process.on('unhandledRejection', (reason: unknown) => {
const detail =
reason instanceof Error
? (reason.stack ?? reason.message)
: String(reason);
logger.error('Unhandled rejection', detail);
process.exit(1);
});
process.on('SIGHUP', () => {
logger.error('SIGHUP received; exiting.');
process.exit(0);
});
process.on('exit', (code) => {
logger.error(`Process exiting (code=${code}).`);
});
/* eslint-enable n/no-process-exit */
}
Loading