@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 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
@@ -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 shutdown, signals, fatal errors, and
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.14.2",
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.14.2"
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
- // Register signal and error event handlers to trigger cleanup before exit.
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
- return () => rejectionInterceptors.delete(interceptor);
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
- if (Bun.isMainThread) {
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
- for (const interceptor of rejectionInterceptors) {
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 shutdown, signals, fatal errors, and
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