@oh-my-pi/pi-utils 18.0.7 → 18.0.8

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.8] - 2026-08-27
6
+
7
+ ### Added
8
+
9
+ - Added the Linux `subreaper` spawn option to retain reparented descendants for process-tree cleanup.
10
+
11
+ ### Fixed
12
+
13
+ - Keep project-directory state unchanged when changing directories fails.
14
+ - Fixed `ptree` timeout cleanup and output capture so timed commands retain their deadline through descendant-held pipes and untimed commands read output to EOF.
15
+
5
16
  ## [18.0.7] - 2026-08-26
6
17
 
7
18
  ### Added
@@ -48,6 +48,10 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
48
48
  export declare function getProjectDir(): string;
49
49
  /** Set the project directory. */
50
50
  export declare function setProjectDir(dir: string): void;
51
+ /** Reset the cached project directory (test seam). */
52
+ export declare function __resetProjectDirCacheForTests(): void;
53
+ /** Whether a path is absent or not a directory. Other stat failures return false. */
54
+ export declare function directoryIsMissing(dir: string): Promise<boolean>;
51
55
  /**
52
56
  * Whether `dir` resolves to an existing directory. Any stat failure — a deleted
53
57
  * path (ENOENT), permission error, or a non-directory — returns `false`, so
@@ -55,6 +59,21 @@ export declare function setProjectDir(dir: string): void;
55
59
  * working directory before {@link setProjectDir} throws on it.
56
60
  */
57
61
  export declare function directoryExists(dir: string): Promise<boolean>;
62
+ /**
63
+ * Whether `dir` both exists and can be entered. POSIX `stat` succeeds for a
64
+ * directory whose own search/execute permission is denied (it only needs
65
+ * +x on the parent chain), so existence alone does not imply `chdir` works.
66
+ * Callers that adopt a directory as a working directory must check this
67
+ * rather than {@link directoryExists} alone.
68
+ */
69
+ export declare function directoryIsEnterable(dir: string): Promise<boolean>;
70
+ /** Whether `dir` is enterable, synchronous variant. See {@link directoryIsEnterable}. */
71
+ export declare function directoryIsEnterableSync(dir: string): boolean;
72
+ /**
73
+ * Project directory when it is enterable, otherwise a safe fallback.
74
+ * Used by spawns that must preserve project-relative behavior when healthy.
75
+ */
76
+ export declare function getSafeProjectCwd(): string;
58
77
  /** Get the config directory name relative to home (e.g. ".omp" or PI_CONFIG_DIR override). */
59
78
  export declare function getConfigDirName(): string;
60
79
  /** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
@@ -10,6 +10,13 @@ import type { Spawn, Subprocess } from "bun";
10
10
  type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
11
11
  /** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
12
12
  type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
13
+ /**
14
+ * Build the Linux child-subreaper entrypoint.
15
+ *
16
+ * @internal Exported so tests can force a missing first libc soname and verify
17
+ * the loader continues to the next candidate.
18
+ */
19
+ export declare function createLinuxSubreaperScript(libcCandidates?: readonly string[]): string;
13
20
  /**
14
21
  * Base for all exceptions representing child process nonzero exit, killed, or
15
22
  * cancellation.
@@ -63,7 +70,7 @@ export declare class ChildProcess<In extends InMask = InMask> {
63
70
  #private;
64
71
  readonly proc: PipedSubprocess<In>;
65
72
  readonly exposeStderr: boolean;
66
- constructor(proc: PipedSubprocess<In>, exposeStderr: boolean, retainFullStderr?: boolean);
73
+ constructor(proc: PipedSubprocess<In>, exposeStderr: boolean, retainFullStderr?: boolean, terminateGroup?: boolean, hardKillTree?: boolean);
67
74
  get pid(): number;
68
75
  get exited(): Promise<number>;
69
76
  get exitCode(): number | null;
@@ -93,6 +100,13 @@ export declare class ChildProcess<In extends InMask = InMask> {
93
100
  type ChildSpawnOptions<In extends InMask = InMask> = Omit<Spawn.SpawnOptions<In, "pipe", "pipe">, "stdout" | "stderr" | "detached"> & {
94
101
  signal?: AbortSignal;
95
102
  detached?: boolean;
103
+ /**
104
+ * On Linux, supervise the command from a child subreaper so descendants
105
+ * remain reachable after changing session and reparenting. Other platforms
106
+ * ignore this option. macOS process groups cannot retain a daemonized
107
+ * descendant that creates a new session and reparents to launchd.
108
+ */
109
+ subreaper?: boolean;
96
110
  /** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
97
111
  stderr?: "full" | null;
98
112
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "18.0.7",
4
+ "version": "18.0.8",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "18.0.7"
34
+ "@oh-my-pi/pi-natives": "18.0.8"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
package/src/dirs.ts CHANGED
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
15
15
  import * as os from "node:os";
16
16
  import * as path from "node:path";
17
17
  import { engines, version } from "../package.json" with { type: "json" };
18
+ import { isEnoent, isEnotdir } from "./fs-error";
18
19
 
19
20
  /** App name (e.g. "omp") */
