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
47 changes: 45 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const config = getConfig();

Expand Down
59 changes: 59 additions & 0 deletions tests/stdio-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>((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");
});
});