Add 'buffer' and 'text' stdio modes to Bun.spawn()

Co-authored-by: jarred <jarred@bun.sh>
This commit is contained in:
Cursor Agent
2025-06-28 03:50:17 +00:00
committed by Jarred Sumner
parent 8566078023
commit ec644923ee
6 changed files with 845 additions and 43 deletions

View File

@@ -22,6 +22,8 @@ pub const Stdio = union(enum) {
array_buffer: JSC.ArrayBuffer.Strong,
memfd: bun.FileDescriptor,
pipe,
buffer,
text,
ipc,
readable_stream: JSC.WebCore.ReadableStream,
@@ -196,6 +198,7 @@ pub const Stdio = union(enum) {
},
.dup2 => .{ .dup2 = .{ .out = stdio.dup2.out, .to = stdio.dup2.to } },
.capture, .pipe, .array_buffer, .readable_stream => .{ .buffer = {} },
.buffer, .text => .{ .buffer = {} },
.ipc => .{ .ipc = {} },
.fd => |fd| .{ .pipe = fd },
.memfd => |fd| .{ .pipe = fd },
@@ -249,6 +252,7 @@ pub const Stdio = union(enum) {
},
.ipc => .{ .ipc = bun.default_allocator.create(uv.Pipe) catch bun.outOfMemory() },
.capture, .pipe, .array_buffer, .readable_stream => .{ .buffer = bun.default_allocator.create(uv.Pipe) catch bun.outOfMemory() },
.buffer, .text => .{ .buffer = bun.default_allocator.create(uv.Pipe) catch bun.outOfMemory() },
.fd => |fd| .{ .pipe = fd },
.dup2 => .{ .dup2 = .{ .out = stdio.dup2.out, .to = stdio.dup2.to } },
.path => |pathlike| .{ .path = pathlike.slice() },
@@ -262,7 +266,7 @@ pub const Stdio = union(enum) {
pub fn toSync(this: *@This(), i: u32) void {
// Piping an empty stdin doesn't make sense
if (i == 0 and this.* == .pipe) {
if (i == 0 and (this.* == .pipe or this.* == .buffer or this.* == .text)) {
this.* = .{ .ignore = {} };
}
}
@@ -281,7 +285,7 @@ pub const Stdio = union(enum) {
pub fn isPiped(self: Stdio) bool {
return switch (self) {
.capture, .array_buffer, .blob, .pipe, .readable_stream => true,
.capture, .array_buffer, .blob, .pipe, .readable_stream, .buffer, .text => true,
.ipc => Environment.isWindows,
else => false,
};
@@ -357,10 +361,14 @@ pub const Stdio = union(enum) {
out_stdio.* = Stdio{ .ignore = {} };
} else if (str.eqlComptime("pipe") or str.eqlComptime("overlapped")) {
out_stdio.* = Stdio{ .pipe = {} };
} else if (str.eqlComptime("buffer")) {
out_stdio.* = Stdio{ .buffer = {} };
} else if (str.eqlComptime("text")) {
out_stdio.* = Stdio{ .text = {} };
} else if (str.eqlComptime("ipc")) {
out_stdio.* = Stdio{ .ipc = {} };
} else {
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'pipe', 'ignore', Bun.file(pathOrFd), number, or null", .{});
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'pipe', 'ignore', 'buffer', 'text', 'ipc', Bun.file(pathOrFd), number, or null", .{});
}
return;
} else if (value.isNumber()) {

View File

@@ -318,49 +318,39 @@ pub fn onCloseIO(this: *Subprocess, kind: StdioKind) void {
},
inline .stdout, .stderr => |tag| {
const out: *Readable = &@field(this, @tagName(tag));
const globalThis = this.globalThis;
// Get the cached promise from the correct property (stdout/stderr)
const get_cached_fn = if (tag == .stdout) JSC.Codegen.JSSubprocess.stdoutGetCached else JSC.Codegen.JSSubprocess.stderrGetCached;
const maybe_promise = if (this.this_jsvalue != .zero) get_cached_fn(this.this_jsvalue) else null;
switch (out.*) {
.pipe => |pipe| {
if (pipe.state == .done) {
out.* = .{ .buffer = CowString.initOwned(pipe.state.done, bun.default_allocator) };
pipe.state = .{ .done = &.{} };
} else {
out.* = .{ .ignore = {} };
pipe.deref();
}
pipe.deref();
},
.buffer_promise => |pipe| {
const bytes = pipe.toOwnedSlice();
if (maybe_promise) |js_promise| {
if (js_promise.asAnyPromise()) |promise_obj| {
const buffer_val = JSC.MarkedArrayBuffer.fromBytes(bytes, bun.default_allocator, .Uint8Array).toNodeBuffer(globalThis);
promise_obj.resolve(globalThis, buffer_val);
}
}
// Replace the readable with the final buffered value
out.* = .{ .buffer = CowString.initOwned(bytes, bun.default_allocator) };
// The pipe already has the data in state.done, we need to take ownership
var bytes = if (pipe.state == .done) brk: {
const data = pipe.state.done;
pipe.state = .{ .done = &.{} }; // Clear to prevent double free
break :brk @constCast(data);
} else @constCast(&.{});
log("buffer_promise onCloseIO: bytes.len={d}, first few bytes={any}", .{bytes.len, bytes[0..@min(10, bytes.len)]});
// Don't resolve the promise here - it will be resolved when accessed
// Convert to buffer_promise_done variant to indicate data is ready but should still return a promise
out.* = .{ .buffer_promise_done = CowString.initOwned(bytes, bun.default_allocator) };
pipe.deref();
},
.text_promise => |pipe| {
const bytes = pipe.toOwnedSlice();
if (maybe_promise) |js_promise| {
if (js_promise.asAnyPromise()) |promise_obj| {
const str = bun.SliceWithUnderlyingString.transcodeFromOwnedSlice(bytes, .utf8) catch |err| {
_ = err; // autofix
promise_obj.reject(globalThis, globalThis.bunVM().exception);
out.* = .{ .buffer = CowString.initOwned(&.{}, bun.default_allocator) };
pipe.deref();
return;
};
promise_obj.resolve(globalThis, str.toJS(globalThis));
}
}
out.* = .{ .buffer = CowString.initOwned(bytes, bun.default_allocator) };
// The pipe already has the data in state.done, we need to take ownership
var bytes = if (pipe.state == .done) brk: {
const data = pipe.state.done;
pipe.state = .{ .done = &.{} }; // Clear to prevent double free
break :brk @constCast(data);
} else @constCast(&.{});
log("text_promise onCloseIO: bytes.len={d}, first few bytes={any}", .{bytes.len, bytes[0..@min(10, bytes.len)]});
// Don't resolve the promise here - it will be resolved when accessed
// Convert to text_promise_done variant to indicate data is ready but should still return a promise
out.* = .{ .text_promise_done = CowString.initOwned(bytes, bun.default_allocator) };
pipe.deref();
},
else => {},
@@ -430,12 +420,16 @@ const Readable = union(enum) {
/// the owning `Readable` will be convered into this variant and the pipe's
/// buffer will be taken as an owned `CowString`.
buffer: CowString,
/// Completed buffer data that should be returned as a resolved promise
buffer_promise_done: CowString,
/// Completed text data that should be returned as a resolved promise
text_promise_done: CowString,
pub fn memoryCost(this: *const Readable) usize {
return switch (this.*) {
.pipe => @sizeOf(PipeReader) + this.pipe.memoryCost(),
.buffer_promise, .text_promise => |p| @sizeOf(PipeReader) + p.memoryCost(),
.buffer => this.buffer.length(),
.buffer, .buffer_promise_done, .text_promise_done => |buf| buf.length(),
else => 0,
};
}
@@ -542,7 +536,7 @@ const Readable = union(enum) {
defer p.detach();
this.* = .{ .closed = {} };
},
.buffer => |*buf| {
.buffer, .buffer_promise_done, .text_promise_done => |*buf| {
buf.deinit(bun.default_allocator);
},
else => {},
@@ -551,6 +545,7 @@ const Readable = union(enum) {
pub fn toJS(this: *Readable, globalThis: *JSC.JSGlobalObject, exited: bool) JSValue {
_ = exited; // autofix
log("Readable.toJS called, variant = {s}", .{@tagName(this.*)});
switch (this.*) {
// should only be reachable when the entire output is buffered.
.memfd => return this.toBufferedValue(globalThis) catch .zero,
@@ -564,34 +559,38 @@ const Readable = union(enum) {
return pipe.toJS(globalThis);
},
.buffer_promise => |pipe| {
log("toJS buffer_promise: pipe state = {s}", .{@tagName(pipe.state)});
// Check if the PipeReader has already finished reading
if (pipe.state == .done) {
log("toJS buffer_promise: state is done, creating resolved promise", .{});
const bytes = pipe.toOwnedSlice();
defer pipe.detach();
defer this.* = .{ .closed = {} };
const buffer = JSC.MarkedArrayBuffer.fromBytes(bytes, bun.default_allocator, .Uint8Array).toNodeBuffer(globalThis);
return JSC.JSPromise.resolvedPromiseValue(globalThis, buffer);
}
log("toJS buffer_promise: state is pending, creating new promise", .{});
// Create a new pending promise
const promise = JSC.JSPromise.create(globalThis).toJS();
// The getter in JS will cache this value
// DON'T detach the pipe or convert to a stream - we need it to resolve the promise later
return promise;
},
.text_promise => |pipe| {
// Check if the PipeReader has already finished reading
if (pipe.state == .done) {
const bytes = pipe.toOwnedSlice();
defer pipe.detach();
defer this.* = .{ .closed = {} };
const str = bun.SliceWithUnderlyingString.transcodeFromOwnedSlice(bytes, .utf8) catch {
// On failure, return a rejected promise with the error
const exception = globalThis.bunVM().exception;
return JSC.JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, exception);
};
var str = bun.SliceWithUnderlyingString.transcodeFromOwnedSlice(bytes, .utf8);
defer str.deinit();
return JSC.JSPromise.resolvedPromiseValue(globalThis, str.toJS(globalThis));
}
// Create a new pending promise
const promise = JSC.JSPromise.create(globalThis).toJS();
// DON'T detach the pipe or convert to a stream - we need it to resolve the promise later
return promise;
},
.buffer => |*buffer| {
@@ -606,6 +605,25 @@ const Readable = union(enum) {
};
return JSC.WebCore.ReadableStream.fromOwnedSlice(globalThis, own, 0);
},
.buffer_promise_done => |*buf| {
defer this.* = .{ .closed = {} };
log("buffer_promise_done toJS: buf.len={d}, first few bytes={any}", .{buf.length(), buf.slice()[0..@min(10, buf.length())]});
const bytes = buf.takeSlice(bun.default_allocator) catch {
globalThis.throwOutOfMemory() catch return .zero;
};
log("buffer_promise_done toJS: after takeSlice bytes.len={d}, first few bytes={any}", .{bytes.len, bytes[0..@min(10, bytes.len)]});
const buffer = JSC.MarkedArrayBuffer.fromBytes(bytes, bun.default_allocator, .Uint8Array).toNodeBuffer(globalThis);
return JSC.JSPromise.resolvedPromiseValue(globalThis, buffer);
},
.text_promise_done => |*buf| {
defer this.* = .{ .closed = {} };
const bytes = buf.takeSlice(bun.default_allocator) catch {
globalThis.throwOutOfMemory() catch return .zero;
};
var str = bun.SliceWithUnderlyingString.transcodeFromOwnedSlice(bytes, .utf8);
defer str.deinit();
return JSC.JSPromise.resolvedPromiseValue(globalThis, str.toJS(globalThis));
},
else => {
return .js_undefined;
},
@@ -642,6 +660,23 @@ const Readable = union(enum) {
return JSC.MarkedArrayBuffer.fromBytes(own, bun.default_allocator, .Uint8Array).toNodeBuffer(globalThis);
},
.buffer_promise_done => |*buf| {
defer this.* = .{ .closed = {} };
const own = buf.takeSlice(bun.default_allocator) catch {
return globalThis.throwOutOfMemory();
};
return JSC.MarkedArrayBuffer.fromBytes(own, bun.default_allocator, .Uint8Array).toNodeBuffer(globalThis);
},
.text_promise_done => |*buf| {
defer this.* = .{ .closed = {} };
const own = buf.takeSlice(bun.default_allocator) catch {
return globalThis.throwOutOfMemory();
};
// For sync mode, return as a string not a buffer
const str = bun.String.createUTF8(own);
bun.default_allocator.free(own);
return str.toJS(globalThis);
},
else => {
return .js_undefined;
},
@@ -654,6 +689,37 @@ pub fn getStderr(
globalThis: *JSGlobalObject,
) JSValue {
this.observable_getters.insert(.stderr);
// For buffer_promise and text_promise modes, we need to handle the race condition
// where the process might have already exited before the getter is called
switch (this.stderr) {
.buffer_promise, .text_promise => {
// Check if there's already a cached promise
if (this.this_jsvalue != .zero) {
if (JSC.Codegen.JSSubprocess.stderrGetCached(this.this_jsvalue)) |cached| {
return cached;
}
}
// If not, create and cache the promise
const promise_value = this.stderr.toJS(globalThis, this.hasExited());
// The generated getter should cache this value
return promise_value;
},
.buffer_promise_done, .text_promise_done => {
// Data is ready but we need to return a promise
// Check if there's already a cached promise
if (this.this_jsvalue != .zero) {
if (JSC.Codegen.JSSubprocess.stderrGetCached(this.this_jsvalue)) |cached| {
return cached;
}
}
// Create and return a resolved promise
const promise_value = this.stderr.toJS(globalThis, this.hasExited());
return promise_value;
},
else => {},
}
return this.stderr.toJS(globalThis, this.hasExited());
}
@@ -670,6 +736,43 @@ pub fn getStdout(
globalThis: *JSGlobalObject,
) JSValue {
this.observable_getters.insert(.stdout);
log("getStdout called, stdout variant = {s}", .{@tagName(this.stdout)});
// For buffer_promise and text_promise modes, we need to handle the race condition
// where the process might have already exited before the getter is called
switch (this.stdout) {
.buffer_promise, .text_promise => {
// Check if there's already a cached promise
if (this.this_jsvalue != .zero) {
if (JSC.Codegen.JSSubprocess.stdoutGetCached(this.this_jsvalue)) |cached| {
log("getStdout returning cached promise", .{});
return cached;
}
}
// If not, create and cache the promise
const promise_value = this.stdout.toJS(globalThis, this.hasExited());
log("getStdout created new promise", .{});
// The generated getter should cache this value
return promise_value;
},
.buffer_promise_done, .text_promise_done => {
// Data is ready but we need to return a promise
// Check if there's already a cached promise
if (this.this_jsvalue != .zero) {
if (JSC.Codegen.JSSubprocess.stdoutGetCached(this.this_jsvalue)) |cached| {
log("getStdout returning cached promise (done variant)", .{});
return cached;
}
}
// Create and return a resolved promise
const promise_value = this.stdout.toJS(globalThis, this.hasExited());
log("getStdout created resolved promise (done variant)", .{});
return promise_value;
},
else => {},
}
// NOTE: ownership of internal buffers is transferred to the JSValue, which
// gets cached on JSSubprocess (created via bindgen). This makes it
// re-accessable to JS code but not via `this.stdout`, which is now `.closed`.
@@ -1163,11 +1266,15 @@ pub const PipeReader = struct {
}
pub fn kind(reader: *const PipeReader, process: *const Subprocess) StdioKind {
if (process.stdout == .pipe and process.stdout.pipe == reader) {
if ((process.stdout == .pipe and process.stdout.pipe == reader) or
(process.stdout == .buffer_promise and process.stdout.buffer_promise == reader) or
(process.stdout == .text_promise and process.stdout.text_promise == reader)) {
return .stdout;
}
if (process.stderr == .pipe and process.stderr.pipe == reader) {
if ((process.stderr == .pipe and process.stderr.pipe == reader) or
(process.stderr == .buffer_promise and process.stderr.buffer_promise == reader) or
(process.stderr == .text_promise and process.stderr.text_promise == reader)) {
return .stderr;
}
@@ -1482,6 +1589,10 @@ const Writable = union(enum) {
.pipe = pipe,
};
},
.buffer, .text => {
// stdin cannot be in buffer or text mode
return Writable{ .ignore = {} };
},
.blob => |blob| {
return Writable{
@@ -2658,6 +2769,14 @@ pub fn spawnMaybeSync(
}
}
if (subprocess.stdout == .buffer_promise or subprocess.stdout == .text_promise) {
const pipe = if (subprocess.stdout == .buffer_promise) subprocess.stdout.buffer_promise else subprocess.stdout.text_promise;
pipe.start(subprocess, loop).assert();
if ((is_sync or !lazy)) {
pipe.readAll();
}
}
if (subprocess.stderr == .pipe) {
subprocess.stderr.pipe.start(subprocess, loop).assert();
@@ -2666,6 +2785,14 @@ pub fn spawnMaybeSync(
}
}
if (subprocess.stderr == .buffer_promise or subprocess.stderr == .text_promise) {
const pipe = if (subprocess.stderr == .buffer_promise) subprocess.stderr.buffer_promise else subprocess.stderr.text_promise;
pipe.start(subprocess, loop).assert();
if ((is_sync or !lazy)) {
pipe.readAll();
}
}
should_close_memfd = false;
if (comptime !is_sync) {
@@ -2734,6 +2861,16 @@ pub fn spawnMaybeSync(
subprocess.stdout.pipe.watch();
}
if (subprocess.stderr == .buffer_promise or subprocess.stderr == .text_promise) {
const pipe = if (subprocess.stderr == .buffer_promise) subprocess.stderr.buffer_promise else subprocess.stderr.text_promise;
pipe.watch();
}
if (subprocess.stdout == .buffer_promise or subprocess.stdout == .text_promise) {
const pipe = if (subprocess.stdout == .buffer_promise) subprocess.stdout.buffer_promise else subprocess.stdout.text_promise;
pipe.watch();
}
jsc_vm.tick();
jsc_vm.eventLoop().autoTick();
}

View File

@@ -209,6 +209,10 @@ pub const ShellSubprocess = struct {
// The shell never uses this
@panic("Unimplemented stdin pipe");
},
.buffer, .text => {
// The shell never uses this
@panic("Unimplemented stdin buffer/text");
},
.blob => |blob| {
return Writable{
@@ -380,6 +384,7 @@ pub const ShellSubprocess = struct {
.blob => Readable{ .ignore = {} },
.memfd => Readable{ .ignore = {} },
.pipe => Readable{ .pipe = PipeReader.create(event_loop, process, result, null, out_type) },
.buffer, .text => Readable{ .pipe = PipeReader.create(event_loop, process, result, null, out_type) },
.array_buffer => {
const readable = Readable{ .pipe = PipeReader.create(event_loop, process, result, null, out_type) };
readable.pipe.buffered_output = .{
@@ -402,6 +407,7 @@ pub const ShellSubprocess = struct {
.blob => Readable{ .ignore = {} },
.memfd => Readable{ .memfd = stdio.memfd },
.pipe => Readable{ .pipe = PipeReader.create(event_loop, process, result, null, out_type) },
.buffer, .text => Readable{ .pipe = PipeReader.create(event_loop, process, result, null, out_type) },
.array_buffer => {
const readable = Readable{ .pipe = PipeReader.create(event_loop, process, result, null, out_type) };
readable.pipe.buffered_output = .{

View File

@@ -0,0 +1,79 @@
import { test, expect, describe } from "bun:test";
import { spawn, spawnSync } from "bun";
describe("Bun.spawn() buffer and text modes - simple", () => {
test("async buffer mode works", async () => {
const proc = spawn({
cmd: ["echo", "hello buffer"],
stdout: "buffer",
});
expect(proc.stdout).toBeInstanceOf(Promise);
const buffer = await proc.stdout;
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.toString()).toBe("hello buffer\n");
});
test("async text mode works", async () => {
const proc = spawn({
cmd: ["echo", "hello text"],
stdout: "text",
});
expect(proc.stdout).toBeInstanceOf(Promise);
const text = await proc.stdout;
expect(typeof text).toBe("string");
expect(text).toBe("hello text\n");
});
test("sync buffer mode works", () => {
const result = spawnSync({
cmd: ["echo", "hello buffer"],
stdout: "buffer",
});
expect(result.success).toBe(true);
expect(result.stdout).toBeDefined();
expect(result.stdout).toBeInstanceOf(Buffer);
if (result.stdout instanceof Buffer) {
expect(result.stdout.toString()).toBe("hello buffer\n");
}
});
test("sync text mode works", () => {
const result = spawnSync({
cmd: ["echo", "hello text"],
stdout: "text",
});
expect(result.success).toBe(true);
expect(result.stdout).toBeDefined();
expect(typeof result.stdout).toBe("string");
expect(result.stdout).toBe("hello text\n");
});
test("stderr buffer mode works", async () => {
const proc = spawn({
cmd: ["sh", "-c", "echo error >&2"],
stderr: "buffer",
});
const stderr = await proc.stderr;
expect(stderr).toBeInstanceOf(Buffer);
expect(stderr.toString()).toBe("error\n");
});
test("both stdout and stderr work together", async () => {
const proc = spawn({
cmd: ["sh", "-c", "echo out && echo err >&2"],
stdout: "buffer",
stderr: "text",
});
const [stdout, stderr] = await Promise.all([proc.stdout, proc.stderr]);
expect(stdout).toBeInstanceOf(Buffer);
expect(stdout.toString()).toBe("out\n");
expect(typeof stderr).toBe("string");
expect(stderr).toBe("err\n");
});
});

View File

@@ -0,0 +1,521 @@
import { test, expect, describe } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import path from "node:path";
describe("Bun.spawn() stdout: 'buffer' and 'text'", () => {
describe("stdout: 'buffer'", () => {
test("returns a promise that resolves to a Buffer", async () => {
const dir = tempDirWithFiles("spawn-buffer-test", {
"echo.js": `console.log("Hello, world!");`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "echo.js")],
env: bunEnv,
stdout: "buffer",
stderr: "pipe",
});
// Should return a promise
expect(proc.stdout).toBeInstanceOf(Promise);
const buffer = await proc.stdout;
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.toString()).toBe("Hello, world!\n");
// Accessing again should return the same promise
const buffer2 = await proc.stdout;
expect(buffer2).toBe(buffer);
// stderr should still be a stream
const stderr = await new Response(proc.stderr).text();
expect(stderr).toBe("");
});
test("handles binary data correctly", async () => {
const dir = tempDirWithFiles("spawn-buffer-binary", {
"binary.js": `
const buf = Buffer.from([0xFF, 0xFE, 0x00, 0x01, 0x02, 0x03]);
process.stdout.write(buf);
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "binary.js")],
env: bunEnv,
stdout: "buffer",
});
const buffer = await proc.stdout;
expect(buffer).toBeInstanceOf(Buffer);
expect(Array.from(buffer)).toEqual([0xFF, 0xFE, 0x00, 0x01, 0x02, 0x03]);
});
test("handles large output", async () => {
const dir = tempDirWithFiles("spawn-buffer-large", {
"large.js": `
const chunk = Buffer.alloc(1024 * 1024, 'A').toString();
for (let i = 0; i < 10; i++) {
process.stdout.write(chunk);
}
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "large.js")],
env: bunEnv,
stdout: "buffer",
});
const buffer = await proc.stdout;
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBe(10 * 1024 * 1024);
expect(buffer.every(byte => byte === 65)).toBe(true); // All 'A's
});
test("works with stderr: 'buffer' too", async () => {
const dir = tempDirWithFiles("spawn-buffer-stderr", {
"both.js": `
console.log("stdout message");
console.error("stderr message");
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "both.js")],
env: bunEnv,
stdout: "buffer",
stderr: "buffer",
});
const [stdout, stderr] = await Promise.all([proc.stdout, proc.stderr]);
expect(stdout).toBeInstanceOf(Buffer);
expect(stdout.toString()).toBe("stdout message\n");
expect(stderr).toBeInstanceOf(Buffer);
expect(stderr.toString()).toBe("stderr message\n");
});
test("handles empty output", async () => {
const dir = tempDirWithFiles("spawn-buffer-empty", {
"empty.js": `// No output`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "empty.js")],
env: bunEnv,
stdout: "buffer",
});
const buffer = await proc.stdout;
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBe(0);
expect(buffer.toString()).toBe("");
});
test("resolves after process exits", async () => {
const dir = tempDirWithFiles("spawn-buffer-timing", {
"delayed.js": `
setTimeout(() => {
console.log("delayed output");
}, 100);
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "delayed.js")],
env: bunEnv,
stdout: "buffer",
});
const buffer = await proc.stdout;
expect(buffer.toString()).toBe("delayed output\n");
// Process should have exited by now
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
});
test("works with maxBuffer", async () => {
const dir = tempDirWithFiles("spawn-buffer-maxbuf", {
"overflow.js": `
const chunk = Buffer.alloc(1024, 'A').toString();
for (let i = 0; i < 10; i++) {
process.stdout.write(chunk);
}
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "overflow.js")],
env: bunEnv,
stdout: "buffer",
maxBuffer: 5 * 1024, // 5KB limit
});
try {
await proc.stdout;
expect.unreachable("Should have been killed due to maxBuffer");
} catch (e) {
// Process should be killed
}
const exitCode = await proc.exited;
expect(exitCode).not.toBe(0);
});
});
describe("stdout: 'text'", () => {
test("returns a promise that resolves to a UTF-8 string", async () => {
const dir = tempDirWithFiles("spawn-text-test", {
"echo.js": `console.log("Hello, world!");`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "echo.js")],
env: bunEnv,
stdout: "text",
stderr: "pipe",
});
// Should return a promise
expect(proc.stdout).toBeInstanceOf(Promise);
const text = await proc.stdout;
expect(typeof text).toBe("string");
expect(text).toBe("Hello, world!\n");
// Accessing again should return the same promise
const text2 = await proc.stdout;
expect(text2).toBe(text);
// stderr should still be a stream
const stderr = await new Response(proc.stderr).text();
expect(stderr).toBe("");
});
test("handles UTF-8 correctly", async () => {
const dir = tempDirWithFiles("spawn-text-utf8", {
"utf8.js": `
console.log("Hello 世界 🌍");
console.log("Emoji: 🎉🎊🎈");
console.log("Accents: café, naïve, résumé");
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "utf8.js")],
env: bunEnv,
stdout: "text",
});
const text = await proc.stdout;
expect(text).toBe("Hello 世界 🌍\nEmoji: 🎉🎊🎈\nAccents: café, naïve, résumé\n");
});
test("handles multi-byte UTF-8 sequences", async () => {
const dir = tempDirWithFiles("spawn-text-multibyte", {
"multibyte.js": `
// 2-byte: £ (U+00A3)
// 3-byte: € (U+20AC)
// 4-byte: 𝄞 (U+1D11E)
console.log("2-byte: £");
console.log("3-byte: €");
console.log("4-byte: 𝄞");
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "multibyte.js")],
env: bunEnv,
stdout: "text",
});
const text = await proc.stdout;
expect(text).toBe("2-byte: £\n3-byte: €\n4-byte: 𝄞\n");
});
test("handles invalid UTF-8 by rejecting", async () => {
const dir = tempDirWithFiles("spawn-text-invalid", {
"invalid.js": `
// Output invalid UTF-8 sequence
process.stdout.write(Buffer.from([0xFF, 0xFE, 0xFD]));
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "invalid.js")],
env: bunEnv,
stdout: "text",
});
try {
await proc.stdout;
expect.unreachable("Should have rejected with invalid UTF-8");
} catch (e) {
// Expected to reject
}
});
test("works with stderr: 'text' too", async () => {
const dir = tempDirWithFiles("spawn-text-stderr", {
"both.js": `
console.log("stdout message");
console.error("stderr message");
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "both.js")],
env: bunEnv,
stdout: "text",
stderr: "text",
});
const [stdout, stderr] = await Promise.all([proc.stdout, proc.stderr]);
expect(typeof stdout).toBe("string");
expect(stdout).toBe("stdout message\n");
expect(typeof stderr).toBe("string");
expect(stderr).toBe("stderr message\n");
});
test("handles empty output", async () => {
const dir = tempDirWithFiles("spawn-text-empty", {
"empty.js": `// No output`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "empty.js")],
env: bunEnv,
stdout: "text",
});
const text = await proc.stdout;
expect(typeof text).toBe("string");
expect(text).toBe("");
});
test("handles large text output", async () => {
const dir = tempDirWithFiles("spawn-text-large", {
"large.js": `
const line = Buffer.alloc(80, 'X').toString() + '\\n';
for (let i = 0; i < 10000; i++) {
process.stdout.write(line);
}
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "large.js")],
env: bunEnv,
stdout: "text",
});
const text = await proc.stdout;
expect(typeof text).toBe("string");
expect(text.length).toBe(10000 * 81); // 80 X's + newline
const lines = text.split('\n');
expect(lines.length).toBe(10001); // 10000 lines + empty string at end
expect(lines[0]).toBe(Buffer.alloc(80, 'X').toString());
});
});
describe("mixed modes", () => {
test("can mix buffer, text, and pipe", async () => {
const dir = tempDirWithFiles("spawn-mixed", {
"mixed.js": `
console.log("stdout");
console.error("stderr");
`,
});
// Test all combinations
const configs = [
{ stdout: "buffer", stderr: "text" },
{ stdout: "text", stderr: "buffer" },
{ stdout: "buffer", stderr: "pipe" },
{ stdout: "pipe", stderr: "buffer" },
{ stdout: "text", stderr: "pipe" },
{ stdout: "pipe", stderr: "text" },
] as const;
for (const config of configs) {
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "mixed.js")],
env: bunEnv,
...config,
});
if (config.stdout === "buffer") {
const buffer = await proc.stdout;
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.toString()).toBe("stdout\n");
} else if (config.stdout === "text") {
const text = await proc.stdout;
expect(typeof text).toBe("string");
expect(text).toBe("stdout\n");
} else {
const text = await new Response(proc.stdout).text();
expect(text).toBe("stdout\n");
}
if (config.stderr === "buffer") {
const buffer = await proc.stderr;
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.toString()).toBe("stderr\n");
} else if (config.stderr === "text") {
const text = await proc.stderr;
expect(typeof text).toBe("string");
expect(text).toBe("stderr\n");
} else {
const text = await new Response(proc.stderr).text();
expect(text).toBe("stderr\n");
}
}
});
});
describe("sync mode (spawnSync)", () => {
test("buffer mode returns buffer in result", () => {
const dir = tempDirWithFiles("spawn-sync-buffer", {
"echo.js": `console.log("sync output");`,
});
const result = Bun.spawnSync({
cmd: [bunExe(), path.join(dir, "echo.js")],
env: bunEnv,
stdout: "buffer",
});
expect(result.success).toBe(true);
expect(result.stdout).toBeDefined();
expect(result.stdout).toBeInstanceOf(Buffer);
expect(result.stdout!.toString()).toBe("sync output\n");
});
test("text mode returns string in result", () => {
const dir = tempDirWithFiles("spawn-sync-text", {
"echo.js": `console.log("sync text");`,
});
const result = Bun.spawnSync({
cmd: [bunExe(), path.join(dir, "echo.js")],
env: bunEnv,
stdout: "text",
});
expect(result.success).toBe(true);
expect(typeof result.stdout).toBe("string");
expect(result.stdout).toBe("sync text\n");
});
test("handles UTF-8 in sync text mode", () => {
const dir = tempDirWithFiles("spawn-sync-text-utf8", {
"utf8.js": `console.log("Hello 世界 🌍");`,
});
const result = Bun.spawnSync({
cmd: [bunExe(), path.join(dir, "utf8.js")],
env: bunEnv,
stdout: "text",
});
expect(result.success).toBe(true);
expect(result.stdout).toBe("Hello 世界 🌍\n");
});
});
describe("edge cases", () => {
test("process that exits immediately", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "process.exit(0)"],
env: bunEnv,
stdout: "buffer",
});
const buffer = await proc.stdout;
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBe(0);
});
test("process that fails", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "throw new Error('test error')"],
env: bunEnv,
stdout: "buffer",
stderr: "text",
});
const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout,
proc.stderr,
proc.exited,
]);
expect(stdout).toBeInstanceOf(Buffer);
expect(stdout.length).toBe(0);
expect(typeof stderr).toBe("string");
expect(stderr).toContain("Error: test error");
expect(exitCode).not.toBe(0);
});
test("stdin still works with buffer/text stdout", async () => {
const dir = tempDirWithFiles("spawn-stdin-buffer", {
"cat.js": `
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => console.log(data));
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), path.join(dir, "cat.js")],
env: bunEnv,
stdin: "pipe",
stdout: "buffer",
});
proc.stdin.write("Hello from stdin");
proc.stdin.end();
const buffer = await proc.stdout;
expect(buffer.toString()).toBe("Hello from stdin\n");
});
test("accessing stdout after it resolves returns same value", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log('test')"],
env: bunEnv,
stdout: "buffer",
});
const buffer1 = await proc.stdout;
const buffer2 = await proc.stdout;
const buffer3 = await proc.stdout;
expect(buffer1).toBe(buffer2);
expect(buffer2).toBe(buffer3);
expect(buffer1.toString()).toBe("test\n");
});
test("stdout promise is created lazily", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log('lazy')"],
env: bunEnv,
stdout: "buffer",
});
// Wait for process to complete
await proc.exited;
// Now access stdout - should still work
const buffer = await proc.stdout;
expect(buffer.toString()).toBe("lazy\n");
});
});
});

