@linxiraos/pi-utils 1.1.14 → 1.1.16

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +2 -52
  2. package/THIRD-PARTY-NOTICES.txt +54 -25
  3. package/dist/types/async.d.ts +18 -0
  4. package/dist/types/browsers.d.ts +2 -30
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +21 -0
  7. package/dist/types/env.d.ts +12 -2
  8. package/dist/types/executable.d.ts +4 -0
  9. package/dist/types/fetch-retry.d.ts +8 -2
  10. package/dist/types/file-lock.d.ts +12 -0
  11. package/dist/types/format.d.ts +7 -0
  12. package/dist/types/index.d.ts +2 -1
  13. package/dist/types/mime.d.ts +1 -0
  14. package/dist/types/path.d.ts +6 -0
  15. package/dist/types/peek-file.d.ts +2 -3
  16. package/dist/types/postmortem.d.ts +26 -5
  17. package/dist/types/procmgr.d.ts +2 -4
  18. package/dist/types/snowflake.d.ts +1 -0
  19. package/dist/types/sqlite.d.ts +27 -7
  20. package/dist/types/stream.d.ts +76 -1
  21. package/dist/types/which.d.ts +8 -2
  22. package/dist/types/yaml-config.d.ts +2 -0
  23. package/package.json +2 -2
  24. package/src/acp/transport.ts +37 -3
  25. package/src/async.ts +31 -0
  26. package/src/browsers.ts +46 -191
  27. package/src/color.ts +1 -1
  28. package/src/dirs.ts +34 -6
  29. package/src/env.ts +81 -34
  30. package/src/executable.ts +17 -0
  31. package/src/fetch-retry.ts +72 -13
  32. package/src/file-lock.ts +36 -2
  33. package/src/format.ts +16 -0
  34. package/src/index.ts +2 -1
  35. package/src/json.ts +12 -1
  36. package/src/logger/rotating-file.ts +40 -3
  37. package/src/logger.ts +16 -4
  38. package/src/mime.ts +3 -7
  39. package/src/path.ts +14 -0
  40. package/src/peek-file.ts +17 -67
  41. package/src/postmortem.ts +95 -47
  42. package/src/procmgr.ts +2 -12
  43. package/src/ptree.ts +36 -5
  44. package/src/snowflake.ts +12 -1
  45. package/src/sqlite.ts +242 -7
  46. package/src/stream.ts +167 -35
  47. package/src/which.ts +38 -14
  48. package/src/xml.ts +16 -0
  49. package/src/yaml-config.ts +8 -0
  50. package/dist/types/glob.d.ts +0 -28
  51. package/src/glob.ts +0 -189
package/src/peek-file.ts CHANGED
@@ -1,67 +1,20 @@
1
1
  /**
2
2
  * Read the first `maxBytes` of a file (offset 0) and pass that slice to `op`.
3
3
  *
4
- * Buffers are reused to avoid allocating on every peek: sync uses one growable
5
- * `Uint8Array`; async uses a small fixed pool of `Buffer`s with a bounded wait
6
- * queue, falling back to a fresh allocation when the pool and queue are saturated
7
- * or when `maxBytes` exceeds the pool slot size.
4
+ * Synchronous peeks reuse one growable `Uint8Array`; asynchronous peeks allocate
5
+ * their result window so callback-retained slices cannot be changed by a later read.
8
6
  */
9
7
  import * as fs from "node:fs";
10
8
 
11
- /** Async pool slot size; larger peeks allocate ad hoc. */
12
- const POOLED_BUFFER_SIZE = 512;
13
- const ASYNC_POOL_SIZE = 10;
14
- /** Cap waiter queue so heavy concurrency does not queue unbounded; overflow uses alloc. */
15
- const MAX_ASYNC_WAITERS = 4;
16
9
  const INITIAL_SYNC_BUFFER_SIZE = 1024;
17
- const EMPTY_BUFFER = Buffer.alloc(0);
10
+ const EMPTY_BUFFER = new Uint8Array(0);
18
11
 