20
21
  export const APP_NAME: string = "omp";
@@ -178,17 +179,50 @@ export function relativePathWithinRoot(root: string, candidate: string): string
178
179
  return relative || null;
179
180
  }
180
181
 
181
- let projectDir = standardizeMacOSPath(process.cwd());
182
+ let projectDir: string | undefined;
182
183
 
183
184
  /** Get the project directory. */
184
185
  export function getProjectDir(): string {
186
+ if (projectDir === undefined) {
187
+ try {
188
+ projectDir = standardizeMacOSPath(process.cwd());
189
+ } catch {
190
+ const candidates = [process.env.PWD, os.homedir(), os.tmpdir()];
191
+ for (const candidate of candidates) {
192
+ if (!candidate || !path.isAbsolute(candidate)) continue;
193
+ try {
194
+ process.chdir(candidate);
195
+ projectDir = standardizeMacOSPath(candidate);
196
+ break;
197
+ } catch {}
198
+ }
199
+ if (projectDir === undefined) {
200
+ throw new Error("Unable to determine an accessible working directory");
201
+ }
202
+ }
203
+ }
185
204
  return projectDir;
186
205
  }
187
206
 
188
207
  /** Set the project directory. */
189
208
  export function setProjectDir(dir: string): void {
190
- projectDir = standardizeMacOSPath(path.resolve(dir));
191
- process.chdir(projectDir);
209
+ const resolved = standardizeMacOSPath(path.resolve(dir));
210
+ process.chdir(resolved);
211
+ projectDir = resolved;
212
+ }
213
+
214
+ /** Reset the cached project directory (test seam). */
215
+ export function __resetProjectDirCacheForTests(): void {
216
+ projectDir = undefined;
217
+ }
218
+
219
+ /** Whether a path is absent or not a directory. Other stat failures return false. */
220
+ export async function directoryIsMissing(dir: string): Promise<boolean> {
221
+ try {
222
+ return !(await fs.promises.stat(dir)).isDirectory();
223
+ } catch (error) {
224
+ return isEnoent(error) || isEnotdir(error);
225
+ }
192
226
  }
193
227
 
