Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
7f39a1859d fix(spawn): close libuv pipes before freeing to prevent handle queue corruption (#27063)
On Windows, libuv tracks all handles in the event loop's handle_queue
doubly-linked list. When uv_pipe_init() is called, the pipe is inserted
into this queue. If the pipe's memory is later freed without calling
uv_close() first, the queue retains dangling pointers. Subsequent
handle insertions (e.g. during Bun.spawn()) crash when traversing the
corrupted linked list.

Three sites were freeing pipe handles without uv_close:

1. process.zig WindowsSpawnOptions.Stdio.deinit(): When spawn failed,
   already-initialized pipes were freed without uv_close(). Now checks
   pipe.loop to determine if the pipe was registered with the event
   loop, and calls uv_close() if so.

2. process.zig spawnProcessWindows IPC handling: Unsupported IPC pipes
   in stdin/stdout/stderr were freed directly. Now uses the same safe
   close-then-destroy pattern.

3. source.zig openPipe(): If pipe.open(fd) failed after pipe.init()
   succeeded, the pipe was destroyed directly. Now calls uv_close()
   with a callback that frees the memory.

Additionally, pipe allocations in stdio.zig are now zeroed so that the
loop field is reliably null before uv_pipe_init, enabling the init
detection in deinit.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-16 14:06:46 +00:00
4 changed files with 86 additions and 8 deletions

View File

@@ -1087,10 +1087,32 @@ pub const WindowsSpawnOptions = struct {
dup2: struct { out: bun.jsc.Subprocess.StdioKind, to: bun.jsc.Subprocess.StdioKind },
pub fn deinit(this: *const Stdio) void {
if (this.* == .buffer) {
bun.default_allocator.destroy(this.buffer);
switch (this.*) {
.buffer => |pipe| closePipeAndDestroy(pipe),
.ipc => |pipe| closePipeAndDestroy(pipe),
else => {},
}
}
/// Close a pipe that may have been initialized with uv_pipe_init.
/// After uv_pipe_init, the pipe is registered in the event loop's
/// handle_queue. Freeing it without uv_close corrupts the queue's
/// linked list, causing segfaults on subsequent handle insertions.
pub fn closePipeAndDestroy(pipe: *bun.windows.libuv.Pipe) void {
if (pipe.loop == null or pipe.isClosed()) {
// Never initialized or already fully closed — safe to free directly.
bun.default_allocator.destroy(pipe);
} else if (!pipe.isClosing()) {
// Initialized and not yet closing — must uv_close to remove from handle queue.
pipe.close(&onPipeCloseForDeinit);
}
// else: isClosing — uv_close was already called, the pending close
// callback owns the lifetime.
}
fn onPipeCloseForDeinit(pipe: *bun.windows.libuv.Pipe) callconv(.c) void {
bun.default_allocator.destroy(pipe);
}
};
pub fn deinit(this: *const WindowsSpawnOptions) void {
@@ -1630,8 +1652,9 @@ pub fn spawnProcessWindows(
stdio.data.fd = fd_i;
},
.ipc => |my_pipe| {
// ipc option inside stdin, stderr or stdout are not supported
bun.default_allocator.destroy(my_pipe);
// ipc option inside stdin, stderr or stdout are not supported.
// Must close properly since the pipe may have been initialized.
WindowsSpawnOptions.Stdio.closePipeAndDestroy(my_pipe);
stdio.flags = uv.UV_IGNORE;
},
.ignore => {

View File

@@ -235,10 +235,10 @@ pub const Stdio = union(enum) {
return .{ .err = .blob_used_as_out };
}
break :brk .{ .buffer = bun.handleOom(bun.default_allocator.create(uv.Pipe)) };
break :brk .{ .buffer = createZeroedPipe() };
},
.ipc => .{ .ipc = bun.handleOom(bun.default_allocator.create(uv.Pipe)) },
.capture, .pipe, .array_buffer, .readable_stream => .{ .buffer = bun.handleOom(bun.default_allocator.create(uv.Pipe)) },
.ipc => .{ .ipc = createZeroedPipe() },
.capture, .pipe, .array_buffer, .readable_stream => .{ .buffer = createZeroedPipe() },
.fd => |fd| .{ .pipe = fd },
.dup2 => .{ .dup2 = .{ .out = stdio.dup2.out, .to = stdio.dup2.to } },
.path => |pathlike| .{ .path = pathlike.slice() },
@@ -487,6 +487,15 @@ pub const Stdio = union(enum) {
}
};
/// Allocate a zero-initialized uv.Pipe. Zero-init ensures `pipe.loop` is null
/// for pipes that were never passed to `uv_pipe_init`, which
/// `closePipeAndDestroy` relies on to decide whether `uv_close` is needed.
fn createZeroedPipe() *uv.Pipe {
const pipe = bun.default_allocator.create(uv.Pipe) catch |err| bun.handleOom(err);
pipe.* = std.mem.zeroes(uv.Pipe);
return pipe;
}
const std = @import("std");
const bun = @import("bun");

View File

@@ -222,7 +222,11 @@ pub const Source = union(enum) {
switch (pipe.open(fd)) {
.err => |err| {
bun.default_allocator.destroy(pipe);
// The pipe was already registered in the event loop's handle_queue
// by uv_pipe_init above. We must call uv_close to properly remove
// it from the queue before freeing the memory, otherwise the
// handle_queue linked list becomes corrupted (dangling pointers).
pipe.close(&onPipeOpenFailClose);
return .{
.err = err,
};
@@ -233,6 +237,10 @@ pub const Source = union(enum) {
return .{ .result = pipe };
}
fn onPipeOpenFailClose(pipe: *Pipe) callconv(.c) void {
bun.default_allocator.destroy(pipe);
}
pub const StdinTTY = struct {
var data: uv.uv_tty_t = undefined;
var lock: bun.Mutex = .{};

View File

@@ -0,0 +1,38 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/27063
// On Windows, when Bun.spawn fails (e.g., ENOENT for a nonexistent executable),
// pipes initialized with uv_pipe_init were freed without calling uv_close first.
// This corrupted libuv's internal handle_queue linked list, causing segfaults
// on subsequent spawn calls.
test("spawning nonexistent executables repeatedly does not crash", async () => {
// Spawn a nonexistent executable multiple times. Before the fix, on Windows
// this would corrupt the libuv handle queue and crash on a subsequent spawn.
for (let i = 0; i < 5; i++) {
try {
const proc = Bun.spawn({
cmd: ["this-executable-does-not-exist-27063"],
stdout: "pipe",
stderr: "pipe",
});
await proc.exited;
} catch {
// Expected to fail - we're testing that it doesn't crash
}
}
// If we get here without crashing, the handle queue is intact.
// Verify a valid spawn still works after the failed ones.
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log('ok')"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout.trim()).toBe("ok");
expect(exitCode).toBe(0);
});