fix(stats): restricted port reclamation to stats processes

Recorded listener command lines across supported platforms and required an omp-stats-specific command identity before terminating Bun, Node, or omp holders. Unrelated runtimes now take the refusal path without receiving a signal.

Fixes #5970
This commit is contained in:
roboomp
2026-07-18 07:16:34 +00:00
parent 4010bef985
commit 4ef017232e
3 changed files with 66 additions and 38 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Fixed
- Reused a live stats dashboard on the requested port and reclaimed stale Bun, Node, or omp listeners instead of failing with `EADDRINUSE` ([#5970](https://github.com/can1357/oh-my-pi/issues/5970)).
- Reused a live stats dashboard on the requested port and reclaimed only confirmed stale omp stats listeners instead of failing with `EADDRINUSE` ([#5970](https://github.com/can1357/oh-my-pi/issues/5970)).
## [17.0.2] - 2026-07-17
+46 -19
View File
@@ -7,11 +7,12 @@ import { $ } from "bun";
const STATS_PROBE_TIMEOUT_MS = 500;
const PROCESS_EXIT_POLL_MS = 50;
const PROCESS_EXIT_POLLS = 10;
const RECLAIMABLE_IMAGES = new Set(["bun", "node", "omp"]);
const STATS_RUNTIME_IMAGES: Record<string, true> = { bun: true, node: true, omp: true, "omp-stats": true };
interface PortHolder {
pid: number;
image: string;
commandLine: string;
}
/** Header stamped on every dashboard response so reuse probes can identify us. */
@@ -96,17 +97,18 @@ async function findLinuxPortHolder(port: number): Promise<PortHolder | null> {
}
if (!ownsSocket) continue;
let commandLine = "";
try {
const rawCommandLine = await Bun.file(`/proc/${pid}/cmdline`).text();
commandLine = rawCommandLine.split("\0").filter(Boolean).join(" ");
} catch {}
try {
const executable = await fs.readlink(`/proc/${pid}/exe`);
return { pid, image: path.basename(executable) };
return { pid, image: path.basename(executable), commandLine };
} catch {
try {
const commandLine = await Bun.file(`/proc/${pid}/cmdline`).text();
const executable = commandLine.split("\0", 1)[0];
return { pid, image: executable ? path.basename(executable) : "unknown" };
} catch {
return { pid, image: "unknown" };
}
const executable = commandLine.split(" ", 1)[0];
return { pid, image: executable ? path.basename(executable) : "unknown", commandLine };
}
}
return null;
@@ -121,15 +123,22 @@ async function findMacPortHolder(port: number): Promise<PortHolder | null> {
if (result.exitCode !== 0) return null;
let pid: number | null = null;
let image = "unknown";
for (const line of result.text().split("\n")) {
if (line.startsWith("p")) {
const parsed = Number.parseInt(line.slice(1), 10);
pid = Number.isSafeInteger(parsed) ? parsed : null;
} else if (line.startsWith("c") && pid !== null) {
return { pid, image: line.slice(1) || "unknown" };
image = line.slice(1) || "unknown";
break;
}
}
return null;
if (pid === null) return null;
const ps = $which("ps");
if (!ps) return { pid, image, commandLine: "" };
const processInfo = await $`${ps} -ww -p ${pid} -o command=`.quiet().nothrow();
return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
}
async function findWindowsPortHolder(port: number): Promise<PortHolder | null> {
@@ -155,13 +164,22 @@ async function findWindowsPortHolder(port: number): Promise<PortHolder | null> {
}
if (pid === null) return null;
let image = "unknown";
const tasklist = $which("tasklist");
if (!tasklist) return { pid, image: "unknown" };
const filter = `PID eq ${pid}`;
const task = await $`${tasklist} /FI ${filter} /FO CSV /NH`.quiet().nothrow();
if (task.exitCode !== 0) return { pid, image: "unknown" };
const imageMatch = /^"((?:[^"]|"")*)"/.exec(task.text().trim());
return { pid, image: imageMatch?.[1]?.replaceAll('""', '"') || "unknown" };
if (tasklist) {
const filter = `PID eq ${pid}`;
const task = await $`${tasklist} /FI ${filter} /FO CSV /NH`.quiet().nothrow();
if (task.exitCode === 0) {
const imageMatch = /^"((?:[^"]|"")*)"/.exec(task.text().trim());
image = imageMatch?.[1]?.replaceAll('""', '"') || "unknown";
}
}
const powershell = $which("powershell") ?? $which("pwsh");
if (!powershell) return { pid, image, commandLine: "" };
const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`;
const processInfo = await $`${powershell} -NoProfile -NonInteractive -Command ${command}`.quiet().nothrow();
return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
}
async function findPortHolder(port: number): Promise<PortHolder | null> {
@@ -214,8 +232,17 @@ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse">
.toLowerCase()
.replace(/\.exe$/, "")
.replace(/ \(deleted\)$/, "");
if (!RECLAIMABLE_IMAGES.has(normalizedImage)) {
throw new Error(`Port ${port} is in use by ${holder.image} (PID ${holder.pid}); refusing to stop it.`);
const normalizedCommand = holder.commandLine.toLowerCase().replaceAll("\\", "/");
const hasStatsIdentity =
normalizedImage === "omp-stats" ||
/(?:^|[/"'\s])omp-stats(?:\.exe)?(?:["'\s]|$)/.test(normalizedCommand) ||
/\/packages\/stats\/src\/index\.ts(?:["'\s]|$)/.test(normalizedCommand) ||
(normalizedImage === "omp" && /(?:^|\s)stats(?:\s|$)/.test(normalizedCommand)) ||
/(?:^|\/)omp(?:\.exe)?["'\s]+stats(?:["'\s]|$)/.test(normalizedCommand);
if (!STATS_RUNTIME_IMAGES[normalizedImage] || !hasStatsIdentity) {
throw new Error(
`Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as an omp stats dashboard; refusing to stop it.`,
);
}
await terminatePortHolder(holder);
@@ -5,7 +5,7 @@ import { startServer } from "../src/server";
const holderProcesses: Array<Subprocess<"ignore", "pipe", "pipe">> = [];
async function startBunHolder(responseExpr: string) {
async function startBunHolder(responseExpr: string, options?: { statsOwned?: boolean }) {
const reservation = Bun.serve({
hostname: "127.0.0.1",
port: 0,
@@ -15,7 +15,9 @@ async function startBunHolder(responseExpr: string) {
reservation.stop(true);
const source = `Bun.serve({ hostname: "127.0.0.1", port: ${port}, fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`;
const child = Bun.spawn([process.execPath, "-e", source], {
const args = [process.execPath, "-e", source];
if (options?.statsOwned) args.push("omp-stats");
const child = Bun.spawn(args, {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
@@ -58,7 +60,7 @@ describe("startServer port conflicts", () => {
expect(server.port).toBe(existing.port);
server.stop();
// The foreign server is untouched: it still answers on the port.
// The existing dashboard is untouched: it still answers on the port.
const response = await fetch(`http://127.0.0.1:${existing.port}/api/stats/models`);
expect(response.status).toBe(200);
expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe("1");
@@ -68,25 +70,24 @@ describe("startServer port conflicts", () => {
}
});
it("does not reuse a foreign 200 responder and reclaims the port instead", async () => {
// An SPA dev server catch-all: 200 JSON, but no dashboard header and not
// the models array shape. Must not be treated as a reusable dashboard.
it("refuses to stop a foreign 200 responder", async () => {
const holder = await startBunHolder('Response.json({ app: "spa" })');
const server = await startServer(holder.port);
try {
expect(server.port).toBe(holder.port);
expect(await holder.child.exited).not.toBe(0);
const response = await fetch(`http://127.0.0.1:${holder.port}/api/stats/models`);
expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe("1");
await response.body?.cancel();
} finally {
server.stop();
}
await expect(startServer(holder.port)).rejects.toThrow("not identifiable as an omp stats dashboard");
expect(holder.child.exitCode).toBeNull();
const response = await fetch(`http://127.0.0.1:${holder.port}/api/stats/models`);
expect(await response.json()).toEqual({ app: "spa" });
});
it("reclaims an unresponsive Bun listener and starts the dashboard", async () => {
const holder = await startBunHolder('new Response("holder", { status: 404 })');
it("refuses to stop an unrelated Bun listener that fails the probe", async () => {
const holder = await startBunHolder('new Response("foreign", { status: 404 })');
await expect(startServer(holder.port)).rejects.toThrow("not identifiable as an omp stats dashboard");
expect(holder.child.exitCode).toBeNull();
});
it("reclaims an unresponsive confirmed stats listener", async () => {
const holder = await startBunHolder('new Response("holder", { status: 404 })', { statsOwned: true });
const server = await startServer(holder.port);
try {