130 lines
3.9 KiB
TypeScript
130 lines
3.9 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
|
|
export type PortProcess = { pid: number; command?: string };
|
|
|
|
export type ForceFreePortResult = {
|
|
killed: PortProcess[];
|
|
waitedMs: number;
|
|
escalatedToSigkill: boolean;
|
|
};
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export function parseLsofOutput(output: string): PortProcess[] {
|
|
const lines = output.split(/\r?\n/).filter(Boolean);
|
|
const results: PortProcess[] = [];
|
|
let current: Partial<PortProcess> = {};
|
|
for (const line of lines) {
|
|
if (line.startsWith("p")) {
|
|
if (current.pid) results.push(current as PortProcess);
|
|
current = { pid: Number.parseInt(line.slice(1), 10) };
|
|
} else if (line.startsWith("c")) {
|
|
current.command = line.slice(1);
|
|
}
|
|
}
|
|
if (current.pid) results.push(current as PortProcess);
|
|
return results;
|
|
}
|
|
|
|
export function listPortListeners(port: number): PortProcess[] {
|
|
try {
|
|
const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFc"], {
|
|
encoding: "utf-8",
|
|
});
|
|
return parseLsofOutput(out);
|
|
} catch (err: unknown) {
|
|
const status = (err as { status?: number }).status;
|
|
const code = (err as { code?: string }).code;
|
|
if (code === "ENOENT") {
|
|
throw new Error("lsof not found; required for --force");
|
|
}
|
|
if (status === 1) return []; // no listeners
|
|
throw err instanceof Error ? err : new Error(String(err));
|
|
}
|
|
}
|
|
|
|
export function forceFreePort(port: number): PortProcess[] {
|
|
const listeners = listPortListeners(port);
|
|
for (const proc of listeners) {
|
|
try {
|
|
process.kill(proc.pid, "SIGTERM");
|
|
} catch (err) {
|
|
throw new Error(
|
|
`failed to kill pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
return listeners;
|
|
}
|
|
|
|
function killPids(listeners: PortProcess[], signal: NodeJS.Signals) {
|
|
for (const proc of listeners) {
|
|
try {
|
|
process.kill(proc.pid, signal);
|
|
} catch (err) {
|
|
throw new Error(
|
|
`failed to kill pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function forceFreePortAndWait(
|
|
port: number,
|
|
opts: {
|
|
/** Total wait budget across signals. */
|
|
timeoutMs?: number;
|
|
/** Poll interval for checking whether lsof reports listeners. */
|
|
intervalMs?: number;
|
|
/** How long to wait after SIGTERM before escalating to SIGKILL. */
|
|
sigtermTimeoutMs?: number;
|
|
} = {},
|
|
): Promise<ForceFreePortResult> {
|
|
const timeoutMs = Math.max(opts.timeoutMs ?? 1500, 0);
|
|
const intervalMs = Math.max(opts.intervalMs ?? 100, 1);
|
|
const sigtermTimeoutMs = Math.min(Math.max(opts.sigtermTimeoutMs ?? 600, 0), timeoutMs);
|
|
|
|
const killed = forceFreePort(port);
|
|
if (killed.length === 0) {
|
|
return { killed, waitedMs: 0, escalatedToSigkill: false };
|
|
}
|
|
|
|
let waitedMs = 0;
|
|
const triesSigterm = intervalMs > 0 ? Math.ceil(sigtermTimeoutMs / intervalMs) : 0;
|
|
for (let i = 0; i < triesSigterm; i++) {
|
|
if (listPortListeners(port).length === 0) {
|
|
return { killed, waitedMs, escalatedToSigkill: false };
|
|
}
|
|
await sleep(intervalMs);
|
|
waitedMs += intervalMs;
|
|
}
|
|
|
|
if (listPortListeners(port).length === 0) {
|
|
return { killed, waitedMs, escalatedToSigkill: false };
|
|
}
|
|
|
|
const remaining = listPortListeners(port);
|
|
killPids(remaining, "SIGKILL");
|
|
|
|
const remainingBudget = Math.max(timeoutMs - waitedMs, 0);
|
|
const triesSigkill = intervalMs > 0 ? Math.ceil(remainingBudget / intervalMs) : 0;
|
|
for (let i = 0; i < triesSigkill; i++) {
|
|
if (listPortListeners(port).length === 0) {
|
|
return { killed, waitedMs, escalatedToSigkill: true };
|
|
}
|
|
await sleep(intervalMs);
|
|
waitedMs += intervalMs;
|
|
}
|
|
|
|
const still = listPortListeners(port);
|
|
if (still.length === 0) {
|
|
return { killed, waitedMs, escalatedToSigkill: true };
|
|
}
|
|
|
|
throw new Error(
|
|
`port ${port} still has listeners after --force: ${still.map((p) => p.pid).join(", ")}`,
|
|
);
|
|
}
|