diff --git a/src/bun.js/api/bun/spawn/stdio.zig b/src/bun.js/api/bun/spawn/stdio.zig index f0f1951e20..8c0d4d0372 100644 --- a/src/bun.js/api/bun/spawn/stdio.zig +++ b/src/bun.js/api/bun/spawn/stdio.zig @@ -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()) { diff --git a/src/bun.js/api/bun/subprocess.zig b/src/bun.js/api/bun/subprocess.zig index 62cdb54654..a78efd9f88 100644 --- a/src/bun.js/api/bun/subprocess.zig +++ b/src/bun.js/api/bun/subprocess.zig @@ -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(); } diff --git a/src/shell/subproc.zig b/src/shell/subproc.zig index 31d0bd575d..ebb1a24c1f 100644 --- a/src/shell/subproc.zig +++ b/src/shell/subproc.zig @@ -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 = .{ diff --git a/test/js/bun/spawn/spawn-buffer-text-simple.test.ts b/test/js/bun/spawn/spawn-buffer-text-simple.test.ts new file mode 100644 index 0000000000..98a7c4ad91 --- /dev/null +++ b/test/js/bun/spawn/spawn-buffer-text-simple.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/test/js/bun/spawn/spawn-stdout-buffer-text.test.ts b/test/js/bun/spawn/spawn-stdout-buffer-text.test.ts new file mode 100644 index 0000000000..0216ade357 --- /dev/null +++ b/test/js/bun/spawn/spawn-stdout-buffer-text.test.ts @@ -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"); + }); + }); +}); \ No newline at end of file diff --git a/test/regression/issue/spawn-buffer-text-modes.test.ts b/test/regression/issue/spawn-buffer-text-modes.test.ts new file mode 100644 index 0000000000..8e5fe18d35 --- /dev/null +++ b/test/regression/issue/spawn-buffer-text-modes.test.ts @@ -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"); + }); +}); \ No newline at end of file