diff --git a/src/cli/commands/server.ts b/src/cli/commands/server.ts index aaec43d..345f480 100644 --- a/src/cli/commands/server.ts +++ b/src/cli/commands/server.ts @@ -1,6 +1,23 @@ import type { Command } from "commander"; import { ensureHome, getConfig } from "../../core/config.ts"; +// Test-only watchdog: when KNOTES_E2E_WATCH_STDIN=1 is set, exit as soon +// as stdin closes. The e2e harness wires its own pipe to our stdin and +// keeps the write end open for the server's lifetime; the kernel closes +// it the moment the harness process dies (including SIGKILL/OOM), so +// reading EOF here is a parent-death signal that can't be missed. +function installStdinWatchdog(): void { + if (process.env["KNOTES_E2E_WATCH_STDIN"] !== "1") return; + const exitOnParentGone = () => { + console.error("E2E watchdog: stdin closed, parent gone, exiting"); + process.exit(0); + }; + process.stdin.on("data", () => {}); + process.stdin.on("end", exitOnParentGone); + process.stdin.on("close", exitOnParentGone); + process.stdin.resume(); +} + export function registerServerCommand(program: Command): void { program .command("server") @@ -11,6 +28,8 @@ export function registerServerCommand(program: Command): void { const config = getConfig(); const port = opts.port ? parseInt(opts.port, 10) : config.webPort; + installStdinWatchdog(); + const { createWebServer } = await import("../../web/server.ts"); const server = createWebServer(port); console.log(`Knotes server running at http://localhost:${port}`); diff --git a/test/e2e/globalSetup.ts b/test/e2e/globalSetup.ts index 8234685..f72aada 100644 --- a/test/e2e/globalSetup.ts +++ b/test/e2e/globalSetup.ts @@ -54,10 +54,22 @@ export async function setup() { await prepareHome(serverHome, false); const port = await getFreePort(); - const serverProcess = spawn("npx", ["tsx", "src/main.ts", "server", "--port", String(port)], { - env: { ...process.env, KNOTES_HOME: serverHome }, + const tsxBin = join(PROJECT_ROOT, "node_modules/.bin/tsx"); + // stdio[0] is a real pipe (not "ignore") so the server can detect our death + // via EOF on stdin — see installStdinWatchdog in src/cli/commands/server.ts. + // The kernel closes the write end the moment this process exits for any + // reason (clean teardown, SIGKILL, OOM), so the server can never miss it. + // detached:true puts the server in its own process group so we can tear + // the whole subtree (tsx wrapper + server) down in one signal on teardown. + const serverProcess = spawn(tsxBin, ["src/main.ts", "server", "--port", String(port)], { + env: { + ...process.env, + KNOTES_HOME: serverHome, + KNOTES_E2E_WATCH_STDIN: "1", + }, cwd: PROJECT_ROOT, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], + detached: true, }); serverProcess.stdout?.on("data", () => {}); serverProcess.stderr?.on("data", () => {}); @@ -81,12 +93,20 @@ export async function setup() { process.env["E2E_SERVER_PORT"] = String(port); return async () => { - if (!serverProcess.killed) serverProcess.kill("SIGTERM"); + const pgid = serverProcess.pid; + const killGroup = (sig: NodeJS.Signals) => { + if (pgid == null) return; + try { process.kill(-pgid, sig); } catch {} + }; + // Closing stdin triggers the server's EOF watchdog (clean exit path). + // The SIGTERM/SIGKILL fallbacks below cover anything that wasn't + // listening on stdin (e.g. the tsx wrapper between us and the server). + serverProcess.stdin?.end(); + killGroup("SIGTERM"); await new Promise((resolve) => { - const onExit = () => resolve(); - serverProcess.once("exit", onExit); + serverProcess.once("exit", () => resolve()); setTimeout(() => { - if (!serverProcess.killed) serverProcess.kill("SIGKILL"); + killGroup("SIGKILL"); setTimeout(resolve, 500); }, 2000); });