diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 8a8a2009a..c14f62f72 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -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 diff --git a/packages/stats/src/port-conflict.ts b/packages/stats/src/port-conflict.ts index 28ad5cebf..c49045d64 100644 --- a/packages/stats/src/port-conflict.ts +++ b/packages/stats/src/port-conflict.ts @@ -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 = { 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 { } 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 { 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 { @@ -155,13 +164,22 @@ async function findWindowsPortHolder(port: number): Promise { } 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 { @@ -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); diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index 21ef852e9..e08f69ceb 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -5,7 +5,7 @@ import { startServer } from "../src/server"; const holderProcesses: Array> = []; -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 {