Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
15ebc6a182 fix(spawn): protect ReadableStream from GC when used as stdin via Response
When `stdin: new Response(data)` is passed to `Bun.spawn`, the Response
body is converted to a ReadableStream and stored in the Stdio union.
Previously, this stored a bare ReadableStream (containing only a raw
JSValue), which was not protected from garbage collection. Between
extraction and when FileSink.assignToStream() creates a Strong reference,
process spawning and option processing could trigger GC cycles that
collect the unprotected ReadableStream, causing heap corruption.

Fix by changing Stdio.readable_stream from ReadableStream to
ReadableStream.Strong, which holds a C++ JSC Strong reference that
prevents the GC from collecting the stream value.

Closes #26979

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 21:30:49 +00:00
4 changed files with 112 additions and 20 deletions

View File

@@ -128,6 +128,15 @@ pub fn spawnMaybeSync(
.{ .pipe = {} },
.{ .inherit = {} },
};
defer {
// Clean up any GC-preventing Strong references in the stdio array.
// This is important for readable_stream which holds a Strong JSC
// reference. For successful paths, the Strong has already been
// consumed (deinited) by Writable.init, so this is a no-op.
for (&stdio) |*s| {
if (s.* == .readable_stream) s.deinit();
}
}
if (comptime is_sync) {
stdio[1] = .{ .pipe = {} };
@@ -812,7 +821,9 @@ pub fn spawnMaybeSync(
}
if (stdio[0] == .readable_stream) {
jsc.Codegen.JSSubprocess.stdinSetCached(out, globalThis, stdio[0].readable_stream.value);
if (stdio[0].readable_stream.held.get()) |stream_value| {
jsc.Codegen.JSSubprocess.stdinSetCached(out, globalThis, stream_value);
}
}
// Cache the terminal JS value if a terminal was created

View File

@@ -13,7 +13,7 @@ pub const Stdio = union(enum) {
memfd: bun.FileDescriptor,
pipe,
ipc,
readable_stream: jsc.WebCore.ReadableStream,
readable_stream: jsc.WebCore.ReadableStream.Strong,
const log = bun.sys.syslog;
@@ -69,8 +69,8 @@ pub const Stdio = union(enum) {
.memfd => |fd| {
fd.close();
},
.readable_stream => {
// ReadableStream cleanup is handled by the subprocess
.readable_stream => |*strong| {
strong.deinit();
},
else => {},
}
@@ -321,7 +321,7 @@ pub const Stdio = union(enum) {
return globalThis.ERR(.BODY_ALREADY_USED, "ReadableStream has already been used", .{}).throw();
}
out_stdio.* = .{ .readable_stream = stream };
out_stdio.* = .{ .readable_stream = .init(stream, globalThis) };
},
}
@@ -416,7 +416,7 @@ pub const Stdio = union(enum) {
if (stream.isDisturbed(globalThis)) {
return globalThis.ERR(.INVALID_STATE, "'{s}' ReadableStream has already been used", .{name}).throw();
}
out_stdio.* = .{ .readable_stream = stream };
out_stdio.* = .{ .readable_stream = .init(stream, globalThis) };
return;
}

View File

@@ -100,7 +100,11 @@ pub const Writable = union(enum) {
_ = err; // autofix
pipe.deref();
if (stdio.* == .readable_stream) {
stdio.readable_stream.cancel(event_loop.global);
if (stdio.readable_stream.get(event_loop.global)) |s| {
var stream = s;
stream.cancel(event_loop.global);
}
stdio.readable_stream.deinit();
}
return error.UnexpectedCreatingStdin;
},
@@ -112,13 +116,19 @@ pub const Writable = union(enum) {
subprocess.flags.has_stdin_destructor_called = false;
if (stdio.* == .readable_stream) {
const assign_result = pipe.assignToStream(&stdio.readable_stream, event_loop.global);
if (assign_result.toError()) |err| {
pipe.deref();
subprocess.deref();
return event_loop.global.throwValue(err);
if (stdio.readable_stream.get(event_loop.global)) |s| {
var stream = s;
const assign_result = pipe.assignToStream(&stream, event_loop.global);
stdio.readable_stream.deinit();
if (assign_result.toError()) |err| {
pipe.deref();
subprocess.deref();
return event_loop.global.throwValue(err);
}
promise_for_stream.* = assign_result;
} else {
stdio.readable_stream.deinit();
}
promise_for_stream.* = assign_result;
}
return Writable{
@@ -173,7 +183,11 @@ pub const Writable = union(enum) {
_ = err; // autofix
pipe.deref();
if (stdio.* == .readable_stream) {
stdio.readable_stream.cancel(event_loop.global);
if (stdio.readable_stream.get(event_loop.global)) |s| {
var stream = s;
stream.cancel(event_loop.global);
}
stdio.readable_stream.deinit();
}
return error.UnexpectedCreatingStdin;
@@ -188,13 +202,19 @@ pub const Writable = union(enum) {
subprocess.flags.deref_on_stdin_destroyed = true;
if (stdio.* == .readable_stream) {
const assign_result = pipe.assignToStream(&stdio.readable_stream, event_loop.global);
if (assign_result.toError()) |err| {
pipe.deref();
subprocess.deref();
return event_loop.global.throwValue(err);
if (stdio.readable_stream.get(event_loop.global)) |s| {
var stream = s;
const assign_result = pipe.assignToStream(&stream, event_loop.global);
stdio.readable_stream.deinit();
if (assign_result.toError()) |err| {
pipe.deref();
subprocess.deref();
return event_loop.global.throwValue(err);
}
promise_for_stream.* = assign_result;
} else {
stdio.readable_stream.deinit();
}
promise_for_stream.* = assign_result;
}
return Writable{

View File

@@ -0,0 +1,61 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/26979
// Bun.spawn with stdin: new Response(data) causes heap corruption when
// concurrent GC pressure exists (e.g. Bun.file().exists() calls and
// another spawn's stdout read). The ReadableStream created from the
// Response body was not protected from garbage collection between
// extraction and when the FileSink took a strong reference.
test("Bun.spawn stdin with Response body does not crash under GC pressure", async () => {
// Run in a subprocess to detect crashes (segfault / assertion failure)
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
async function run() {
const fileOps = Array.from({ length: 10 }, () => Bun.file("/tmp/nope").exists());
const outer = Bun.spawn(["cat"], {
stdin: new Response("y".repeat(100)),
stdout: "pipe",
stderr: "pipe"
});
const outerText = new Response(outer.stdout).text();
const inner = Bun.spawn(["cat"], {
stdin: new Response("x".repeat(20000)),
stdout: "pipe"
});
const innerText = await new Response(inner.stdout).text();
if (innerText !== "x".repeat(20000)) throw new Error("inner mismatch: " + innerText.length);
await inner.exited;
const outerResult = await outerText;
if (outerResult !== "y".repeat(100)) throw new Error("outer mismatch: " + outerResult.length);
await outer.exited;
await Promise.all(fileOps);
}
await run();
await run();
await run();
console.log("OK");
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe("OK");
expect(exitCode).toBe(0);
}, 30_000);