@oh-my-pi/pi-utils 18.14.1 → 18.15.0
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 +16 -0
- package/dist/types/async.d.ts +5 -0
- package/dist/types/color.d.ts +2 -0
- package/dist/types/postmortem.d.ts +3 -2
- package/package.json +2 -2
- package/src/async.ts +13 -0
- package/src/color.ts +14 -2
- package/src/postmortem.ts +54 -27
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.15.0] - 2026-11-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added abortable `sleepUntil` for wall-clock deadline waits that include system sleep, with bounded timer slices to recheck deadlines after resume ([#17606](https://github.com/can1357/oh-my-pi/pull/17606) by [@zhiland](https://github.com/zhiland)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Importing pi-utils no longer installs process-wide signal and fatal handlers in embedding hosts; exit-time cleanup stays installed ([#17674](https://github.com/can1357/oh-my-pi/issues/17674)).
|
|
14
|
+
|
|
15
|
+
## [18.14.2] - 2026-10-31
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Added `oklabDistance`, the perceptual distance between two hex colors ([#17520](https://github.com/can1357/oh-my-pi/pull/17520))
|
|
20
|
+
|
|
5
21
|
## [18.14.1] - 2026-10-31
|
|
6
22
|
|
|
7
23
|
### Added
|
package/dist/types/async.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
|
16
16
|
* the unelapsed duration instead of shortening the requested sleep.
|
|
17
17
|
*/
|
|
18
18
|
export declare function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Wait for a wall-clock deadline, including time spent suspended. Some platforms
|
|
21
|
+
* exclude suspend from relative timers, so recheck at least once per second.
|
|
22
|
+
*/
|
|
23
|
+
export declare function sleepUntil(deadlineMs: number, signal?: AbortSignal): Promise<void>;
|
|
19
24
|
/**
|
|
20
25
|
* Wrap a promise with a timeout and optional abort signal.
|
|
21
26
|
* Rejects with the given error or a new error containing the given message if
|
package/dist/types/color.d.ts
CHANGED
|
@@ -99,6 +99,8 @@ export interface OKLCH {
|
|
|
99
99
|
* hues, so carrying them between colors preserves the palette's "weight".
|
|
100
100
|
*/
|
|
101
101
|
export declare function hexToOklch(hex: string): OKLCH;
|
|
102
|
+
/** Perceptual distance between two hex colors: Euclidean OKLab, 0 when identical, 1 from black to white. */
|
|
103
|
+
export declare function oklabDistance(a: string, b: string): number;
|
|
102
104
|
/**
|
|
103
105
|
* The sRGB gamut cusp for an OKLCH hue: the lightness/chroma point where the
|
|
104
106
|
* hue reaches its maximum chroma inside sRGB.
|
|
@@ -178,6 +178,8 @@ export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintPro
|
|
|
178
178
|
* Reports a caught top-level failure after terminal owners restore their display, then exits.
|
|
179
179
|
*/
|
|
180
180
|
export declare function fatal(error: unknown): Promise<never>;
|
|
181
|
+
/** Installs OMP's process-wide signal and fatal handlers for a CLI-owned process. */
|
|
182
|
+
export declare function installProcessHandlers(): void;
|
|
181
183
|
/** Controls when a registered cleanup callback participates in cleanup passes. */
|
|
182
184
|
export interface CleanupRegistrationOptions {
|
|
183
185
|
/**
|
|
@@ -201,8 +203,7 @@ export interface CleanupRegistrationOptions {
|
|
|
201
203
|
waitMessage?: string;
|
|
202
204
|
}
|
|
203
205
|
/**
|
|
204
|
-
* Registers a cleanup callback for
|
|
205
|
-
* repeatable manual cleanup passes.
|
|
206
|
+
* Registers a cleanup callback for manual passes and, when installed, process shutdown.
|
|
206
207
|
*
|
|
207
208
|
* Registrations persist across keep-alive {@link cleanup} passes and run at
|
|
208
209
|
* most once per pass. Set `exitOnly` for resources the continuing process still
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.15.0",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@oh-my-pi/pi-natives": "18.
|
|
59
|
+
"@oh-my-pi/pi-natives": "18.15.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/bun": "^1.3.14"
|
package/src/async.ts
CHANGED
|
@@ -29,6 +29,19 @@ export async function sleepLong(delayMs: number, signal?: AbortSignal): Promise<
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Wait for a wall-clock deadline, including time spent suspended. Some platforms
|
|
34
|
+
* exclude suspend from relative timers, so recheck at least once per second.
|
|
35
|
+
*/
|
|
36
|
+
export async function sleepUntil(deadlineMs: number, signal?: AbortSignal): Promise<void> {
|
|
37
|
+
while (true) {
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
const remaining = deadlineMs - Date.now();
|
|
40
|
+
if (!(remaining > 0)) return;
|
|
41
|
+
await scheduler.wait(Math.min(remaining, 1_000), { signal });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* Wrap a promise with a timeout and optional abort signal.
|
|
34
47
|
* Rejects with the given error or a new error containing the given message if
|
package/src/color.ts
CHANGED
|
@@ -311,6 +311,12 @@ function oklabToLinearRgb(L: number, a: number, b: number): { r: number; g: numb
|
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
/** Convert a hex color to OKLab. */
|
|
315
|
+
function hexToOklab(hex: string): { L: number; a: number; b: number } {
|
|
316
|
+
const rgb = hexToRgb(hex);
|
|
317
|
+
return linearRgbToOklab(linearizeChannel(rgb.r), linearizeChannel(rgb.g), linearizeChannel(rgb.b));
|
|
318
|
+
}
|
|
319
|
+
|
|
314
320
|
/**
|
|
315
321
|
* Convert a hex color to OKLCH (perceptual lightness/chroma/hue).
|
|
316
322
|
*
|
|
@@ -318,14 +324,20 @@ function oklabToLinearRgb(L: number, a: number, b: number): { r: number; g: numb
|
|
|
318
324
|
* hues, so carrying them between colors preserves the palette's "weight".
|
|
319
325
|
*/
|
|
320
326
|
export function hexToOklch(hex: string): OKLCH {
|
|
321
|
-
const
|
|
322
|
-
const lab = linearRgbToOklab(linearizeChannel(rgb.r), linearizeChannel(rgb.g), linearizeChannel(rgb.b));
|
|
327
|
+
const lab = hexToOklab(hex);
|
|
323
328
|
const c = Math.hypot(lab.a, lab.b);
|
|
324
329
|
let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
|
|
325
330
|
if (h < 0) h += 360;
|
|
326
331
|
return { l: lab.L, c, h };
|
|
327
332
|
}
|
|
328
333
|
|
|
334
|
+
/** Perceptual distance between two hex colors: Euclidean OKLab, 0 when identical, 1 from black to white. */
|
|
335
|
+
export function oklabDistance(a: string, b: string): number {
|
|
336
|
+
const x = hexToOklab(a);
|
|
337
|
+
const y = hexToOklab(b);
|
|
338
|
+
return Math.hypot(x.L - y.L, x.a - y.a, x.b - y.b);
|
|
339
|
+
}
|
|
340
|
+
|
|
329
341
|
/**
|
|
330
342
|
* Max OKLab saturation (C/L) that stays inside sRGB for the hue direction
|
|
331
343
|
* `(a, b)`, via Björn Ottosson's polynomial fit plus one Halley refinement.
|
package/src/postmortem.ts
CHANGED
|
@@ -307,9 +307,7 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
|
|
|
307
307
|
return cleanupPromise;
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
//
|
|
311
|
-
// Main thread: full signal handling (SIGINT, SIGTERM, SIGHUP) + exceptions + exit
|
|
312
|
-
// Worker thread: exit only (workers use self.addEventListener for exceptions)
|
|
310
|
+
// CLI-owned main threads handle signals and fatal errors; workers handle exit only.
|
|
313
311
|
let inspectorOpened = false;
|
|
314
312
|
|
|
315
313
|
/** Origin of an EPIPE raised by a process communication channel. */
|
|
@@ -558,13 +556,47 @@ export function isExpectedCleanupError(reason: unknown): boolean {
|
|
|
558
556
|
/** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */
|
|
559
557
|
const rejectionInterceptors = new Set<(reason: unknown) => boolean>();
|
|
560
558
|
|
|
559
|
+
function interceptRejection(reason: unknown): boolean {
|
|
560
|
+
for (const interceptor of rejectionInterceptors) {
|
|
561
|
+
try {
|
|
562
|
+
if (interceptor(reason)) return true;
|
|
563
|
+
} catch (interceptorErr) {
|
|
564
|
+
logger.warn("Unhandled-rejection interceptor threw; continuing with fatal path", { err: interceptorErr });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Without the CLI's handlers (SDK embedding), interceptors still need a listener. It exists only while
|
|
572
|
+
* interceptors do, and rethrows what none consumes unless the host listens too, keeping Bun's default.
|
|
573
|
+
*/
|
|
574
|
+
let embeddedRejectionListener: ((reason: unknown) => void) | undefined;
|
|
575
|
+
|
|
561
576
|
/**
|
|
562
577
|
* Register an interceptor consulted before an unhandled rejection tears the
|
|
563
578
|
* process down. A consuming interceptor owns reporting and keeps the process alive.
|
|
564
579
|
*/
|
|
565
580
|
export function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void {
|
|
566
581
|
rejectionInterceptors.add(interceptor);
|
|
567
|
-
|
|
582
|
+
if (!processHandlersInstalled && Bun.isMainThread && !embeddedRejectionListener) {
|
|
583
|
+
const listener = (reason: unknown) => {
|
|
584
|
+
if (interceptRejection(reason) || process.listenerCount("unhandledRejection") > 1) return;
|
|
585
|
+
throw reason;
|
|
586
|
+
};
|
|
587
|
+
embeddedRejectionListener = listener;
|
|
588
|
+
process.on("unhandledRejection", listener);
|
|
589
|
+
}
|
|
590
|
+
return () => {
|
|
591
|
+
rejectionInterceptors.delete(interceptor);
|
|
592
|
+
if (rejectionInterceptors.size === 0) removeEmbeddedRejectionListener();
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function removeEmbeddedRejectionListener(): void {
|
|
597
|
+
if (!embeddedRejectionListener) return;
|
|
598
|
+
process.off("unhandledRejection", embeddedRejectionListener);
|
|
599
|
+
embeddedRejectionListener = undefined;
|
|
568
600
|
}
|
|
569
601
|
|
|
570
602
|
/**
|
|
@@ -685,7 +717,22 @@ export async function fatal(error: unknown): Promise<never> {
|
|
|
685
717
|
return exitAfterFatal(report, "Fatal error", err, Reason.UNHANDLED_REJECTION);
|
|
686
718
|
}
|
|
687
719
|
|
|
688
|
-
|
|
720
|
+
// Exit-time cleanup is safe in any host: it neither delays nor triggers an exit. Signal and fatal
|
|
721
|
+
// handlers change the process lifecycle, so only {@link installProcessHandlers} adds them.
|
|
722
|
+
process.on("exit", () => {
|
|
723
|
+
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
let processHandlersInstalled = false;
|
|
727
|
+
|
|
728
|
+
/** Installs OMP's process-wide signal and fatal handlers for a CLI-owned process. */
|
|
729
|
+
export function installProcessHandlers(): void {
|
|
730
|
+
if (processHandlersInstalled) return;
|
|
731
|
+
processHandlersInstalled = true;
|
|
732
|
+
removeEmbeddedRejectionListener();
|
|
733
|
+
// Workers report failures through their own error listeners to the parent.
|
|
734
|
+
if (!Bun.isMainThread) return;
|
|
735
|
+
|
|
689
736
|
process
|
|
690
737
|
.on("SIGINT", async () => {
|
|
691
738
|
await runCleanup(Reason.SIGINT);
|
|
@@ -745,15 +792,7 @@ if (Bun.isMainThread) {
|
|
|
745
792
|
logger.warn("Ignoring expected cleanup rejection", { err });
|
|
746
793
|
return;
|
|
747
794
|
}
|
|
748
|
-
|
|
749
|
-
try {
|
|
750
|
-
if (interceptor(reason)) return;
|
|
751
|
-
} catch (interceptorErr) {
|
|
752
|
-
logger.warn("Unhandled-rejection interceptor threw; continuing with fatal path", {
|
|
753
|
-
err: interceptorErr,
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
}
|
|
795
|
+
if (interceptRejection(reason)) return;
|
|
757
796
|
await exitAfterFatal(
|
|
758
797
|
formatFatalError("Unhandled Rejection", err),
|
|
759
798
|
"Unhandled rejection",
|
|
@@ -761,9 +800,6 @@ if (Bun.isMainThread) {
|
|
|
761
800
|
Reason.UNHANDLED_REJECTION,
|
|
762
801
|
);
|
|
763
802
|
})
|
|
764
|
-
.on("exit", async () => {
|
|
765
|
-
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
766
|
-
})
|
|
767
803
|
.on("SIGTERM", async () => {
|
|
768
804
|
// A stopped session otherwise leaves its last frame above the shell prompt,
|
|
769
805
|
// which reads as a crash. A repeated SIGTERM joins the same cleanup and the
|
|
@@ -777,14 +813,6 @@ if (Bun.isMainThread) {
|
|
|
777
813
|
await runCleanup(Reason.SIGHUP);
|
|
778
814
|
exitProcess(129); // 128 + SIGHUP (1)
|
|
779
815
|
});
|
|
780
|
-
} else {
|
|
781
|
-
// Worker thread: only register exit handler for cleanup.
|
|
782
|
-
// DO NOT register uncaughtException/unhandledRejection handlers here -
|
|
783
|
-
// they would swallow errors before the worker's own handlers (self.addEventListener)
|
|
784
|
-
// can report failures back to the parent thread.
|
|
785
|
-
process.on("exit", () => {
|
|
786
|
-
void runCleanup(Reason.EXIT);
|
|
787
|
-
});
|
|
788
816
|
}
|
|
789
817
|
|
|
790
818
|
/** Controls when a registered cleanup callback participates in cleanup passes. */
|
|
@@ -811,8 +839,7 @@ export interface CleanupRegistrationOptions {
|
|
|
811
839
|
}
|
|
812
840
|
|
|
813
841
|
/**
|
|
814
|
-
* Registers a cleanup callback for
|
|
815
|
-
* repeatable manual cleanup passes.
|
|
842
|
+
* Registers a cleanup callback for manual passes and, when installed, process shutdown.
|
|
816
843
|
*
|
|
817
844
|
* Registrations persist across keep-alive {@link cleanup} passes and run at
|
|
818
845
|
* most once per pass. Set `exitOnly` for resources the continuing process still
|