fix(cli): make gateway --force resilient to lsof EACCES

This commit is contained in:
Peter Steinberger
2026-02-26 23:02:47 +01:00
parent c03adfb41a
commit 39f7dbfe02
3 changed files with 266 additions and 16 deletions

View File

@@ -8,6 +8,12 @@ vi.mock("node:child_process", async () => {
};
});
const tryListenOnPortMock = vi.hoisted(() => vi.fn());
vi.mock("../infra/ports-probe.js", () => ({
tryListenOnPort: (...args: unknown[]) => tryListenOnPortMock(...args),
}));
import { execFileSync } from "node:child_process";
import {
forceFreePort,
@@ -23,6 +29,7 @@ describe("gateway --force helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
originalKill = process.kill.bind(process);
tryListenOnPortMock.mockReset();
});
afterEach(() => {
@@ -80,11 +87,13 @@ describe("gateway --force helpers", () => {
let call = 0;
(execFileSync as unknown as Mock).mockImplementation(() => {
call += 1;
// 1st call: initial listeners to kill; 2nd call: still listed; 3rd call: gone.
// 1st call: initial listeners to kill.
// 2nd/3rd calls: still listed.
// 4th call: gone.
if (call === 1) {
return ["p42", "cnode", ""].join("\n");
}
if (call === 2) {
if (call === 2 || call === 3) {
return ["p42", "cnode", ""].join("\n");
}
return "";
@@ -105,7 +114,7 @@ describe("gateway --force helpers", () => {
expect(killMock).toHaveBeenCalledWith(42, "SIGTERM");
expect(res.killed).toEqual<PortProcess[]>([{ pid: 42, command: "node" }]);
expect(res.escalatedToSigkill).toBe(false);
expect(res.waitedMs).toBeGreaterThan(0);
expect(res.waitedMs).toBe(100);
vi.useRealTimers();
});
@@ -116,7 +125,7 @@ describe("gateway --force helpers", () => {
(execFileSync as unknown as Mock).mockImplementation(() => {
call += 1;
// 1st call: initial kill list; then keep showing until after SIGKILL.
if (call <= 6) {
if (call <= 7) {
return ["p42", "cnode", ""].join("\n");
}
return "";
@@ -140,4 +149,80 @@ describe("gateway --force helpers", () => {
vi.useRealTimers();
});
it("falls back to fuser when lsof is permission denied", async () => {
(execFileSync as unknown as Mock).mockImplementation((cmd: string) => {
if (cmd.includes("lsof")) {
const err = new Error("spawnSync lsof EACCES") as NodeJS.ErrnoException;
err.code = "EACCES";
throw err;
}
return "18789/tcp: 4242\n";
});
tryListenOnPortMock.mockResolvedValue(undefined);
const result = await forceFreePortAndWait(18789, { timeoutMs: 500, intervalMs: 100 });
expect(result.escalatedToSigkill).toBe(false);
expect(result.killed).toEqual<PortProcess[]>([{ pid: 4242 }]);
expect(execFileSync).toHaveBeenCalledWith(
"fuser",
["-k", "-TERM", "18789/tcp"],
expect.objectContaining({ encoding: "utf-8" }),
);
});
it("uses fuser SIGKILL escalation when port stays busy", async () => {
vi.useFakeTimers();
(execFileSync as unknown as Mock).mockImplementation((cmd: string, args: string[]) => {
if (cmd.includes("lsof")) {
const err = new Error("spawnSync lsof EACCES") as NodeJS.ErrnoException;
err.code = "EACCES";
throw err;
}
if (args.includes("-TERM")) {
return "18789/tcp: 1337\n";
}
if (args.includes("-KILL")) {
return "18789/tcp: 1337\n";
}
return "";
});
const busyErr = Object.assign(new Error("in use"), { code: "EADDRINUSE" });
tryListenOnPortMock
.mockRejectedValueOnce(busyErr)
.mockRejectedValueOnce(busyErr)
.mockRejectedValueOnce(busyErr)
.mockResolvedValueOnce(undefined);
const promise = forceFreePortAndWait(18789, {
timeoutMs: 300,
intervalMs: 100,
sigtermTimeoutMs: 100,
});
await vi.runAllTimersAsync();
const result = await promise;
expect(result.escalatedToSigkill).toBe(true);
expect(result.waitedMs).toBe(100);
expect(execFileSync).toHaveBeenCalledWith(
"fuser",
["-k", "-KILL", "18789/tcp"],
expect.objectContaining({ encoding: "utf-8" }),
);
vi.useRealTimers();
});
it("throws when lsof is unavailable and fuser is missing", async () => {
(execFileSync as unknown as Mock).mockImplementation((cmd: string) => {
const err = new Error(`spawnSync ${cmd} ENOENT`) as NodeJS.ErrnoException;
err.code = "ENOENT";
throw err;
});
await expect(forceFreePortAndWait(18789, { timeoutMs: 200, intervalMs: 100 })).rejects.toThrow(
/fuser not found/i,
);
});
});