Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
facb0b2fc3 fix(windows): prevent use-after-free in pipe writer close with pending writes
When close() was called on a Windows pipe writer while an async write
(uv_write or uv_fs_write) was still in-flight, onCloseSource() was
called synchronously, notifying the parent immediately. The parent's
onClose handler could then free resources (like StreamBuffer memory)
that the pending write callback would later access, causing memory
corruption and "switch on corrupt value" panics.

Fixes:

1. **Defer onCloseSource when write is pending**: Both
   WindowsBufferedWriter and WindowsStreamingWriter now check
   hasPendingAsyncWrite() before calling onCloseSource(). When a write
   is in-flight, the notification is deferred to onWriteComplete or
   onFsWriteComplete, ensuring buffers remain valid through the callback.

2. **Source-null early return in callbacks**: onWriteComplete and
   onFsWriteComplete now check if source was set to null (indicating
   close() already ran) and complete the deferred onCloseSource()
   instead of accessing potentially freed parent resources.

3. **Null handle.data guards in PipeReader**: onStreamAlloc and
   onStreamRead now safely return when handle.data is null, preventing
   panics during handle cleanup races.

4. **Safe pipe/tty close callbacks**: onPipeClose and onTTYClose in both
   PipeReader and PipeWriter now use the handle parameter directly for
   destroy instead of casting through handle.data, which may be stale.

