@linxiraos/pi-utils 1.1.4 → 1.1.6

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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * LaTeX delimiter grammar for agent-authored Markdown: where does a math span
3
+ * begin and end, in source offsets. Carries no rendering policy — what to do
4
+ * with an unclosed opener, whether a body is typesettable, and how it is
5
+ * displayed belong to the renderer (Unicode in the TUI, KaTeX in collab web).
6
+ */
7
+
8
+ /** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
9
+ export type MathOpener = "$" | "$$" | "\\(" | "\\[";
10
+
11
+ /** A closed math span found in the source. */
12
+ export interface MathSpan {
13
+ opener: MathOpener;
14
+ /** True for the display forms `$$…$$` and `\[…\]`. */
15
+ display: boolean;
16
+ /** Offset one past the closing delimiter. */
17
+ end: number;
18
+ /** Source between the delimiters, verbatim. */
19
+ body: string;
20
+ }
21
+
22
+ /** An own-line display block: opener and closer each alone on their line. */
23
+ export interface MathBlock {
24
+ /** Both delimiter lines, the body, and the trailing newline. */
25
+ raw: string;
26
+ body: string;
27
+ }
28
+
29
+ // Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
30
+ // their own line (≤3 leading spaces). Matched at the block level — before
31
+ // paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
32
+ // row breaks) survives as one unit and blank lines inside the block don't split
33
+ // it. The own-line requirement leaves inline `$$…$$` inside prose to the span
34
+ // grammar below. `\r?\n` at each line boundary keeps the grammar CRLF-safe for
35
+ // direct callers; marked-fed renderers already normalize line endings first.
36
+ const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\$\$[ \t]*(?:\r?\n|$)/;
37
+ const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\\\][ \t]*(?:\r?\n|$)/;
38
+
39
+ /**
40
+ * Leftmost offset at or after `from` where an opener could begin. A scan hint,
41
+ * not a decision: whether that candidate is really math — escaped, currency,
42
+ * unclosed — is decided by {@link mathSpanAt}.
43
+ */
44
+ // Three indexOf scans instead of a `/\$|\\\(|\\\[/` alternation — marked calls
45
+ // this on the remaining source at every inline position, where the alternation
46
+ // showed up in CPU profiles (part of a ~4.3% start() tail).
47
+ export function mathStartIndex(source: string, from = 0): number | undefined {
48
+ let best = source.indexOf("$", from);
49
+ const paren = source.indexOf("\\(", from);
50
+ if (paren !== -1 && (best === -1 || paren < best)) best = paren;
51
+ const bracket = source.indexOf("\\[", from);
52
+ if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
53
+ return best === -1 ? undefined : best;
54
+ }
55
+
56
+ /** Math opener at `at`, or `undefined` when no delimiter starts there. */
57
+ export function mathOpenerAt(source: string, at: number): MathOpener | undefined {
58
+ const first = source.charCodeAt(at);
59
+ if (first === 0x24 /* $ */) return source.charCodeAt(at + 1) === 0x24 ? "$$" : "$";
60
+ if (first !== 0x5c /* \ */) return undefined;
61
+ const second = source.charCodeAt(at + 1);
62
+ if (second === 0x28 /* ( */) return "\\(";
63
+ if (second === 0x5b /* [ */) return "\\[";
64
+ return undefined;
65
+ }
66
+
67
+ /**
68
+ * The span opened at `at`, or `undefined` when the run is not math — including
69
+ * an opener the source escaped, so `\$x$` and `\\(x\)` are literal text.
70
+ *
71
+ * `from` bounds how far back the escape scan may look. Leave it at 0 when
72
+ * reading raw source. Pass the offset your own walk resumed at if you have
73
+ * already consumed the escapes behind it, as `renderMathInText` does: after it
74
+ * emits the `\\` of `\\\(x\)`, the `\(` that follows is a real opener even
75
+ * though a backslash precedes it.
76
+ */
77
+ export function mathSpanAt(source: string, at: number, from = 0): MathSpan | undefined {
78
+ const opener = mathOpenerAt(source, at);
79
+ if (opener === undefined || escapedAt(source, at, from)) return undefined;
80
+ const bodyStart = at + opener.length;
81
+ const closeAt = opener === "$" ? dollarCloserIndex(source, at) : closerIndex(source, opener, bodyStart);
82
+ if (closeAt === -1) return undefined;
83
+ const body = source.slice(bodyStart, closeAt);
84
+ // `dollarCloserIndex` already rejects an all-space `$…$`; `$$ $$` needs the
85
+ // same guard here, while `\(\)` and `\[\]` are unambiguous enough to keep.
86
+ if (opener === "$$" && body.trim() === "") return undefined;
87
+ return { opener, display: opener === "$$" || opener === "\\[", end: closeAt + opener.length, body };
88
+ }
89
+
90
+ /** The own-line display block starting at offset 0, or `undefined`. */
91
+ export function mathBlockAt(source: string): MathBlock | undefined {
92
+ const match = MATH_BLOCK_DOLLAR.exec(source) ?? MATH_BLOCK_BRACKET.exec(source);
93
+ if (!match || match[1].trim() === "") return undefined;
94
+ return { raw: match[0], body: match[1] };
95
+ }
96
+
97
+ /**
98
+ * Offset of the `$$` / `\)` / `\]` that closes a span, or -1. In `\(a \\) b\)`
99
+ * the `\\` is a TeX row break, so that `)` is body text and the span closes at
100
+ * the final `\)`.
101
+ */
102
+ function closerIndex(source: string, opener: MathOpener, from: number): number {
103
+ // Dollar closers equal their openers; the bracket forms flip the bracket.
104
+ const closer = opener === "\\(" ? "\\)" : opener === "\\[" ? "\\]" : opener;
105
+ for (let at = source.indexOf(closer, from); at !== -1; at = source.indexOf(closer, at + 1)) {
106
+ if (!escapedAt(source, at, from)) return at;
107
+ }
108
+ return -1;
109
+ }
110
+
111
+ /** An odd run of backslashes back to `from` escapes the delimiter at `index`. */
112
+ function escapedAt(source: string, index: number, from: number): boolean {
113
+ let backslashes = 0;
114
+ for (let at = index - 1; at >= from && source.charCodeAt(at) === 0x5c /* \ */; at--) backslashes++;
115
+ return backslashes % 2 === 1;
116
+ }
117
+
118
+ /**
119
+ * Offset of the `$` that closes an inline span opened at `open`, or -1. Pandoc's
120
+ * anti-currency heuristics: the opener must not be followed by whitespace, the
121
+ * closer must not be preceded by whitespace nor followed by a digit, `\$` is a
122
+ * literal dollar, and the span may not cross a newline — so "$5 and $10" is
123
+ * prose, not math.
124
+ */
125
+ function dollarCloserIndex(source: string, open: number): number {
126
+ const after = source[open + 1];
127
+ if (after === undefined || after === " " || after === "\t" || after === "\n" || after === "$") return -1;
128
+ for (let at = open + 1; at < source.length; at++) {
129
+ const char = source[at];
130
+ if (char === "\\") {
131
+ at++;
132
+ continue;
133
+ }
134
+ if (char === "\n") return -1;
135
+ if (char !== "$") continue;
136
+ const before = source[at - 1];
137
+ if (before === " " || before === "\t") return -1;
138
+ const next = source[at + 1];
139
+ if (next !== undefined && next >= "0" && next <= "9") continue; // currency: keep scanning
140
+ return source.slice(open + 1, at).trim().length > 0 ? at : -1;
141
+ }
142
+ return -1;
143
+ }
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
 
