mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-13 19:10:39 +00:00
fix(cli): make gateway --force resilient to lsof EACCES
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user