Compare commits

...

2 Commits

Author SHA1 Message Date
Jarred Sumner
0d98f005c1 Merge branch 'main' into claude/fix-spawn-pipe-cleanup-on-error 2026-02-15 00:53:43 -08:00
Dylan Conway
6b776ab56a fix(spawn): close libuv pipes before freeing on spawn failure to prevent handle queue corruption
When `Bun.spawn` fails on Windows (e.g., ENOENT for a nonexistent
executable), pipes that were already initialized with `uv_pipe_init`
were being freed directly with `allocator.destroy()` without first
calling `uv_close()`. This left dangling pointers in libuv's handle
queue linked list, corrupting it. Subsequent spawn calls would
eventually crash with a segfault in `uv__queue_insert_tail` when
attempting to insert a new handle into the corrupted list.

The fix ensures that `WindowsSpawnOptions.Stdio.deinit()` properly
calls `uv_close` on pipes that were initialized before freeing them.
Also fixes a potential use-after-free where unsupported IPC pipes in
stdin/stdout/stderr were freed inside `spawnProcessWindows` but the
caller's deinit would later access the dangling pointer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-14 21:30:17 -08:00
2 changed files with 41 additions and 8 deletions

View File

@@ -1087,10 +1087,33 @@ 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.
/// If the pipe was initialized, we must call uv_close before freeing
/// to properly remove it from the loop's handle queue; otherwise
/// the handle queue linked list becomes corrupted.
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. This shouldn't happen in practice since
// deinit is only called on the error path before any close is initiated.
}
fn onPipeCloseForDeinit(pipe: *bun.windows.libuv.Pipe) callconv(.c) void {
bun.default_allocator.destroy(pipe);
}
};
pub fn deinit(this: *const WindowsSpawnOptions) void {
@@ -1629,9 +1652,10 @@ pub fn spawnProcessWindows(
stdio.flags = uv.UV_INHERIT_FD;
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 => {
// ipc option inside stdin, stderr or stdout are not supported.
// Don't destroy the pipe here — the caller's deinit() will handle it
// (the pipe was never uv_pipe_init'd so it can be freed directly).
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 is required so that
/// `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() *bun.windows.libuv.Pipe {
const pipe = bun.handleOom(bun.default_allocator.create(bun.windows.libuv.Pipe));
pipe.* = std.mem.zeroes(bun.windows.libuv.Pipe);
return pipe;
}
const std = @import("std");
const bun = @import("bun");