Skip to content
Merged
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
19 changes: 19 additions & 0 deletions src/cli/commands/server.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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}`);
Expand Down
34 changes: 27 additions & 7 deletions test/e2e/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {});
Expand All @@ -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<void>((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);
});
Expand Down
Loading