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((resolve) => setTimeout(resolve, ms)); } export function parseLsofOutput(output: string): PortProcess[] { const lines = output.split(/\r?\n/).filter(Boolean); const results: PortProcess[] = []; let current: Partial = {}; 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 { 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(", ")}`, ); }