From 6cb87299524e32645aec2f26143fd6135fe35f66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:47:28 +0000 Subject: [PATCH] fix: harden windows gateway stop cleanup --- src/daemon/schtasks.stop.test.ts | 46 ++++++++++++++++++++++++++++++++ src/daemon/schtasks.ts | 36 +++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/daemon/schtasks.stop.test.ts b/src/daemon/schtasks.stop.test.ts index d2d43de3ca2..8142ff0d839 100644 --- a/src/daemon/schtasks.stop.test.ts +++ b/src/daemon/schtasks.stop.test.ts @@ -121,6 +121,52 @@ describe("Scheduled Task stop/restart cleanup", () => { }); }); + it("force-kills remaining busy port listeners when the first stop pass does not free the port", async () => { + await withWindowsEnv(async ({ env }) => { + await writeGatewayScript(env); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + ); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]); + inspectPortUsage.mockResolvedValueOnce({ + port: 18789, + status: "busy", + listeners: [{ pid: 4242, command: "node.exe" }], + hints: [], + }); + for (let i = 0; i < 20; i += 1) { + inspectPortUsage.mockResolvedValueOnce({ + port: 18789, + status: "busy", + listeners: [{ pid: 4242, command: "node.exe" }], + hints: [], + }); + } + inspectPortUsage + .mockResolvedValueOnce({ + port: 18789, + status: "busy", + listeners: [{ pid: 5252, command: "node.exe" }], + hints: [], + }) + .mockResolvedValueOnce({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }); + + const stdout = new PassThrough(); + await stopScheduledTask({ env, stdout }); + + expect(killProcessTree).toHaveBeenNthCalledWith(1, 4242, { graceMs: 300 }); + expect(killProcessTree).toHaveBeenNthCalledWith(2, expect.any(Number), { graceMs: 300 }); + expect(inspectPortUsage.mock.calls.length).toBeGreaterThanOrEqual(22); + }); + }); + it("falls back to inspected gateway listeners when sync verification misses on Windows", async () => { await withWindowsEnv(async ({ env }) => { await writeGatewayScript(env); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index fcd8b08b1af..3a92f0944fc 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -463,6 +463,24 @@ async function waitForGatewayPortRelease(port: number, timeoutMs = 5_000): Promi return false; } +async function terminateBusyPortListeners(port: number): Promise { + const diagnostics = await inspectPortUsage(port).catch(() => null); + if (diagnostics?.status !== "busy") { + return []; + } + const pids = Array.from( + new Set( + diagnostics.listeners + .map((listener) => listener.pid) + .filter((pid): pid is number => Number.isFinite(pid) && pid > 0), + ), + ); + for (const pid of pids) { + await terminateGatewayProcessTree(pid, 300); + } + return pids; +} + async function resolveFallbackRuntime(env: GatewayServiceEnv): Promise { const port = resolveConfiguredGatewayPort(env); if (!port) { @@ -655,7 +673,14 @@ export async function stopScheduledTask({ stdout, env }: GatewayServiceControlAr await terminateScheduledTaskGatewayListeners(effectiveEnv); await terminateInstalledStartupRuntime(effectiveEnv); if (stopPort) { - await waitForGatewayPortRelease(stopPort); + const released = await waitForGatewayPortRelease(stopPort); + if (!released) { + await terminateBusyPortListeners(stopPort); + const releasedAfterForce = await waitForGatewayPortRelease(stopPort, 2_000); + if (!releasedAfterForce) { + throw new Error(`gateway port ${stopPort} is still busy after stop`); + } + } } stdout.write(`${formatLine("Stopped Scheduled Task", taskName)}\n`); } @@ -684,7 +709,14 @@ export async function restartScheduledTask({ await terminateScheduledTaskGatewayListeners(effectiveEnv); await terminateInstalledStartupRuntime(effectiveEnv); if (restartPort) { - await waitForGatewayPortRelease(restartPort); + const released = await waitForGatewayPortRelease(restartPort); + if (!released) { + await terminateBusyPortListeners(restartPort); + const releasedAfterForce = await waitForGatewayPortRelease(restartPort, 2_000); + if (!releasedAfterForce) { + throw new Error(`gateway port ${restartPort} is still busy before restart`); + } + } } const res = await execSchtasks(["/Run", "/TN", taskName]); if (res.code !== 0) {