Fixes #27138

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 10:22:40 +00:00
3 changed files with 156 additions and 17 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();
@@ -1199,13 +1199,15 @@ 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);
// Use the handle directly for destroy, not handle.data which may be null
// during cleanup races.
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);
// Use the handle directly for destroy, not handle.data which may be null
// during cleanup races.
bun.default_allocator.destroy(handle);
}
pub fn onRead(this: *WindowsBufferedReader, amount: bun.sys.Maybe(usize), slice: []u8, hasMore: ReadState) void {

View File

@@ -804,22 +804,28 @@ fn BaseWindowsPipeWriter(
}
fn onPipeClose(handle: *uv.Pipe) callconv(.c) void {
const this = bun.cast(*uv.Pipe, handle.data);
bun.default_allocator.destroy(this);
// Use the handle directly for destroy, not handle.data which may be
// stale during cleanup races.
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);
// Use the handle directly for destroy, not handle.data which may be
// stale during cleanup races.
bun.default_allocator.destroy(handle);
}
pub fn close(this: *WindowsPipeWriter) void {
this.is_done = true;
const source = this.source orelse return;
// Check for in-flight file write before detaching. detach()
// nulls fs.data so onFsWriteComplete can't recover the writer
// to call deref(). We must balance processSend's ref() here.
const has_inflight_write = if (@hasField(WindowsPipeWriter, "current_payload")) switch (source) {
// Check if there's a pending async write before closing.
// If so, we must defer onCloseSource() to the write-complete
// callback, because the parent's onClose handler may free
// resources that the pending callback still needs to access.
const has_pending_async_write = this.hasPendingAsyncWrite();
// For StreamingWriter: also check the file state for in-flight
// writes so we can balance processSend's ref().
const has_inflight_file_write = if (@hasField(WindowsPipeWriter, "current_payload")) switch (source) {
.sync_file, .file => |file| file.state == .operating or file.state == .canceling,
else => false,
} else false;
@@ -844,9 +850,16 @@ fn BaseWindowsPipeWriter(
},
}
this.source = null;
this.onCloseSource();
if (!has_pending_async_write) {
// Safe to notify parent immediately — no pending callback.
this.onCloseSource();
}
// When has_pending_async_write is true, onCloseSource() will
// be called from onWriteComplete/onFsWriteComplete after the
// pending write callback completes safely.
// Deref last — this may free the parent and `this`.
if (has_inflight_write) {
if (has_inflight_file_write) {
this.parent.deref();
}
}
@@ -999,7 +1012,22 @@ pub fn WindowsBufferedWriter(Parent: type, function_table: anytype) type {
return .success;
}
/// Returns true if there is an outstanding async write request
/// (uv_write or uv_fs_write) that hasn't completed yet.
pub fn hasPendingAsyncWrite(this: *const WindowsWriter) bool {
return this.pending_payload_size > 0;
}
fn onWriteComplete(this: *WindowsWriter, status: uv.ReturnCode) void {
// If the source was closed while a write was in-flight,
// close() deferred onCloseSource(). Complete it now that
// the write callback has safely finished.
if (this.source == null) {
this.pending_payload_size = 0;
this.onCloseSource();
return;
}
const written = this.pending_payload_size;
this.pending_payload_size = 0;
if (status.toError(.write)) |err| {
@@ -1038,6 +1066,14 @@ pub fn WindowsBufferedWriter(Parent: type, function_table: anytype) type {
const this = bun.cast(*WindowsWriter, parent_ptr);
// If source was closed while write was in-flight, close()
// deferred onCloseSource(). Complete it now.
if (this.source == null) {
this.pending_payload_size = 0;
this.onCloseSource();
return;
}
if (was_canceled) {
// Canceled write - clear pending state
this.pending_payload_size = 0;
@@ -1302,6 +1338,12 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
return (this.outgoing.isNotEmpty() or this.current_payload.isNotEmpty());
}
/// Returns true if there is an outstanding async write request
/// (uv_write or uv_fs_write) that hasn't completed yet.
pub fn hasPendingAsyncWrite(this: *const WindowsWriter) bool {
return this.current_payload.isNotEmpty();
}
fn isDone(this: *WindowsWriter) bool {
// done is flags andd no more data queued? so we are done!
return this.is_done and !this.hasPendingData();
@@ -1312,6 +1354,16 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
// processSend before submitting the async write request.
defer this.parent.deref();
// If the source was closed while a write was in-flight,
// close() deferred onCloseSource(). Complete it now that
// the write callback has safely finished.
if (this.source == null) {
this.current_payload.reset();
this.outgoing.reset();
this.onCloseSource();
return;
}
if (status.toError(.write)) |err| {
this.last_write_result = .{ .err = err };
log("onWrite() = {s}", .{err.name()});
@@ -1369,6 +1421,16 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
const this = bun.cast(*WindowsWriter, parent_ptr);
// If source was closed while write was in-flight, close()
// deferred onCloseSource(). Complete it now and deref.
if (this.source == null) {
this.current_payload.reset();
this.outgoing.reset();
this.parent.deref();
this.onCloseSource();
return;
}
if (was_canceled) {
// Canceled write - reset buffers and deref to balance processSend ref
this.current_payload.reset();

View File

@@ -0,0 +1,75 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Regression test for https://github.com/oven-sh/bun/issues/27138
// "switch on corrupt value" panic on Windows during long-running sessions
// with heavy spawn usage. Root cause: use-after-free when pipe writer's
// close() was called while an async write was still in-flight, causing
// onCloseSource() to fire synchronously and free resources that the
// pending write callback would later access.
test("rapid spawn/close cycles should not crash", async () => {
// Spawn many short-lived processes that write to stdin and immediately
// close. This exercises the close-while-write-pending path.
const iterations = 50;
const promises: Promise<void>[] = [];
for (let i = 0; i < iterations; i++) {
promises.push(
(async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "process.stdin.resume(); setTimeout(() => process.exit(0), 10)"],
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Write data to stdin, then immediately close.
// This creates the race condition where close() is called
// while the write may still be in-flight.
try {
proc.stdin.write("hello world\n".repeat(100));
} catch {
// Write may fail if process exits first - that's fine
}
proc.stdin.end();
await proc.exited;
})(),
);
}
await Promise.all(promises);
// If we get here without crashing, the test passes.
expect(true).toBe(true);
});
test("concurrent spawn with stdout/stderr reading should not corrupt memory", async () => {
// Spawn processes that produce output and read from them concurrently.
// This exercises the pipe reader close path.
const iterations = 30;
const promises: Promise<void>[] = [];
for (let i = 0; i < iterations; i++) {
promises.push(
(async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `console.log("x".repeat(1024)); console.error("y".repeat(1024));`],
env: bunEnv,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.length).toBeGreaterThan(0);
expect(stderr.length).toBeGreaterThan(0);
expect(exitCode).toBe(0);
})(),
);
}
await Promise.all(promises);
});