19
- const asyncPool = Array.from({ length: ASYNC_POOL_SIZE }, () => Buffer.allocUnsafe(POOLED_BUFFER_SIZE));
20
- const availableAsyncPoolIndexes = Array.from({ length: ASYNC_POOL_SIZE }, (_, index) => index);
21
- const asyncPoolWaiters: Array<(index: number) => void> = [];
22
12
  let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
23
13
 
24
- /** Returns a pool slot index, or `-1` when the caller should use a standalone buffer. */
25
- function acquireAsyncPoolIndex(): Promise<number> | number {
26
- const index = availableAsyncPoolIndexes.pop();
27
- if (index !== undefined) {
28
- return index;
29
- }
30
- if (asyncPoolWaiters.length >= MAX_ASYNC_WAITERS) {
31
- return -1;
32
- }
33
- const { promise, resolve } = Promise.withResolvers<number>();
34
- asyncPoolWaiters.push(resolve);
35
- return promise;
36
- }
37
-
38
- function releaseAsyncPoolIndex(index: number): void {
39
- if (index < 0) {
40
- return;
41
- }
42
- const waiter = asyncPoolWaiters.shift();
43
- if (waiter) {
44
- waiter(index);
45
- return;
46
- }
47
- availableAsyncPoolIndexes.push(index);
48
- }
49
-
50
- async function withAsyncPoolBuffer<T>(maxBytes: number, op: (buffer: Buffer) => Promise<T>): Promise<T> {
51
- if (maxBytes <= 0) {
52
- return op(EMPTY_BUFFER);
53
- }
54
- if (maxBytes > POOLED_BUFFER_SIZE) {
55
- return op(Buffer.allocUnsafe(maxBytes));
56
- }
57
-
58
- const poolIndex = await acquireAsyncPoolIndex();
59
- const buffer = poolIndex >= 0 ? asyncPool[poolIndex] : Buffer.allocUnsafe(maxBytes);
60
- try {
61
- return await op(buffer.subarray(0, maxBytes));
62
- } finally {
63
- releaseAsyncPoolIndex(poolIndex);
64
- }
14
+ function allocateWindow(length: number): Uint8Array {
15
+ const buffer = Buffer.allocUnsafe(length);
16
+ // A plain view preserves Uint8Array.slice semantics without zeroing bytes the read replaces.
17
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
65
18
  }
66
19
 
67
20
  function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
@@ -104,10 +57,9 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
104
57
 
105
58
  const fileHandle = await fs.promises.open(filePath, "r");
106
59
  try {
107
- return await withAsyncPoolBuffer(maxBytes, async buffer => {
108
- const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
109
- return op(buffer.subarray(0, bytesRead));
110
- });
60
+ const buffer = allocateWindow(maxBytes);
61
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
62
+ return op(buffer.subarray(0, bytesRead));
111
63
  } finally {
112
64
  await fileHandle.close();
113
65
  }
@@ -116,9 +68,8 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
116
68
  /**
117
69
  * Read up to the last `maxBytes` of `filePath` and pass that slice to `op`.
118
70
  *
119
- * The tail mirror of {@link peekFile}: same pooled-buffer strategy (no per-call
120
- * allocation for small reads), but the read is positioned at `size - len` so the
121
- * window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
71
+ * The tail mirror of {@link peekFile}: the read is positioned at `size - len` so
72
+ * the window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
122
73
  * returned. A multi-byte codepoint straddling the leading cut decodes to a
123
74
  * replacement char — callers that parse line-oriented tails drop the partial
124
75
  * leading line anyway.
@@ -135,10 +86,9 @@ export async function peekFileTail<T>(filePath: string, maxBytes: number, op: (t
135
86
  if (len <= 0) {
136
87
  return op(EMPTY_BUFFER);
137
88
  }
138
- return await withAsyncPoolBuffer(len, async buffer => {
139
- const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
140
- return op(buffer.subarray(0, bytesRead));
141
- });
89
+ const buffer = allocateWindow(len);
90
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
91
+ return op(buffer.subarray(0, bytesRead));
142
92
  } finally {
143
93
  await fileHandle.close();
144
94
  }
@@ -168,7 +118,7 @@ export async function peekFileEnds<T>(
168
118
  const headLen = prefixBytes > 0 ? Math.min(prefixBytes, size) : 0;
169
119
  const tailLen = suffixBytes > 0 ? Math.min(suffixBytes, size) : 0;
170
120
 
171
- const head = headLen > 0 ? Buffer.allocUnsafe(headLen) : EMPTY_BUFFER;
121
+ const head = headLen > 0 ? allocateWindow(headLen) : EMPTY_BUFFER;
172
122
  const headBytesRead = headLen > 0 ? (await fileHandle.read(head, 0, head.byteLength, 0)).bytesRead : 0;
173
123
  const headSlice = head.subarray(0, headBytesRead);
174
124
 
@@ -179,7 +129,7 @@ export async function peekFileEnds<T>(
179
129
  return op(headSlice, headSlice.subarray(Math.max(0, headBytesRead - tailLen)));
180
130
  }
181
131
 
182
- const tail = Buffer.allocUnsafe(tailLen);
132
+ const tail = allocateWindow(tailLen);
183
133
  const { bytesRead: tailBytesRead } = await fileHandle.read(tail, 0, tail.byteLength, size - tailLen);
184
134
  return op(headSlice, tail.subarray(0, tailBytesRead));
185
135
  } finally {
package/src/postmortem.ts CHANGED
@@ -7,8 +7,6 @@
7
7
  */
8
8
 
9
9
  import * as fs from "node:fs";
10
- import inspector from "node:inspector";
11
- import { isMainThread } from "node:worker_threads";
12
10
  import * as logger from "./logger";
13
11
  import { restoreTerminalStderr } from "./stderr-guard";
14
12
 
@@ -59,6 +57,26 @@ export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit"
59
57
 
60
58
  type HardExitFn = (code?: number) => never;
61
59
 
60
+ /**
61
+ * Walk a guarded exit primitive down to the native it shadows.
62
+ *
63
+ * `withHostGuard` stamps each throwing replacement with the primitive it
64
+ * shadows under {@link NATIVE_PROCESS_EXIT}; nested guard windows stack, so a
65
+ * single unwrap can still land on another throwing stub. Follow the chain
66
+ * (cycle-guarded) until a link carries no stamp — that link is native.
67
+ */
68
+ function nativeHardExit(fn: HardExitFn | undefined): HardExitFn | undefined {
69
+ let current = fn;
70
+ const seen = new Set<HardExitFn>();
71
+ while (typeof current === "function" && !seen.has(current)) {
72
+ seen.add(current);
73
+ const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
74
+ if (typeof behind !== "function") return current;
75
+ current = behind as HardExitFn;
76
+ }
77
+ return typeof current === "function" ? current : undefined;
78
+ }
79
+
62
80
  /**
63
81
  * Hard-exit the process through the native primitive, resolved on every call.
64
82
  *
@@ -70,14 +88,30 @@ type HardExitFn = (code?: number) => never;
70
88
  * init could freeze the throwing stub forever and turn every later shutdown
71
89
  * (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
72
90
  * guard is active the stub carries the native exit under
73
- * {@link NATIVE_PROCESS_EXIT}; unwrapping it lets a mid-guard signal still exit
74
- * (#6488). Otherwise the current `process.reallyExit`/`process.exit` is native.
91
+ * {@link NATIVE_PROCESS_EXIT} (#6488).
92
+ *
93
+ * Both globals are reinstalled to their natives before exiting: Bun's
94
+ * `process.exit` re-reads `process.reallyExit` at call time, so exiting through
95
+ * one primitive while its sibling still holds the throwing stub re-enters the
96
+ * guard and loops the rejection storm (#11789). After restoring, `reallyExit`
97
+ * (the low-level primitive) is preferred; `process.exit` and finally `SIGKILL`
98
+ * are fallbacks so a poisoned or absent chain can never leave the process alive.
75
99
  */
76
- function exitProcess(code: number): never {
77
- const current: HardExitFn = typeof process.reallyExit === "function" ? process.reallyExit : process.exit;
78
- const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
79
- const nativeExit = typeof behind === "function" ? (behind as HardExitFn) : current;
80
- return nativeExit.call(process, code) as never;
100
+ export function exitProcess(code: number): never {
101
+ const reallyExit = nativeHardExit(typeof process.reallyExit === "function" ? process.reallyExit : undefined);
102
+ const exit = nativeHardExit(process.exit as HardExitFn);
103
+ if (reallyExit) process.reallyExit = reallyExit as typeof process.reallyExit;
104
+ if (exit) process.exit = exit as typeof process.exit;
105
+ try {
106
+ reallyExit?.call(process, code);
107
+ } catch {}
108
+ try {
109
+ exit?.call(process, code);
110
+ } catch {}
111
+ try {
112
+ process.kill(process.pid, "SIGKILL");
113
+ } catch {}
114
+ throw new Error(`exitProcess(${code}) failed to terminate the process`);
81
115
  }
82
116
  let cleanupPromise: Promise<void> | undefined;
83
117
  let stdioDisconnectRegistrations = 0;
@@ -290,19 +324,47 @@ function faultWorkerIpcChannels(err: Error): void {
290
324
  }
291
325
 
292
326
  /**
293
- * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
327
+ * Graceful shutdown driven by `process.stdout`'s own `error` event.
294
328
  *
295
- * Stdio protocol servers call this for their process lifetime so a closed
296
- * client pipe runs registered cleanup callbacks instead of the fatal path.
297
- * The returned callback removes the registration.
329
+ * A closed stdout consumer (`omp --help | head`, an ACP client dropping the
330
+ * pipe) delivers the broken-pipe write here — attributable to stdout by
331
+ * construction, unlike a process-wide `syscall: "write"` match that a closed
332
+ * subprocess stdin or socket would also satisfy — so it runs cleanup and exits
333
+ * 0 (Unix `| head` semantics).
334
+ *
335
+ * Only the broken-pipe case is claimed. A non-EPIPE stdout error (a revoked PTY
336
+ * reporting `EIO`) is left for other `error` listeners: the TUI installs its own
337
+ * stdout handler that treats a disconnect as SIGHUP/exit-129, and this listener
338
+ * is installed first on an interactive launch, so forcing a fatal exit here
339
+ * would preempt that established path. Attaching a listener already suppresses
340
+ * Node's default throw, so deferring is a safe no-op when no other listener runs.
341
+ */
342
+ function onStdoutDisconnect(err: Error): void {
343
+ if (classifyBrokenPipe(err) !== "stdio-write") return;
344
+ logger.warn("Stdout peer disconnected; shutting down gracefully", { err });
345
+ void runQuit(0, "native", { drainStdout: false });
346
+ }
347
+
348
+ /**
349
+ * Treat a closed stdout consumer as a graceful peer disconnect for the caller's
350
+ * active lifetime. Attaches one shared `process.stdout` `error` listener,
351
+ * ref-counted across registrants (the ACP protocol server, the one-shot CLI
352
+ * entry). The returned callback removes the registration; the listener detaches
353
+ * when the last registrant unregisters.
298
354
  */
299
355
  export function registerStdioDisconnectHandling(): () => void {
300
356
  let registered = true;
357
+ if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
358
+ process.stdout.on("error", onStdoutDisconnect);
359
+ }
301
360
  stdioDisconnectRegistrations++;
302
361
  return () => {
303
362
  if (!registered) return;
304
363
  registered = false;
305
364
  stdioDisconnectRegistrations--;
365
+ if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
366
+ process.stdout.removeListener("error", onStdoutDisconnect);
367
+ }
306
368
  };
307
369
  }
