Document & cover some missing spawn/spawnSync options (#24417)

This commit is contained in:
Alistair Smith
2025-11-06 14:37:26 -08:00
committed by GitHub
parent e01f454635
commit 44402ad27a
5 changed files with 334 additions and 10 deletions

View File

@@ -5289,7 +5289,12 @@ declare module "bun" {
options: udp.ConnectSocketOptions<DataBinaryType>,
): Promise<udp.ConnectedSocket<DataBinaryType>>;
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<In extends Writable, Out extends Readable, Err extends Readable> {
/**
* @deprecated use BaseOptions or the specific options for the specific {@link spawn} or {@link spawnSync} usage
*/
type OptionsObject<In extends Writable, Out extends Readable, Err extends Readable> = BaseOptions<In, Out, Err>;
interface BaseOptions<In extends Writable, Out extends Readable, Err extends Readable> {
/**
* 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 parents 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<void>;
/**
* 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<void>;
/**
* 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<In extends Writable, Out extends Readable, Err extends Readable>
extends BaseOptions<In, Out, Err> {}
interface SpawnOptions<In extends Writable, Out extends Readable, Err extends Readable>
extends BaseOptions<In, Out, Err> {
/**
* 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 Readable> = X extends "pipe" | undefined
? ReadableStream<Uint8Array<ArrayBuffer>>
: 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<In, Out, Err> & {
options: SpawnOptions.SpawnOptions<In, Out, Err> & {
/**
* The command to run
*
@@ -5856,7 +5952,7 @@ declare module "bun" {
* ```
*/
cmds: string[],
options?: SpawnOptions.OptionsObject<In, Out, Err>,
options?: SpawnOptions.SpawnOptions<In, Out, Err>,
): Subprocess<In, Out, Err>;
/**
@@ -5878,7 +5974,7 @@ declare module "bun" {
const Out extends SpawnOptions.Readable = "pipe",
const Err extends SpawnOptions.Readable = "pipe",
>(
options: SpawnOptions.OptionsObject<In, Out, Err> & {
options: SpawnOptions.SpawnSyncOptions<In, Out, Err> & {
/**
* The command to run
*
@@ -5929,7 +6025,7 @@ declare module "bun" {
* ```
*/
cmds: string[],
options?: SpawnOptions.OptionsObject<In, Out, Err>,
options?: SpawnOptions.SpawnSyncOptions<In, Out, Err>,
): SyncSubprocess<Out, Err>;
/** Utility type for any process from {@link Bun.spawn()} with both stdout and stderr set to `"pipe"` */

View File

@@ -262,7 +262,7 @@ declare module "bun:test" {
*/
each<T extends Readonly<[any, ...any[]]>>(table: readonly T[]): Describe<[...T]>;
each<T extends any[]>(table: readonly T[]): Describe<[...T]>;
each<T>(table: T[]): Describe<[T]>;
each<const T>(table: T[]): Describe<[T]>;
}
/**
* Describes a group of related tests.
@@ -552,7 +552,7 @@ declare module "bun:test" {
*/
each<T extends Readonly<[unknown, ...unknown[]]>>(table: readonly T[]): Test<T>;
each<T extends unknown[]>(table: readonly T[]): Test<T>;
each<T>(table: T[]): Test<[T]>;
each<const T>(table: T[]): Test<[T]>;
}
/**
* Runs a test.

View File

@@ -187,3 +187,45 @@ tsd.expectAssignable<NullSubprocess>(Bun.spawn([], { stdio: ["ignore", "inherit"
tsd.expectAssignable<NullSubprocess>(Bun.spawn([], { stdio: [null, null, null] }));
tsd.expectAssignable<SyncSubprocess<Bun.SpawnOptions.Readable, Bun.SpawnOptions.Readable>>(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<ReadableStream<Uint8Array<ArrayBuffer>>>();
}
{
// valid: lazy false is also allowed
const p2 = Bun.spawn(["echo", "hello"], {
stdout: "pipe",
stderr: "pipe",
lazy: false,
});
tsd.expectType(p2.stderr).is<ReadableStream<Uint8Array<ArrayBuffer>>>();
}
{
// 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,
});
}

View File

@@ -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],

View File

@@ -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<void>[] = [];
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<void>();
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<void>();
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<void>();
const disc = Promise.withResolvers<void>();
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);
});
});