From 341dfd9b2d92bfe10b55af884d5ba7e7b6eec0f2 Mon Sep 17 00:00:00 2001 From: Christopher Kelbley Date: Sat, 26 Sep 2026 14:00:27 -0700 Subject: [PATCH] fix: preserve host signal handlers on shutdown --- packages/engine/src/utils/shutdown.test.ts | 52 ++++++++++++++++++++++ packages/engine/src/utils/shutdown.ts | 29 +++++++++--- 2 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 packages/engine/src/utils/shutdown.test.ts diff --git a/packages/engine/src/utils/shutdown.test.ts b/packages/engine/src/utils/shutdown.test.ts new file mode 100644 index 00000000..dcdc35b3 --- /dev/null +++ b/packages/engine/src/utils/shutdown.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { clearGracefulShutdown, gracefulShutdown } from "./shutdown"; + +describe("graceful shutdown handlers", () => { + it("clears only signal handlers registered by Sidequest", () => { + const hostSigintHandler = vi.fn(); + const hostSigtermHandler = vi.fn(); + process.on("SIGINT", hostSigintHandler); + process.on("SIGTERM", hostSigtermHandler); + + try { + const sigintListenersBefore = process.listeners("SIGINT"); + const sigtermListenersBefore = process.listeners("SIGTERM"); + + gracefulShutdown(vi.fn(), "Test", true); + + expect(process.listenerCount("SIGINT")).toBe(sigintListenersBefore.length + 1); + expect(process.listenerCount("SIGTERM")).toBe(sigtermListenersBefore.length + 1); + + clearGracefulShutdown(); + + expect(process.listeners("SIGINT")).toEqual(sigintListenersBefore); + expect(process.listeners("SIGTERM")).toEqual(sigtermListenersBefore); + } finally { + clearGracefulShutdown(); + process.off("SIGINT", hostSigintHandler); + process.off("SIGTERM", hostSigtermHandler); + } + }); + + it("does not remove host handlers when Sidequest signal handling is disabled", () => { + const hostSigintHandler = vi.fn(); + const hostSigtermHandler = vi.fn(); + process.on("SIGINT", hostSigintHandler); + process.on("SIGTERM", hostSigtermHandler); + + try { + const sigintListenersBefore = process.listeners("SIGINT"); + const sigtermListenersBefore = process.listeners("SIGTERM"); + + gracefulShutdown(vi.fn(), "Test", false); + clearGracefulShutdown(); + + expect(process.listeners("SIGINT")).toEqual(sigintListenersBefore); + expect(process.listeners("SIGTERM")).toEqual(sigtermListenersBefore); + } finally { + clearGracefulShutdown(); + process.off("SIGINT", hostSigintHandler); + process.off("SIGTERM", hostSigtermHandler); + } + }); +}); diff --git a/packages/engine/src/utils/shutdown.ts b/packages/engine/src/utils/shutdown.ts index 251d70df..1bd7784c 100644 --- a/packages/engine/src/utils/shutdown.ts +++ b/packages/engine/src/utils/shutdown.ts @@ -2,6 +2,13 @@ import { logger } from "@sidequest/core"; let shuttingDown = false; +interface RegisteredSignalHandler { + signal: "SIGINT" | "SIGTERM"; + handler: () => Promise; +} + +const registeredSignalHandlers: RegisteredSignalHandler[] = []; + /** * Handles the shutdown process, ensuring it only runs once and logs appropriately. * @param fn The async function to run during shutdown. @@ -33,17 +40,25 @@ async function shutdown(fn: () => void | Promise, tag: string, signal: str */ export function gracefulShutdown(fn: () => void | Promise, tag: string, enabled: boolean) { if (enabled) { - process.on("SIGINT", async () => { + const sigintHandler = async () => { await shutdown(fn, tag, "SIGINT"); - }); - - process.on("SIGTERM", async () => { + }; + const sigtermHandler = async () => { await shutdown(fn, tag, "SIGTERM"); - }); + }; + + process.on("SIGINT", sigintHandler); + process.on("SIGTERM", sigtermHandler); + registeredSignalHandlers.push( + { signal: "SIGINT", handler: sigintHandler }, + { signal: "SIGTERM", handler: sigtermHandler }, + ); } } export function clearGracefulShutdown() { - process.removeAllListeners("SIGINT"); - process.removeAllListeners("SIGTERM"); + for (const { signal, handler } of registeredSignalHandlers) { + process.off(signal, handler); + } + registeredSignalHandlers.length = 0; }