@oh-my-pi/pi-utils 18.0.8 → 18.0.10

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,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.10] - 2026-08-28
6
+
7
+ ### Added
8
+
9
+ - Added `postmortem.drainStdout` to flush buffered standard output before process exit or exec-replacement.
10
+ - Added an `exitOnly` option to `postmortem.register` for resources that should remain available during keep-alive cleanup and be released only on actual process exit.
11
+ - Added `hexToOklch` and `oklchToHex` color conversion utilities with sRGB gamut mapping that reduces chroma when necessary.
12
+ - Added `checkpointWal` to checkpoint committed SQLite WAL frames without blocking concurrent readers.
13
+
14
+ ### Fixed
15
+
16
+ - Fixed repeatable `postmortem` cleanup behavior so persistent resources and callbacks registered during cleanup remain active until the eventual process exit.
17
+ - Fixed asynchronous `postmortem` cleanup so callbacks registered during a cleanup pass are awaited before cleanup completes, including during signal-driven exits.
18
+
19
+ ## [18.0.9] - 2026-08-28
20
+
21
+ ### Fixed
22
+
23
+ - Fixed error handling so unrelated aborted requests and closed-connection failures are no longer silently suppressed.
24
+
5
25
  ## [18.0.8] - 2026-08-27
6
26
 
7
27
  ### Added
@@ -17,4 +17,6 @@ export declare class AsyncDrain<T> {
17
17
  constructor(delayMs?: number);
18
18
  /** Queue `value`; `hnd` receives the whole batch when the window closes. */
19
19
  push(value: T, hnd: (values: T[]) => Promise<void> | void): Promise<void>;
20
+ /** Runs the pending batch handler immediately and returns its completion promise. */
21
+ flush(): Promise<void>;
20
22
  }
@@ -82,6 +82,40 @@ export declare function adjustHsv(hex: string, adj: HSVAdjustment): string;
82
82
  * Convert HSL (h: 0-360, s: 0-1, l: 0-1) to a CSS hex string.
83
83
  */
84
84
  export declare function hslToHex(h: number, s: number, l: number): string;
85
+ export interface OKLCH {
86
+ /** Perceptual lightness (0-1) */
87
+ l: number;
88
+ /** Chroma (0 = gray; sRGB peaks around 0.37) */
89
+ c: number;
90
+ /** Hue in degrees (0-360) */
91
+ h: number;
92
+ }
93
+ /**
94
+ * Convert a hex color to OKLCH (perceptual lightness/chroma/hue).
95
+ *
96
+ * Unlike HSL, equal `l`/`c` values look equally bright and colorful across
97
+ * hues, so carrying them between colors preserves the palette's "weight".
98
+ */
99
+ export declare function hexToOklch(hex: string): OKLCH;
100
+ /**
101
+ * The sRGB gamut cusp for an OKLCH hue: the lightness/chroma point where the
102
+ * hue reaches its maximum chroma inside sRGB.
103
+ *
104
+ * The cusp lightness varies wildly per hue (yellow ≈ 0.97, blue ≈ 0.45), so
105
+ * transferring absolute OKLCH lightness/chroma between hues distorts
106
+ * vividness; normalize against the cusp instead. See `getSessionAccentHex`.
107
+ */
108
+ export declare function oklchCusp(h: number): {
109
+ l: number;
110
+ c: number;
111
+ };
112
+ /**
113
+ * Convert OKLCH to a CSS hex string, gamut-mapping by chroma reduction.
114
+ *
115
+ * Out-of-gamut inputs keep their lightness and hue while chroma is bisected
116
+ * down until the color fits sRGB, matching CSS Color 4's recommended intent.
117
+ */
118
+ export declare function oklchToHex(oklch: OKLCH): string;
85
119
  /**
86
120
  * Perceptual luma (gamma-encoded BT.709 weights over raw sRGB), normalized to 0..1.
87
121
  *
@@ -107,10 +107,10 @@ export declare function registerStdioDisconnectHandling(): () => void;
107
107
  */
108
108
  export declare function markExpectedCleanupError<T extends object>(reason: T): T;
