Compare commits

...

5 Commits

Author SHA1 Message Date
Claude
0b34568280 Fix sync text mode memory management and error test assertion
- Fix sync text mode in Bun.spawn to use createUTF8ForJS for proper memory management
- Fix test assertion that was checking for old error message format

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-28 09:40:39 +02:00
Claude
e34950b993 Fix buffer and text mode promise resolution in Bun.spawn()
This commit properly implements promise resolution for the new buffer
and text modes in Bun.spawn(). The key changes include:

1. Added pending_promise field to PipeReader to store unresolved promises
2. Implemented proper promise resolution in onReaderDone when data is ready
3. Used inline switch statements throughout for cleaner control flow
4. Removed the intermediate buffer_promise_done/text_promise_done states
5. Fixed race conditions where processes exit before stdout/stderr is accessed

The implementation now correctly:
- Creates and stores promises when first accessed
- Resolves promises when pipe reading completes
- Handles both async and sync modes (though sync text mode has a string
  encoding issue that needs further investigation)
- Properly enters/exits the event loop for promise resolution

5 out of 6 tests now pass. The remaining issue is with sync text mode
returning garbled string data, which appears to be a memory lifecycle issue.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-28 09:40:39 +02:00
Cursor Agent
c8fe9523cb Add test for Bun.spawn stdout buffer promise resolution
Co-authored-by: jarred <jarred@bun.sh>
2025-06-28 09:40:39 +02:00
Cursor Agent
ec644923ee Add 'buffer' and 'text' stdio modes to Bun.spawn()
Co-authored-by: jarred <jarred@bun.sh>
2025-06-28 09:40:39 +02:00
Cursor Agent
8566078023 Implement buffer and text promise handling for subprocess stdout/stderr
Co-authored-by: jarred <jarred@bun.sh>
2025-06-28 09:40:39 +02:00
7 changed files with 914 additions and 14 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,15 +318,20 @@ pub fn onCloseIO(this: *Subprocess, kind: StdioKind) void {
},
inline .stdout, .stderr => |tag| {
const out: *Readable = &@field(this, @tagName(tag));
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();
},
inline .buffer_promise, .text_promise => |pipe| {
// Keep the pipe reference for now - the promise resolution
// already happened in onReaderDone but we need to keep the state
// so getStdout/getStderr can still work
_ = pipe;
},
else => {},
}
@@ -383,6 +388,8 @@ const Readable = union(enum) {
fd: bun.FileDescriptor,
memfd: bun.FileDescriptor,
pipe: *PipeReader,
buffer_promise: *PipeReader, // New variant for "buffer"
text_promise: *PipeReader, // New variant for "text"
inherit: void,
ignore: void,
closed: void,
@@ -397,7 +404,8 @@ const Readable = union(enum) {
pub fn memoryCost(this: *const Readable) usize {
return switch (this.*) {
.pipe => @sizeOf(PipeReader) + this.pipe.memoryCost(),
.buffer => this.buffer.length(),
inline .buffer_promise, .text_promise => |p| @sizeOf(PipeReader) + p.memoryCost(),
.buffer => |buf| buf.length(),
else => 0,
};
}
@@ -405,6 +413,7 @@ const Readable = union(enum) {
pub fn hasPendingActivity(this: *const Readable) bool {
return switch (this.*) {
.pipe => this.pipe.hasPendingActivity(),
inline .buffer_promise, .text_promise => |p| p.hasPendingActivity(),
else => false,
};
}
@@ -414,6 +423,9 @@ const Readable = union(enum) {
.pipe => {
this.pipe.updateRef(true);
},
inline .buffer_promise, .text_promise => |p| {
p.updateRef(true);
},
else => {},
}
}
@@ -423,6 +435,9 @@ const Readable = union(enum) {
.pipe => {
this.pipe.updateRef(false);
},
inline .buffer_promise, .text_promise => |p| {
p.updateRef(false);
},
else => {},
}
}
@@ -433,7 +448,7 @@ const Readable = union(enum) {
assertStdioResult(result);
if (comptime Environment.isPosix) {
if (stdio == .pipe) {
if (stdio == .pipe or stdio == .buffer or stdio == .text) {
_ = bun.sys.setNonblocking(result.?);
}
}
@@ -445,6 +460,8 @@ const Readable = union(enum) {
.memfd => if (Environment.isPosix) Readable{ .memfd = stdio.memfd } else Readable{ .ignore = {} },
.dup2 => |dup2| if (Environment.isPosix) Output.panic("TODO: implement dup2 support in Stdio readable", .{}) else Readable{ .fd = dup2.out.toFd() },
.pipe => Readable{ .pipe = PipeReader.create(event_loop, process, result, max_size) },
.buffer => Readable{ .buffer_promise = PipeReader.create(event_loop, process, result, max_size) },
.text => Readable{ .text_promise = PipeReader.create(event_loop, process, result, max_size) },
.array_buffer, .blob => Output.panic("TODO: implement ArrayBuffer & Blob support in Stdio readable", .{}),
.capture => Output.panic("TODO: implement capture support in Stdio readable", .{}),
.readable_stream => Readable{ .ignore = {} }, // ReadableStream is handled separately
@@ -471,6 +488,9 @@ const Readable = union(enum) {
.pipe => {
this.pipe.close();
},
inline .buffer_promise, .text_promise => |p| {
p.close();
},
else => {},
}
}
@@ -488,6 +508,10 @@ const Readable = union(enum) {
defer pipe.detach();
this.* = .{ .closed = {} };
},
inline .buffer_promise, .text_promise => |p| {
defer p.detach();
this.* = .{ .closed = {} };
},
.buffer => |*buf| {
buf.deinit(bun.default_allocator);
},
@@ -497,6 +521,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,
@@ -509,6 +534,59 @@ const Readable = union(enum) {
this.* = .{ .closed = {} };
return pipe.toJS(globalThis);
},
.buffer_promise => |pipe| {
log("toJS buffer_promise: pipe state = {s}", .{@tagName(pipe.state)});
// If we already have a pending promise, return it
if (pipe.pending_promise) |promise| {
log("toJS buffer_promise: returning existing promise", .{});
return promise.toJS();
}
// 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.state.done;
// Don't use toOwnedSlice as it might have already been called
const bytes_copy = bun.default_allocator.alloc(u8, bytes.len) catch {
globalThis.throwOutOfMemory() catch return .zero;
};
@memcpy(bytes_copy, bytes);
defer this.* = .{ .closed = {} };
const buffer = JSC.MarkedArrayBuffer.fromBytes(bytes_copy, 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 and store it
const promise = JSC.JSPromise.create(globalThis);
pipe.pending_promise = promise;
return promise.toJS();
},
.text_promise => |pipe| {
// If we already have a pending promise, return it
if (pipe.pending_promise) |promise| {
return promise.toJS();
}
// Check if the PipeReader has already finished reading
if (pipe.state == .done) {
const bytes = pipe.state.done;
const bytes_copy = bun.default_allocator.alloc(u8, bytes.len) catch {
globalThis.throwOutOfMemory() catch return .zero;
};
@memcpy(bytes_copy, bytes);
defer this.* = .{ .closed = {} };
var str = bun.SliceWithUnderlyingString.transcodeFromOwnedSlice(bytes_copy, .utf8);
defer str.deinit();
return JSC.JSPromise.resolvedPromiseValue(globalThis, str.toJS(globalThis));
}
// Create a new pending promise and store it
const promise = JSC.JSPromise.create(globalThis);
pipe.pending_promise = promise;
return promise.toJS();
},
.buffer => |*buffer| {
defer this.* = .{ .closed = {} };
@@ -544,6 +622,19 @@ const Readable = union(enum) {
this.* = .{ .closed = {} };
return pipe.toBuffer(globalThis);
},
inline .buffer_promise, .text_promise => |pipe, tag| {
defer pipe.detach();
this.* = .{ .closed = {} };
// For text mode, return a string instead of a buffer
if (tag == .text_promise) {
const bytes = pipe.toOwnedSlice();
// Use createUTF8ForJS which properly handles the memory for JS
return bun.String.createUTF8ForJS(globalThis, bytes);
}
return pipe.toBuffer(globalThis);
},
.buffer => |*buf| {
defer this.* = .{ .closed = {} };
const own = buf.takeSlice(bun.default_allocator) catch {
@@ -564,6 +655,24 @@ 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) {
inline .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;
}
}
// Create and cache the promise (resolved or pending based on variant)
const promise_value = this.stderr.toJS(globalThis, this.hasExited());
return promise_value;
},
else => {},
}
return this.stderr.toJS(globalThis, this.hasExited());
}
@@ -580,6 +689,28 @@ 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) {
inline .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;
}
}
// Create and cache the promise
const promise_value = this.stdout.toJS(globalThis, this.hasExited());
log("getStdout created promise", .{});
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`.
@@ -992,6 +1123,7 @@ pub const PipeReader = struct {
err: bun.sys.Error,
} = .{ .pending = {} },
stdio_result: StdioResult,
pending_promise: ?*JSC.JSPromise = null,
pub const IOReader = bun.io.BufferedReader;
pub const Poll = IOReader;
@@ -1067,18 +1199,61 @@ pub const PipeReader = struct {
this.state = .{ .done = owned };
if (this.process) |process| {
this.process = null;
process.onCloseIO(this.kind(process));
// Determine if this is stdout or stderr
const stdio_kind = this.kind(process);
// If we have a pending promise for buffer/text mode, we need to resolve it now
if (this.pending_promise) |promise| {
const globalThis = process.globalThis;
const event_loop = globalThis.bunVM().eventLoop();
event_loop.enter();
defer event_loop.exit();
const is_stdout = stdio_kind == .stdout;
const readable = if (is_stdout) &process.stdout else &process.stderr;
// Resolve the promise based on the mode
switch (readable.*) {
.buffer_promise => {
// Don't use 'owned' directly as it will be freed later
const bytes = bun.default_allocator.alloc(u8, owned.len) catch bun.outOfMemory();
@memcpy(bytes, owned);
const buffer = JSC.MarkedArrayBuffer.fromBytes(bytes, bun.default_allocator, .Uint8Array).toNodeBuffer(globalThis);
promise.resolve(globalThis, buffer);
},
.text_promise => {
// Don't use 'owned' directly as it will be freed later
const bytes = bun.default_allocator.alloc(u8, owned.len) catch bun.outOfMemory();
@memcpy(bytes, owned);
var str = bun.SliceWithUnderlyingString.transcodeFromOwnedSlice(bytes, .utf8);
defer str.deinit();
promise.resolve(globalThis, str.toJS(globalThis));
},
else => {},
}
this.pending_promise = null;
}
process.onCloseIO(stdio_kind);
this.deref();
}
}
pub fn kind(reader: *const PipeReader, process: *const Subprocess) StdioKind {
if (process.stdout == .pipe and process.stdout.pipe == reader) {
return .stdout;
switch (process.stdout) {
inline .pipe, .buffer_promise, .text_promise => |p| {
if (p == reader) return .stdout;
},
else => {},
}
if (process.stderr == .pipe and process.stderr.pipe == reader) {
return .stderr;
switch (process.stderr) {
inline .pipe, .buffer_promise, .text_promise => |p| {
if (p == reader) return .stderr;
},
else => {},
}
@panic("We should be either stdout or stderr");
@@ -1392,6 +1567,10 @@ const Writable = union(enum) {
.pipe = pipe,
};
},
.buffer, .text => {
// stdin cannot be in buffer or text mode
return Writable{ .ignore = {} };
},
.blob => |blob| {
return Writable{
@@ -2568,6 +2747,16 @@ pub fn spawnMaybeSync(
}
}
switch (subprocess.stdout) {
inline .buffer_promise, .text_promise => |pipe| {
pipe.start(subprocess, loop).assert();
if ((is_sync or !lazy)) {
pipe.readAll();
}
},
else => {},
}
if (subprocess.stderr == .pipe) {
subprocess.stderr.pipe.start(subprocess, loop).assert();
@@ -2576,6 +2765,16 @@ pub fn spawnMaybeSync(
}
}
switch (subprocess.stderr) {
inline .buffer_promise, .text_promise => |pipe| {
pipe.start(subprocess, loop).assert();
if ((is_sync or !lazy)) {
pipe.readAll();
}
},
else => {},
}
should_close_memfd = false;
if (comptime !is_sync) {
@@ -2644,6 +2843,16 @@ pub fn spawnMaybeSync(
subprocess.stdout.pipe.watch();
}
switch (subprocess.stderr) {
inline .buffer_promise, .text_promise => |pipe| pipe.watch(),
else => {},
}
switch (subprocess.stdout) {
inline .buffer_promise, .text_promise => |pipe| pipe.watch(),
else => {},
}
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 = .{

26
test-simple-buffer.js Normal file
View File

@@ -0,0 +1,26 @@
const proc = Bun.spawn({
cmd: ['echo', 'hello'],
stdout: 'buffer'
});
console.log('1. Created process');
console.log('2. proc.stdout is Promise:', proc.stdout instanceof Promise);
const timeoutId = setTimeout(() => {
console.log('TIMEOUT: Promise never resolved!');
process.exit(1);
}, 2000);
proc.stdout.then(buffer => {
clearTimeout(timeoutId);
console.log('3. Promise resolved!');
console.log('4. buffer:', buffer);
console.log('5. buffer instanceof Buffer:', buffer instanceof Buffer);
console.log('6. buffer.toString():', buffer.toString());
console.log('SUCCESS');
process.exit(0);
}).catch(err => {
clearTimeout(timeoutId);
console.log('ERROR:', err);
process.exit(1);
});

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("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");
});
});