@@ -146,6 +202,33 @@ export function isIpcSendEpipe(err: Error): boolean {
146
202
  return classifyBrokenPipe(err) === "ipc-send";
147
203
  }
148
204
 
205
+ /**
206
+ * Whether an uncaught error is Bun's asynchronous `ERR_SOCKET_CLOSED` thrown
207
+ * from inside `node:net` internals with no application frames on the stack.
208
+ *
209
+ * Bun ≥1.4 can fire the close callback of an already-closed `node:net` socket
210
+ * on a fresh stack; the throw bypasses every callsite try/catch and surfaces
211
+ * here as a process-level uncaughtException. Closing an already-closed socket
212
+ * is inherently a no-op — the socket owner's own `error`/`close` handlers
213
+ * still drive recovery — so tearing the session down for it is pure loss.
214
+ * Only frameless internal stacks qualify: an `ERR_SOCKET_CLOSED` raised
215
+ * through application code keeps the fatal path.
216
+ */
217
+ export function isInternalSocketClosedError(err: unknown): boolean {
218
+ if (!(err instanceof Error) || !("code" in err) || err.code !== "ERR_SOCKET_CLOSED") return false;
219
+ const frames = (err.stack ?? "").split("\n").slice(1);
220
+ if (frames.length === 0) return false;
221
+ let hasNetFrame = false;
222
+ const internal = frames.every(frame => {
223
+ const trimmed = frame.trim();
224
+ if (trimmed === "" || trimmed === "at unknown" || trimmed === "at native") return true;
225
+ if (!/\(node:[^)]*\)$/.test(trimmed) && !/^at node:/.test(trimmed)) return false;
226
+ hasNetFrame ||= trimmed.includes("node:net:");
227
+ return true;
228
+ });
229
+ return internal && hasNetFrame;
230
+ }
231
+
149
232
  /**
150
233
  * Detect Bun's advanced-serialization (structured-clone) IPC decode failure.
151
234
  *
@@ -236,25 +319,29 @@ const EXPECTED_CLEANUP = Symbol.for("omp.expectedCleanupError");
236
319
  * consumer. Returns the same error for inline use at the `abort()` callsite.
237
320
  */
