From 44402ad27abb6eba7dc115677f8dcc297fd15d91 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 6 Nov 2025 14:37:26 -0800 Subject: [PATCH] Document & cover some missing spawn/spawnSync options (#24417) --- packages/bun-types/bun.d.ts | 108 +++++++++++- packages/bun-types/test.d.ts | 4 +- test/integration/bun-types/fixture/spawn.ts | 42 +++++ test/integration/bun-types/fixture/test.ts | 4 + test/js/bun/spawn/spawn.test.ts | 186 +++++++++++++++++++- 5 files changed, 334 insertions(+), 10 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index c4be932f93..6d66e097c5 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -5289,7 +5289,12 @@ declare module "bun" { options: udp.ConnectSocketOptions, ): Promise>; - namespace SpawnOptions { + /** + * @deprecated use {@link Bun.Spawn} instead + */ + export import SpawnOptions = Spawn; + + namespace Spawn { /** * Option for stdout/stderr */ @@ -5320,7 +5325,12 @@ declare module "bun" { | Response | Request; - interface OptionsObject { + /** + * @deprecated use BaseOptions or the specific options for the specific {@link spawn} or {@link spawnSync} usage + */ + type OptionsObject = BaseOptions; + + interface BaseOptions { /** * The current working directory of the process * @@ -5328,6 +5338,22 @@ declare module "bun" { */ cwd?: string; + /** + * Run the child in a separate process group, detached from the parent. + * + * - POSIX: calls `setsid()` so the child starts a new session and becomes + * the process group leader. It can outlive the parent and receive + * signals independently of the parent’s terminal/process group. + * - Windows: sets `UV_PROCESS_DETACHED`, allowing the child to outlive + * the parent and receive signals independently. + * + * Note: stdio may keep the parent process alive. Pass `stdio: ["ignore", + * "ignore", "ignore"]` to the spawn constructor to prevent this. + * + * @default false + */ + detached?: boolean; + /** * The environment variables of the process * @@ -5430,6 +5456,48 @@ declare module "bun" { error?: ErrorLike, ): void | Promise; + /** + * Called exactly once when the IPC channel between the parent and this + * subprocess is closed. After this runs, no further IPC messages will be + * delivered. + * + * When it fires: + * - The child called `process.disconnect()` or the parent called + * `subprocess.disconnect()`. + * - The child exited for any reason (normal exit or due to a signal like + * `SIGILL`, `SIGKILL`, etc.). + * - The child replaced itself with a program that does not support Bun + * IPC. + * + * Notes: + * - This callback indicates that the pipe is closed; it is not an error + * by itself. Use {@link onExit} or {@link Subprocess.exited} to + * determine why the process ended. + * - It may occur before or after {@link onExit} depending on timing; do + * not rely on ordering. Typically, if you or the child call + * `disconnect()` first, this fires before {@link onExit}; if the + * process exits without an explicit disconnect, either may happen + * first. + * - Only runs when {@link ipc} is enabled and runs at most once per + * subprocess. + * - If the child becomes a zombie (exited but not yet reaped), the IPC is + * already closed, and this callback will fire (or may already have + * fired). + * + * @example + * + * ```ts + * const subprocess = spawn({ + * cmd: ["echo", "hello"], + * ipc: (message) => console.log(message), + * onDisconnect: () => { + * console.log("IPC channel disconnected"); + * }, + * }); + * ``` + */ + onDisconnect?(): void | Promise; + /** * When specified, Bun will open an IPC channel to the subprocess. The passed callback is called for * incoming messages, and `subprocess.send` can send messages to the subprocess. Messages are serialized @@ -5549,6 +5617,34 @@ declare module "bun" { maxBuffer?: number; } + interface SpawnSyncOptions + extends BaseOptions {} + + interface SpawnOptions + extends BaseOptions { + /** + * If true, stdout and stderr pipes will not automatically start reading + * data. Reading will only begin when you access the `stdout` or `stderr` + * properties. + * + * This can improve performance when you don't need to read output + * immediately. + * + * @default false + * + * @example + * ```ts + * const subprocess = Bun.spawn({ + * cmd: ["echo", "hello"], + * lazy: true, // Don't start reading stdout until accessed + * }); + * // stdout reading hasn't started yet + * await subprocess.stdout.text(); // Now reading starts + * ``` + */ + lazy?: boolean; + } + type ReadableToIO = X extends "pipe" | undefined ? ReadableStream> : X extends BunFile | ArrayBufferView | number @@ -5806,7 +5902,7 @@ declare module "bun" { const Out extends SpawnOptions.Readable = "pipe", const Err extends SpawnOptions.Readable = "inherit", >( - options: SpawnOptions.OptionsObject & { + options: SpawnOptions.SpawnOptions & { /** * The command to run * @@ -5856,7 +5952,7 @@ declare module "bun" { * ``` */ cmds: string[], - options?: SpawnOptions.OptionsObject, + options?: SpawnOptions.SpawnOptions, ): Subprocess; /** @@ -5878,7 +5974,7 @@ declare module "bun" { const Out extends SpawnOptions.Readable = "pipe", const Err extends SpawnOptions.Readable = "pipe", >( - options: SpawnOptions.OptionsObject & { + options: SpawnOptions.SpawnSyncOptions & { /** * The command to run * @@ -5929,7 +6025,7 @@ declare module "bun" { * ``` */ cmds: string[], - options?: SpawnOptions.OptionsObject, + options?: SpawnOptions.SpawnSyncOptions, ): SyncSubprocess; /** Utility type for any process from {@link Bun.spawn()} with both stdout and stderr set to `"pipe"` */ diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index bdcea698b5..9550d16ece 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -262,7 +262,7 @@ declare module "bun:test" { */ each>(table: readonly T[]): Describe<[...T]>; each(table: readonly T[]): Describe<[...T]>; - each(table: T[]): Describe<[T]>; + each(table: T[]): Describe<[T]>; } /** * Describes a group of related tests. @@ -552,7 +552,7 @@ declare module "bun:test" { */ each>(table: readonly T[]): Test; each(table: readonly T[]): Test; - each(table: T[]): Test<[T]>; + each(table: T[]): Test<[T]>; } /** * Runs a test. diff --git a/test/integration/bun-types/fixture/spawn.ts b/test/integration/bun-types/fixture/spawn.ts index 40fca022ad..a82be44476 100644 --- a/test/integration/bun-types/fixture/spawn.ts +++ b/test/integration/bun-types/fixture/spawn.ts @@ -187,3 +187,45 @@ tsd.expectAssignable(Bun.spawn([], { stdio: ["ignore", "inherit" tsd.expectAssignable(Bun.spawn([], { stdio: [null, null, null] })); tsd.expectAssignable>(Bun.spawnSync([], {})); + +// Lazy option types (async only) +{ + // valid: lazy usable with async spawn + const p1 = Bun.spawn(["echo", "hello"], { + stdout: "pipe", + stderr: "pipe", + lazy: true, + }); + tsd.expectType(p1.stdout).is>>(); +} + +{ + // valid: lazy false is also allowed + const p2 = Bun.spawn(["echo", "hello"], { + stdout: "pipe", + stderr: "pipe", + lazy: false, + }); + tsd.expectType(p2.stderr).is>>(); +} + +{ + // invalid: lazy is not supported in spawnSync + Bun.spawnSync(["echo", "hello"], { + stdout: "pipe", + stderr: "pipe", + // @ts-expect-error lazy applies only to async spawn + lazy: true, + }); +} + +{ + // invalid: lazy is not supported in spawnSync (object overload) + // @ts-expect-error lazy applies only to async spawn + Bun.spawnSync({ + cmd: ["echo", "hello"], + stdout: "pipe", + stderr: "pipe", + lazy: true, + }); +} diff --git a/test/integration/bun-types/fixture/test.ts b/test/integration/bun-types/fixture/test.ts index 314b3683a8..5e132697d9 100644 --- a/test/integration/bun-types/fixture/test.ts +++ b/test/integration/bun-types/fixture/test.ts @@ -79,6 +79,10 @@ describe("bun:test", () => { }); }); +test.each([1, 2, 3])("test.each", a => { + expectType<1 | 2 | 3>(a); +}); + // inference should work when data is passed directly in test.each([ ["a", true, 5], diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index e611f2b924..f7a7c5dfb8 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -480,6 +480,7 @@ for (let [gcTick, label] of [ } resolve && resolve(); + // @ts-expect-error resolve = undefined; })(); await promise; @@ -679,9 +680,10 @@ describe("should not hang", () => { it( "sleep " + sleep, async () => { - const runs = []; + const runs: Promise[] = []; + let initialMaxFD = -1; - for (let order of [ + for (const order of [ ["sleep", "kill", "unref", "exited"], ["sleep", "unref", "kill", "exited"], ["kill", "sleep", "unref", "exited"], @@ -789,6 +791,7 @@ describe("close handling", () => { await exitPromise; })(); + Bun.gc(false); await Bun.sleep(0); @@ -837,3 +840,182 @@ it("error does not UAF", async () => { } expect(emsg).toInclude(" "); }); + +describe("onDisconnect", () => { + it.todoIf(isWindows)("ipc delivers message", async () => { + const msg = Promise.withResolvers(); + + let ipcMessage: unknown; + + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + process.send("hello"); + Promise.resolve().then(() => process.exit(0)); + `, + ], + ipc: message => { + ipcMessage = message; + msg.resolve(); + }, + stdio: ["inherit", "inherit", "inherit"], + env: bunEnv, + }); + + await msg.promise; + expect(ipcMessage).toBe("hello"); + expect(await proc.exited).toBe(0); + }); + + it.todoIf(isWindows)("onDisconnect callback is called when IPC disconnects", async () => { + const disc = Promise.withResolvers(); + + let disconnectCalled = false; + + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + Promise.resolve().then(() => { + process.disconnect(); + process.exit(0); + }); + `, + ], + // Ensure IPC channel is opened without relying on a message + ipc: () => {}, + onDisconnect: () => { + disconnectCalled = true; + disc.resolve(); + }, + stdio: ["inherit", "inherit", "inherit"], + env: bunEnv, + }); + + await disc.promise; + expect(disconnectCalled).toBe(true); + expect(await proc.exited).toBe(0); + }); + + it("onDisconnect is not called when IPC is not used", async () => { + await using proc = spawn({ + cmd: [bunExe(), "-e", "console.log('hello')"], + onDisconnect: () => { + expect().fail("onDisconnect was called()"); + }, + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }); + expect(await proc.exited).toBe(0); + }); +}); + +describe("argv0", () => { + it("argv0 option changes process.argv0 but not executable", async () => { + await using proc = spawn({ + cmd: [bunExe(), "-e", "console.log(process.argv0); console.log(process.execPath)"], + argv0: "custom-argv0", + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + env: bunEnv, + }); + + const output = await proc.stdout.text(); + const lines = output.trim().split(/\r?\n/); + expect(lines[0]).toBe("custom-argv0"); + expect(path.normalize(lines[1])).toBe(path.normalize(bunExe())); + await proc.exited; + }); + + it("argv0 option works with spawnSync", () => { + const argv0 = "custom-argv0-sync"; + + const proc = spawnSync({ + cmd: [bunExe(), "-e", "console.log(JSON.stringify({ argv0: process.argv0, execPath: process.execPath }))"], + argv0, + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + env: bunEnv, + }); + + const output = JSON.parse(proc.stdout.toString().trim()); + expect(output).toEqual({ argv0, execPath: path.normalize(bunExe()) }); + }); + + it("argv0 defaults to cmd[0] when not specified", async () => { + await using proc = spawn({ + cmd: [bunExe(), "-e", "console.log(process.argv0)"], + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + env: bunEnv, + }); + + const output = await proc.stdout.text(); + expect(output.trim()).toBe(bunExe()); + await proc.exited; + }); +}); + +describe("option combinations", () => { + it("detached + argv0 works together", async () => { + await using proc = spawn({ + cmd: [bunExe(), "-e", "console.log(process.argv0)"], + detached: true, + argv0: "custom-name", + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + env: bunEnv, + }); + + const output = await proc.stdout.text(); + expect(output.trim()).toBe("custom-name"); + await proc.exited; + }); + + it.todoIf(isWindows)("onDisconnect + ipc + serialization works together", async () => { + let messageReceived = false; + let disconnectCalled = false; + + const msg = Promise.withResolvers(); + const disc = Promise.withResolvers(); + + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + process.send({type: "hello", data: "world"}); + Promise.resolve().then(() => { + process.disconnect(); + process.exit(0); + }); + `, + ], + ipc: message => { + expect(message).toEqual({ type: "hello", data: "world" }); + messageReceived = true; + msg.resolve(); + }, + onDisconnect: () => { + disconnectCalled = true; + disc.resolve(); + }, + serialization: "advanced", + stdio: ["inherit", "inherit", "inherit"], + env: bunEnv, + }); + + await Promise.all([msg.promise, disc.promise]); + expect(messageReceived).toBe(true); + expect(disconnectCalled).toBe(true); + expect(await proc.exited).toBe(0); + }); +});