All files / kernel-cli/src/commands daemon-entry.ts

0% Statements 0/65
0% Branches 0/28
0% Functions 0/20
0% Lines 0/60

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207                                                                                                                                                                                                                                                                                                                                                                                                                             
import '@metamask/kernel-shims/endoify-node';
import { makeKernel } from '@metamask/kernel-node-runtime';
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;
});
 
/**
 * Main daemon entry point. Starts the daemon process and keeps it running.
 */
async function main(): Promise<void> {
  await mkdir(ocapDir, { recursive: true });
 
  const socketPath =
    process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'daemon.sock');
 
  const dbFilename = join(ocapDir, 'kernel.sqlite');
  const { kernel, kernelDatabase } = await makeKernel({
    resetStorage: false,
    dbFilename,
    logger,
  });
 
  const pidPath = join(ocapDir, 'daemon.pid');
 
  // Interlock: refuse to start a second daemon under the same OCAP_HOME.
  // The socket-binding interlock in startDaemon handles the live-socket
  // case; this catches the rarer case where an orphan still holds the
  // kernel.sqlite locks but its socket file has already been unlinked.
  const existingPid = await readDaemonPid(pidPath);
  if (existingPid !== undefined && isProcessAlive(existingPid)) {
    throw new Error(
      `Daemon is already running (pid ${existingPid}) under ${ocapDir}. ` +
        `Use 'ocap daemon stop' first.`,
    );
  }
  if (existingPid !== undefined) {
    // Stale pid file — owner is dead, take over.
    await rm(pidPath, { force: true });
  }
 
  let handle: DaemonHandle;
  try {
    await kernel.initIdentity();
    await writeFile(pidPath, String(process.pid));
 
    handle = await startDaemon({
      socketPath,
      kernel,
      kernelDatabase,
      onShutdown: async () => shutdown('RPC shutdown'),
    });
  } catch (error) {
    try {
      kernel.stop().catch(() => undefined);
      kernelDatabase.close();
    } catch {
      // Best-effort cleanup.
    }
    rm(pidPath, { force: true }).catch(() => undefined);
    throw error;
  }
 
  logger.info(`Daemon started. Socket: ${handle.socketPath}`);
 
  let shutdownPromise: Promise<void> | undefined;
  /**
   * Shut down the daemon idempotently. Concurrent calls coalesce.
   *
   * @param reason - A label describing why shutdown was triggered.
   * @returns A promise that resolves when shutdown completes.
   */
  async function shutdown(reason: string): Promise<void> {
    if (shutdownPromise === undefined) {
      logger.info(`Shutting down (${reason})...`);
      shutdownPromise = handle.close().finally(() => {
        rm(pidPath, { force: true }).catch(() => undefined);
      });
    }
    return shutdownPromise;
  }
 
  process.on('SIGTERM', () => {
    shutdown('SIGTERM').catch(() => (process.exitCode = 1));
  });
  process.on('SIGINT', () => {
    shutdown('SIGINT').catch(() => (process.exitCode = 1));
  });
}
 
/**
 * Read the PID from `daemon.pid`, returning `undefined` if missing or
 * unparseable.
 *
 * @param pidPath - Path to the pid file.
 * @returns The parsed pid, or `undefined`.
 */
async function readDaemonPid(pidPath: string): Promise<number | undefined> {
  let raw: string;
  try {
    raw = await readFile(pidPath, 'utf-8');
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
      return undefined;
    }
    throw error;
  }
  const pid = Number(raw.trim());
  return Number.isFinite(pid) && pid > 0 ? pid : undefined;
}
 
/**
 * Create a file transport that writes logs to a file.
 *
 * @param logFilePath - The log file path.
 * @returns A log transport function.
 */
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
    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 */
}