Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
3c7cb6fe29 fix(win): close libuv pipe handles before freeing to prevent handle_queue corruption
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 in uv__queue_insert_tail (queue.h:81).

Three sites were freeing pipe handles without uv_close:

1. 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.

2. 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.

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

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-14 10:07:24 +00:00
3 changed files with 45 additions and 7 deletions

View File

@@ -1087,10 +1087,30 @@ 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 => {},
}
}
/// Properly close a libuv pipe handle before freeing its memory.
/// 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_handle: *bun.windows.libuv.Pipe) void {
if (pipe_handle.loop != null) {
// Pipe was initialized with uv_pipe_init - must close properly.
pipe_handle.close(&onSpawnPipeCleanupClose);
} else {
// Pipe was allocated but never initialized - safe to free directly.
bun.default_allocator.destroy(pipe_handle);
}
}
fn onSpawnPipeCleanupClose(pipe_handle: *bun.windows.libuv.Pipe) callconv(.c) void {
bun.default_allocator.destroy(pipe_handle);
}
};
pub fn deinit(this: *const WindowsSpawnOptions) void {
@@ -1631,7 +1651,7 @@ pub fn spawnProcessWindows(
},
.ipc => |my_pipe| {
// ipc option inside stdin, stderr or stdout are not supported
bun.default_allocator.destroy(my_pipe);
WindowsSpawnOptions.Stdio.closePipeAndDestroy(my_pipe);
stdio.flags = uv.UV_IGNORE;
},
.ignore => {

View File

@@ -196,6 +196,16 @@ pub const Stdio = union(enum) {
};
}
/// Allocate a zeroed Pipe so that `loop` is null before `uv_pipe_init`.
/// This allows `WindowsSpawnOptions.Stdio.deinit` to detect whether the
/// pipe was registered with the event loop and must be closed via
/// `uv_close` before the memory can be freed.
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;
}
fn toWindows(
stdio: *@This(),
i: i32,
@@ -235,10 +245,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() },

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 = .{};