109
109
  /**
110
- * Whether `reason` (or any error in its `cause` chain) was marked via
111
- * {@link markExpectedCleanupError}. Walks the chain because the unhandled
112
- * reason is often a wrapper (`AbortError`) with the marked abort reason as
113
- * its `cause`.
110
+ * Whether `reason` (or any object in its bounded `cause` chain) was explicitly
111
+ * marked via {@link markExpectedCleanupError}. Runtime error names and codes
112
+ * are intentionally insufficient: unmarked `AbortError` and socket failures
113
+ * can originate from application code and must remain fatal when unhandled.
114
114
  */
115
115
  export declare function isExpectedCleanupError(reason: unknown): boolean;
116
116
  /**
@@ -123,15 +123,33 @@ export declare function interceptUnhandledRejections(interceptor: (reason: unkno
123
123
  * through an uncaught exception or unhandled rejection.
124
124
  */
125
125
  export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
126
+ /** Controls when a registered cleanup callback participates in cleanup passes. */
127
+ export interface CleanupRegistrationOptions {
128
+ /**
129
+ * Run only on a real exit, never during a manual keep-alive cleanup.
130
+ * The registration remains armed when a keep-alive pass skips it.
131
+ */
132
+ exitOnly?: boolean;
133
+ }
126
134
  /**
127
- * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
135
+ * Registers a cleanup callback for shutdown, signals, fatal errors, and
136
+ * repeatable manual cleanup passes.
137
+ *
138
+ * Registrations persist across keep-alive {@link cleanup} passes and run at
139
+ * most once per pass. Set `exitOnly` for resources the continuing process still
140
+ * holds (open databases, cached handles): keep-alive passes skip the callback
141
+ * without consuming its registration, while the eventual real exit runs it.
142
+ *
143
+ * A callback registered during a running keep-alive pass joins future passes;
144
+ * normal callbacks also run immediately for the current pass. Registrations
145
+ * made during a real exit run immediately.
128
146
  *
129
- * Returns a Callback instance that can be used to cancel (unregister) or manually clean up.
130
- * If register is called after cleanup already began, invokes callback on a microtask.
147
+ * Returns a function that permanently cancels the registration.
131
148
  */
132
- export declare function register(id: string, callback: (reason: Reason) => void | Promise<void>): () => void;
149
+ export declare function register(id: string, callback: (reason: Reason) => void | Promise<void>, options?: CleanupRegistrationOptions): () => void;
133
150
  /**
134
- * Runs all cleanup callbacks without exiting.
151
+ * Runs all cleanup callbacks without exiting, then re-arms the system so
152
+ * resources opened afterwards are still cleaned at the eventual real exit.
135
153
  * Use this in workers or when you need to clean up but continue execution.
136
154
  */
137
155
  export declare function cleanup(): Promise<void>;
@@ -140,6 +158,12 @@ export interface QuitOptions {
140
158
  /** Wait for buffered stdout before exiting; disable after the terminal has disconnected. */
141
159
  drainStdout?: boolean;
142
160
  }
161
+ /**
162
+ * Waits (bounded) for buffered stdout to reach the terminal. Used before
163
+ * process exit and before an exec-replace, where unflushed output would be
164
+ * lost with the process image.
165
+ */
166
+ export declare function drainStdout(): Promise<void>;
143
167
  /**
144
168
  * Runs all cleanup callbacks and exits through the current `process.exit`.
145
169
  *
@@ -7,6 +7,9 @@
7
7
  * one implementation here prevents the classifiers from drifting between the
8
8
  * credential store and the model cache.
9
9
  */
