From 40bd70c9cd5265e468fb401bb7042a018eac9b1b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:28:20 -0300 Subject: [PATCH] fix(cli): surface the real spawn error in process supervisor (#8091) (#8158) --- bin/cli/runtime/processSupervisor.mjs | 22 +++- .../fixes/8091-supervisor-spawn-err.md | 1 + ...rocess-supervisor-spawn-error-8091.test.ts | 103 ++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/8091-supervisor-spawn-err.md create mode 100644 tests/unit/cli-process-supervisor-spawn-error-8091.test.ts diff --git a/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index 8afa50744f..10a7730f74 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -73,12 +73,32 @@ export class ServerSupervisor { return this.child; } - handleExit(code) { + handleExit(code, err) { // Node.js v24+ requires process.exit() to receive a number. Spawn-error events // deliver err.code (a string like 'ENOENT') via the 'error' listener; normalise here. const exitCode = typeof code === "number" ? code : null; cleanupPidFile("server"); + // #8091: the child's spawn 'error' listener passes `err` through as a second + // argument, but it used to be silently dropped — the user only ever saw the + // hardcoded "code=-1" with a permanently empty crash log, with no way to + // diagnose why the child never started (ENOENT/EACCES/bad path/etc.). Surface + // the real reason immediately, both on the console and in the crash-log buffer + // so `dumpCrashLog()` shows it too. + if (err) { + const detail = [ + err.code && `code=${err.code}`, + err.syscall && `syscall=${err.syscall}`, + err.path && `path=${err.path}`, + err.message, + ] + .filter(Boolean) + .join(" "); + const line = `⚠ Spawn error: ${detail || String(err)}`; + console.error(line); + this.crashLog.push(line); + } + // #4425: only exit on an intentional shutdown. A spontaneous code-0 exit (e.g. a // systemd MemoryMax cgroup kill, which reports the process exited cleanly) is anomalous // and must be restarted, not treated as a graceful stop that leaves the gateway dead. diff --git a/changelog.d/fixes/8091-supervisor-spawn-err.md b/changelog.d/fixes/8091-supervisor-spawn-err.md new file mode 100644 index 0000000000..787321882e --- /dev/null +++ b/changelog.d/fixes/8091-supervisor-spawn-err.md @@ -0,0 +1 @@ +- fix(cli): surface the real spawn error (err.code/path/syscall/message) in the process supervisor instead of silently swallowing it on a child spawn failure (#8091) diff --git a/tests/unit/cli-process-supervisor-spawn-error-8091.test.ts b/tests/unit/cli-process-supervisor-spawn-error-8091.test.ts new file mode 100644 index 0000000000..894abb7384 --- /dev/null +++ b/tests/unit/cli-process-supervisor-spawn-error-8091.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #8091: when the child process itself fails to spawn (ENOENT/EACCES/etc.), the +// 'error' listener passes `err` into handleExit(-1, err) but handleExit only ever +// declared a single `code` parameter — the real reason (err.code/err.path/err.message) +// was silently dropped. The user only ever saw "Server exited (code=-1)" plus a +// permanently empty crash log, with no way to diagnose the underlying spawn failure. +// +// This test spawns a server that does not exist on disk, which forces a deterministic, +// cross-platform ENOENT 'error' event (no Windows/Bun needed to reproduce the bug class), +// and asserts the real error surfaces both via console.error and via crashLog/dumpCrashLog(). + +process.env.PORT = "0"; + +test("ServerSupervisor surfaces the real spawn error (ENOENT) instead of swallowing it (#8091)", async () => { + const { ServerSupervisor } = await import("../../bin/cli/runtime/processSupervisor.mjs"); + + const logs: string[] = []; + const origErr = console.error.bind(console); + console.error = (...args: unknown[]) => { + logs.push(args.map((a) => String(a)).join(" ")); + }; + + const origExit = process.exit.bind(process); + // @ts-ignore + process.exit = () => {}; + + const supervisor = new ServerSupervisor({ + serverPath: "/definitely/does/not/exist/server.js", + env: {}, + maxRestarts: 0, + }); + + // Real spawn+'error' path the supervisor uses in production; ENOENT fires async. + supervisor.start(); + + await new Promise((resolve) => { + const check = setInterval(() => { + if (logs.length || supervisor.crashLog.length) { + clearInterval(check); + resolve(); + } + }, 10); + setTimeout(() => { + clearInterval(check); + resolve(); + }, 2000); + }); + + // @ts-ignore + process.exit = origExit; + console.error = origErr; + + const printed = logs.join("\n"); + const inCrashLog = supervisor.crashLog.join("\n"); + + assert.ok( + printed.includes("ENOENT") || inCrashLog.includes("ENOENT"), + `expected the real spawn error (ENOENT) to be surfaced via console.error or crashLog, got:\nconsole.error: ${printed}\ncrashLog: ${inCrashLog}` + ); +}); + +test("ServerSupervisor.handleExit(code, err) logs err.code/err.path/err.message and pushes into crashLog", async () => { + const { ServerSupervisor } = await import("../../bin/cli/runtime/processSupervisor.mjs"); + + const logs: string[] = []; + const origErr = console.error.bind(console); + console.error = (...args: unknown[]) => { + logs.push(args.map((a) => String(a)).join(" ")); + }; + + const supervisor = new ServerSupervisor({ + serverPath: "/fake/server.js", + env: {}, + maxRestarts: 5, + }); + // @ts-ignore — stub start() so the scheduled restart never spawns a real process + supervisor.start = () => null; + supervisor.startedAt = Date.now() - 100; + + const fakeErr = Object.assign(new Error("spawn /fake/server.js ENOENT"), { + code: "ENOENT", + path: "/fake/server.js", + syscall: "spawn", + }); + supervisor.handleExit(-1, fakeErr); + + console.error = origErr; + + const printed = logs.join("\n"); + assert.ok(printed.includes("ENOENT"), `expected err.code (ENOENT) to be printed, got: ${printed}`); + assert.ok( + printed.includes("/fake/server.js"), + `expected err.path to be printed, got: ${printed}` + ); + assert.ok( + supervisor.crashLog.some((l: string) => l.includes("ENOENT")), + `expected the error to be pushed into crashLog, got: ${JSON.stringify(supervisor.crashLog)}` + ); + + await new Promise((r) => setTimeout(r, 1100)); // drain the scheduled restart timer +});