Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
82a55d41fe fix(shell,io): fix use-after-free in shell interpreter and pipe reader on Windows
Fix three related crashes reported in #26856:

1. Shell interpreter error path: When `setupIOBeforeRun()` failed in `runFromJS()`,
   `#deinitFromExec()` freed the interpreter immediately via `allocator.destroy(this)`.
   Since the interpreter is GC-managed, the GC finalizer would later access freed memory.
   Fix: clean up runtime resources with `#derefRootShellAndIOIfNeeded(true)` and
   `keep_alive.disable()`, letting the GC finalizer handle destruction.

2. Pipe reader EOF callbacks: `closeImpl` set `pipe.data`/`tty.data` to self-referencing
   pointers, but pending `onStreamRead`/`onStreamAlloc` callbacks from libuv would cast
   the invalid pointer to `WindowsBufferedReader`, causing a segfault.
   Fix: set `pipe.data`/`tty.data` to null and add null guards in the callbacks. The
   `onPipeClose`/`onTTYClose` handlers now use the handle parameter directly instead of
   casting from the data pointer.

Closes #26856

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-10 02:19:39 +00:00
4 changed files with 89 additions and 10 deletions

View File

@@ -760,7 +760,7 @@ pub const WindowsBufferedReader = struct {
return Type.onReaderError(@as(*Type, @ptrCast(@alignCast(this))), err);
}
fn loop(this: *anyopaque) *Async.Loop {
return Type.loop(@as(*Type, @alignCast(@ptrCast(this))));
return Type.loop(@as(*Type, @ptrCast(@alignCast(this))));
}
};
return .{
@@ -955,14 +955,14 @@ pub const WindowsBufferedReader = struct {
}
fn onStreamAlloc(handle: *uv.Handle, suggested_size: usize, buf: *uv.uv_buf_t) callconv(.c) void {
var this = bun.cast(*WindowsBufferedReader, handle.data);
const this: *WindowsBufferedReader = @ptrCast(@alignCast(handle.data orelse return));
const result = this.getReadBufferWithStableMemoryAddress(suggested_size);
buf.* = uv.uv_buf_t.init(result);
}
fn onStreamRead(handle: *uv.uv_handle_t, nread: uv.ReturnCodeI64, buf: *const uv.uv_buf_t) callconv(.c) void {
const stream = bun.cast(*uv.uv_stream_t, handle);
var this = bun.cast(*WindowsBufferedReader, stream.data);
const this: *WindowsBufferedReader = @ptrCast(@alignCast(stream.data orelse return));
const nread_int = nread.int();
@@ -1157,7 +1157,7 @@ pub const WindowsBufferedReader = struct {
file.detach();
},
.pipe => |pipe| {
pipe.data = pipe;
pipe.data = null;
this.flags.is_paused = true;
pipe.close(onPipeClose);
},
@@ -1165,7 +1165,7 @@ pub const WindowsBufferedReader = struct {
if (Source.StdinTTY.isStdinTTY(tty)) {
// Node only ever closes stdin on process exit.
} else {
tty.data = tty;
tty.data = null;
tty.close(onTTYClose);
}
@@ -1199,13 +1199,11 @@ pub const WindowsBufferedReader = struct {
}
fn onPipeClose(handle: *uv.Pipe) callconv(.c) void {
const this = bun.cast(*uv.Pipe, handle.data);
bun.default_allocator.destroy(this);
bun.default_allocator.destroy(handle);
}
fn onTTYClose(handle: *uv.uv_tty_t) callconv(.c) void {
const this = bun.cast(*uv.uv_tty_t, handle.data);
bun.default_allocator.destroy(this);
bun.default_allocator.destroy(handle);
}
pub fn onRead(this: *WindowsBufferedReader, amount: bun.sys.Maybe(usize), slice: []u8, hasMore: ReadState) void {

View File

@@ -1154,7 +1154,11 @@ pub const Interpreter = struct {
_ = callframe; // autofix
if (this.setupIOBeforeRun().asErr()) |e| {
defer this.#deinitFromExec();
// Clean up resources but let the GC finalizer handle deallocation.
// Using #deinitFromExec() here would free the interpreter immediately,
// causing a use-after-free when GC later calls deinitFromFinalizer().
this.keep_alive.disable();
this.#derefRootShellAndIOIfNeeded(true);
const shellerr = bun.shell.ShellErr.newSys(e);
return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop });
}

View File

@@ -0,0 +1,31 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Regression test for https://github.com/oven-sh/bun/issues/26832
// Segfault when libuv delivers pipe EOF callbacks after the reader is cleaned up.
// The crash occurs when spawning many subprocesses and closing them rapidly,
// causing a race between pipe close and pending EOF callbacks.
test("rapid subprocess spawn and close does not crash", async () => {
const iterations = 50;
const promises: Promise<void>[] = [];
for (let i = 0; i < iterations; i++) {
promises.push(
(async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "process.stdout.write('x'); process.exit(0)"],
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
// Read and immediately discard - the key is rapid open/close cycles
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(exitCode).toBe(0);
})(),
);
}
await Promise.all(promises);
});

View File

@@ -0,0 +1,46 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Issue #26847: Segfault in shell interpreter GC finalization
// When setupIOBeforeRun() fails in the JS path, the interpreter was freed
// immediately via deinitFromExec(). When GC later ran deinitFromFinalizer(),
// it accessed already-freed memory, causing a segfault.
//
// The exact error path (dup() failure) is hard to trigger from JS, but this
// test exercises the shell interpreter + GC interaction to verify there are
// no lifetime issues when many shell interpreters are created and collected.
test("shell interpreter GC finalization does not crash", async () => {
const code = `
// Create many shell interpreters and let them be collected by GC.
// This stresses the GC finalization path of ShellInterpreter objects.
for (let i = 0; i < 100; i++) {
// Create shell promises but don't await them, so they get GC'd
Bun.$\`echo \${i}\`.quiet();
}
// Force garbage collection to finalize the shell interpreter objects.
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);
// Also test the normal path: run and await shell commands, then GC
for (let i = 0; i < 10; i++) {
await Bun.$\`echo \${i}\`.quiet();
}
Bun.gc(true);
Bun.gc(true);
console.log("OK");
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("OK");
expect(exitCode).toBe(0);
});