@oh-my-pi/omp-stats 16.2.4 → 16.2.6

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
+ ## [16.2.6] - 2026-06-29
6
+
7
+ ### Fixed
8
+
9
+ - Fixed application crashes and Bun aborts on macOS and when parsing large stats session files, including during `omp --smoke-test` runs, by utilizing a more resilient serial parser and lenient line scanner.
10
+
5
11
  ## [16.2.3] - 2026-06-28
6
12
 
7
13
  ### Added
@@ -29,8 +29,13 @@ export interface SyncOptions {
29
29
  * spawn path on a fresh install (no session files = early return), so a
30
30
  * dedicated probe is the only reliable signal.
31
31
  *
32
- * Resolves with the worker's `import.meta.url` (caller-visible diagnostics);
33
- * rejects on transport error, error response, or timeout.
32
+ * No-op on darwin: `syncAllSessions` keeps macOS on the serial parser path
33
+ * (see {@link defaultWorkerCount}) so the worker spawn surface is unreachable
34
+ * from the CLI, and probing it under the hardened runtime in
35
+ * `scripts/ci-macos-sign.sh` would re-enter the Bun-worker abort surface that
36
+ * motivated the darwin serial default in the first place.
37
+ *
38
+ * Rejects on transport error, error response, or timeout.
34
39
  */
35
40
  export declare function smokeTestSyncWorker({ timeoutMs }?: {
36
41
  timeoutMs?: number;
@@ -38,11 +43,11 @@ export declare function smokeTestSyncWorker({ timeoutMs }?: {
38
43
  /**
39
44
  * Sync all session files to the database.
40
45
  *
41
- * Parsing fans out across a worker pool (one in-flight job per worker)
42
- * while DB writes and offset bookkeeping stay on the calling thread so the
43
- * single SQLite handle stays uncontended. `onProgress` fires once per
44
- * completed file (skipped files included so the bar walks at a steady
45
- * rate).
46
+ * `workers: 1` parses inline. Larger pools fan parsing out across workers
47
+ * (one in-flight job per worker) while DB writes and offset bookkeeping stay on
48
+ * the calling thread so the single SQLite handle stays uncontended.
49
+ * `onProgress` fires once per completed file (skipped files included so the
50
+ * bar walks at a steady rate).
46
51
  */
47
52
  export declare function syncAllSessions(opts?: SyncOptions): Promise<{
48
53
  processed: 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": "16.2.4",
4
+ "version": "16.2.6",
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": "16.2.4",
43
- "@oh-my-pi/pi-catalog": "16.2.4",
44
- "@oh-my-pi/pi-utils": "16.2.4",
42
+ "@oh-my-pi/pi-ai": "16.2.6",
43
+ "@oh-my-pi/pi-catalog": "16.2.6",
44
+ "@oh-my-pi/pi-utils": "16.2.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "chart.js": "^4.5.1",
47
47
  "date-fns": "^4.4.0",
package/src/aggregator.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  setFileOffset,
24
24
  updateUserMessageLinks,
25
25
  } from "./db";
26
- import { getSessionEntry, listAllSessionFiles, type ParseSessionResult } from "./parser";
26
+ import { getSessionEntry, listAllSessionFiles, type ParseSessionResult, parseSessionFile } from "./parser";
27
27
  import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
28
28
  // Coding-agent binary/bundle workers route through the CLI entrypoint with a
29
29
  // hidden argv mode, so the compiled binary and npm bundle only need one
@@ -68,6 +68,9 @@ export interface SyncOptions {
68
68
  }
69
69
 
70
70
  function defaultWorkerCount(): number {
71
+ // Bun 1.3.x can abort the macOS process when stats sync workers re-enter
72
+ // the compiled `omp` binary. Keep macOS on the documented serial path.
73
+ if (process.platform === "darwin") return 1;
71
74
  // `navigator.hardwareConcurrency` is the portable answer in Bun; fall
72
75
  // back to a small fixed pool if it's somehow unavailable.
73
76
  const hw = typeof navigator !== "undefined" ? (navigator.hardwareConcurrency ?? 0) : 0;
@@ -149,10 +152,16 @@ function dispatch(handle: WorkerHandle, request: SyncWorkerRequest): Promise<Par
149
152
  * spawn path on a fresh install (no session files = early return), so a
150
153
  * dedicated probe is the only reliable signal.
151
154
  *
152
- * Resolves with the worker's `import.meta.url` (caller-visible diagnostics);
153
- * rejects on transport error, error response, or timeout.
155
+ * No-op on darwin: `syncAllSessions` keeps macOS on the serial parser path
156
+ * (see {@link defaultWorkerCount}) so the worker spawn surface is unreachable
157
+ * from the CLI, and probing it under the hardened runtime in
158
+ * `scripts/ci-macos-sign.sh` would re-enter the Bun-worker abort surface that
159
+ * motivated the darwin serial default in the first place.
160
+ *
161
+ * Rejects on transport error, error response, or timeout.
154
162
  */
155
163
  export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: number } = {}): Promise<void> {
164
+ if (process.platform === "darwin") return;
156
165
  const worker = createSyncWorker();
157
166
  const { promise, resolve, reject } = Promise.withResolvers<void>();
158
167
  const timer = setTimeout(() => reject(new Error(`sync worker did not pong within ${timeoutMs}ms`)), timeoutMs);
@@ -183,11 +192,11 @@ export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: n
183
192
  /**
184
193
  * Sync all session files to the database.
185
194
  *
186
- * Parsing fans out across a worker pool (one in-flight job per worker)
187
- * while DB writes and offset bookkeeping stay on the calling thread so the
188
- * single SQLite handle stays uncontended. `onProgress` fires once per
189
- * completed file (skipped files included so the bar walks at a steady
190
- * rate).
195
+ * `workers: 1` parses inline. Larger pools fan parsing out across workers
196
+ * (one in-flight job per worker) while DB writes and offset bookkeeping stay on
197
+ * the calling thread so the single SQLite handle stays uncontended.
198
+ * `onProgress` fires once per completed file (skipped files included so the
199
+ * bar walks at a steady rate).
191
200
  */
192
201
  export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
193
202
  await initDb();
@@ -200,10 +209,6 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
200
209
  let completed = 0;
201
210
  let cursor = 0;
202
211
 
203
- const poolSize = Math.max(1, Math.min(files.length, opts?.workers ?? defaultWorkerCount()));
204
- const handles: WorkerHandle[] = [];
205
- for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
206
-
207
212
  const report = (sessionFile: string) => {
208
213
  completed++;
209
214
  opts?.onProgress?.({
@@ -214,34 +219,53 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
214
219
  });
215
220
  };
216
221
 
222
+ const processFile = async (
223
+ sessionFile: string,
224
+ parse: (sessionFile: string, fromOffset: number) => Promise<ParseSessionResult>,
225
+ ): Promise<void> => {
226
+ let fileStats: fs.Stats;
227
+ try {
228
+ fileStats = await fs.promises.stat(sessionFile);
229
+ } catch {
230
+ report(sessionFile);
231
+ return;
232
+ }
233
+ const lastModified = fileStats.mtimeMs;
234
+ const stored = getFileOffset(sessionFile);
235
+ if (stored && stored.lastModified >= lastModified) {
236
+ report(sessionFile);
237
+ return;
238
+ }
239
+
240
+ const fromOffset = stored?.offset ?? 0;
241
+ const result = await parse(sessionFile, fromOffset);
242
+ const inserted = applyParseResult(sessionFile, lastModified, result);
243
+ if (inserted > 0) {
244
+ totalProcessed += inserted;
245
+ filesProcessed++;
246
+ }
247
+ report(sessionFile);
248
+ };
249
+
250
+ const requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
251
+ if (requestedWorkers === 1) {
252
+ for (const sessionFile of files) {
253
+ await processFile(sessionFile, parseSessionFile);
254
+ }
255
+ return { processed: totalProcessed, files: filesProcessed };
256
+ }
257
+
258
+ const poolSize = Math.min(files.length, requestedWorkers);
259
+
260
+ const handles: WorkerHandle[] = [];
261
+ for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
262
+
217
263
  async function drain(handle: WorkerHandle): Promise<void> {
218
264
  while (true) {
219
265
  const idx = cursor++;
220
266
  if (idx >= files.length) return;
221
267
  const sessionFile = files[idx];
222
-
223
- let fileStats: fs.Stats;
224
- try {
225
- fileStats = await fs.promises.stat(sessionFile);
226
- } catch {
227
- report(sessionFile);
228
- continue;
229
- }
230
- const lastModified = fileStats.mtimeMs;
231
- const stored = getFileOffset(sessionFile);
232
- if (stored && stored.lastModified >= lastModified) {
233
- report(sessionFile);
234
- continue;
235
- }
236
-
237
- const fromOffset = stored?.offset ?? 0;
238
- const result = await dispatch(handle, { sessionFile, fromOffset });
239
- const inserted = applyParseResult(sessionFile, lastModified, result);
240
- if (inserted > 0) {
241
- totalProcessed += inserted;
242
- filesProcessed++;
243
- }
244
- report(sessionFile);
268
+ await processFile(sessionFile, (file, fromOffset) => dispatch(handle, { sessionFile: file, fromOffset }));
245
269
  }
246
270
  }
247
271
 
package/src/parser.ts CHANGED
@@ -164,54 +164,53 @@ function extractStats(
164
164
  }
165
165
 
166
166
  const LF = 0x0a;
167
+ const CR = 0x0d;
168
+ const jsonLineDecoder = new TextDecoder();
167
169
 
168
- function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
169
- const entries: SessionEntry[] = [];
170
- let cursor = 0;
171
-
172
- while (cursor < bytes.length) {
173
- const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
174
- for (const value of values as SessionEntry[]) {
175
- entries.push(value);
176
- }
177
-
178
- if (error) {
179
- const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
180
- if (nextNewline === -1) break;
181
- cursor = nextNewline + 1;
182
- continue;
183
- }
184
-
185
- if (read <= cursor) break;
186
- cursor = read;
187
- if (done) break;
170
+ function parseJsonLine(bytes: Uint8Array, start: number, end: number): SessionEntry | null {
171
+ while (end > start && bytes[end - 1] === CR) end--;
172
+ if (end <= start) return null;
173
+ try {
174
+ return JSON.parse(jsonLineDecoder.decode(bytes.subarray(start, end))) as SessionEntry;
175
+ } catch {
176
+ return null;
188
177
  }
189
-
190
- return { entries, read: cursor };
191
178
  }
192
179
 
193
- function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
180
+ function visitSessionEntriesLenient(bytes: Uint8Array, visit: (entry: SessionEntry) => void): number {
194
181
  let cursor = 0;
195
- let currentServiceTier: ServiceTier | undefined;
182
+ let read = 0;
196
183
 
197
184
  while (cursor < bytes.length) {
198
- const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
199
- for (const value of values as SessionEntry[]) {
200
- if (isServiceTierChange(value)) currentServiceTier = value.serviceTier ?? undefined;
185
+ const newline = bytes.indexOf(LF, cursor);
186
+ const hasNewline = newline !== -1;
187
+ const lineEnd = hasNewline ? newline : bytes.length;
188
+ const entry = parseJsonLine(bytes, cursor, lineEnd);
189
+ if (entry) {
190
+ visit(entry);
191
+ read = hasNewline ? newline + 1 : lineEnd;
192
+ } else if (hasNewline) {
193
+ read = newline + 1;
194
+ } else {
195
+ break;
201
196
  }
197
+ cursor = hasNewline ? newline + 1 : lineEnd;
198
+ }
202
199
 
203
- if (error) {
204
- const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
205
- if (nextNewline === -1) break;
206
- cursor = nextNewline + 1;
207
- continue;
208
- }
200
+ return read;
201
+ }
209
202
 
210
- if (read <= cursor) break;
211
- cursor = read;
212
- if (done) break;
213
- }
203
+ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
204
+ const entries: SessionEntry[] = [];
205
+ const read = visitSessionEntriesLenient(bytes, entry => entries.push(entry));
206
+ return { entries, read };
207
+ }
214
208
 
209
+ function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
210
+ let currentServiceTier: ServiceTier | undefined;
211
+ visitSessionEntriesLenient(bytes, entry => {
212
+ if (isServiceTierChange(entry)) currentServiceTier = entry.serviceTier ?? undefined;
213
+ });
215
214
  return currentServiceTier;
216
215
  }
217
216
  /**