@oh-my-pi/pi-utils 18.14.2 → 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 +10 -0
- package/dist/types/async.d.ts +5 -0
- package/dist/types/postmortem.d.ts +3 -2
- package/package.json +2 -2
- package/src/async.ts +13 -0
- package/src/postmortem.ts +54 -27
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
5
15
|
## [18.14.2] - 2026-10-31
|
|
6
16
|
|
|
7
17
|
### 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
|
|
@@ -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/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
|