From c8c8094594c69aff003ede73ec194ca71c39c80a Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sun, 11 Jan 2026 10:47:17 -0500 Subject: [PATCH] Fix dev shutdown to stop orphaned services --- scripts/dev-services.ts | 63 ++++++++++++++++++++++++++++++++++----- scripts/dev.ts | 66 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 115 insertions(+), 14 deletions(-) diff --git a/scripts/dev-services.ts b/scripts/dev-services.ts index f435237..bd3cf7b 100644 --- a/scripts/dev-services.ts +++ b/scripts/dev-services.ts @@ -12,12 +12,61 @@ type Child = { const children: Child[] = []; let shuttingDown = false; +const sleep = (delayMs: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +}; + +const waitForExit = async (proc: Bun.Subprocess, timeoutMs: number): Promise => { + const result = await Promise.race([ + proc.exited.then(() => true), + sleep(timeoutMs).then(() => false) + ]); + return result; +}; + +const signalProcess = (pid: number, signal: NodeJS.Signals): boolean => { + try { + process.kill(-pid, signal); + return true; + } catch { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } + } +}; + +const stopChild = async (child: Child, timeoutMs = 5000): Promise => { + const pid = child.process.pid; + if (!pid) { + return; + } + + if (!signalProcess(pid, "SIGINT")) { + return; + } + + const exited = await waitForExit(child.process, timeoutMs); + if (exited) { + return; + } + + if (!signalProcess(pid, "SIGKILL")) { + return; + } + + await waitForExit(child.process, 2000); +}; + const spawnChild = ({ name, cmd, cwd }: ChildSpec): void => { const proc = Bun.spawn(cmd, { cwd, stdin: "inherit", stdout: "inherit", - stderr: "inherit" + stderr: "inherit", + detached: true }); children.push({ name, process: proc }); @@ -30,26 +79,26 @@ const spawnChild = ({ name, cmd, cwd }: ChildSpec): void => { const exitCode = code ?? 0; const statusLabel = exitCode === 0 ? "exited" : "failed"; console.error(`[dev-services] ${name} ${statusLabel} (${exitCode})`); - shutdown(exitCode); + void shutdown(exitCode); }); }; -const shutdown = (code: number): void => { +const shutdown = async (code: number): Promise => { if (shuttingDown) { return; } shuttingDown = true; - for (const child of children) { - child.process.kill(); + if (children.length > 0) { + await Promise.all(children.map((child) => stopChild(child))); } process.exit(code); }; -process.on("SIGINT", () => shutdown(0)); -process.on("SIGTERM", () => shutdown(0)); +process.on("SIGINT", () => void shutdown(0)); +process.on("SIGTERM", () => void shutdown(0)); const tasks: ChildSpec[] = [ { name: "ingest-options", cmd: ["bun", "run", "dev"], cwd: "services/ingest-options" }, diff --git a/scripts/dev.ts b/scripts/dev.ts index b8bd265..c13a338 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -18,6 +18,50 @@ const sleep = (delayMs: number): Promise => { return new Promise((resolve) => setTimeout(resolve, delayMs)); }; +const waitForExit = async (proc: Bun.Subprocess, timeoutMs: number): Promise => { + const result = await Promise.race([ + proc.exited.then(() => true), + sleep(timeoutMs).then(() => false) + ]); + return result; +}; + +const signalProcess = (pid: number, signal: NodeJS.Signals): boolean => { + try { + process.kill(-pid, signal); + return true; + } catch { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } + } +}; + +const stopChild = async (child: Child, timeoutMs = 5000): Promise => { + const pid = child.process.pid; + if (!pid) { + return; + } + + if (!signalProcess(pid, "SIGINT")) { + return; + } + + const exited = await waitForExit(child.process, timeoutMs); + if (exited) { + return; + } + + if (!signalProcess(pid, "SIGKILL")) { + return; + } + + await waitForExit(child.process, 2000); +}; + const parseBool = (value: string | undefined): boolean => { if (!value) { return false; @@ -75,7 +119,8 @@ const spawnChild = ({ name, cmd, cwd }: ChildSpec): void => { cwd, stdin: "inherit", stdout: "inherit", - stderr: "inherit" + stderr: "inherit", + detached: true }); children.push({ name, process: proc }); @@ -93,26 +138,33 @@ const spawnChild = ({ name, cmd, cwd }: ChildSpec): void => { "[dev] Infra failed. Ensure Docker is installed and the daemon is running (OrbStack or Docker Desktop), then retry." ); } - shutdown(exitCode); + void shutdown(exitCode); }); }; -const shutdown = (code: number): void => { +const shutdown = async (code: number): Promise => { if (shuttingDown) { return; } shuttingDown = true; - for (const child of children) { - child.process.kill(); + const infra = children.find((child) => child.name === "infra") ?? null; + const services = children.filter((child) => child.name !== "infra"); + + if (services.length > 0) { + await Promise.all(services.map((child) => stopChild(child))); + } + + if (infra) { + await stopChild(infra, 8000); } process.exit(code); }; -process.on("SIGINT", () => shutdown(0)); -process.on("SIGTERM", () => shutdown(0)); +process.on("SIGINT", () => void shutdown(0)); +process.on("SIGTERM", () => void shutdown(0)); const waitForInfra = async (): Promise => { const natsTarget = parseUrlHostPort(process.env.NATS_URL ?? "", "127.0.0.1", 4222);