238
321
  export function markExpectedCleanupError<T extends object>(reason: T): T {
239
- (reason as Record<PropertyKey, unknown>)[EXPECTED_CLEANUP] = true;
322
+ Reflect.set(reason, EXPECTED_CLEANUP, true);
240
323
  return reason;
241
324
  }
242
325
 
243
- /**
244
- * Whether `reason` (or any error in its `cause` chain) was marked via
245
- * {@link markExpectedCleanupError}. Walks the chain because the unhandled
246
- * reason is often a wrapper (`AbortError`) with the marked abort reason as
247
- * its `cause`.
248
- */
249
- export function isExpectedCleanupError(reason: unknown): boolean {
326
+ function hasExpectedCleanupMarker(reason: unknown): boolean {
250
327
  let current: unknown = reason;
251
328
  for (let depth = 0; depth < 8 && current !== null && typeof current === "object"; depth++) {
252
- if ((current as Record<PropertyKey, unknown>)[EXPECTED_CLEANUP] === true) return true;
253
- current = (current as { cause?: unknown }).cause;
329
+ if (Reflect.get(current, EXPECTED_CLEANUP) === true) return true;
330
+ current = Reflect.get(current, "cause");
254
331
  }
255
332
  return false;
256
333
  }
257
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
+
258
345
  /** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */
259
346
  const rejectionInterceptors = new Set<(reason: unknown) => boolean>();
260
347
 
@@ -338,9 +425,20 @@ if (isMainThread) {
338
425
  const url = inspector.url();
339
426
  process.stderr.write(`Inspector opened: ${url}\n`);
340
427
  })
