diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index ffded1bc4..e47462e18 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { fileURLToPath } from "url"; import dotenv from "dotenv"; @@ -12,34 +12,44 @@ const inspectArg = process.env.PASEO_NODE_INSPECT ?? "--inspect"; const inspectArgs = inspectArg === "0" || inspectArg === "false" || inspectArg === "off" ? [] : [inspectArg]; -// The supervisor handles SIGINT/SIGTERM itself and needs time to drain the -// worker gracefully. Ignore them here so spawnSync blocks until the supervisor -// finishes shutting down, instead of the parent dying on Ctrl-C and releasing -// the shell while the daemon is still logging. -process.on("SIGINT", () => {}); -process.on("SIGTERM", () => {}); +const supervisorArgs = [ + ...inspectArgs, + "--heapsnapshot-near-heap-limit=3", + "--max-old-space-size=3072", + "--report-on-fatalerror", + "--report-directory=/tmp/paseo-reports", + ...process.execArgv, + daemonRunnerEntry, + "--dev", + ...process.argv.slice(2), +]; -const result = spawnSync( - process.execPath, - [ - ...inspectArgs, - "--heapsnapshot-near-heap-limit=3", - "--max-old-space-size=3072", - "--report-on-fatalerror", - "--report-directory=/tmp/paseo-reports", - ...process.execArgv, - daemonRunnerEntry, - "--dev", - ...process.argv.slice(2), - ], - { - stdio: "inherit", - env: process.env, - }, -); +const supervisor = spawn(process.execPath, supervisorArgs, { + stdio: "inherit", + env: process.env, +}); -if (result.error) { - throw result.error; +function exitCodeForSignal(signal: NodeJS.Signals): number { + return signal === "SIGINT" ? 130 : 1; } -process.exit(result.status ?? 1); +function forwardSignal(signal: NodeJS.Signals): void { + if (supervisor.exitCode !== null || supervisor.signalCode !== null || supervisor.killed) { + return; + } + supervisor.kill(signal); +} + +// The supervisor handles SIGINT/SIGTERM itself and needs time to drain the +// worker gracefully. Keep this wrapper alive until the supervisor exits so npm +// does not release the shell while the daemon is still logging final shutdown. +process.on("SIGINT", () => forwardSignal("SIGINT")); +process.on("SIGTERM", () => forwardSignal("SIGTERM")); + +supervisor.on("error", (error) => { + throw error; +}); + +supervisor.on("exit", (code, signal) => { + process.exit(code ?? (signal ? exitCodeForSignal(signal) : 1)); +}); diff --git a/packages/server/scripts/supervision-parity.test.ts b/packages/server/scripts/supervision-parity.test.ts index b88dcb202..a834587e5 100644 --- a/packages/server/scripts/supervision-parity.test.ts +++ b/packages/server/scripts/supervision-parity.test.ts @@ -14,4 +14,12 @@ describe("supervision parity", () => { expect(daemonRunnerCalls + devRunnerCalls).toBe(1); }); + + test("dev runner waits asynchronously for supervisor shutdown", () => { + const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8"); + + expect(devRunner).toContain('import { spawn } from "node:child_process"'); + expect(devRunner).not.toContain("spawnSync"); + expect(devRunner).toContain('supervisor.on("exit"'); + }); });