308
370
 
@@ -416,48 +478,52 @@ async function exitAfterFatal(output: string, logMessage: string, err: Error, re
416
478
  }
417
479
  }
418
480
 
481
+ /** Contain an EPIPE from an optional worker IPC `send()` (#2997, #9158). */
482
+ function handleWorkerSendEpipe(err: Error): boolean {
483
+ if (!isIpcSendEpipe(err)) return false;
484
+ logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
485
+ return true;
486
+ }
487
+
419
488
  /**
420
489
  * Reports a caught top-level failure after terminal owners restore their display, then exits.
421
490
  */
422
491
  export async function fatal(error: unknown): Promise<never> {
423
492
  const err = error instanceof Error ? error : new Error(String(error));
424
493
  const output = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n${formatFatalRecoveryHints()}`;
425
- if (!isMainThread) {
494
+ if (!Bun.isMainThread) {
426
495
  process.stderr.write(output);
427
496
  process.exit(1);
428
497
  }
429
498
  return exitAfterFatal(output, "Fatal error", err, Reason.UNHANDLED_REJECTION);
430
499
  }
431
500
 
432
- if (isMainThread) {
501
+ if (Bun.isMainThread) {
433
502
  process
434
503
  .on("SIGINT", async () => {
435
504
  await runCleanup(Reason.SIGINT);
436
505
  exitProcess(130); // 128 + SIGINT (2)
437
506
  })
438
- .on("SIGUSR1", () => {
507
+ .on("SIGUSR1", async () => {
439
508
  if (inspectorOpened) return;
440
509
  inspectorOpened = true;
510
+ // Signal-only boundary: successful startup never constructs inspector.
511
+ const { default: inspector } = await import("node:inspector");
441
512
  inspector.open(undefined, undefined, false);
442
513
  const url = inspector.url();
443
514
  process.stderr.write(`Inspector opened: ${url}\n`);
444
515
  })
445
516
  .on("uncaughtException", async thrown => {
446
- // Only explicitly marked exceptions are safe here. Structural
447
- // AbortError/socket classification is limited to promise rejections:
448
- // a synchronously thrown error may indicate an application bug.
517
+ // Expected cleanup is safe globally; unrelated synchronous errors stay fatal.
449
518
  if (hasExpectedCleanupMarker(thrown)) {
450
519
  logger.warn("Ignoring expected cleanup exception", { err: thrown });
451
520
  return;
452
521
  }
453
522
  const err = thrown instanceof Error ? thrown : new Error(String(thrown));
454
- // Bun can surface a worker IPC send race through uncaughtException
455
- // instead of unhandledRejection. Apply the same optional-worker
456
- // containment in either global error channel.
457
- if (isIpcSendEpipe(err)) {
458
- logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
459
- return;
460
- }
523
+ // A worker IPC `send()` race can surface through either global error event;
524
+ // contain it in both. Stdout write disconnects are attributed to stdout by
525
+ // registerStdioDisconnectHandling's `error` listener, not classified here.
526
+ if (handleWorkerSendEpipe(err)) return;
461
527
  // A malformed advanced-serialization frame from a worker subprocess
462
528
  // surfaces here as a process-level uncaughtException (oven-sh/bun#37287)
463
529
  // rather than in the channel's ipc() callback, and Bun gives no way to
@@ -466,7 +532,7 @@ if (isMainThread) {
466
532
  // worker so its owning client rejects in-flight requests and recycles
467
533
  // the subprocess — a worker that sent a bad frame but stays alive would
468
534
  // otherwise never fire onExit and leave callers awaiting forever.
469
- // Mirrors the ipc-send EPIPE containment below (#9158, #2997).
535
+ // See the analogous worker IPC containment in handleBrokenPipe (#9158, #2997).
470
536
  if (isWorkerIpcDeserializeError(err)) {
471
537
  logger.warn("Malformed worker IPC frame; faulting active worker subsystems", { err });
472
538
  faultWorkerIpcChannels(err);
@@ -487,25 +553,7 @@ if (isMainThread) {
487
553
  })
488
554
  .on("unhandledRejection", async reason => {
489
555
  const err = reason instanceof Error ? reason : new Error(String(reason));
490
- const brokenPipeSource = classifyBrokenPipe(err);
491
- // EPIPE from an IPC `send()` (`syscall: "send"`) originates from a
492
- // worker subprocess whose pipe broke between the exit being observed
493
- // and the next `proc.send()` — a race window that Bun surfaces as an
494
- // async rejection rather than the synchronous "cannot be used after
495
- // the process has exited" guard. Every `send()` target is an optional
496
- // worker subsystem (TTS, STT, tiny-title, MCP servers), so a broken
497
- // send pipe must never take down the whole session. Log and continue
498
- // instead of exiting; the owning client detects the dead worker via
499
- // its own `onExit`/error path and respawns or disables it. See #2997.
500
- if (brokenPipeSource === "ipc-send") {
501
- logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
502
- return;
503
- }
504
- if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
505
- logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
506
- await runQuit(0, "native");
507
- return;
508
- }
556
+ if (handleWorkerSendEpipe(err)) return;
509
557
  if (isExpectedCleanupError(reason)) {
510
558
  logger.warn("Ignoring expected cleanup rejection", { err });
511
559
  return;
@@ -656,7 +704,7 @@ export async function drainStdout(): Promise<void> {
656
704
  async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
657
705
  await runCleanup(Reason.MANUAL);
658
706
 
659
- if (!isMainThread) {
707
+ if (!Bun.isMainThread) {
660
708
  return; // Workers: cleanup done, let worker exit naturally
661
709
  }
662
710
 
package/src/procmgr.ts CHANGED
@@ -4,8 +4,10 @@ import { Process, ProcessStatus } from "@linxiraos/pi-natives";
4
4
  import type { Subprocess } from "bun";
5
5
  import { getAgentDir, MAIN_CONFIG_FILENAMES } from "./dirs";
6
6
  import { $env, filterChildShellEnv } from "./env";
7
+ import { isExecutable } from "./executable";
7
8
  import { $which } from "./which";
8
9
 
10
+ export { isExecutable };
9
11
  export interface ShellConfig {
10
12
  shell: string;
11
13
  args: string[];
@@ -20,18 +22,6 @@ export interface ShellConfigOptions {
20
22
  }
21
23
  let cachedShellConfig: ShellConfig | null = null;
22
24
 
23
- /**
24
- * Check if a shell binary is executable.
25
- */
26
- export function isExecutable(path: string): boolean {
27
- try {
28
- fs.accessSync(path, fs.constants.X_OK);
29
- return true;
30
- } catch {
31
- return false;
32
- }
33
- }
34
-
35
25
  /**
36
26
  * Build the spawn environment (cached).
37
27
  */
package/src/ptree.ts CHANGED
@@ -17,6 +17,8 @@ type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe"
17
17
 
18
18
  const LINUX_SUBREAPER_COMMAND_ENV = "OMP_PTREE_SUBREAPER_COMMAND";
19
19
  const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "OMP_PTREE_SUBREAPER_BUN_BE_BUN";
20
+ const SUBREAPER_KILL_WINDOW_MS = 100;
21
+ const SUBREAPER_KILL_POLL_MS = 5;
20
22
 
21
23
  /**
22
24
  * Build the Linux child-subreaper entrypoint.
@@ -189,6 +191,8 @@ export class ChildProcess<In extends InMask = InMask> {
189
191
  #stderrStream?: ReadableStream<Uint8Array>;
190
192
  // Termination in flight after kill(); aborted exits await it before reporting.
191
193
  #terminating?: Promise<boolean | void>;
194
+ // A hard subreaper sweep must remain authoritative across overlapping kill requests.
195
+ #hardKillSweep?: Promise<void>;
192
196
  #terminateGroup: boolean;
193
197
  #hardKillTree: boolean;
194
198
  // Windows has no process groups. Retaining the root's native handle pins
@@ -340,14 +344,22 @@ export class ChildProcess<In extends InMask = InMask> {
340
344
  // group leader; wait() still needs to report the later deadline.
341
345
  if (this.proc.exitCode !== null) this.#exitReason = reason;
342
346
  }
347
+ // An AbortSignal can race a timeout after its hard subreaper sweep has
348
+ // started. Do not replace that sweep with a normal root termination: the
349
+ // root must stay alive until adopted descendants have been collected.
350
+ if (this.#hardKillSweep) return;
343
351
  if (gracefulMs !== undefined && gracefulMs < 0 && this.#hardKillTree && this.proc.exitCode === null) {
344
- // terminate() sends its polite wave to the root before rebuilding the
345
- // hard-kill tree. A subreaper root can die in that gap and release its
346
- // adopted descendants, so snapshot and hard-kill the live tree first.
352
+ // Keep the subreaper alive while descendants are killed. A single
353
+ // killTree() snapshot can miss a worker whose parent exits during the
354
+ // walk and reparents it to the subreaper after that root was enumerated.
347
355
  const root = Process.fromPid(this.proc.pid);
348
356
  if (root) {
349
- root.killTree(9);
350
- this.#terminating = Promise.resolve();
357
+ const sweep = this.#hardKillSubreaperTree(root).catch(e => void e);
358
+ this.#hardKillSweep = sweep;
359
+ this.#terminating = sweep;
360
+ void sweep.finally(() => {
361
+ if (this.#hardKillSweep === sweep) this.#hardKillSweep = undefined;
362
+ });
351
363
  return;
352
364
  }
353
365
  }
@@ -386,6 +398,25 @@ export class ChildProcess<In extends InMask = InMask> {
386
398
  }
387
399
  }
388
400
 
401
+ async #hardKillSubreaperTree(root: Process): Promise<void> {
402
+ try {
403
+ const deadline = Date.now() + SUBREAPER_KILL_WINDOW_MS;
404
+ let emptySweeps = 0;
405
+ while (emptySweeps < 2 && Date.now() < deadline) {
406
+ const children = root.children();
407
+ if (children.length === 0) {
408
+ emptySweeps++;
409
+ } else {
410
+ emptySweeps = 0;
411
+ for (const child of children) child.killTree(9);
412
+ }
413
+ if (emptySweeps < 2) await Bun.sleep(SUBREAPER_KILL_POLL_MS);
414
+ }
415
+ } finally {
416
+ root.killTree(9);
417
+ }
418
+ }
419
+
389
420
  // ── Output helpers ───────────────────────────────────────────────────
390
421
 
391
422
  async #throwIfAborted(): Promise<void> {
package/src/snowflake.ts CHANGED
@@ -4,6 +4,7 @@ function randu32() {
4
4
 
5
5
  const EPOCH = 1420070400000;
6
6
  const MAX_SEQ = 0x3fffff;
7
+ const MAX_DT = 2 ** 42 - 1;
7
8
 
8
9
  // Snowflake as a hex string (16 chars, zero-padded).
9
10
  //
@@ -25,14 +26,24 @@ namespace Snowflake {
25
26
  //
26
27
  export const MAX_SEQUENCE = MAX_SEQ;
27
28
 
29
+ // Last timestamp representable in the 42-bit timestamp field (~year 2154).
30
+ //
31
+ export const MAX_TIMESTAMP = EPOCH + MAX_DT;
32
+
28
33
  // Formats a sequence and timestamp into a snowflake hex string.
29
34
  //
30
35
  // dt fits well within BigInt range: (dt << 22) | seq stays under 2^64 for
31
36
  // any dt < 2^42 (~year 2154), so a single 64-bit format is exact — and
32
37
  // measures ~1.7x faster than stitching four 16-bit hex segments.
33
38
  //
39
+ // dt is saturated into [0, 2^42) so the result is always a valid snowflake:
40
+ // a negative delta (a timestamp before EPOCH) would otherwise render a
41
+ // leading "-", and a delta past ~2154 would widen the string beyond 16
42
+ // chars. Both cases produce a value that fails this module's own valid().
43
+ //
34
44
  export function formatParts(dt: number, seq: number): Snowflake {
35
- return ((BigInt(dt) << 22n) | BigInt(seq)).toString(16).padStart(16, "0") as Snowflake;
45
+ const clamped = Math.min(Math.max(dt, 0), MAX_DT);
46
+ return ((BigInt(clamped) << 22n) | BigInt(seq)).toString(16).padStart(16, "0") as Snowflake;
36
47
  }
37
48
 
38
49
  // Snowflake generator type.