10
+ import type { Database } from "bun:sqlite";
11
+ /** Checkpoints committed WAL frames without waiting for concurrent readers. */
12
+ export declare function checkpointWal(db: Database): void;
10
13
  /**
11
14
  * SQLite's busy result-code family — base `SQLITE_BUSY` plus the extended
12
15
  * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "18.0.8",
4
+ "version": "18.0.10",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "18.0.8"
34
+ "@oh-my-pi/pi-natives": "18.0.10"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
package/src/async.ts CHANGED
@@ -59,6 +59,7 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, message: string,
59
59
  export class AsyncDrain<T> {
60
60
  #queue?: T[];
61
61
  #promise = Promise.resolve();
62
+ #flush?: () => void;
62
63
 
63
64
  constructor(readonly delayMs: number = 0) {}
64
65
 
@@ -66,21 +67,28 @@ export class AsyncDrain<T> {
66
67
  push(value: T, hnd: (values: T[]) => Promise<void> | void): Promise<void> {
67
68
  let queue = this.#queue;
68
69
  if (!queue) {
69
- this.#queue = queue = [];
70
+ const batch: T[] = [];
71
+ this.#queue = batch;
72
+ queue = batch;
70
73
  const { promise, resolve, reject } = Promise.withResolvers<void>();
71
74
  const exec = (): void => {
75
+ if (this.#queue !== batch) return;
76
+ this.#queue = undefined;
77
+ this.#flush = undefined;
72
78
  try {
73
- if (this.#queue === queue) {
74
- this.#queue = undefined;
75
- }
76
- resolve(hnd(queue!));
79
+ resolve(hnd(batch));
77
80
  } catch (error) {
78
81
  reject(error);
79
82
  }
80
83
  };
81
84
  if (this.delayMs > 0) {
82
- setTimeout(exec, this.delayMs);
85
+ const timer = setTimeout(exec, this.delayMs);
86
+ this.#flush = () => {
87
+ clearTimeout(timer);
88
+ exec();
89
+ };
83
90
  } else {
91
+ this.#flush = exec;
84
92
  queueMicrotask(exec);
85
93
  }
86
94
  this.#promise = promise;
@@ -88,4 +96,10 @@ export class AsyncDrain<T> {
88
96
  queue.push(value);
89
97
  return this.#promise;
90
98
  }
99
+
100
+ /** Runs the pending batch handler immediately and returns its completion promise. */
101
+ flush(): Promise<void> {
102
+ this.#flush?.();
103
+ return this.#promise;
104
+ }
91
105
  }
package/src/color.ts CHANGED
@@ -272,6 +272,179 @@ function linearizeChannel(channel: number): number {
272
272
  const c = channel / 255;
273
273
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
274
274
  }
