From cb76e5c8993375f2274005f7dcb218538a8fd1d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 18:18:41 +0100 Subject: [PATCH] fix(gateway): restart watch after child sigterm --- scripts/watch-node.mjs | 8 +++++++- src/infra/watch-node.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 572ca6ec3b7..9be82a898eb 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -8,6 +8,8 @@ import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mj const WATCH_NODE_RUNNER = "scripts/run-node.mjs"; const WATCH_RESTART_SIGNAL = "SIGTERM"; +const WATCH_RESTARTABLE_CHILD_EXIT_CODES = new Set([143]); +const WATCH_RESTARTABLE_CHILD_SIGNALS = new Set(["SIGTERM"]); const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args]; @@ -27,6 +29,10 @@ const resolveRepoPath = (filePath, cwd) => { const isIgnoredWatchPath = (filePath, cwd) => !isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd)); +const shouldRestartAfterChildExit = (exitCode, exitSignal) => + (typeof exitCode === "number" && WATCH_RESTARTABLE_CHILD_EXIT_CODES.has(exitCode)) || + (typeof exitSignal === "string" && WATCH_RESTARTABLE_CHILD_SIGNALS.has(exitSignal)); + export async function runWatchMain(params = {}) { const deps = { spawn: params.spawn ?? spawn, @@ -90,7 +96,7 @@ export async function runWatchMain(params = {}) { if (shuttingDown) { return; } - if (restartRequested) { + if (restartRequested || shouldRestartAfterChildExit(exitCode, exitSignal)) { restartRequested = false; startRunner(); return; diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index bb5f27b19b6..d2cf509210a 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -147,6 +147,38 @@ describe("watch-node script", () => { expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); }); + it("restarts when the runner exits with a SIGTERM-derived code unexpectedly", async () => { + const childA = Object.assign(new EventEmitter(), { + kill: vi.fn(), + }); + const childB = Object.assign(new EventEmitter(), { + kill: vi.fn(() => {}), + }); + const spawn = vi.fn().mockReturnValueOnce(childA).mockReturnValueOnce(childB); + const watcher = Object.assign(new EventEmitter(), { + close: vi.fn(async () => {}), + }); + const createWatcher = vi.fn(() => watcher); + const fakeProcess = createFakeProcess(); + + const runPromise = runWatchMain({ + args: ["gateway", "--force"], + createWatcher, + process: fakeProcess, + spawn, + }); + + childA.emit("exit", 143, null); + await new Promise((resolve) => setImmediate(resolve)); + expect(spawn).toHaveBeenCalledTimes(2); + + fakeProcess.emit("SIGINT"); + const exitCode = await runPromise; + expect(exitCode).toBe(130); + expect(childB.kill).toHaveBeenCalledWith("SIGTERM"); + expect(watcher.close).toHaveBeenCalledTimes(1); + }); + it("forces no-respawn for watch children even when supervisor hints are inherited", async () => { const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();