Skip to content

Commit 6cb8729

Browse files
committed
fix: harden windows gateway stop cleanup
1 parent 51fe0bf commit 6cb8729

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

src/daemon/schtasks.stop.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,52 @@ describe("Scheduled Task stop/restart cleanup", () => {
121121
});
122122
});
123123

124+
it("force-kills remaining busy port listeners when the first stop pass does not free the port", async () => {
125+
await withWindowsEnv(async ({ env }) => {
126+
await writeGatewayScript(env);
127+
schtasksResponses.push(
128+
{ code: 0, stdout: "", stderr: "" },
129+
{ code: 0, stdout: "", stderr: "" },
130+
{ code: 0, stdout: "", stderr: "" },
131+
);
132+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]);
133+
inspectPortUsage.mockResolvedValueOnce({
134+
port: 18789,
135+
status: "busy",
136+
listeners: [{ pid: 4242, command: "node.exe" }],
137+
hints: [],
138+
});
139+
for (let i = 0; i < 20; i += 1) {
140+
inspectPortUsage.mockResolvedValueOnce({
141+
port: 18789,
142+
status: "busy",
143+
listeners: [{ pid: 4242, command: "node.exe" }],
144+
hints: [],
145+
});
146+
}
147+
inspectPortUsage
148+
.mockResolvedValueOnce({
149+
port: 18789,
150+
status: "busy",
151+
listeners: [{ pid: 5252, command: "node.exe" }],
152+
hints: [],
153+
})
154+
.mockResolvedValueOnce({
155+
port: 18789,
156+
status: "free",
157+
listeners: [],
158+
hints: [],
159+
});
160+
161+
const stdout = new PassThrough();
162+
await stopScheduledTask({ env, stdout });
163+
164+
expect(killProcessTree).toHaveBeenNthCalledWith(1, 4242, { graceMs: 300 });
165+
expect(killProcessTree).toHaveBeenNthCalledWith(2, expect.any(Number), { graceMs: 300 });
166+
expect(inspectPortUsage.mock.calls.length).toBeGreaterThanOrEqual(22);
167+
});
168+
});
169+
124170
it("falls back to inspected gateway listeners when sync verification misses on Windows", async () => {
125171
await withWindowsEnv(async ({ env }) => {
126172
await writeGatewayScript(env);

src/daemon/schtasks.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,24 @@ async function waitForGatewayPortRelease(port: number, timeoutMs = 5_000): Promi
463463
return false;
464464
}
465465

466+
async function terminateBusyPortListeners(port: number): Promise<number[]> {
467+
const diagnostics = await inspectPortUsage(port).catch(() => null);
468+
if (diagnostics?.status !== "busy") {
469+
return [];
470+
}
471+
const pids = Array.from(
472+
new Set(
473+
diagnostics.listeners
474+
.map((listener) => listener.pid)
475+
.filter((pid): pid is number => Number.isFinite(pid) && pid > 0),
476+
),
477+
);
478+
for (const pid of pids) {
479+
await terminateGatewayProcessTree(pid, 300);
480+
}
481+
return pids;
482+
}
483+
466484
async function resolveFallbackRuntime(env: GatewayServiceEnv): Promise<GatewayServiceRuntime> {
467485
const port = resolveConfiguredGatewayPort(env);
468486
if (!port) {
@@ -655,7 +673,14 @@ export async function stopScheduledTask({ stdout, env }: GatewayServiceControlAr
655673
await terminateScheduledTaskGatewayListeners(effectiveEnv);
656674
await terminateInstalledStartupRuntime(effectiveEnv);
657675
if (stopPort) {
658-
await waitForGatewayPortRelease(stopPort);
676+
const released = await waitForGatewayPortRelease(stopPort);
677+
if (!released) {
678+
await terminateBusyPortListeners(stopPort);
679+
const releasedAfterForce = await waitForGatewayPortRelease(stopPort, 2_000);
680+
if (!releasedAfterForce) {
681+
throw new Error(`gateway port ${stopPort} is still busy after stop`);
682+
}
683+
}
659684
}
660685
stdout.write(`${formatLine("Stopped Scheduled Task", taskName)}\n`);
661686
}
@@ -684,7 +709,14 @@ export async function restartScheduledTask({
684709
await terminateScheduledTaskGatewayListeners(effectiveEnv);
685710
await terminateInstalledStartupRuntime(effectiveEnv);
686711
if (restartPort) {
687-
await waitForGatewayPortRelease(restartPort);
712+
const released = await waitForGatewayPortRelease(restartPort);
713+
if (!released) {
714+
await terminateBusyPortListeners(restartPort);
715+
const releasedAfterForce = await waitForGatewayPortRelease(restartPort, 2_000);
716+
if (!releasedAfterForce) {
717+
throw new Error(`gateway port ${restartPort} is still busy before restart`);
718+
}
719+
}
688720
}
689721
const res = await execSchtasks(["/Run", "/TN", taskName]);
690722
if (res.code !== 0) {

0 commit comments

Comments
 (0)