194
228
  /**
@@ -205,6 +239,44 @@ export async function directoryExists(dir: string): Promise<boolean> {
205
239
  }
206
240
  }
207
241
 
242
+ /**
243
+ * Whether `dir` both exists and can be entered. POSIX `stat` succeeds for a
244
+ * directory whose own search/execute permission is denied (it only needs
245
+ * +x on the parent chain), so existence alone does not imply `chdir` works.
246
+ * Callers that adopt a directory as a working directory must check this
247
+ * rather than {@link directoryExists} alone.
248
+ */
249
+ export async function directoryIsEnterable(dir: string): Promise<boolean> {
250
+ try {
251
+ const [stats] = await Promise.all([fs.promises.stat(dir), fs.promises.access(dir, fs.constants.X_OK)]);
252
+ return stats.isDirectory();
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
257
+
258
+ /** Whether `dir` is enterable, synchronous variant. See {@link directoryIsEnterable}. */
259
+ export function directoryIsEnterableSync(dir: string): boolean {
260
+ try {
261
+ fs.accessSync(dir, fs.constants.X_OK);
262
+ return fs.statSync(dir).isDirectory();
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Project directory when it is enterable, otherwise a safe fallback.
270
+ * Used by spawns that must preserve project-relative behavior when healthy.
271
+ */
272
+ export function getSafeProjectCwd(): string {
273
+ try {
274
+ const dir = getProjectDir();
275
+ if (directoryIsEnterableSync(dir)) return dir;
276
+ } catch {}
277
+ return os.homedir();
278
+ }
279
+
208
280
  /** Get the config directory name relative to home (e.g. ".omp" or PI_CONFIG_DIR override). */
209
281
  export function getConfigDirName(): string {
210
282
  return process.env.PI_CONFIG_DIR || CONFIG_DIR_NAME;
package/src/env.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { getAgentDir, getConfigRootDir, refreshDirsFromEnv } from "./dirs";
4
+ import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
5
5
 
6
6
  export * from "./worker-host";
7
7
 
@@ -228,7 +228,7 @@ export function parseEnvFile(filePath: string): Record<string, string> {
228
228
  const homeEnv = parseEnvFile(path.join(os.homedir(), ".env"));
229
229
  const piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
230
230
  const agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
231
- const projectEnv = parseEnvFile(path.join(process.cwd(), ".env"));
231
+ const projectEnv = parseEnvFile(path.join(getProjectDir(), ".env"));
232
232
 
233
233
  for (const key of Object.keys(Bun.env)) {
234
234
  const value = Bun.env[key];
package/src/ptree.ts CHANGED
@@ -15,6 +15,83 @@ type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
15
15
  /** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
16
16
  type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
17
17
 
18
+ const LINUX_SUBREAPER_COMMAND_ENV = "OMP_PTREE_SUBREAPER_COMMAND";
19
+ const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "OMP_PTREE_SUBREAPER_BUN_BE_BUN";
20
+
21
+ /**
22
+ * Build the Linux child-subreaper entrypoint.
23
+ *
24
+ * @internal Exported so tests can force a missing first libc soname and verify
25
+ * the loader continues to the next candidate.
26
+ */
27
+ export function createLinuxSubreaperScript(libcCandidates: readonly string[] = ["libc.so.6", "libc.so"]): string {
28
+ return `
29
+ import { dlopen, FFIType } from "bun:ffi";
30
+
31
+ let libc;
32
+ for (const soname of ${JSON.stringify(libcCandidates)}) {
33
+ try {
34
+ libc = dlopen(soname, {
35
+ prctl: {
36
+ args: [FFIType.i32, FFIType.u64, FFIType.u64, FFIType.u64, FFIType.u64],
37
+ returns: FFIType.i32,
38
+ },
39
+ waitpid: {
40
+ args: [FFIType.i32, FFIType.ptr, FFIType.i32],
41
+ returns: FFIType.i32,
42
+ },
43
+ });
44
+ break;
45
+ } catch {}
46
+ }
47
+ if (!libc) throw new Error("failed to load libc for Linux child supervision");
48
+
49
+ if (libc.symbols.prctl(36, 1, 0, 0, 0) !== 0) {
50
+ throw new Error("failed to become a Linux child subreaper");
51
+ }
52
+
53
+ const commandJson = Bun.env.${LINUX_SUBREAPER_COMMAND_ENV};
54
+ if (!commandJson) throw new Error("missing supervised command");
55
+ const callerBunBeBun = Bun.env.${LINUX_SUBREAPER_BUN_BE_BUN_ENV};
56
+ delete Bun.env.${LINUX_SUBREAPER_COMMAND_ENV};
57
+ delete Bun.env.${LINUX_SUBREAPER_BUN_BE_BUN_ENV};
58
+ if (callerBunBeBun === undefined) delete Bun.env.BUN_BE_BUN;
59
+ else Bun.env.BUN_BE_BUN = callerBunBeBun;
60
+ const command = JSON.parse(commandJson);
61
+ const child = Bun.spawn(command, {
62
+ stdin: "inherit",
63
+ stdout: "pipe",
64
+ stderr: "pipe",
65
+ windowsHide: true,
66
+ env: Bun.env,
67
+ });
68
+
69
+ async function relay(stream, destination) {
70
+ const writer = destination.writer();
71
+ for await (const chunk of stream) writer.write(chunk);
72
+ await writer.flush();
73
+ }
74
+
75
+ function hasLiveChildren() {
76
+ let childPid;
77
+ do {
78
+ childPid = libc.symbols.waitpid(-1, null, 1);
79
+ } while (childPid > 0);
80
+ return childPid === 0;
81
+ }
82
+
83
+ const [exitCode] = await Promise.all([
84
+ child.exited,
85
+ relay(child.stdout, Bun.stdout),
86
+ relay(child.stderr, Bun.stderr),
87
+ ]);
88
+ while (hasLiveChildren()) await Bun.sleep(10);
89
+ process.exit(exitCode ?? 1);
90
+ `;
91
+ }
92
+
93
+ const LINUX_SUBREAPER_SCRIPT = createLinuxSubreaperScript();
94
+
18
95
  // ── Exceptions ───────────────────────────────────────────────────────────────
19
96
 
20
97
  /**
@@ -103,13 +180,30 @@ export class ChildProcess<In extends InMask = InMask> {
103
180
  #exitReasonPending?: Exception;
104
181
  #stderrDone: Promise<void>;
105
182
  #exited: Promise<number>;
183
+ #openPipeReaders = 1;
184
+ // Pipe reads race this cutoff only when attachTimeout() configures a
185
+ // command deadline. Untimed commands preserve complete EOF-based capture.
186
+ #drainCutoff: Promise<void>;
187
+ #resolveDrainCutoff: () => void;
188
+ #timeoutTimer?: NodeJS.Timeout;
106
189
  #stderrStream?: ReadableStream<Uint8Array>;
107
-
190
+ // Termination in flight after kill(); aborted exits await it before reporting.
191
+ #terminating?: Promise<boolean | void>;
192
+ #terminateGroup: boolean;
193
+ #hardKillTree: boolean;
194
+ // Windows has no process groups. Retaining the root's native handle pins
195
+ // its PID after exit so killTree() can still enumerate its original children.
196
+ #windowsRootProcess?: Process;
108
197
  constructor(
109
198
  readonly proc: PipedSubprocess<In>,
110
199
  readonly exposeStderr: boolean,
111
200
  retainFullStderr = exposeStderr,
201
+ terminateGroup = false,
202
+ hardKillTree = false,
112
203
  ) {
204
+ this.#terminateGroup = terminateGroup;
205
+ this.#hardKillTree = hardKillTree;
206
+ this.#windowsRootProcess = process.platform === "win32" ? (Process.fromPid(proc.pid) ?? undefined) : undefined;
113
207
  if (retainFullStderr) this.#stderrChunks = [];
114
208
  // Eagerly drain stderr into a truncated tail, retaining raw chunks only for explicit full capture.
115
209
  const dec = new TextDecoder();
@@ -123,22 +217,39 @@ export class ChildProcess<In extends InMask = InMask> {
123
217
  this.#stderrStream = teeStream;
124
218
  stderrStream = drainStream;
125
219
  }
220
+ // Normalize Bun's exited promise into our exitReason / exitedCleanly model.
221
+ const { promise, resolve, reject } = Promise.withResolvers<number>();
222
+ this.#exited = promise;
223
+ const drainCutoff = Promise.withResolvers<void>();
224
+ this.#drainCutoff = drainCutoff.promise;
225
+ this.#resolveDrainCutoff = drainCutoff.resolve;
226
+ // The cutoff remains pending for untimed commands, preserving complete
227
+ // EOF-based capture. attachTimeout() resolves it at the command deadline.
228
+
229
+ const pipeCutoff = this.#drainCutoff;
126
230
  this.#stderrDone = (async () => {
231
+ const reader = stderrStream.getReader();
127
232
  try {
128
- for await (const chunk of stderrStream) {
129
- this.#stderrChunks?.push(chunk);
130
- this.#stderrTail += dec.decode(chunk, { stream: true });
233
+ for (;;) {
234
+ const chunk = await Promise.race([
235
+ reader.read().then(r => ({ cutoff: false as const, r })),
236
+ pipeCutoff.then(() => ({ cutoff: true as const })),
237
+ ]);
238
+ if (chunk.cutoff) {
239
+ await reader.cancel().catch(() => {});
240
+ break;
241
+ }
242
+ if (chunk.r.done) break;
243
+ this.#stderrChunks?.push(chunk.r.value);
244
+ this.#stderrTail += dec.decode(chunk.r.value, { stream: true });
131
245
  trim();
132
246
  }
133
247
  } catch {}
248
+ this.#openPipeReaders--;
134
249
  this.#stderrTail += dec.decode();
135
250
  trim();
136
251
  })();
137
252
 
138
- // Normalize Bun's exited promise into our exitReason / exitedCleanly model.
139
- const { promise, resolve, reject } = Promise.withResolvers<number>();
140
- this.#exited = promise;
141
-
142
253
  proc.exited
143
254
  .catch(() => null)
144
255
  .then(async exitCode => {
@@ -153,6 +264,11 @@ export class ChildProcess<In extends InMask = InMask> {
153
264
  }
154
265
 
155
266
  await this.#stderrDone;
267
+ if (this.#exitReasonPending) {
268
+ this.#exitReason = this.#exitReasonPending;
269
+ reject(this.#exitReasonPending);
270
+ return;
271
+ }
156
272
 
157
273
  if (exitCode !== null) {
158
274
  this.#exitReason = new NonZeroExitError(exitCode, this.#stderrTail);
@@ -218,44 +334,160 @@ export class ChildProcess<In extends InMask = InMask> {
218
334
  }
219
335
 
220
336
  kill(reason?: Exception, gracefulMs?: number) {
221
- if (reason && !this.#exitReasonPending) this.#exitReasonPending = reason;
222
- if (!this.proc.killed)
223
- void Process.fromPid(this.proc.pid)
224
- ?.terminate(gracefulMs === undefined ? undefined : { gracefulMs })
337
+ if (reason && !this.#exitReasonPending) {
338
+ this.#exitReasonPending = reason;
339
+ // The normalized exit promise may already have resolved from a dead
340
+ // group leader; wait() still needs to report the later deadline.
341
+ if (this.proc.exitCode !== null) this.#exitReason = reason;
342
+ }
343
+ 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.
347
+ const root = Process.fromPid(this.proc.pid);
348
+ if (root) {
349
+ root.killTree(9);
350
+ this.#terminating = Promise.resolve();
351
+ return;
352
+ }
353
+ }
354
+ if (
355
+ this.proc.exitCode !== null &&
356
+ this.#terminateGroup &&
357
+ this.#openPipeReaders > 0 &&
358
+ process.platform !== "win32"
359
+ ) {
360
+ // Bun detached children are POSIX session/process-group leaders. If
361
+ // the leader has exited, the native Process handle cannot rediscover
362
+ // its PGID, but a pipe-holding descendant keeps that exact group alive.
363
+ try {
364
+ process.kill(-this.proc.pid, "SIGKILL");
365
+ } catch {}
366
+ this.#terminating = Promise.resolve();
367
+ return;
368
+ }
369
+ if (this.proc.exitCode !== null && this.#windowsRootProcess && this.#openPipeReaders > 0) {
370
+ // The retained handle keeps the dead root PID reserved, making the
371
+ // Windows Toolhelp descendant walk identity-safe after root exit.
372
+ this.#windowsRootProcess.killTree();
373
+ this.#terminating = Promise.resolve();
374
+ return;
375
+ }
376
+ if (!this.proc.killed) {
377
+ const options =
378
+ gracefulMs === undefined
379
+ ? this.#terminateGroup
380
+ ? { group: true }
381
+ : undefined
382
+ : { gracefulMs, group: this.#terminateGroup };
383
+ this.#terminating = (this.#windowsRootProcess ?? Process.fromPid(this.proc.pid))
384
+ ?.terminate(options)
225
385
  ?.catch(e => void e);
386
+ }
226
387
  }
227
388
 
228
389
  // ── Output helpers ───────────────────────────────────────────────────
229
390
 
391
+ async #throwIfAborted(): Promise<void> {
392
+ const exitReason = this.exitReason;
393
+ if (!exitReason?.aborted) return;
394
+ if (this.#terminating) await this.#terminating;
395
+ throw exitReason;
396
+ }
397
+
230
398
  async text(): Promise<string> {
231
- const p = new Response(this.stdout).text();
399
+ const p = this.#readStream(this.proc.stdout);
232
400
  if (this.#nothrow) return p;
233
401
  const [text] = await Promise.all([p, this.exitedCleanly]);
402
+ await this.#throwIfAborted();
234
403
  return text;
235
404
  }
236
405
 
237
- async blob(): Promise<Blob> {
238
- const p = new Response(this.stdout).blob();
406
+ /**
407
+ * Read a pipe fully, stopping early only at an explicit command deadline.
408
+ */
409
+ async #readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
410
+ this.#openPipeReaders++;
411
+ const reader = stream.getReader();
412
+ const dec = new TextDecoder();
413
+ let out = "";
414
+ try {
415
+ for (;;) {
416
+ const chunk = await Promise.race([
417
+ reader.read().then(r => ({ cutoff: false as const, r })),
418
+ this.#drainCutoff.then(() => ({ cutoff: true as const })),
419
+ ]);
420
+ if (chunk.cutoff) {
421
+ await reader.cancel().catch(() => {});
422
+ break;
423
+ }
424
+ if (chunk.r.done) break;
425
+ out += dec.decode(chunk.r.value, { stream: true });
426
+ }
427
+ } catch {
428
+ // A cancelled or failed read keeps whatever was already collected.
429
+ }
430
+ this.#openPipeReaders--;
431
+ return out + dec.decode();
432
+ }
433
+
434
+ async #readBytes(): Promise<Uint8Array> {
435
+ const reader = this.proc.stdout.getReader();
436
+ this.#openPipeReaders++;
437
+ const chunks: Uint8Array[] = [];
438
+ let length = 0;
439
+ try {
440
+ for (;;) {
441
+ const chunk = await Promise.race([
442
+ reader.read().then(r => ({ cutoff: false as const, r })),
443
+ this.#drainCutoff.then(() => ({ cutoff: true as const })),
444
+ ]);
445
+ if (chunk.cutoff) {
446
+ await reader.cancel().catch(() => {});
447
+ break;
448
+ }
449
+ if (chunk.r.done) break;
450
+ chunks.push(chunk.r.value);
451
+ length += chunk.r.value.byteLength;
452
+ }
453
+ } catch {
454
+ // A cancelled or failed read keeps whatever was already collected.
455
+ } finally {
456
+ this.#openPipeReaders--;
457
+ reader.releaseLock();
458
+ }
459
+
460
+ const bytes = new Uint8Array(length);
461
+ let offset = 0;
462
+ for (const chunk of chunks) {
463
+ bytes.set(chunk, offset);
464
+ offset += chunk.byteLength;
465
+ }
466
+ return bytes;
467
+ }
468
+
469
+ async #readOutputBytes(waitForCleanExit = false): Promise<Uint8Array> {
470
+ const p = this.#readBytes();
239
471
  if (this.#nothrow) return p;
240
- const [blob] = await Promise.all([p, this.exitedCleanly]);
241
- return blob;
472
+ const bytes = waitForCleanExit ? (await Promise.all([p, this.exitedCleanly]))[0] : await p;
473
+ await this.#throwIfAborted();
474
+ return bytes;
475
+ }
476
+
477
+ async blob(): Promise<Blob> {
478
+ return new Blob([await this.#readOutputBytes(true)]);
242
479
  }
243
480
 
244
481
  async json(): Promise<unknown> {
245
- return new Response(this.stdout).json();
482
+ return JSON.parse(new TextDecoder().decode(await this.#readOutputBytes()));
246
483
  }
247
484
 
248
485
  async arrayBuffer(): Promise<ArrayBuffer> {
249
- return new Response(this.stdout).arrayBuffer();
486
+ return (await this.#readOutputBytes()).buffer as ArrayBuffer;
250
487
  }
251
488
 
252
489
  async bytes(): Promise<Uint8Array> {
253
- // Bun's `Response(stream).bytes()` returns the raw `ArrayBuffer` once the
254
- // stream emits more than one chunk (subprocess stdout chunks past ~128 KB).
255
- // Normalize at the contract boundary so every caller — SSH read,
256
- // `decodeUtf8Text`, callers slicing with `.subarray` — sees a `Uint8Array`.
257
- const body = (await new Response(this.stdout).bytes()) as Uint8Array | ArrayBuffer;
258
- return body instanceof Uint8Array ? body : new Uint8Array(body);
490
+ return this.#readOutputBytes();
259
491
  }
260
492
 
261
493
  // ── Wait ─────────────────────────────────────────────────────────────
@@ -267,7 +499,7 @@ export class ChildProcess<In extends InMask = InMask> {
267
499
  throw new Error('Full stderr capture must be requested when spawning the process (pass stderr: "full")');
268
500
  }
269
501
 
270
- const stdoutP = new Response(this.stdout).text();
502
+ const stdoutP = this.#readStream(this.proc.stdout);
271
503
  const stderrP =
272
504
  stderrMode === "full" && stderrChunks
273
505
  ? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(stderrChunks)))
@@ -282,12 +514,17 @@ export class ChildProcess<In extends InMask = InMask> {
282
514
  if (err instanceof Exception) exitError = err;
283
515
  else throw err;
284
516
  }
285
-
517
+ this.#clearTimeout();
286
518
  if (!exitError) exitError = this.exitReason;
287
519
  if (!exitError && this.exitCode !== null && this.exitCode !== 0) {
288
520
  exitError = new NonZeroExitError(this.exitCode, this.#stderrTail);
289
521
  }
290
522
 
523
+ // On abort/timeout, hold the result until the tree is actually gone: the
524
+ // native terminate() is graceful-first, and reporting before it finishes
525
+ // would leave timed-out descendants alive past the caller's budget.
526
+ if (exitError?.aborted && this.#terminating) await this.#terminating;
527
+
291
528
  const exitCode = this.exitCode ?? (exitError && !exitError.aborted ? exitError.exitCode : null);
292
529
  const ok = exitCode === 0;
293
530
 
@@ -307,18 +544,32 @@ export class ChildProcess<In extends InMask = InMask> {
307
544
  this.#exited.catch(() => {}).finally(() => signal.removeEventListener("abort", onAbort));
308
545
  }
309
546
 
547
+ #clearTimeout(): void {
548
+ if (!this.#timeoutTimer) return;
549
+ clearTimeout(this.#timeoutTimer);
550
+ this.#timeoutTimer = undefined;
551
+ }
552
+
310
553
  attachTimeout(ms: number): void {
311
554
  if (ms <= 0 || this.proc.killed) return;
312
555
  this.#exited.catch(() => {});
313
- Promise.race([
314
- Bun.sleep(ms).then(() => true),
315
- this.proc.exited.then(
316
- () => false,
317
- () => false,
318
- ),
319
- ]).then(timedOut => {
320
- if (timedOut) this.kill(new TimeoutError(ms, this.#stderrTail));
321
- });
556
+ // One unref'd deadline controls both termination and pipe collection.
557
+ // A clean command clears it in wait(), so fast invocations do not hold
558
+ // the event loop for the unused remainder.
559
+ const timer = setTimeout(() => {
560
+ // A detached group can remain alive after its leader exits. Only use
561
+ // the dead-leader fallback while an inherited pipe proves that exact
562
+ // group still has a live member; this avoids stale-PGID reuse.
563
+ if (
564
+ this.proc.exitCode === null ||
565
+ (this.#openPipeReaders > 0 && (this.#terminateGroup || this.#windowsRootProcess))
566
+ ) {
567
+ this.kill(new TimeoutError(ms, this.#stderrTail), -1);
568
+ }
569
+ this.#resolveDrainCutoff();
570
+ }, ms);
571
+ timer.unref?.();
572
+ this.#timeoutTimer = timer;
322
573
  }
323
574
 
324
575
  [Symbol.dispose](): void {
@@ -336,6 +587,13 @@ type ChildSpawnOptions<In extends InMask = InMask> = Omit<
336
587
  > & {
337
588
  signal?: AbortSignal;
338
589
  detached?: boolean;
590
+ /**
591
+ * On Linux, supervise the command from a child subreaper so descendants
592
+ * remain reachable after changing session and reparenting. Other platforms
593
+ * ignore this option. macOS process groups cannot retain a daemonized
594
+ * descendant that creates a new session and reparents to launchd.
595
+ */
596
+ subreaper?: boolean;
339
597
  /** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
340
598
  stderr?: "full" | null;
341
599
  };
@@ -345,15 +603,26 @@ function spawnInternal<In extends InMask = InMask>(
345
603
  opts: ChildSpawnOptions<In> | undefined,
346
604
  retainFullStderr: boolean,
347
605
  ): ChildProcess<In> {
348
- const { timeout = -1, signal, stderr, ...rest } = opts ?? {};
349
- const child = Bun.spawn(cmd, {
606
+ const { timeout = -1, signal, stderr, detached, subreaper = false, ...rest } = opts ?? {};
607
+ const useSubreaper = subreaper && process.platform === "linux";
608
+ const commandEnv = rest.env ?? Bun.env;
609
+ const child = Bun.spawn(useSubreaper ? [process.execPath, "-e", LINUX_SUBREAPER_SCRIPT] : cmd, {
350
610
  stdin: "ignore",
351
611
  stdout: "pipe",
352
612
  stderr: "pipe",
353
613
  windowsHide: true,
614
+ detached,
354
615
  ...rest,
616
+ env: useSubreaper
617
+ ? {
618
+ ...commandEnv,
619
+ BUN_BE_BUN: "1",
620
+ [LINUX_SUBREAPER_COMMAND_ENV]: JSON.stringify(cmd),
621
+ [LINUX_SUBREAPER_BUN_BE_BUN_ENV]: commandEnv.BUN_BE_BUN,
622
+ }
623
+ : rest.env,
355
624
  });
356
- const cp = new ChildProcess(child, stderr === "full", retainFullStderr);
625
+ const cp = new ChildProcess(child, stderr === "full", retainFullStderr, detached === true, useSubreaper);
357
626
  if (signal) cp.attachSignal(signal);
358
627
  if (timeout > 0) cp.attachTimeout(timeout);
359
628
  return cp;