275
+ /** Gamma-encode a linear 0..1 channel back to 0..255 sRGB. */
276
+ function delinearizeChannel(linear: number): number {
277
+ const c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055;
278
+ return c * 255;
279
+ }
280
+
281
+ export interface OKLCH {
282
+ /** Perceptual lightness (0-1) */
283
+ l: number;
284
+ /** Chroma (0 = gray; sRGB peaks around 0.37) */
285
+ c: number;
286
+ /** Hue in degrees (0-360) */
287
+ h: number;
288
+ }
289
+
290
+ /** Convert linear sRGB (0..1 channels) to OKLab (Björn Ottosson's reference matrices). */
291
+ function linearRgbToOklab(r: number, g: number, b: number): { L: number; a: number; b: number } {
292
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
293
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
294
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
295
+ return {
296
+ L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
297
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
298
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
299
+ };
300
+ }
301
+
302
+ /** Convert OKLab back to linear sRGB; channels may fall outside 0..1 when out of gamut. */
303
+ function oklabToLinearRgb(L: number, a: number, b: number): { r: number; g: number; b: number } {
304
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
305
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
306
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
307
+ return {
308
+ r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
309
+ g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
310
+ b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Convert a hex color to OKLCH (perceptual lightness/chroma/hue).
316
+ *
317
+ * Unlike HSL, equal `l`/`c` values look equally bright and colorful across
318
+ * hues, so carrying them between colors preserves the palette's "weight".
319
+ */
320
+ export function hexToOklch(hex: string): OKLCH {
321
+ const rgb = hexToRgb(hex);
322
+ const lab = linearRgbToOklab(linearizeChannel(rgb.r), linearizeChannel(rgb.g), linearizeChannel(rgb.b));
323
+ const c = Math.hypot(lab.a, lab.b);
324
+ let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
325
+ if (h < 0) h += 360;
326
+ return { l: lab.L, c, h };
327
+ }
328
+
329
+ /**
330
+ * Max OKLab saturation (C/L) that stays inside sRGB for the hue direction
331
+ * `(a, b)`, via Björn Ottosson's polynomial fit plus one Halley refinement.
332
+ */
333
+ function computeMaxSaturation(a: number, b: number): number {
334
+ // Select the channel that clips first for this hue direction.
335
+ let k0: number, k1: number, k2: number, k3: number, k4: number;
336
+ let wl: number, wm: number, ws: number;
337
+ if (-1.88170328 * a - 0.80936493 * b > 1) {
338
+ // red channel
339
+ k0 = 1.19086277;
340
+ k1 = 1.76576728;
341
+ k2 = 0.59662641;
342
+ k3 = 0.75515197;
343
+ k4 = 0.56771245;
344
+ wl = 4.0767416621;
345
+ wm = -3.3077115913;
346
+ ws = 0.2309699292;
347
+ } else if (1.81444104 * a - 1.19445276 * b > 1) {
348
+ // green channel
349
+ k0 = 0.73956515;
350
+ k1 = -0.45954404;
351
+ k2 = 0.08285427;
352
+ k3 = 0.1254107;
353
+ k4 = 0.14503204;
354
+ wl = -1.2684380046;
355
+ wm = 2.6097574011;
356
+ ws = -0.3413193965;
357
+ } else {
358
+ // blue channel
359
+ k0 = 1.35733652;
360
+ k1 = -0.00915799;
361
+ k2 = -1.1513021;
362
+ k3 = -0.50559606;
363
+ k4 = 0.00692167;
364
+ wl = -0.0041960863;
365
+ wm = -0.7034186147;
366
+ ws = 1.707614701;
367
+ }
368
+ const S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
369
+
370
+ // One Halley step against f = (channel at S) - 0: polishes the fit to
371
+ // well below hex quantization error.
372
+ const kl = 0.3963377774 * a + 0.2158037573 * b;
373
+ const km = -0.1055613458 * a - 0.0638541728 * b;
374
+ const ks = -0.0894841775 * a - 1.291485548 * b;
375
+ const l_ = 1 + S * kl;
376
+ const m_ = 1 + S * km;
377
+ const s_ = 1 + S * ks;
378
+ const f = wl * l_ ** 3 + wm * m_ ** 3 + ws * s_ ** 3;
379
+ const f1 = wl * 3 * kl * l_ * l_ + wm * 3 * km * m_ * m_ + ws * 3 * ks * s_ * s_;
380
+ const f2 = wl * 6 * kl * kl * l_ + wm * 6 * km * km * m_ + ws * 6 * ks * ks * s_;
381
+ return S - (f * f1) / (f1 * f1 - 0.5 * f * f2);
382
+ }
383
+
384
+ /**
385
+ * The sRGB gamut cusp for an OKLCH hue: the lightness/chroma point where the
386
+ * hue reaches its maximum chroma inside sRGB.
387
+ *
388
+ * The cusp lightness varies wildly per hue (yellow ≈ 0.97, blue ≈ 0.45), so
389
+ * transferring absolute OKLCH lightness/chroma between hues distorts
390
+ * vividness; normalize against the cusp instead. See `getSessionAccentHex`.
391
+ */
392
+ export function oklchCusp(h: number): { l: number; c: number } {
393
+ const hRad = (h * Math.PI) / 180;
394
+ const a = Math.cos(hRad);
395
+ const b = Math.sin(hRad);
396
+ const sCusp = computeMaxSaturation(a, b);
397
+ const rgb = oklabToLinearRgb(1, sCusp * a, sCusp * b);
398
+ const lCusp = Math.cbrt(1 / Math.max(rgb.r, rgb.g, rgb.b));
399
+ return { l: lCusp, c: lCusp * sCusp };
400
+ }
401
+
402
+ /** Slack allowed on linear channels before a color counts as out of sRGB gamut. */
403
+ const GAMUT_EPSILON = 1e-4;
404
+
405
+ /** True when all linear channels sit inside sRGB (within {@link GAMUT_EPSILON}). */
406
+ function inSrgbGamut(rgb: { r: number; g: number; b: number }): boolean {
407
+ return (
408
+ rgb.r >= -GAMUT_EPSILON &&
409
+ rgb.r <= 1 + GAMUT_EPSILON &&
410
+ rgb.g >= -GAMUT_EPSILON &&
411
+ rgb.g <= 1 + GAMUT_EPSILON &&
412
+ rgb.b >= -GAMUT_EPSILON &&
413
+ rgb.b <= 1 + GAMUT_EPSILON
414
+ );
415
+ }
416
+
417
+ /**
418
+ * Convert OKLCH to a CSS hex string, gamut-mapping by chroma reduction.
419
+ *
420
+ * Out-of-gamut inputs keep their lightness and hue while chroma is bisected
421
+ * down until the color fits sRGB, matching CSS Color 4's recommended intent.
422
+ */
423
+ export function oklchToHex(oklch: OKLCH): string {
424
+ const l = Math.max(0, Math.min(1, oklch.l));
425
+ const hRad = (oklch.h * Math.PI) / 180;
426
+ const cos = Math.cos(hRad);
427
+ const sin = Math.sin(hRad);
428
+ const at = (c: number) => oklabToLinearRgb(l, c * cos, c * sin);
429
+
430
+ let rgb = at(oklch.c);
431
+ if (!inSrgbGamut(rgb)) {
432
+ // `lo` always fits (chroma 0 is the gray axis), `hi` never does.
433
+ let lo = 0;
434
+ let hi = oklch.c;
435
+ for (let i = 0; i < 20; i++) {
436
+ const mid = (lo + hi) / 2;
437
+ if (inSrgbGamut(at(mid))) lo = mid;
438
+ else hi = mid;
439
+ }
440
+ rgb = at(lo);
441
+ }
442
+ return rgbToHex({
443
+ r: Math.max(0, Math.min(255, delinearizeChannel(rgb.r))),
444
+ g: Math.max(0, Math.min(255, delinearizeChannel(rgb.g))),
445
+ b: Math.max(0, Math.min(255, delinearizeChannel(rgb.b))),
446
+ });
447
+ }
275
448
 
276
449
  /**
277
450
  * Perceptual luma (gamma-encoded BT.709 weights over raw sRGB), normalized to 0..1.
package/src/postmortem.ts CHANGED
@@ -24,10 +24,25 @@ export enum Reason {
24
24
  MANUAL = "manual", // Manual cleanup (not triggered by process)
25
25
  }
26
26
 
27
- // Internal list of active cleanup callbacks (in registration order)
28
- const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
29
- // Tracks cleanup run state (to prevent recursion/reentry issues)
27
+ interface CleanupRegistration {
28
+ id: string;
29
+ callback: (reason: Reason) => Promise<void> | void;
30
+ exitOnly: boolean;
31
+ cancelled: boolean;
32
+ lastPass: number;
33
+ }
34
+
35
+ // Active cleanup callbacks in registration order. Registrations survive
36
+ // keep-alive passes; `lastPass` enforces at-most-once invocation per pass.
37
+ const callbackList: CleanupRegistration[] = [];
38
+ // Tracks cleanup run state (to prevent recursion/reentry issues).
30
39
  let cleanupStage: "idle" | "running" | "complete" = "idle";
40
+ let cleanupPass = 0;
41
+ let activeCleanupReason: Reason | undefined;
42
+ let activeCleanupKeepAlive = false;
43
+ // Promises of callbacks invoked late (registered while a pass runs), joined by
44
+ // the active pass before it settles so `cleanup()`/signal exits await them.
45
+ let activeLatePromises: Promise<void>[] | undefined;
31
46
  const CLEANUP_DEADLINE_MS = 10_000;
32
47
  /**
33
48
  * Symbol stamped by the extension-load guard onto the throwing replacement it
@@ -78,13 +93,32 @@ export interface FatalRecoveryHint {
78
93
  type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined;
79
94
  const fatalRecoveryHintProviders = new Set<FatalRecoveryHintProvider>();
80
95
 
96
+ function invokeCleanup(
97
+ registration: CleanupRegistration,
98
+ reason: Reason,
99
+ keepAlive: boolean,
100
+ pass: number,
101
+ ): Promise<void> | void {
102
+ if (registration.cancelled || registration.lastPass === pass) return;
103
+ if (registration.exitOnly && keepAlive) return;
104
+ registration.lastPass = pass;
105
+ return registration.callback(reason);
106
+ }
107
+
81
108
  /**
82
109
  * Internal: runs all registered cleanup callbacks for the given reason.
83
- * Ensures each callback is invoked at most once. Handles errors and prevents reentrancy.
110
+ * Ensures each registration is invoked at most once per pass, handles errors,
111
+ * and prevents reentrancy.
112
+ *
113
+ * `keepAlive` marks a manual cleanup that keeps the process running (see
114
+ * {@link cleanup}). Such a pass returns the stage to `idle`; registrations stay
115
+ * active for later resources and the eventual real exit. Exit-only callbacks
116
+ * skip keep-alive passes without consuming their registration. An exit-driven
117
+ * pass instead settles to `complete` and stays there.
84
118
  *
85
119
  * Returns a Promise that settles after all cleanups complete or error out.
86
120
  */
87
- function runCleanup(reason: Reason): Promise<void> {
121
+ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
88
122
  switch (cleanupStage) {
89
123
  case "idle":
90
124
  cleanupStage = "running";
@@ -95,30 +129,52 @@ function runCleanup(reason: Reason): Promise<void> {
95
129
  return Promise.resolve();
96
130
  }
97
131
 
98
- // Call .cleanup() for each callback that is still "armed".
99
- // Use Promise.try to handle sync/async, but only those armed.
100
- const promises = callbackList.toReversed().map(callback => {
101
- return Promise.try(() => callback(reason));
132
+ const pass = ++cleanupPass;
133
+ activeCleanupReason = reason;
134
+ activeCleanupKeepAlive = keepAlive;
135
+ const late: Promise<void>[] = [];
136
+ activeLatePromises = late;
137
+ const settle = (): void => {
138
+ if (activeLatePromises === late) activeLatePromises = undefined;
139
+ if (cleanupPass !== pass) return;
140
+ cleanupStage = keepAlive ? "idle" : "complete";
141
+ if (keepAlive) {
142
+ activeCleanupReason = undefined;
143
+ activeCleanupKeepAlive = false;
144
+ }
145
+ };
146
+
147
+ // Snapshot the pass. Registrations added while a keep-alive cleanup runs are
148
+ // invoked by register() when appropriate and remain active for later passes.
149
+ const promises = callbackList.toReversed().map(registration => {
150
+ return Promise.try(() => invokeCleanup(registration, reason, keepAlive, pass));
102
151
  });
103
152
 
104
- const cleanupSettled = Promise.allSettled(promises).then(results => {
153
+ const cleanupSettled = Promise.allSettled(promises).then(async results => {
105
154
  for (const result of results) {
106
155
  if (result.status === "rejected") {
107
156
  const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
108
157
  logger.error("Cleanup callback failed", { err, stack: err.stack });
109
158
  }
110
159
  }
111
- cleanupStage = "complete";
160
+ // Join callbacks registered while this pass ran (already error-caught);
161
+ // each batch may register more. The deadline race still bounds the pass.
162
+ while (late.length > 0) await Promise.allSettled(late.splice(0));
163
+ settle();
112
164
  });
113
165
  const deadline = Promise.withResolvers<void>();
114
166
  const deadlineTimer = setTimeout(() => {
115
167
  logger.error("Cleanup deadline exceeded; proceeding with exit", { reason });
116
- cleanupStage = "complete";
168
+ settle();
117
169
  deadline.resolve();
118
170
  }, CLEANUP_DEADLINE_MS);
119
- cleanupPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
171
+ const passPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
120
172
  clearTimeout(deadlineTimer);
173
+ // A re-armed pass must drop only its own settled promise; an older
174
+ // deadline-limited pass may finish after a newer one has already started.
175
+ if (keepAlive && cleanupPass === pass && cleanupPromise === passPromise) cleanupPromise = undefined;
121
176
  });
177
+ cleanupPromise = passPromise;
122
178
  return cleanupPromise;
123
179
  }
124
180
 
@@ -263,25 +319,29 @@ const EXPECTED_CLEANUP = Symbol.for("omp.expectedCleanupError");
263
319
  * consumer. Returns the same error for inline use at the `abort()` callsite.
264
320
  */
265
321
  export function markExpectedCleanupError<T extends object>(reason: T): T {
266
- (reason as Record<PropertyKey, unknown>)[EXPECTED_CLEANUP] = true;
322
+ Reflect.set(reason, EXPECTED_CLEANUP, true);
267
323
  return reason;
268
324
  }
269
325
 
270
- /**
271
- * Whether `reason` (or any error in its `cause` chain) was marked via
272
- * {@link markExpectedCleanupError}. Walks the chain because the unhandled
273
- * reason is often a wrapper (`AbortError`) with the marked abort reason as
274
- * its `cause`.
275
- */
276
- export function isExpectedCleanupError(reason: unknown): boolean {
326
+ function hasExpectedCleanupMarker(reason: unknown): boolean {
277
327
  let current: unknown = reason;
278
328
  for (let depth = 0; depth < 8 && current !== null && typeof current === "object"; depth++) {
279
- if ((current as Record<PropertyKey, unknown>)[EXPECTED_CLEANUP] === true) return true;
280
- current = (current as { cause?: unknown }).cause;
329
+ if (Reflect.get(current, EXPECTED_CLEANUP) === true) return true;
330
+ current = Reflect.get(current, "cause");
281
331
  }
282
332
  return false;
283
333
  }
284
334
 
335
+ /**
336
+ * Whether `reason` (or any object in its bounded `cause` chain) was explicitly
337
+ * marked via {@link markExpectedCleanupError}. Runtime error names and codes
338
+ * are intentionally insufficient: unmarked `AbortError` and socket failures
339
+ * can originate from application code and must remain fatal when unhandled.
340
+ */
341
+ export function isExpectedCleanupError(reason: unknown): boolean {
342
+ return hasExpectedCleanupMarker(reason);
343
+ }
344
+
285
345
  /** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */
286
346
  const rejectionInterceptors = new Set<(reason: unknown) => boolean>();
287
347
 
@@ -366,7 +426,10 @@ if (isMainThread) {
366
426
  process.stderr.write(`Inspector opened: ${url}\n`);
367
427
  })
368
428
  .on("uncaughtException", async thrown => {
369
- if (isExpectedCleanupError(thrown)) {
429
+ // Only explicitly marked exceptions are safe here. Structural
430
+ // AbortError/socket classification is limited to promise rejections:
431
+ // a synchronously thrown error may indicate an application bug.
432
+ if (hasExpectedCleanupMarker(thrown)) {
370
433
  logger.warn("Ignoring expected cleanup exception", { err: thrown });
371
434
  return;
372
435
  }
@@ -457,57 +520,92 @@ if (isMainThread) {
457
520
  });
458
521
  }
459
522
 
523
+ /** Controls when a registered cleanup callback participates in cleanup passes. */
524
+ export interface CleanupRegistrationOptions {
525
+ /**
526
+ * Run only on a real exit, never during a manual keep-alive cleanup.
527
+ * The registration remains armed when a keep-alive pass skips it.
528
+ */
529
+ exitOnly?: boolean;
530
+ }
531
+
460
532
  /**
461
- * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
533
+ * Registers a cleanup callback for shutdown, signals, fatal errors, and
534
+ * repeatable manual cleanup passes.
535
+ *
536
+ * Registrations persist across keep-alive {@link cleanup} passes and run at
537
+ * most once per pass. Set `exitOnly` for resources the continuing process still
538
+ * holds (open databases, cached handles): keep-alive passes skip the callback
539
+ * without consuming its registration, while the eventual real exit runs it.
540
+ *
541
+ * A callback registered during a running keep-alive pass joins future passes;
542
+ * normal callbacks also run immediately for the current pass. Registrations
543
+ * made during a real exit run immediately.
462
544
  *
463
- * Returns a Callback instance that can be used to cancel (unregister) or manually clean up.
464
- * If register is called after cleanup already began, invokes callback on a microtask.
545
+ * Returns a function that permanently cancels the registration.
465
546
  */
466
- export function register(id: string, callback: (reason: Reason) => void | Promise<void>): () => void {
467
- let done = false;
468
- const exec = (reason: Reason) => {
469
- if (done) return;
470
- done = true;
547
+ export function register(
548
+ id: string,
549
+ callback: (reason: Reason) => void | Promise<void>,
550
+ options: CleanupRegistrationOptions = {},
551
+ ): () => void {
552
+ const registration: CleanupRegistration = {
553
+ id,
554
+ callback,
555
+ exitOnly: options.exitOnly ?? false,
556
+ cancelled: false,
557
+ lastPass: 0,
558
+ };
559
+ const cancel = (): void => {
560
+ registration.cancelled = true;
561
+ const index = callbackList.indexOf(registration);
562
+ if (index >= 0) callbackList.splice(index, 1);
563
+ };
564
+ const invokeLate = (reason: Reason, keepAlive: boolean): void => {
471
565
  try {
472
- return callback(reason);
473
- } catch (e) {
474
- const err = e instanceof Error ? e : new Error(String(e));
566
+ const pending = invokeCleanup(registration, reason, keepAlive, cleanupPass);
567
+ if (!pending) return;
568
+ const tracked = pending.catch(error => {
569
+ const err = error instanceof Error ? error : new Error(String(error));
570
+ logger.error("Cleanup callback failed", { err, id, stack: err.stack });
571
+ });
572
+ // Join the active pass so cleanup()/signal exits await it; after a
573
+ // completed exit pass there is nothing left to join.
574
+ activeLatePromises?.push(tracked);
575
+ } catch (error) {
576
+ const err = error instanceof Error ? error : new Error(String(error));
475
577
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
476
578
  }
477
579
  };
478
580
 
479
- const cancel = () => {
480
- const index = callbackList.indexOf(exec);
481
- if (index >= 0) {
482
- callbackList.splice(index, 1);
483
- }
484
- done = true;
485
- };
581
+ if (cleanupStage === "idle") {
582
+ callbackList.push(registration);
583
+ return cancel;
584
+ }
486
585
 
487
- if (cleanupStage !== "idle") {
488
- // Cleanup is already in progress or complete; run late registrations once
489
- // without re-entering the global cleanup pass.
490
- logger.debug("Cleanup already started; running late callback once", { id });
491
- try {
492
- callback(Reason.MANUAL);
493
- } catch (e) {
494
- const err = e instanceof Error ? e : new Error(String(e));
495
- logger.error("Cleanup callback failed", { err, id, stack: err.stack });
496
- }
497
- return () => {};
586
+ const reason = activeCleanupReason ?? Reason.MANUAL;
587
+ if (cleanupStage === "running" && activeCleanupKeepAlive) {
588
+ // The current pass already snapshotted its callbacks. Keep the new owner
589
+ // registered for future passes; normal callbacks also join this pass now.
590
+ callbackList.push(registration);
591
+ if (!registration.exitOnly) invokeLate(reason, true);
592
+ return cancel;
498
593
  }
499
594
 
500
- // Register callback as "armed" (active).
501
- callbackList.push(exec);
595
+ // A real exit is running or complete. There is no later pass to arm for, so
596
+ // invoke every late registration now, including exit-only callbacks.
597
+ logger.debug("Cleanup already started; running late callback once", { id });
598
+ invokeLate(reason, false);
502
599
  return cancel;
503
600
  }
504
601
 
505
602
  /**
506
- * Runs all cleanup callbacks without exiting.
603
+ * Runs all cleanup callbacks without exiting, then re-arms the system so
604
+ * resources opened afterwards are still cleaned at the eventual real exit.
507
605
  * Use this in workers or when you need to clean up but continue execution.
508
606
  */
509
607
  export function cleanup(): Promise<void> {
510
- return runCleanup(Reason.MANUAL);
608
+ return runCleanup(Reason.MANUAL, true);
511
609
  }
512
610
 
513
611
  /** Controls how manual process shutdown handles terminal output. */
@@ -516,6 +614,18 @@ export interface QuitOptions {
516
614
  drainStdout?: boolean;
517
615
  }
518
616
 
617
+ /**
618
+ * Waits (bounded) for buffered stdout to reach the terminal. Used before
619
+ * process exit and before an exec-replace, where unflushed output would be
620
+ * lost with the process image.
621
+ */
622
+ export async function drainStdout(): Promise<void> {
623
+ if (process.stdout.writableLength === 0) return;
624
+ const { promise, resolve } = Promise.withResolvers<void>();
625
+ process.stdout.once("drain", resolve);
626
+ await Promise.race([promise, Bun.sleep(5000)]);
627
+ }
628
+
519
629
  async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
520
630
  await runCleanup(Reason.MANUAL);
521
631
 
@@ -523,10 +633,8 @@ async function runQuit(code: number, exitMode: "guarded" | "native", options: Qu
523
633
  return; // Workers: cleanup done, let worker exit naturally
524
634
  }
525
635
 
526
- if (options.drainStdout !== false && process.stdout.writableLength > 0) {
527
- const { promise, resolve } = Promise.withResolvers<void>();
528
- process.stdout.once("drain", resolve);
529
- await Promise.race([promise, Bun.sleep(5000)]);
636
+ if (options.drainStdout !== false) {
637
+ await drainStdout();
530
638
  }
531
639
 
532
640
  switch (exitMode) {
package/src/sqlite.ts CHANGED
@@ -7,6 +7,12 @@
7
7
  * one implementation here prevents the classifiers from drifting between the
8
8
  * credential store and the model cache.
9
9
  */
10
+ import type { Database } from "bun:sqlite";
11
+
12
+ /** Checkpoints committed WAL frames without waiting for concurrent readers. */
13
+ export function checkpointWal(db: Database): void {
14
+ db.run("PRAGMA wal_checkpoint(PASSIVE)");
15
+ }
10
16
 
11
17
  /**
12
18
  * SQLite's busy result-code family — base `SQLITE_BUSY` plus the extended