@oh-my-pi/pi-utils 18.13.1 → 18.13.3

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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.13.3] - 2026-10-28
6
+
7
+ ### Changed
8
+
9
+ - Updated Bun engine requirement to ≥1.4.4
10
+
11
+ ## [18.13.2] - 2026-10-27
12
+
13
+ ### Added
14
+
15
+ - Added `deadlineMs` and `waitMessage` cleanup registration options: shutdown waits longer for that callback, but only while it is still pending, and says so on stderr once nothing else is left to wait for ([#17128](https://github.com/can1357/oh-my-pi/pull/17128) by [@sjawhar](https://github.com/sjawhar))
16
+ - A process stopped by SIGTERM now prints its registered recovery commands after the terminal is handed back, so a stopped omp session shows `omp --resume <id>` instead of leaving its last frame above the shell prompt like a crash ([#17119](https://github.com/can1357/oh-my-pi/pull/17119) by [@sjawhar](https://github.com/sjawhar))
17
+
18
+ ### Fixed
19
+
20
+ - Fixed plain TAR archives requiring a whole-container memory buffer to list entries or read a selected member ([#17175](https://github.com/can1357/oh-my-pi/pull/17175) by [@alphastorm](https://github.com/alphastorm))
21
+
5
22
  ## [18.13.1] - 2026-10-27
6
23
 
7
24
  ### Added
package/README.md CHANGED
@@ -29,7 +29,7 @@ Import from the root barrel or per-module subpaths (`@oh-my-pi/pi-utils/<module>
29
29
  bun add @oh-my-pi/pi-utils
30
30
  ```
31
31
 
32
- Ships TypeScript source directly (no build step); requires Bun ≥ 1.3.14.
32
+ Ships TypeScript source directly (no build step); requires Bun ≥ 1.4.4.
33
33
 
34
34
  ## References
35
35
 
@@ -93,6 +93,7 @@ export interface ToolCallLocation extends Meta {
93
93
  export interface ToolCall extends Meta {
94
94
  toolCallId: ToolCallId;
95
95
  title: string;
96
+ name?: string | null;
96
97
  kind?: ToolKind | null;
97
98
  status?: ToolCallStatus | null;
98
99
  content?: ToolCallContent[] | null;
@@ -6,7 +6,7 @@ export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: Fo
6
6
  * pass over `open()` walks the headers, and member reads open it again.
7
7
  */
8
8
  export declare function readTarEntriesFromStream(open: () => AsyncIterable<Uint8Array>, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
9
- /** Read and index a tar source after one bounded whole-stream read. */
9
+ /** Index a plain tar by ranging over headers and retaining lazy member offsets. */
10
10
  export declare const readTar: FormatReader;
11
11
  /** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
12
12
  export declare function sniffTar(bytes: Uint8Array): boolean;
@@ -8,7 +8,7 @@ export type WritableArchiveFormat = "zip" | "tar" | "tar.gz" | "tar.zst" | "asar
8
8
  * Where to read an archive from: an extension-inferred filesystem path, a
9
9
  * format-tagged filesystem path, in-memory bytes, or any caller-provided
10
10
  * {@link ByteSource} (e.g. `httpByteSource` for ranged remote reads).
11
- * File- and source-backed ZIP/ASAR/RAR/7z/ISO are read lazily.
11
+ * File- and source-backed TAR/ZIP/ASAR/RAR/7z/ISO are read lazily.
12
12
  */
13
13
  export type ArchiveSource = string | {
14
14
  bytes: Uint8Array;
@@ -10,7 +10,6 @@ export * from "./file-lock.js";
10
10
  export * from "./format.js";
11
11
  export * from "./frontmatter.js";
12
12
  export * from "./fs-error.js";
13
- export * from "./fs-open.js";
14
13
  export * from "./incoming-json.js";
15
14
  export * from "./json.js";
16
15
  export * from "./json-parse.js";
@@ -2,6 +2,13 @@
2
2
  * Try to parse JSON, returning null on failure.
3
3
  */
4
4
  export declare function tryParseJson<T = unknown>(content: string): T | null;
5
+ /**
6
+ * Index just past the JSON object or array that opens at `text[start]`, or -1
7
+ * when it does not close within `text`. String literals are skipped, honoring
8
+ * escapes, so brackets inside values do not count. Malformed input yields some
9
+ * end that a parser then rejects.
10
+ */
11
+ export declare function jsonContainerEnd(text: string, start: number): number;
5
12
  /**
6
13
  * Serialize JSON while preserving bigint precision as decimal strings.
7
14
  *
@@ -52,7 +52,7 @@ export declare const NATIVE_PROCESS_EXIT: unique symbol;
52
52
  * signal and fatal exit.
53
53
  */
54
54
  export declare function exitProcess(code: number): never;
55
- /** User-facing command printed before fatal cleanup so interrupted work can be resumed. */
55
+ /** User-facing command printed after fatal or SIGTERM cleanup so interrupted work can be resumed. */
56
56
  export interface FatalRecoveryHint {
57
57
  /** Stable label identifying the recoverable session or process. */
58
58
  label: string;
@@ -168,7 +168,10 @@ export declare function isExpectedCleanupError(reason: unknown): boolean;
168
168
  export declare function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void;
169
169
  /**
170
170
  * Register a synchronous recovery command to print when the process exits
171
- * through an uncaught exception or unhandled rejection.
171
+ * through an uncaught exception, an unhandled rejection, or SIGTERM.
172
+ *
173
+ * Providers are read once cleanup finishes (or hits its deadline), so they
174
+ * report what teardown left behind: a session it saved or one it dropped.
172
175
  */
173
176
  export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
174
177
  /**
@@ -182,6 +185,20 @@ export interface CleanupRegistrationOptions {
182
185
  * The registration remains armed when a keep-alive pass skips it.
183
186
  */
184
187
  exitOnly?: boolean;
188
+ /**
189
+ * How long a pass waits for this callback, when that is longer than the
190
+ * default 10 s cleanup deadline. It extends the pass only while the callback
191
+ * is still pending: one that settles early ends its hold at once.
192
+ */
193
+ deadlineMs?: number;
194
+ /**
195
+ * With `deadlineMs`: a line printed to stderr, with the remaining wait
196
+ * appended, if exit is still waiting on this callback once every other
197
+ * callback has finished (a terminal owner has then released the screen), or
198
+ * at the default 10 s deadline when another callback is still running.
199
+ * Nothing is printed when the callback finishes first.
200
+ */
201
+ waitMessage?: string;
185
202
  }
186
203
  /**
187
204
  * Registers a cleanup callback for shutdown, signals, fatal errors, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.13.1",
3
+ "version": "18.13.3",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,12 +57,12 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.13.1"
60
+ "@oh-my-pi/pi-natives": "18.13.3"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
64
64
  },
65
65
  "engines": {
66
- "bun": ">=1.3.14"
66
+ "bun": ">=1.4.4"
67
67
  }
68
68
  }
@@ -102,6 +102,7 @@ export interface ToolCallLocation extends Meta {
102
102
  export interface ToolCall extends Meta {
103
103
  toolCallId: ToolCallId;
104
104
  title: string;
105
+ name?: string | null;
105
106
  kind?: ToolKind | null;
106
107
  status?: ToolCallStatus | null;
107
108
  content?: ToolCallContent[] | null;
package/src/ar/tar.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  normalizeArchiveEntryPath,
17
17
  normalizeArchiveLookupPath,
18
18
  } from "./paths";
19
- import { readAllBytes } from "./source";
19
+ import type { ByteSource } from "./source";
20
20
  import type { ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
21
21
 
22
22
  const BLOCK_SIZE = 512;
@@ -53,8 +53,44 @@ interface PendingTarLink {
53
53
  targetPath: string;
54
54
  }
55
55
 
56
+ interface TarCursor {
57
+ readonly position: number;
58
+ read(length: number): Promise<Uint8Array>;
59
+ skip(length: number): Promise<boolean>;
60
+ }
61
+
62
+ /** Seek over plain tar payloads without fetching their bytes. */
63
+ class TarSourceCursor implements TarCursor {
64
+ readonly #source: ByteSource;
65
+ #position = 0;
66
+
67
+ constructor(source: ByteSource) {
68
+ this.#source = source;
69
+ }
70
+
71
+ get position(): number {
72
+ return this.#position;
73
+ }
74
+
75
+ async read(length: number): Promise<Uint8Array> {
76
+ const size = Math.min(length, this.#source.size - this.#position);
77
+ if (size === 0) return new Uint8Array(0);
78
+ const end = this.#position + size;
79
+ const bytes = await this.#source.read(this.#position, end);
80
+ if (bytes.byteLength !== size) throw new ArchiveError("Invalid archive: truncated data");
81
+ this.#position = end;
82
+ return bytes;
83
+ }
84
+
85
+ async skip(length: number): Promise<boolean> {
86
+ if (length > this.#source.size - this.#position) return false;
87
+ this.#position += length;
88
+ return true;
89
+ }
90
+ }
91
+
56
92
  /** Forward-only reader over tar bytes that arrive in chunks: one buffer, or a decompressing stream. */
57
- class TarByteStream {
93
+ class TarByteStream implements TarCursor {
58
94
  readonly #chunks: AsyncIterator<Uint8Array> | Iterator<Uint8Array>;
59
95
  #chunk: Uint8Array = new Uint8Array(0);
60
96
  #position = 0;
@@ -119,6 +155,27 @@ interface TarPayload {
119
155
  read(offset: number, size: number, memberPath: string): Promise<Uint8Array>;
120
156
  }
121
157
 
158
+ class TarSourcePayload implements TarPayload {
159
+ readonly #source: ByteSource;
160
+
161
+ constructor(source: ByteSource) {
162
+ this.#source = source;
163
+ }
164
+
165
+ async read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
166
+ try {
167
+ const bytes = await this.#source.read(offset, offset + size);
168
+ if (bytes.byteLength !== size) {
169
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
170
+ }
171
+ return bytes;
172
+ } catch (error) {
173
+ if (error instanceof ArchiveError) throw error;
174
+ throw new ArchiveError(error instanceof Error ? error.message : "Failed to read tar member");
175
+ }
176
+ }
177
+ }
178
+
122
179
  class TarBufferPayload implements TarPayload {
123
180
  readonly #buffer: Uint8Array;
124
181
 
@@ -533,7 +590,7 @@ function resolvePendingLinks(
533
590
 
534
591
  /** Read one metadata record's payload, bounded by `maxIndexSize`, and step over its padding. */
535
592
  async function readTarMetadata(
536
- stream: TarByteStream,
593
+ stream: TarCursor,
537
594
  size: number,
538
595
  limits: ArchiveLimits,
539
596
  what: string,
@@ -551,7 +608,7 @@ async function readTarMetadata(
551
608
  * stepped over, never held: entries point into `payload` by stream offset.
552
609
  */
553
610
  async function readTarEntries(
554
- stream: TarByteStream,
611
+ stream: TarCursor,
555
612
  payload: TarPayload,
556
613
  options: FormatReadOptions,
557
614
  ): Promise<ArchiveIndexEntry[]> {
@@ -737,18 +794,17 @@ export async function readTarEntriesFromStream(
737
794
  }
738
795
  }
739
796
 
740
- /** Read and index a tar source after one bounded whole-stream read. */
797
+ /** Index a plain tar by ranging over headers and retaining lazy member offsets. */
741
798
  export const readTar: FormatReader = async (source, options) => {
742
- assertInMemorySize(source.size, options.limits);
743
- let bytes: Uint8Array;
799
+ if (!Number.isSafeInteger(source.size) || source.size < 0) {
800
+ throw new ArchiveError("Archive is too large to read safely");
801
+ }
744
802
  try {
745
- bytes = await readAllBytes(source);
803
+ return await readTarEntries(new TarSourceCursor(source), new TarSourcePayload(source), options);
746
804
  } catch (error) {
747
805
  if (error instanceof ArchiveError) throw error;
748
806
  throw new ArchiveError(error instanceof Error ? error.message : "Failed to read tar archive");
749
807
  }
750
- if (bytes.byteLength !== source.size) throw new ArchiveError("Invalid archive: truncated data");
751
- return readTarEntriesFromBuffer(bytes, options);
752
808
  };
753
809
 
754
810
  /** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
package/src/ar/types.ts CHANGED
@@ -37,7 +37,7 @@ export type WritableArchiveFormat = "zip" | "tar" | "tar.gz" | "tar.zst" | "asar
37
37
  * Where to read an archive from: an extension-inferred filesystem path, a
38
38
  * format-tagged filesystem path, in-memory bytes, or any caller-provided
39
39
  * {@link ByteSource} (e.g. `httpByteSource` for ranged remote reads).
40
- * File- and source-backed ZIP/ASAR/RAR/7z/ISO are read lazily.
40
+ * File- and source-backed TAR/ZIP/ASAR/RAR/7z/ISO are read lazily.
41
41
  */
42
42
  export type ArchiveSource =
43
43
  | string
package/src/index.ts CHANGED
@@ -10,7 +10,6 @@ export * from "./file-lock";
10
10
  export * from "./format";
11
11
  export * from "./frontmatter";
12
12
  export * from "./fs-error";
13
- export * from "./fs-open";
14
13
  export * from "./incoming-json";
15
14
  export * from "./json";
16
15
  export * from "./json-parse";
package/src/json.ts CHANGED
@@ -9,6 +9,31 @@ export function tryParseJson<T = unknown>(content: string): T | null {
9
9
  }
10
10
  }
11
11
 
12
+ /**
13
+ * Index just past the JSON object or array that opens at `text[start]`, or -1
14
+ * when it does not close within `text`. String literals are skipped, honoring
15
+ * escapes, so brackets inside values do not count. Malformed input yields some
16
+ * end that a parser then rejects.
17
+ */
18
+ export function jsonContainerEnd(text: string, start: number): number {
19
+ let depth = 0;
20
+ for (let index = start; index < text.length; index++) {
21
+ const code = text.charCodeAt(index);
22
+ if (code === 0x22) {
23
+ for (index++; index < text.length; index++) {
24
+ const inner = text.charCodeAt(index);
25
+ if (inner === 0x5c) index++;
26
+ else if (inner === 0x22) break;
27
+ }
28
+ } else if (code === 0x7b || code === 0x5b) {
29
+ depth++;
30
+ } else if ((code === 0x7d || code === 0x5d) && --depth === 0) {
31
+ return index + 1;
32
+ }
33
+ }
34
+ return -1;
35
+ }
36
+
12
37
  /**
13
38
  * Serialize JSON while preserving bigint precision as decimal strings.
14
39
  *
@@ -3,7 +3,6 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { localDay } from "../dirs";
6
- import { openCloexecSync } from "../fs-open";
7
6
 
8
7
  /** Write batching for {@link RotatingFileSink}. */
9
8
  export interface RotatingFileBatchOptions {
@@ -87,7 +86,7 @@ export class RotatingFileSink {
87
86
 
88
87
  #openFd(filePath: string): void {
89
88
  this.#closeFd();
90
- this.#fd = openCloexecSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
89
+ this.#fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
91
90
  try {
92
91
  this.#onRotate?.(filePath);
93
92
  } catch {
package/src/postmortem.ts CHANGED
@@ -27,10 +27,26 @@ interface CleanupRegistration {
27
27
  id: string;
28
28
  callback: (reason: Reason) => Promise<void> | void;
29
29
  exitOnly: boolean;
30
+ /** How long a pass waits for this callback, when longer than {@link CLEANUP_DEADLINE_MS}. */
31
+ deadlineMs: number | undefined;
32
+ /** Printed to stderr when exit waits on this callback alone; see {@link CleanupRegistrationOptions.waitMessage}. */
33
+ waitMessage: string | undefined;
30
34
  cancelled: boolean;
31
35
  lastPass: number;
32
36
  }
33
37
 
38
+ /** The deadline of one cleanup pass, which callbacks with their own `deadlineMs` hold open. */
39
+ interface CleanupPassDeadline {
40
+ /**
41
+ * Keep the pass open until `pending` settles, for at most `deadlineMs`. A
42
+ * `waitMessage` is printed to stderr if the pass is still waiting on it once
43
+ * every other callback has finished.
44
+ */
45
+ extend(pending: Promise<unknown>, deadlineMs: number, waitMessage?: string): void;
46
+ /** Whether a callback with its own deadline still holds the pass open. */
47
+ extended(): boolean;
48
+ }
49
+
34
50
  // Active cleanup callbacks in registration order. Registrations survive
35
51
  // keep-alive passes; `lastPass` enforces at-most-once invocation per pass.
36
52
  const callbackList: CleanupRegistration[] = [];
@@ -42,6 +58,8 @@ let activeCleanupKeepAlive = false;
42
58
  // Promises of callbacks invoked late (registered while a pass runs), joined by
43
59
  // the active pass before it settles so `cleanup()`/signal exits await them.
44
60
  let activeLatePromises: Promise<void>[] | undefined;
61
+ // Deadline of the running pass, so callbacks invoked late can extend it too.
62
+ let activePassDeadline: CleanupPassDeadline | undefined;
45
63
  const CLEANUP_DEADLINE_MS = 10_000;
46
64
  /**
47
65
  * Symbol stamped by the extension-load guard onto the throwing replacement it
@@ -122,7 +140,7 @@ export function exitProcess(code: number): never {
122
140
  let cleanupPromise: Promise<void> | undefined;
123
141
  let stdioDisconnectRegistrations = 0;
124
142
 
125
- /** User-facing command printed before fatal cleanup so interrupted work can be resumed. */
143
+ /** User-facing command printed after fatal or SIGTERM cleanup so interrupted work can be resumed. */
126
144
  export interface FatalRecoveryHint {
127
145
  /** Stable label identifying the recoverable session or process. */
128
146
  label: string;
@@ -184,10 +202,75 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
184
202
  }
185
203
  };
186
204
 
205
+ // The pass ends once every callback settles, or at the deadline: CLEANUP_DEADLINE_MS,
206
+ // held open while a callback with a longer `deadlineMs` is still pending.
207
+ const deadline = Promise.withResolvers<void>();
208
+ const deadlineTimers: NodeJS.Timeout[] = [];
209
+ let baseDeadlineElapsed = false;
210
+ let openExtensions = 0;
211
+ let passDone = false;
212
+ const expireIfDue = (): void => {
213
+ if (passDone || !baseDeadlineElapsed || openExtensions > 0) return;
214
+ passDone = true;
215
+ logger.error("Cleanup deadline exceeded; proceeding with exit", { reason });
216
+ settle();
217
+ deadline.resolve();
218
+ };
219
+ // Holds that still keep the pass open and have a message for the user. The
220
+ // message is printed once the pass waits on nothing else, so a terminal owner
221
+ // has already released the screen and the user sees why exit is slow.
222
+ const announced: { message: string; until: number; open: () => boolean }[] = [];
223
+ let othersSettled = false;
224
+ const announce = (): void => {
225
+ if (passDone || !othersSettled) return;
226
+ for (const hold of announced.splice(0)) {
227
+ if (!hold.open()) continue;
228
+ const seconds = Math.max(1, Math.ceil((hold.until - Date.now()) / 1000));
229
+ try {
230
+ fs.writeSync(2, `${hold.message} (up to ${seconds} s)…\n`);
231
+ } catch {}
232
+ }
233
+ };
234
+ const passDeadline: CleanupPassDeadline = {
235
+ extend(pending, deadlineMs, waitMessage) {
236
+ if (passDone || deadlineMs <= 0) return;
237
+ openExtensions++;
238
+ let open = true;
239
+ const close = (): void => {
240
+ if (!open) return;
241
+ open = false;
242
+ openExtensions--;
243
+ // Check on a later macrotask: when this was the last pending callback, the
244
+ // pass completes first and no deadline is reported.
245
+ deadlineTimers.push(setTimeout(expireIfDue, 0));
246
+ };
247
+ deadlineTimers.push(setTimeout(close, deadlineMs));
248
+ pending.then(close, close);
249
+ if (waitMessage !== undefined) {
250
+ announced.push({ message: waitMessage, until: Date.now() + deadlineMs, open: () => open });
251
+ // On a later macrotask, so a callback that is already done closes first.
252
+ if (othersSettled) deadlineTimers.push(setTimeout(announce, 0));
253
+ }
254
+ },
255
+ extended: () => !passDone && openExtensions > 0,
256
+ };
257
+ activePassDeadline = passDeadline;
258
+
187
259
  // Snapshot the pass. Registrations added while a keep-alive cleanup runs are
188
260
  // invoked by register() when appropriate and remain active for later passes.
189
- const promises = callbackList.toReversed().map(registration => {
190
- return Promise.try(() => invokeCleanup(registration, reason, keepAlive, pass));
261
+ const snapshot = callbackList.toReversed();
262
+ const promises = snapshot.map(registration => {
263
+ return Promise.try(() => {
264
+ const pending = invokeCleanup(registration, reason, keepAlive, pass);
265
+ if (pending && registration.deadlineMs !== undefined) {
266
+ passDeadline.extend(pending, registration.deadlineMs, registration.waitMessage);
267
+ }
268
+ return pending;
269
+ });
270
+ });
271
+ void Promise.allSettled(promises.filter((_, index) => snapshot[index]?.deadlineMs === undefined)).then(() => {
272
+ othersSettled = true;
273
+ deadlineTimers.push(setTimeout(announce, 0));
191
274
  });
192
275
 
193
276
  const cleanupSettled = Promise.allSettled(promises).then(async results => {
@@ -202,14 +285,20 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
202
285
  while (late.length > 0) await Promise.allSettled(late.splice(0));
203
286
  settle();
204
287
  });
205
- const deadline = Promise.withResolvers<void>();
206
- const deadlineTimer = setTimeout(() => {
207
- logger.error("Cleanup deadline exceeded; proceeding with exit", { reason });
208
- settle();
209
- deadline.resolve();
210
- }, CLEANUP_DEADLINE_MS);
288
+ deadlineTimers.push(
289
+ setTimeout(() => {
290
+ baseDeadlineElapsed = true;
291
+ // The pass no longer waits on the other callbacks, so a hold still open is
292
+ // what exit waits on; terminal owners released the screen synchronously.
293
+ othersSettled = true;
294
+ announce();
295
+ expireIfDue();
296
+ }, CLEANUP_DEADLINE_MS),
297
+ );
211
298
  const passPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
212
- clearTimeout(deadlineTimer);
299
+ passDone = true;
300
+ for (const timer of deadlineTimers) clearTimeout(timer);
301
+ if (activePassDeadline === passDeadline) activePassDeadline = undefined;
213
302
  // A re-armed pass must drop only its own settled promise; an older
214
303
  // deadline-limited pass may finish after a newer one has already started.
215
304
  if (keepAlive && cleanupPass === pass && cleanupPromise === passPromise) cleanupPromise = undefined;
@@ -480,7 +569,10 @@ export function interceptUnhandledRejections(interceptor: (reason: unknown) => b
480
569
 
481
570
  /**
482
571
  * Register a synchronous recovery command to print when the process exits
483
- * through an uncaught exception or unhandled rejection.
572
+ * through an uncaught exception, an unhandled rejection, or SIGTERM.
573
+ *
574
+ * Providers are read once cleanup finishes (or hits its deadline), so they
575
+ * report what teardown left behind: a session it saved or one it dropped.
484
576
  */
485
577
  export function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void {
486
578
  fatalRecoveryHintProviders.add(provider);
@@ -494,10 +586,10 @@ function escapeFatalHintText(value: string): string {
494
586
  });
495
587
  }
496
588
 
497
- function formatFatalRecoveryHints(): string {
589
+ function formatFatalRecoveryHints(providers: Iterable<FatalRecoveryHintProvider>): string {
498
590
  const lines: string[] = [];
499
591
  const seenCommands = new Set<string>();
500
- for (const provider of fatalRecoveryHintProviders) {
592
+ for (const provider of providers) {
501
593
  try {
502
594
  const hint = provider();
503
595
  if (!hint?.command || seenCommands.has(hint.command)) continue;
@@ -519,21 +611,54 @@ function formatFatalError(label: string, err: Error): string {
519
611
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
520
612
  }
521
613
 
614
+ /** Write a final report to stderr once terminal owners have handed the terminal back. */
615
+ function writeTerminalReport(output: string): void {
616
+ restoreTerminalStderr();
617
+ // A revoked terminal can make stream writes raise another fatal error. Use
618
+ // the descriptor directly so failure stays synchronous and contained.
619
+ try {
620
+ fs.writeSync(2, output);
621
+ } catch {}
622
+ }
623
+
624
+ let recoveryHintsWritten = false;
625
+
626
+ /**
627
+ * Print the recovery commands at most once per process. Callers snapshot the
628
+ * providers before cleanup, because a session's own cleanup unregisters its
629
+ * provider, and call this after cleanup, because teardown can still save the
630
+ * session (an unsent draft, an aborted first reply) or drop an empty one.
631
+ */
632
+ function writeRecoveryHints(providers: readonly FatalRecoveryHintProvider[], heading = ""): void {
633
+ if (recoveryHintsWritten) return;
634
+ const hints = formatFatalRecoveryHints(providers);
635
+ if (!hints) return;
636
+ recoveryHintsWritten = true;
637
+ writeTerminalReport(`${heading}${hints}`);
638
+ }
639
+
522
640
  async function exitAfterFatal(output: string, logMessage: string, err: Error, reason: Reason): Promise<never> {
523
- const forcedExit = setTimeout(() => exitProcess(1), CLEANUP_DEADLINE_MS);
641
+ const hintProviders = [...fatalRecoveryHintProviders];
642
+ // The forced exit backs up the pass deadline, so it waits while a callback with
643
+ // its own `deadlineMs` holds the pass open; that pass still ends at its deadline.
644
+ const forceExit = (): void => {
645
+ if (activePassDeadline?.extended()) {
646
+ forcedExit = setTimeout(forceExit, CLEANUP_DEADLINE_MS);
647
+ return;
648
+ }
649
+ writeRecoveryHints(hintProviders);
650
+ exitProcess(1);
651
+ };
652
+ let forcedExit = setTimeout(forceExit, CLEANUP_DEADLINE_MS);
524
653
  try {
525
654
  // Cleanup callbacks are invoked synchronously before runCleanup returns its
526
655
  // completion promise. TUI owners therefore hand the cursor back before the
527
656
  // fatal report is written, while slower resource cleanup continues afterward.
528
657
  const cleanup = runCleanup(reason);
529
- restoreTerminalStderr();
530
- // A revoked terminal can make stream writes raise another fatal error. Use
531
- // the descriptor directly so failure stays synchronous and contained.
532
- try {
533
- fs.writeSync(2, output);
534
- } catch {}
658
+ writeTerminalReport(output);
535
659
  logger.error(logMessage, { err });
536
660
  await cleanup;
661
+ writeRecoveryHints(hintProviders);
537
662
  } finally {
538
663
  clearTimeout(forcedExit);
539
664
  exitProcess(1);
@@ -552,12 +677,12 @@ function handleWorkerSendEpipe(err: Error): boolean {
552
677
  */
553
678
  export async function fatal(error: unknown): Promise<never> {
554
679
  const err = error instanceof Error ? error : new Error(String(error));
555
- const output = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n${formatFatalRecoveryHints()}`;
680
+ const report = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n`;
556
681
  if (!Bun.isMainThread) {
557
- process.stderr.write(output);
682
+ process.stderr.write(`${report}${formatFatalRecoveryHints(fatalRecoveryHintProviders)}`);
558
683
  process.exit(1);
559
684
  }
560
- return exitAfterFatal(output, "Fatal error", err, Reason.UNHANDLED_REJECTION);
685
+ return exitAfterFatal(report, "Fatal error", err, Reason.UNHANDLED_REJECTION);
561
686
  }
562
687
 
563
688
  if (Bun.isMainThread) {
@@ -607,7 +732,7 @@ if (Bun.isMainThread) {
607
732
  return;
608
733
  }
609
734
  await exitAfterFatal(
610
- `${formatFatalError("Uncaught Exception", err)}${formatFatalRecoveryHints()}`,
735
+ formatFatalError("Uncaught Exception", err),
611
736
  "Uncaught exception",
612
737
  err,
613
738
  Reason.UNCAUGHT_EXCEPTION,
@@ -630,7 +755,7 @@ if (Bun.isMainThread) {
630
755
  }
631
756
  }
632
757
  await exitAfterFatal(
633
- `${formatFatalError("Unhandled Rejection", err)}${formatFatalRecoveryHints()}`,
758
+ formatFatalError("Unhandled Rejection", err),
634
759
  "Unhandled rejection",
635
760
  err,
636
761
  Reason.UNHANDLED_REJECTION,
@@ -640,7 +765,12 @@ if (Bun.isMainThread) {
640
765
  void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
641
766
  })
642
767
  .on("SIGTERM", async () => {
768
+ // A stopped session otherwise leaves its last frame above the shell prompt,
769
+ // which reads as a crash. A repeated SIGTERM joins the same cleanup and the
770
+ // hints still print once.
771
+ const hintProviders = [...fatalRecoveryHintProviders];
643
772
  await runCleanup(Reason.SIGTERM);
773
+ writeRecoveryHints(hintProviders, "\n[Stopped by SIGTERM]");
644
774
  exitProcess(143); // 128 + SIGTERM (15)
645
775
  })
646
776
  .on("SIGHUP", async () => {
@@ -664,6 +794,20 @@ export interface CleanupRegistrationOptions {
664
794
  * The registration remains armed when a keep-alive pass skips it.
665
795
  */
666
796
  exitOnly?: boolean;
797
+ /**
798
+ * How long a pass waits for this callback, when that is longer than the
799
+ * default 10 s cleanup deadline. It extends the pass only while the callback
800
+ * is still pending: one that settles early ends its hold at once.
801
+ */
802
+ deadlineMs?: number;
803
+ /**
804
+ * With `deadlineMs`: a line printed to stderr, with the remaining wait
805
+ * appended, if exit is still waiting on this callback once every other
806
+ * callback has finished (a terminal owner has then released the screen), or
807
+ * at the default 10 s deadline when another callback is still running.
808
+ * Nothing is printed when the callback finishes first.
809
+ */
810
+ waitMessage?: string;
667
811
  }
668
812
 
669
813
  /**
@@ -690,6 +834,8 @@ export function register(
690
834
  id,
691
835
  callback,
692
836
  exitOnly: options.exitOnly ?? false,
837
+ deadlineMs: options.deadlineMs,
838
+ waitMessage: options.waitMessage,
693
839
  cancelled: false,
694
840
  lastPass: 0,
695
841
  };
@@ -709,6 +855,9 @@ export function register(
709
855
  // Join the active pass so cleanup()/signal exits await it; after a
710
856
  // completed exit pass there is nothing left to join.
711
857
  activeLatePromises?.push(tracked);
858
+ if (registration.deadlineMs !== undefined) {
859
+ activePassDeadline?.extend(tracked, registration.deadlineMs, registration.waitMessage);
860
+ }
712
861
  } catch (error) {
713
862
  const err = error instanceof Error ? error : new Error(String(error));
714
863
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
@@ -1,17 +0,0 @@
1
- /**
2
- * `fs.openSync` with close-on-exec, matching Node's default.
3
- *
4
- * libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
5
- * dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
6
- * `bun@>=1.4` pin), and descriptors it returns survive
7
- * into any child spawned through a path that does not close strays itself — the
8
- * bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
9
- * this for every descriptor kept open past such a spawn (session transcripts,
10
- * logs, spools); a child must never hold a writable handle to them (#13224).
11
- *
12
- * Flags are numeric only: the bit cannot be OR-ed into `"a"`/`"w+"` strings.
13
- *
14
- * @example
15
- * const fd = openCloexecSync(logPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
16
- */
17
- export declare function openCloexecSync(filePath: string, flags: number, mode?: number): number;
package/src/fs-open.ts DELETED
@@ -1,32 +0,0 @@
1
- /** Close-on-exec file opens for descriptors held past a child spawn. */
2
- import * as fs from "node:fs";
3
-
4
- /**
5
- * `O_CLOEXEC`, which neither Node nor Bun exposes in `fs.constants`.
6
- *
7
- * Kernel ABI values: `0o2000000` on Linux, `0x0100_0000` on Darwin. Windows has
8
- * no `exec` and its CRT handles are inherited only when a spawn explicitly asks
9
- * for it, so the bit is 0 there — as it is on any other platform, where adding
10
- * an unknown flag bit would be worse than leaving the descriptor inheritable.
11
- */
12
- const O_CLOEXEC = process.platform === "linux" ? 0o2000000 : process.platform === "darwin" ? 0x0100_0000 : 0;
13
-
14
- /**
15
- * `fs.openSync` with close-on-exec, matching Node's default.
16
- *
17
- * libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
18
- * dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
19
- * `bun@>=1.4` pin), and descriptors it returns survive
20
- * into any child spawned through a path that does not close strays itself — the
21
- * bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
22
- * this for every descriptor kept open past such a spawn (session transcripts,
23
- * logs, spools); a child must never hold a writable handle to them (#13224).
24
- *
25
- * Flags are numeric only: the bit cannot be OR-ed into `"a"`/`"w+"` strings.
26
- *
27
- * @example
28
- * const fd = openCloexecSync(logPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
29
- */
30
- export function openCloexecSync(filePath: string, flags: number, mode?: number): number {
31
- return fs.openSync(filePath, flags | O_CLOEXEC, mode);
32
- }