From e23b6fb2ba75bf6c5ce68b0286bc8d445b80d428 Mon Sep 17 00:00:00 2001 From: Ajay Elika <73169130+ajay99511@users.noreply.github.com> Date: Mon, 2 Mar 2026 07:33:59 -0700 Subject: [PATCH] fix(gateway): add Windows-compatible port detection using netstat fallback (openclaw#29239) thanks @ajay99511 Verified: - pnpm vitest src/cli/program.force.test.ts - pnpm check - pnpm build Co-authored-by: ajay99511 <73169130+ajay99511@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- src/cli/ports.ts | 26 +++++++++++++ src/cli/program.force.test.ts | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/cli/ports.ts b/src/cli/ports.ts index 30ebd3f41..e2bfa67aa 100644 --- a/src/cli/ports.ts +++ b/src/cli/ports.ts @@ -158,6 +158,32 @@ export function parseLsofOutput(output: string): PortProcess[] { } export function listPortListeners(port: number): PortProcess[] { + if (process.platform === "win32") { + try { + const out = execFileSync("netstat", ["-ano", "-p", "TCP"], { encoding: "utf-8" }); + const lines = out.split(/\r?\n/).filter(Boolean); + const results: PortProcess[] = []; + for (const line of lines) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 5 && parts[3] === "LISTENING") { + const localAddress = parts[1]; + const addressPort = localAddress.split(":").pop(); + if (addressPort === String(port)) { + const pid = Number.parseInt(parts[4], 10); + if (!Number.isNaN(pid) && pid > 0) { + if (!results.some((p) => p.pid === pid)) { + results.push({ pid }); + } + } + } + } + } + return results; + } catch (err: unknown) { + throw new Error(`netstat failed: ${String(err)}`, { cause: err }); + } + } + try { const lsof = resolveLsofCommandSync(); const out = execFileSync(lsof, ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFc"], { diff --git a/src/cli/program.force.test.ts b/src/cli/program.force.test.ts index ac0f02904..bca24ba62 100644 --- a/src/cli/program.force.test.ts +++ b/src/cli/program.force.test.ts @@ -25,15 +25,20 @@ import { describe("gateway --force helpers", () => { let originalKill: typeof process.kill; + let originalPlatform: NodeJS.Platform; beforeEach(() => { vi.clearAllMocks(); originalKill = process.kill.bind(process); + originalPlatform = process.platform; tryListenOnPortMock.mockReset(); + // Pin to linux so all lsof tests are platform-invariant. + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); }); afterEach(() => { process.kill = originalKill; + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); }); it("parses lsof output into pid/command pairs", () => { @@ -226,3 +231,68 @@ describe("gateway --force helpers", () => { ); }); }); + +describe("gateway --force helpers (Windows netstat path)", () => { + let originalKill: typeof process.kill; + let originalPlatform: NodeJS.Platform; + + beforeEach(() => { + vi.clearAllMocks(); + originalKill = process.kill.bind(process); + originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + }); + + afterEach(() => { + process.kill = originalKill; + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + }); + + const makeNetstatOutput = (port: number, ...pids: number[]) => + [ + "Proto Local Address Foreign Address State PID", + ...pids.map( + (pid) => ` TCP 0.0.0.0:${port} 0.0.0.0:0 LISTENING ${pid}`, + ), + ].join("\r\n"); + + it("returns empty list when netstat finds no listeners on the port", () => { + (execFileSync as unknown as Mock).mockReturnValue(makeNetstatOutput(9999, 42)); + expect(listPortListeners(18789)).toEqual([]); + }); + + it("parses PIDs from netstat output correctly", () => { + (execFileSync as unknown as Mock).mockReturnValue(makeNetstatOutput(18789, 42, 99)); + expect(listPortListeners(18789)).toEqual([{ pid: 42 }, { pid: 99 }]); + }); + + it("does not incorrectly match a port that is a substring (e.g. 80 vs 8080)", () => { + (execFileSync as unknown as Mock).mockReturnValue(makeNetstatOutput(8080, 42)); + expect(listPortListeners(80)).toEqual([]); + }); + + it("deduplicates PIDs that appear multiple times", () => { + (execFileSync as unknown as Mock).mockReturnValue(makeNetstatOutput(18789, 42, 42)); + expect(listPortListeners(18789)).toEqual([{ pid: 42 }]); + }); + + it("throws a descriptive error when netstat fails", () => { + (execFileSync as unknown as Mock).mockImplementation(() => { + throw new Error("access denied"); + }); + expect(() => listPortListeners(18789)).toThrow(/netstat failed/); + }); + + it("kills Windows listeners and returns metadata", () => { + (execFileSync as unknown as Mock).mockReturnValue(makeNetstatOutput(18789, 42, 99)); + const killMock = vi.fn(); + process.kill = killMock; + + const killed = forceFreePort(18789); + + expect(killMock).toHaveBeenCalledTimes(2); + expect(killMock).toHaveBeenCalledWith(42, "SIGTERM"); + expect(killMock).toHaveBeenCalledWith(99, "SIGTERM"); + expect(killed).toEqual([{ pid: 42 }, { pid: 99 }]); + }); +});