341
- .on("uncaughtException", async err => {
342
- if (isExpectedCleanupError(err)) {
343
- logger.warn("Ignoring expected cleanup exception", { err });
428
+ .on("uncaughtException", async 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)) {
433
+ logger.warn("Ignoring expected cleanup exception", { err: thrown });
434
+ return;
435
+ }
436
+ const err = thrown instanceof Error ? thrown : new Error(String(thrown));
437
+ // Bun can surface a worker IPC send race through uncaughtException
438
+ // instead of unhandledRejection. Apply the same optional-worker
439
+ // containment in either global error channel.
440
+ if (isIpcSendEpipe(err)) {
441
+ logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
344
442
  return;
345
443
  }
346
444
  // A malformed advanced-serialization frame from a worker subprocess
@@ -357,6 +455,12 @@ if (isMainThread) {
357
455
  faultWorkerIpcChannels(err);
358
456
  return;
359
457
  }
458
+ if (isInternalSocketClosedError(err)) {
459
+ logger.warn("Ignoring async ERR_SOCKET_CLOSED from node:net internals; socket owner recovers itself", {
460
+ err,
461
+ });
462
+ return;
463
+ }
360
464
  await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
361
465
  })
362
466
  .on("unhandledRejection", async reason => {
@@ -416,57 +520,92 @@ if (isMainThread) {
416
520
  });
417
521
  }
418
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
+
419
532
  /**
420
- * 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.
421
544
  *
422
- * Returns a Callback instance that can be used to cancel (unregister) or manually clean up.
423
- * If register is called after cleanup already began, invokes callback on a microtask.
545
+ * Returns a function that permanently cancels the registration.
424
546
  */
425
- export function register(id: string, callback: (reason: Reason) => void | Promise<void>): () => void {
426
- let done = false;
427
- const exec = (reason: Reason) => {
428
- if (done) return;
429
- 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 => {
430
565
  try {
431
- return callback(reason);
432
- } catch (e) {
433
- 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));
434
577
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
435
578
  }
436
579
  };
437
580
 
438
- const cancel = () => {
439
- const index = callbackList.indexOf(exec);
440
- if (index >= 0) {
441
- callbackList.splice(index, 1);
442
- }
443
- done = true;
444
- };
581
+ if (cleanupStage === "idle") {
582
+ callbackList.push(registration);
583
+ return cancel;
584
+ }
445
585
 
446
- if (cleanupStage !== "idle") {
447
- // Cleanup is already in progress or complete; run late registrations once
448
- // without re-entering the global cleanup pass.
449
- logger.debug("Cleanup already started; running late callback once", { id });
450
- try {
451
- callback(Reason.MANUAL);
452
- } catch (e) {
453
- const err = e instanceof Error ? e : new Error(String(e));
454
- logger.error("Cleanup callback failed", { err, id, stack: err.stack });
455
- }
456
- 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;
457
593
  }
458
594
 
459
- // Register callback as "armed" (active).
460
- 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);
461
599
  return cancel;
462
600
  }
463
601
 
464
602
  /**
465
- * 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.
466
605
  * Use this in workers or when you need to clean up but continue execution.
467
606
  */
468
607
  export function cleanup(): Promise<void> {
469
- return runCleanup(Reason.MANUAL);
608
+ return runCleanup(Reason.MANUAL, true);
470
609
  }
471
610
 
472
611
  /** Controls how manual process shutdown handles terminal output. */
@@ -475,6 +614,18 @@ export interface QuitOptions {
475
614
  drainStdout?: boolean;
476
615
  }
477
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
+
478
629
  async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
479
630
  await runCleanup(Reason.MANUAL);
480
631
 
@@ -482,10 +633,8 @@ async function runQuit(code: number, exitMode: "guarded" | "native", options: Qu
482
633
  return; // Workers: cleanup done, let worker exit naturally
483
634
  }
484
635
 
485
- if (options.drainStdout !== false && process.stdout.writableLength > 0) {
486
- const { promise, resolve } = Promise.withResolvers<void>();
487
- process.stdout.once("drain", resolve);
488
- await Promise.race([promise, Bun.sleep(5000)]);
636
+ if (options.drainStdout !== false) {
637
+ await drainStdout();
489
638
  }
490
639
 
491
640
  switch (exitMode) {