@oh-my-pi/omp-stats 17.0.4 → 17.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.5] - 2026-07-18
6
+
7
+ ### Fixed
8
+
9
+ - Fixed an EADDRINUSE error by properly reusing the live stats dashboard on the requested port and reclaiming stale listeners (#5970).
10
+
5
11
  ## [17.0.2] - 2026-07-17
6
12
 
7
13
  ### Fixed
@@ -0,0 +1,4 @@
1
+ /** Header stamped on every dashboard response so reuse probes can identify us. */
2
+ export declare const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
3
+ /** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */
4
+ export declare function recoverStatsPort(port: number): Promise<"retry" | "reuse">;
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export declare function handleApi(req: Request): Promise<Response>;
5
5
  /**
6
- * Start the HTTP server.
6
+ * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
7
7
  */
8
8
  export declare function startServer(port?: number): Promise<{
9
9
  port: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "17.0.4",
4
+ "version": "17.0.5",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,9 +39,9 @@
39
39
  "fmt": "biome format --write ."
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "17.0.4",
43
- "@oh-my-pi/pi-catalog": "17.0.4",
44
- "@oh-my-pi/pi-utils": "17.0.4",
42
+ "@oh-my-pi/pi-ai": "17.0.5",
43
+ "@oh-my-pi/pi-catalog": "17.0.5",
44
+ "@oh-my-pi/pi-utils": "17.0.5",
45
45
  "@tailwindcss/node": "^4.3.2",
46
46
  "chart.js": "^4.5.1",
47
47
  "date-fns": "^4.4.0",
@@ -0,0 +1,250 @@
1
+ import type { Dirent } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { $which } from "@oh-my-pi/pi-utils";
5
+ import { $ } from "bun";
6
+
7
+ const STATS_PROBE_TIMEOUT_MS = 500;
8
+ const PROCESS_EXIT_POLL_MS = 50;
9
+ const PROCESS_EXIT_POLLS = 10;
10
+ const STATS_RUNTIME_IMAGES: Record<string, true> = { bun: true, node: true, omp: true, "omp-stats": true };
11
+
12
+ interface PortHolder {
13
+ pid: number;
14
+ image: string;
15
+ commandLine: string;
16
+ }
17
+
18
+ /** Header stamped on every dashboard response so reuse probes can identify us. */
19
+ export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
20
+
21
+ async function probeStatsDashboard(port: number): Promise<boolean> {
22
+ try {
23
+ const response = await fetch(`http://localhost:${port}/api/stats/models`, {
24
+ signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS),
25
+ });
26
+ if (response.status !== 200) {
27
+ await response.body?.cancel();
28
+ return false;
29
+ }
30
+ // A live omp-stats dashboard stamps this header on every response.
31
+ if (response.headers.get(STATS_DASHBOARD_HEADER)) {
32
+ await response.body?.cancel();
33
+ return true;
34
+ }
35
+ // Older dashboards predate the header; fall back to the response shape
36
+ // (`/api/stats/models` returns a JSON array) so we never reuse — or later
37
+ // kill — a foreign 200 responder such as an SPA dev server catch-all.
38
+ if (!(response.headers.get("content-type") ?? "").includes("application/json")) {
39
+ await response.body?.cancel();
40
+ return false;
41
+ }
42
+ return Array.isArray(await response.json());
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ async function findLinuxPortHolder(port: number): Promise<PortHolder | null> {
49
+ const socketInodes = new Set<string>();
50
+ for (const tablePath of ["/proc/net/tcp", "/proc/net/tcp6"]) {
51
+ let table: string;
52
+ try {
53
+ table = await Bun.file(tablePath).text();
54
+ } catch {
55
+ continue;
56
+ }
57
+
58
+ for (const line of table.split("\n").slice(1)) {
59
+ const fields = line.trim().split(/\s+/);
60
+ const localAddress = fields[1];
61
+ const state = fields[3];
62
+ const inode = fields[9];
63
+ if (!localAddress || state !== "0A" || !inode) continue;
64
+ const encodedPort = localAddress.slice(localAddress.lastIndexOf(":") + 1);
65
+ if (Number.parseInt(encodedPort, 16) === port) socketInodes.add(inode);
66
+ }
67
+ }
68
+ if (socketInodes.size === 0) return null;
69
+
70
+ let processes: Dirent[];
71
+ try {
72
+ processes = await fs.readdir("/proc", { withFileTypes: true });
73
+ } catch {
74
+ return null;
75
+ }
76
+
77
+ for (const entry of processes) {
78
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
79
+ const pid = Number.parseInt(entry.name, 10);
80
+ let descriptors: string[];
81
+ try {
82
+ descriptors = await fs.readdir(`/proc/${pid}/fd`);
83
+ } catch {
84
+ continue;
85
+ }
86
+
87
+ let ownsSocket = false;
88
+ for (const descriptor of descriptors) {
89
+ try {
90
+ const target = await fs.readlink(`/proc/${pid}/fd/${descriptor}`);
91
+ const match = /^socket:\[(\d+)]$/.exec(target);
92
+ if (match?.[1] && socketInodes.has(match[1])) {
93
+ ownsSocket = true;
94
+ break;
95
+ }
96
+ } catch {}
97
+ }
98
+ if (!ownsSocket) continue;
99
+
100
+ let commandLine = "";
101
+ try {
102
+ const rawCommandLine = await Bun.file(`/proc/${pid}/cmdline`).text();
103
+ commandLine = rawCommandLine.split("\0").filter(Boolean).join(" ");
104
+ } catch {}
105
+
106
+ try {
107
+ const executable = await fs.readlink(`/proc/${pid}/exe`);
108
+ return { pid, image: path.basename(executable), commandLine };
109
+ } catch {
110
+ const executable = commandLine.split(" ", 1)[0];
111
+ return { pid, image: executable ? path.basename(executable) : "unknown", commandLine };
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ async function findMacPortHolder(port: number): Promise<PortHolder | null> {
118
+ const lsof = $which("lsof") ?? ((await Bun.file("/usr/sbin/lsof").exists()) ? "/usr/sbin/lsof" : null);
119
+ if (!lsof) return null;
120
+
121
+ const selector = `-iTCP:${port}`;
122
+ const result = await $`${lsof} -nP ${selector} -sTCP:LISTEN -Fpc`.quiet().nothrow();
123
+ if (result.exitCode !== 0) return null;
124
+
125
+ let pid: number | null = null;
126
+ let image = "unknown";
127
+ for (const line of result.text().split("\n")) {
128
+ if (line.startsWith("p")) {
129
+ const parsed = Number.parseInt(line.slice(1), 10);
130
+ pid = Number.isSafeInteger(parsed) ? parsed : null;
131
+ } else if (line.startsWith("c") && pid !== null) {
132
+ image = line.slice(1) || "unknown";
133
+ break;
134
+ }
135
+ }
136
+ if (pid === null) return null;
137
+
138
+ const ps = $which("ps");
139
+ if (!ps) return { pid, image, commandLine: "" };
140
+ const processInfo = await $`${ps} -ww -p ${pid} -o command=`.quiet().nothrow();
141
+ return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
142
+ }
143
+
144
+ async function findWindowsPortHolder(port: number): Promise<PortHolder | null> {
145
+ const netstat = $which("netstat");
146
+ if (!netstat) return null;
147
+
148
+ const result = await $`${netstat} -ano -p TCP`.quiet().nothrow();
149
+ if (result.exitCode !== 0) return null;
150
+
151
+ let pid: number | null = null;
152
+ for (const line of result.text().split("\n")) {
153
+ const fields = line.trim().split(/\s+/);
154
+ if (fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING") continue;
155
+ const localAddress = fields[1];
156
+ if (!localAddress || Number.parseInt(localAddress.slice(localAddress.lastIndexOf(":") + 1), 10) !== port) {
157
+ continue;
158
+ }
159
+ const parsed = Number.parseInt(fields[4] ?? "", 10);
160
+ if (Number.isSafeInteger(parsed)) {
161
+ pid = parsed;
162
+ break;
163
+ }
164
+ }
165
+ if (pid === null) return null;
166
+
167
+ let image = "unknown";
168
+ const tasklist = $which("tasklist");
169
+ if (tasklist) {
170
+ const filter = `PID eq ${pid}`;
171
+ const task = await $`${tasklist} /FI ${filter} /FO CSV /NH`.quiet().nothrow();
172
+ if (task.exitCode === 0) {
173
+ const imageMatch = /^"((?:[^"]|"")*)"/.exec(task.text().trim());
174
+ image = imageMatch?.[1]?.replaceAll('""', '"') || "unknown";
175
+ }
176
+ }
177
+
178
+ const powershell = $which("powershell") ?? $which("pwsh");
179
+ if (!powershell) return { pid, image, commandLine: "" };
180
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`;
181
+ const processInfo = await $`${powershell} -NoProfile -NonInteractive -Command ${command}`.quiet().nothrow();
182
+ return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
183
+ }
184
+
185
+ async function findPortHolder(port: number): Promise<PortHolder | null> {
186
+ if (process.platform === "linux") return findLinuxPortHolder(port);
187
+ if (process.platform === "darwin") return findMacPortHolder(port);
188
+ if (process.platform === "win32") return findWindowsPortHolder(port);
189
+ return null;
190
+ }
191
+
192
+ async function terminatePortHolder(holder: PortHolder): Promise<void> {
193
+ try {
194
+ process.kill(holder.pid, "SIGTERM");
195
+ } catch (error) {
196
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
197
+ throw new Error(`Failed to stop ${holder.image} (PID ${holder.pid})`, { cause: error });
198
+ }
199
+
200
+ for (let attempt = 0; attempt < PROCESS_EXIT_POLLS; attempt++) {
201
+ await Bun.sleep(PROCESS_EXIT_POLL_MS);
202
+ try {
203
+ process.kill(holder.pid, 0);
204
+ } catch (error) {
205
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
206
+ throw new Error(`Failed to inspect ${holder.image} (PID ${holder.pid})`, { cause: error });
207
+ }
208
+ }
209
+
210
+ try {
211
+ process.kill(holder.pid, "SIGKILL");
212
+ } catch (error) {
213
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
214
+ throw new Error(`Failed to kill ${holder.image} (PID ${holder.pid})`, { cause: error });
215
+ }
216
+ await Bun.sleep(PROCESS_EXIT_POLL_MS);
217
+ }
218
+
219
+ /** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */
220
+ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> {
221
+ if (await probeStatsDashboard(port)) return "reuse";
222
+
223
+ const holder = await findPortHolder(port);
224
+ if (!holder) {
225
+ throw new Error(`Port ${port} is in use, but the listening process could not be identified.`);
226
+ }
227
+ if (holder.pid === process.pid) {
228
+ throw new Error(`Port ${port} is held by the current process (${holder.image}, PID ${holder.pid}).`);
229
+ }
230
+
231
+ const normalizedImage = holder.image
232
+ .toLowerCase()
233
+ .replace(/\.exe$/, "")
234
+ .replace(/ \(deleted\)$/, "");
235
+ const normalizedCommand = holder.commandLine.toLowerCase().replaceAll("\\", "/");
236
+ const hasStatsIdentity =
237
+ normalizedImage === "omp-stats" ||
238
+ /(?:^|[/"'\s])omp-stats(?:\.exe)?(?:["'\s]|$)/.test(normalizedCommand) ||
239
+ /\/packages\/stats\/src\/index\.ts(?:["'\s]|$)/.test(normalizedCommand) ||
240
+ (normalizedImage === "omp" && /(?:^|\s)stats(?:\s|$)/.test(normalizedCommand)) ||
241
+ /(?:^|\/)omp(?:\.exe)?["'\s]+stats(?:["'\s]|$)/.test(normalizedCommand);
242
+ if (!STATS_RUNTIME_IMAGES[normalizedImage] || !hasStatsIdentity) {
243
+ throw new Error(
244
+ `Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as an omp stats dashboard; refusing to stop it.`,
245
+ );
246
+ }
247
+
248
+ await terminatePortHolder(holder);
249
+ return "retry";
250
+ }
package/src/server.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import { decodeEmbeddedClientArchive } from "./embedded-client";
21
21
  import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
22
22
  import { getGainDashboardStats } from "./gain-aggregator";
23
+ import { recoverStatsPort, STATS_DASHBOARD_HEADER } from "./port-conflict";
23
24
 
24
25
  const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
25
26
 
@@ -293,23 +294,20 @@ async function handleStatic(requestPath: string): Promise<Response> {
293
294
  return new Response("Not Found", { status: 404 });
294
295
  }
295
296
 
296
- /**
297
- * Start the HTTP server.
298
- */
299
- export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
300
- await ensureClientBuild();
301
-
297
+ function createDashboardServer(port: number) {
302
298
  const server = Bun.serve({
303
299
  port,
304
300
  async fetch(req) {
305
301
  const url = new URL(req.url);
306
302
  const path = url.pathname;
307
303
 
308
- // CORS headers for local development
309
- const corsHeaders = {
304
+ // CORS headers for local development; the identity header lets another
305
+ // omp session's reuse probe positively recognize this dashboard.
306
+ const corsHeaders: Record<string, string> = {
310
307
  "Access-Control-Allow-Origin": "*",
311
308
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
312
309
  "Access-Control-Allow-Headers": "Content-Type",
310
+ [STATS_DASHBOARD_HEADER]: "1",
313
311
  };
314
312
 
315
313
  if (req.method === "OPTIONS") {
@@ -327,8 +325,8 @@ export async function startServer(port = 3847): Promise<{ port: number; stop: ()
327
325
 
328
326
  // Add CORS headers to all responses
329
327
  const headers = new Headers(response.headers);
330
- for (const [key, value] of Object.entries(corsHeaders)) {
331
- headers.set(key, value);
328
+ for (const key in corsHeaders) {
329
+ headers.set(key, corsHeaders[key]);
332
330
  }
333
331
 
334
332
  return new Response(response.body, {
@@ -344,9 +342,39 @@ export async function startServer(port = 3847): Promise<{ port: number; stop: ()
344
342
  }
345
343
  },
346
344
  });
345
+ return server;
346
+ }
347
+
348
+ /**
349
+ * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
350
+ */
351
+ export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
352
+ await ensureClientBuild();
353
+
354
+ try {
355
+ const server = createDashboardServer(port);
356
+ return {
357
+ port: server.port ?? port,
358
+ stop: () => server.stop(),
359
+ };
360
+ } catch (error) {
361
+ if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
362
+
363
+ const recovery = await recoverStatsPort(port);
364
+ if (recovery === "reuse") {
365
+ return { port, stop: () => {} };
366
+ }
347
367
 
348
- return {
349
- port: server.port ?? port,
350
- stop: () => server.stop(),
351
- };
368
+ try {
369
+ const server = createDashboardServer(port);
370
+ return {
371
+ port: server.port ?? port,
372
+ stop: () => server.stop(),
373
+ };
374
+ } catch (retryError) {
375
+ throw new Error(`Failed to start stats dashboard on port ${port} after reclaiming it.`, {
376
+ cause: retryError,
377
+ });
378
+ }
379
+ }
352
380
  }