From f12c44be1834360490a5d66e286adfee38202c3a Mon Sep 17 00:00:00 2001 From: S0ya13 Date: Fri, 16 Oct 2026 17:08:02 +0200 Subject: [PATCH] Exit (don't spin) when orphaned or fatally errored --- src/index.ts | 47 ++++++++++++++++++++++++++-- tests/stdio-lifecycle.test.ts | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tests/stdio-lifecycle.test.ts diff --git a/src/index.ts b/src/index.ts index e18dc3d..254b138 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,14 +18,57 @@ import { buildServer } from "./server.js"; export { buildServer }; +// Once set, the process is on its way out: no further handler may re-enter +// the shutdown path (or the logging path that feeds the spin described below). +let shuttingDown = false; + +/** + * Best-effort final error report. Logging itself can throw here — e.g. when + * stderr is a broken pipe after the parent went away — so every write is + * guarded. An unreportable error is still fatal; it is just a quiet one. + */ +function reportFatal(context: string, error: unknown): void { + try { + logError(context, error); + } catch { + try { + process.stderr.write(`[fatal] ${context}\n`); + } catch { + /* stderr is gone; nothing left to report through. */ + } + } +} + +function shutdown(exitCode: number): void { + if (shuttingDown) return; + shuttingDown = true; + process.exit(exitCode); +} + +// A fatal error in a stdio server is unrecoverable: the connection state is +// unknown and the client cannot be answered reliably. Log once and exit so a +// supervisor can restart cleanly. NOTE: never just log-and-continue here — +// when stdio is broken (orphaned process, EPIPE on every write), each +// logging attempt throws again and the process spins at 100% CPU forever. process.on("uncaughtException", (error) => { - logError("Uncaught exception.", error); + reportFatal("Uncaught exception.", error); + shutdown(1); }); process.on("unhandledRejection", (reason) => { - logError("Unhandled rejection.", reason); + reportFatal("Unhandled rejection.", reason); + shutdown(1); }); +// Nobody left on the other end of stdin: this server is an orphan. Exit +// instead of idling (or spinning) forever. +process.stdin.on("end", () => shutdown(0)); +process.stdin.on("close", () => shutdown(0)); + +// Supervisor-initiated shutdown is a clean shutdown, not an error. +process.on("SIGTERM", () => shutdown(0)); +process.on("SIGINT", () => shutdown(0)); + async function main(): Promise { const config = getConfig(); diff --git a/tests/stdio-lifecycle.test.ts b/tests/stdio-lifecycle.test.ts new file mode 100644 index 0000000..76bd160 --- /dev/null +++ b/tests/stdio-lifecycle.test.ts @@ -0,0 +1,59 @@ +/** + * Lifecycle regression tests for the stdio entrypoint (`src/index.ts`). + * + * Guards against the orphan-spin bug: when the server loses its parent + * (stdin closed) or hits a fatal error, it must exit promptly instead of + * idling — or worse, spinning at 100% CPU — forever. + * + * These tests spawn the compiled entrypoint, so `npm run build` must run + * before `npm test` (skipped otherwise). + */ + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const entrypoint = join(rootDir, "dist", "index.js"); + +describe.skipIf(!existsSync(entrypoint))("stdio lifecycle", () => { + it("exits 0 promptly when stdin is closed (orphaned server)", () => { + const result = spawnSync(process.execPath, [entrypoint], { + input: "", + timeout: 10_000, + encoding: "utf8", + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + }); + + it("logs once and exits non-zero on a fatal error instead of spinning", async () => { + // NOTE: stdin is deliberately held open here. Without the fix, the + // process logs the error and keeps running (spinning forever once + // orphaned with broken stdio); the test would time out and fail. + const trigger = + "data:text/javascript,setTimeout(()=>{throw new Error('boom-test')},200);"; + const child = spawn(process.execPath, ["--import", trigger, entrypoint], { + stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + const exitCode = await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve(null); + }, 10_000); + child.on("exit", (code) => { + clearTimeout(timer); + resolve(code); + }); + }); + expect(exitCode).not.toBeNull(); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Uncaught exception"); + }); +});