View File

@@ -0,0 +1,51 @@
import { describe, test, expect } from "bun:test";
import { spawn, spawnSync } from "bun";
// Regression test for stdout: "buffer" and stdout: "text" modes
describe("spawn buffer and text modes", () => {
test("stdout: 'buffer' returns a promise that resolves to a Buffer", async () => {
const proc = spawn({
cmd: ["echo", "hello buffer"],
stdout: "buffer",
});
expect(proc.stdout).toBeInstanceOf(Promise);
const buffer = await proc.stdout;
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.toString()).toBe("hello buffer\n");
});
test("stdout: 'text' returns a promise that resolves to a string", async () => {
const proc = spawn({
cmd: ["echo", "hello text"],
stdout: "text",
});
expect(proc.stdout).toBeInstanceOf(Promise);
const text = await proc.stdout;
expect(typeof text).toBe("string");
expect(text).toBe("hello text\n");
});
test("spawnSync with stdout: 'buffer' returns Buffer in result", () => {
const result = spawnSync({
cmd: ["echo", "sync buffer"],
stdout: "buffer",
});
expect(result.success).toBe(true);
expect(result.stdout).toBeInstanceOf(Buffer);
expect(result.stdout?.toString()).toBe("sync buffer\n");
});
test("spawnSync with stdout: 'text' returns string in result", () => {
const result = spawnSync({
cmd: ["echo", "sync text"],
stdout: "text",
});
expect(result.success).toBe(true);
expect(typeof result.stdout).toBe("string");
expect(result.stdout).toBe("sync text\n");
});
});