io(windows): honor CLOSE_HANDLE in WindowsBufferedReader close path - #33931
Conversation
FileResponseStream clears ReaderFlags::CLOSE_HANDLE so it can close the fd itself in Drop. PosixBufferedReader checks the flag before closing; WindowsBufferedReader never did, so close_impl's File::detach() queued a second uv_fs_close on the same CRT fd. Between the two async closes an unrelated uv_fs_open could be handed the recycled slot, surfacing as an EBADF on its subsequent fstat or read.
|
Updated 5:52 PM PT - Jul 10th, 2026
❌ @robobun, your commit d23a604 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 33931That installs a local version of the PR into your bun-33931 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe change updates Windows borrowed-file detachment and boxed-file reclamation across readers and writers, while adding a stress regression test for concurrent completed and aborted served-file requests. ChangesWindows file descriptor lifecycle
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/http/bun-serve-file.test.ts`:
- Line 1071: Gate the regression tests “Response(Bun.file) does not double-close
the fd on Windows” and the related test at the second occurrence behind the
imported `isWindows` guard. Ensure `isWindows` is imported from `harness`, and
condition both tests so they run only on Windows, avoiding unnecessary POSIX
workload and timeout impact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ec35f2ee-8442-4ea8-bd02-5d56b42fc32c
📒 Files selected for processing (2)
src/io/PipeReader.rstest/js/bun/http/bun-serve-file.test.ts
On POSIX the reader honors CLOSE_HANDLE so the double-close never occurs; running the 160-round fixture there is dead workload.
The CI runner supplies --timeout=90000 (270000 under ASAN); an explicit per-test timeout caps the ASAN-scaled budget. The test is Windows-only and completes in ~5s on release lanes.
There was a problem hiding this comment.
I didn't find any bugs, but this touches Windows fd lifecycle/ownership in the libuv close path — worth a human look, especially to confirm uv_fs_close(-1) is safe across all detach() states (in-flight read vs. idle).
Extended reasoning...
Overview
This PR adds a 5-line guard to WindowsBufferedReader::close_impl in src/io/PipeReader.rs: when WindowsFlags::CLOSE_HANDLE is cleared, it sets file.file = -1 before calling detach(), so the subsequent uv_fs_close targets an invalid fd instead of double-closing one the parent (FileResponseStream) already owns and closes. It also adds a Windows-gated stress test to test/js/bun/http/bun-serve-file.test.ts that reproduces the EBADF race via concurrent full/aborted Response(Bun.file) requests plus canary fd checks.
Security risks
None identified. This is an internal fd-lifecycle fix; no user-controlled input, auth, crypto, or trust-boundary code is touched.
Level of scrutiny
High. Per this repo's review history, fd/handle ownership and "released exactly once" is the single most-blocked category of native change. The fix is small but sits squarely in that space:
- It changes Windows-only libuv fd close ordering — a code path I cannot exercise on this Linux host, and the PR's own verification is probabilistic (5/15 fail-before).
- Correctness depends on
uv_fs_close(loop, req, -1, cb)being a harmless failure whose callback still fires to reclaim theBox<File>. I traceddetach()→start_close()and this holds for both the idle (Deinitialized) and in-flight (Operating→complete()→start_close()) paths, but a maintainer familiar with the Windows libuv wrapper should confirm there's no third path (e.g.SyncFile) wherefile.fileis read before the close for something other thanuv_fs_close. - The
mut filebinding change on the match arm is fine —fileis a movedBox<File>, so mutating the field beforeinto_rawis sound.
Other factors
- The diagnosis in the PR description is detailed and traces the exact double-close (FileResponseStream::Drop's
Closer::close+File::detach()'s unconditionaluv_fs_close), which matches what I see insrc/io/source.rsandsrc/runtime/server/FileResponseStream.rs:182. - The one CodeRabbit nit (gate the test to Windows) has been addressed and resolved; the explicit per-test timeout was also dropped in a follow-up commit.
- The test asserts
stderr: ""exactly, which the repo guidelines caution against for debug/ASAN builds — but it's Windows-only where ASAN doesn't run, so likely fine in practice. - No CODEOWNERS entry covers these paths.
|
Re the
Nothing reads On the |
…se(-1) Replace the file.file = -1 sentinel with an explicit detach_borrowed_fd() path: when CLOSE_HANDLE is cleared, the reader's close_impl now either drops the Box<File> immediately (idle) or nulls fs.data and lets the in-flight on_file_read callback reclaim the Box without scheduling a close. on_file_read's detached branch is updated to handle both the close_after_operation case (on_close_complete frees) and the borrowed-fd case (free here).
|
Agreed, swapped it out in 5539d12. The fix is still at the same layer (the reader must honor
On whether it belongs in |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/io/PipeReader.rs`:
- Around line 1506-1509: Condense the ownership and safety comments near the
detached file handling and the corresponding block around the second occurrence
into no more than three lines each, while preserving the key details about
parent fd ownership, the absence of an active close, and safe reclamation of the
Box.
In `@src/io/source.rs`:
- Around line 175-178: Shorten the documentation comment above the detach method
to no more than three lines while preserving its explanation of parent ownership
and callback-versus-caller cleanup behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 93d06f8f-e996-414e-8e47-fdbe24045bd8
📒 Files selected for processing (2)
src/io/PipeReader.rssrc/io/source.rs
BaseWindowsPipeWriter::close's !owns_fd() branch open-coded the same null-fs.data / stop / state-check sequence; route it through the shared File::detach_borrowed_fd so the reader and writer close paths share one contract. Also condense the new comments to three lines and restore an explicit timeout on the stress test (the fixture legitimately takes ~16s under a Windows debug build; there is no Windows ASAN lane to cap).
There was a problem hiding this comment.
No bugs found, and my earlier dedup suggestion was applied in f43b74f. This still touches Windows-only libuv Box<File> lifecycle across async callbacks (unsafe raw-pointer reclaim), so it warrants a human look before merge.
Checked: detach_borrowed_fd state transitions — idle → caller drops Box, Operating/Canceling → on_file_read's null-parent branch reclaims after complete() leaves state Deinitialized; the owned-fd detach() path is unchanged (state Closing → on_close_complete frees). The writer refactor at PipeWriter.rs:1213 is behavior-preserving — on_fs_write_complete's null-parent branch already reclaims on state == Deinitialized. close_after_operation is left unset by the new helper, so complete() never schedules a close for a borrowed fd.
Extended reasoning...
Overview
The PR fixes a Windows-only double-close: WindowsBufferedReader::close_impl unconditionally called File::detach() (which queues uv_fs_close) even when CLOSE_HANDLE was cleared by FileResponseStream, which then closed the same CRT fd again in its own Drop. The fix adds File::detach_borrowed_fd() (nulls fs.data, cancels any in-flight op, does not set close_after_operation) and branches on CLOSE_HANDLE in the reader's close path. on_file_read's detached branch is extended to reclaim the Box<File> when no close was scheduled. After my earlier inline suggestion, the writer's !owns_fd() branch was refactored to use the same helper.
Security risks
None. This is fd-lifecycle plumbing with no user-input parsing, auth, or crypto surface.
Level of scrutiny
High. This is exactly the CLAUDE.md "most-blocked category": raw-pointer heap::into_raw/heap::take round-trips of a Box<File> whose uv_fs_t may still be owned by libuv, with reclaim split across three call sites (close_impl, on_file_read, on_close_complete) depending on async state. A missed reclaim leaks; a double reclaim or premature drop UAFs the uv_fs_t under a threadpool worker. The fix is Windows-only and the Linux host can't observe fail-before, so verification depends on the author's 8/8 Windows debug run and CI.
Other factors
I traced every FileState at the point detach_borrowed_fd() is called (Deinitialized/Operating/Canceling; never Closing because close_impl runs before any start_close) and confirmed each ends with exactly one heap::take. The writer-side change is a pure refactor: the pre-existing inline sequence (stop() → null fs.data → branch on state == Deinitialized) is byte-equivalent to the new helper modulo statement order, and uv_cancel doesn't synchronously fire the callback so ordering is immaterial. The test is Windows-gated, follows harness conventions, and asserts a combined {stdout, stderr, exitCode} object. Given the unsafe/async nature and that Windows CI hasn't reported yet, a human should sign off.
|
It shouldn't be calling uv_fs_close(-1). |
|
It doesn't anymore as of 5539d12 / f43b74f (the PR body was stale; just updated it). When pub fn detach_borrowed_fd(&mut self) -> bool {
self.fs.data = core::ptr::null_mut();
self.stop();
self.state != FileState::Deinitialized
}If idle the caller drops the |
There was a problem hiding this comment.
I didn't find any bugs, but this touches Box ownership hand-off across libuv fs callbacks on Windows — worth a human sign-off given Jarred has already engaged.
Checked: detach_borrowed_fd() leaves close_after_operation unset, so complete() lands in Deinitialized and on_file_read's new state != Closing branch reclaims the Box — equivalent to the pre-existing state == Deinitialized check in both on_fs_write_complete callbacks (PipeWriter.rs:1627, 2197). The writer's !owns_fd() refactor is behavior-identical to the removed inline sequence (stop() doesn't synchronously fire the fs callback, so null-fs.data ordering is immaterial). No path leaves the Box unreclaimed and none double-frees.
Extended reasoning...
Overview
This PR fixes a Windows-only fd double-close in WindowsBufferedReader::close_impl. FileResponseStream clears CLOSE_HANDLE on its embedded reader so it can close the fd itself in Drop, and PosixBufferedReader honors that flag — but WindowsBufferedReader defined the flag and never read it, so close_impl unconditionally called File::detach() which schedules uv_fs_close on an fd the parent is also closing. The fix adds File::detach_borrowed_fd() (nulls fs.data, calls stop(), does not set close_after_operation) and branches on CLOSE_HANDLE in close_impl. on_file_read's detached-parent branch is extended to reclaim the Box when complete() didn't schedule a close. The writer's pre-existing !owns_fd() open-coded sequence in BaseWindowsPipeWriter::close is replaced with the same helper (my earlier suggestion, now applied). A Windows-gated stress test is added.
Security risks
None. This is an internal I/O lifetime fix with no user-controlled input reaching the changed code paths; the fd in question is opened by Bun itself for Response(Bun.file(path)).
Level of scrutiny
High. The change is small and well-reasoned, but it sits squarely in the most-blocked review category for this repo: native memory safety around raw-pointer Box ownership handed to libuv callbacks. Correctness hinges on the FileState state machine — after complete(), state is either Deinitialized (borrowed detach → reclaim in on_file_read) or Closing (owned detach → on_close_complete reclaims). I traced all four combinations (owned/borrowed × idle/in-flight) and each has exactly one reclaimer, and the writer callbacks at PipeWriter.rs:1627/2197 already implement the same contract with the equivalent state == Deinitialized predicate. But this is Windows-only code I cannot execute, and a mistake here is a UAF or fd leak rather than a test failure.
Other factors
Jarred already engaged on this PR (flagging the earlier uv_fs_close(-1) approach, which was replaced) and hasn't approved yet; deferring so he or another maintainer can confirm the final shape. All prior review feedback (CodeRabbit's isWindows gate and comment-length nits, my writer-dedup suggestion) has been applied. The test asserts stderr: "", which is fine here since there is no Windows ASAN lane.
|
CI status on the re-roll (build 71663):
All Windows lanes are green. Ready for review. |
Symptom
Intermittent
EBADF: bad file descriptor, fstatin unrelatedBun.file(path).text()calls on Windows. The most visible CI victim istest/cli/install/bun-install.test.ts(15 flaky hits across the last 40 PR builds, spread across many different test cases), because its dummy registry serves every tarball vianew Response(Bun.file(path)).Cause
FileResponseStream::startclearsReaderFlags::CLOSE_HANDLEon itsBufferedReaderso it can close the fd itself inDrop(src/runtime/server/FileResponseStream.rs:182).PosixBufferedReaderchecks that flag before closing (src/io/PipeReader.rs:283/356/374/385).WindowsBufferedReaderdefines the flag (:1143) and sets it inDefault(:1155) but never reads it, soclose_impl'sSource::Filearm unconditionally callsFile::detach()which queuesuv_fs_closeon the same CRT fd thatFileResponseStream::Dropalready queued aCloser::closefor (:547).Both closes are async on the libuv threadpool. Between close #1 freeing the CRT slot and close #2 running, an unrelated
uv_fs_open(from anotherResponse(Bun.file)open, or aBun.file().text()) can be handed the recycled slot; close #2 then closes the wrong fd, and its nextfstatorreadsees EBADF.Fix
Honor
WindowsFlags::CLOSE_HANDLEinWindowsBufferedReader::close_implvia a newFile::detach_borrowed_fd()that mirrorsdetach()but leavesclose_after_operationunset, so nouv_fs_closeis scheduled for a parent-owned fd:close_implwithCLOSE_HANDLEset: unchanged (detach()schedulesuv_fs_closenow or after the pending read).close_implwithCLOSE_HANDLEcleared:detach_borrowed_fd(). If idle, drop theBox<File>there; if a read is in flight, nullfs.dataand leton_file_read's detached branch reclaim the Box aftercomplete().on_file_read'sparent_ptr.is_null()path now handles both shapes (stateClosing:on_close_completefrees; otherwise: free here).BaseWindowsPipeWriter::close's!owns_fd()branch already open-coded the same sequence and now routes through the shareddetach_borrowed_fd(), so the reader and writer close paths share one contract.Verification
The double-close only exists on Windows (POSIX honors the flag), so the Linux gate cannot observe fail-before. Windows x64-baseline at 91675d0:
EBADF: bad file descriptor, fstat 'served.bin'The new test is gated behind
isWindows.no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/bun-serve-file.test.ts