Skip to content

Windows: release FileSink keep-alive ref for Bun.file(fd).writer() on a borrowed fd - #36107

Open
robobun wants to merge 7 commits into
mainfrom
farm/d19f8897/fix-blob-pipe-fd-leak-windows
Open

Windows: release FileSink keep-alive ref for Bun.file(fd).writer() on a borrowed fd#36107
robobun wants to merge 7 commits into
mainfrom
farm/d19f8897/fix-blob-pipe-fd-leak-windows

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

// child spawned with stdio[3] = 'pipe'
for (let i = 0; i < 8; i++) {
  const w = Bun.file(3).writer();
  await w.write('x');
  await w.end();
}
Bun.gc(true);
// fileSinkInternals.liveCount() == 8  (before: one per iteration leaked)

Cause

Both Windows sink-creation blocks in Blob.rs (get_writer and pipe_readable_stream_to_blob) set writer.owns_fd = false for a caller-supplied fd and then called writer.start(fd, true), which calls Source::open and for a named-pipe/tty fd creates a uv_pipe_t/uv_tty_t that adopts the underlying HANDLE. Every uv_write is async so the first write returns Pending and FileSink::to_result takes the must_be_kept_alive_until_eof self-ref. On end():

// WindowsStreamingWriter::end()
if !self.has_pending_data() {
    if !self.owns_fd { return; }   // <-- on_close never fires
    self.close();
}

close() never runs, Parent::on_close never fires, and clear_keep_alive_ref is never called: one native FileSink (and its Box<uv::Pipe>) leaks for the process lifetime per writer. A borrowed regular-file fd leaks the same way via the Source::File path.

Fix

  • New dup_borrowed_pipe_for_uv() helper: for a borrowed pipe/tty fd, DuplicateHandle + wrap as a uv fd, and pass that to start() with owns_fd = true. end() then runs close(), on_close fires, the keep-alive ref is released, and uv_close only closes the dup so the caller's fd stays open. Both get_writer and pipe_readable_stream_to_blob call the helper; a path-opened fd (or the dup) is now also closed if start() itself fails, which it was not before.
  • WindowsStreamingWriter::end() now lets close() run for a borrowed Source::File/SyncFile: the File close path already routes through detach_borrowed_fd() and leaves the fd open, it just never reached there. Pipe/Tty with !owns_fd still early-return, but the dup above makes that state unreachable from the Blob paths.

Verification

New Windows-only tests in test/js/bun/util/filesink.test.ts spawn a child with stdio[3] = 'pipe' and a temp-file fd, run 8 Bun.file(fd).writer() / write / end iterations, and assert fileSinkInternals.liveCount() did not grow and fstatSync(fd) still succeeds.

system bun this PR
pipe fd Received: 8 pass
file fd Received: 8 pass

filesink.test.ts, bun-write.test.js, spawn-stdin-readable-stream.test.ts, child-process-stdio.test.js, process-stdio.test.ts all pass on Windows x64 debug. bun run rust:check-all is clean on all 10 targets.

Related

#35993 applies the same dup to get_writer as part of backing createWriteStream with a FileSink; this PR covers pipe_readable_stream_to_blob as well (reachable today via S3 Bun.write to a pipe fd, and via plain Bun.write(Bun.file(pipeFd), readableStream) once #31689 lands). Overlap with #35993 is the identical get_writer hunk and the end() condition.


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/util/filesink.test.ts

… a borrowed fd

On Windows the two FileSink-creation blocks in Blob.rs (get_writer and
pipe_readable_stream_to_blob) set owns_fd = false for a caller-supplied fd
and called start(fd, true), which opens a uv_pipe_t/uv_tty_t that adopts the
underlying HANDLE. The first write returns Pending (uv writes are async) and
FileSink takes a must_be_kept_alive_until_eof self-ref. On end(),
WindowsStreamingWriter::end() early-returned for !owns_fd, so close() never
ran, on_close never fired, and the self-ref was never released: one native
FileSink (plus its Box<uv::Pipe>) leaked per writer for the process lifetime.

For pipe/tty fds, dup the borrowed fd before start() so the writer owns its
own handle; end() then runs close(), on_close fires, the keep-alive ref is
released, and the caller's fd stays open. For regular-file fds, let end()
call close() even with !owns_fd: the File close path already takes
detach_borrowed_fd() and leaves the fd open, it just never reached there.
On a start() error the dup (or a path-opened fd) is now closed; before, a
path-opened fd leaked on that error path.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Windows borrowed fd ownership

Layer / File(s) Summary
Duplicate borrowed handles
src/runtime/webcore/Blob.rs
Windows Blob writer paths duplicate borrowed pipe or TTY handles, track ownership, and conditionally close duplicated descriptors on startup failure.
Preserve borrowed handles on end
src/io/PipeWriter.rs
WindowsStreamingWriter::end avoids closing caller-owned Pipe and Tty handles while preserving existing behavior for owned and file handles.
Validate fd retention and sink lifetime
test/js/bun/util/filesink.test.ts
Windows subprocess tests verify borrowed descriptors remain open and native FileSink instances do not leak.

Possibly related PRs

  • oven-sh/bun#35953: Similar borrowed pipe fd duplication and ownership handling in the Blob/FileSink pipeline.
  • oven-sh/bun#35993: Similar Windows borrowed pipe and TTY ownership fixes across FileSink write paths.

Suggested reviewers: jarred-sumner, dylan-conway, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main Windows FileSink leak fix for borrowed fds.
Description check ✅ Passed It explains the bug, fix, and verification, though it uses different headings than the template.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:30 AM PT - Jul 27th, 2026

@robobun, your commit 28bc03d has 1 failures in Build #83426 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36107

That installs a local version of the PR into your bun-36107 executable, so you can run:

bun-36107 --bun

Comment thread src/io/PipeWriter.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/io/PipeWriter.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread test/js/bun/util/filesink.test.ts Outdated
Comment thread src/io/PipeWriter.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I also checked that the end() change is safe for borrowed file fds — BaseWindowsPipeWriter::close() routes Source::File/SyncFile through detach_borrowed_fd() when !owns_fd (PipeWriter.rs:1149), so the caller's fd is not closed; the Pipe/Tty arms there do unconditionally uv_close, which is why the dup is needed. FileSink::init is still handed the original fd (not the dup), so the JS-visible .fd keeps reporting the caller's descriptor.

Extended reasoning...

Confirmed the PR's stated invariant that the File close path already honours !owns_fd: close() at PipeWriter.rs:1144-1154 branches on owns_fd() and calls detach_borrowed_fd() for the borrowed case, so letting File/SyncFile through the new end() guard reaches on_close_source() (releasing the keep-alive ref) without closing the caller's fd. The Pipe/Tty arms at 1157-1172 have no such check and hand the handle straight to uv_close, which matches the PR's rationale for duping borrowed pipe/tty fds. Also traced FileSink::init(fd, ...) — it stores the original fd in self.fd while start(writer_fd, ...) gets the dup, so get_fd() still returns what the user passed. Deferring to a human on the broader Windows handle-lifecycle reasoning and the stated overlap with #35993.

Comment thread test/js/bun/util/filesink.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found, but this touches Windows fd/HANDLE ownership and libuv lifecycle in WindowsStreamingWriter::end() and both Blob sink-creation paths, and overlaps with #35993 — worth a human look at the ownership transitions.

Checked: the end() relaxation only reaches WindowsStreamingWriter (shell IOWriter uses WindowsBufferedWriter, unaffected); close() for borrowed File/SyncFile routes through detach_borrowed_fd() so the caller's fd is not closed; start_sync (stdout/stderr) produces SyncFile, so it's covered by the new match arm rather than the pipe early-return. FileSink::init(fd, ...) still receiving the original (not dup'd) fd is fine — it's only read back for the fd-number getter. Both earlier nits (temp-file cleanup, cp.on('error', reject)) are addressed.

Extended reasoning...

Overview

Windows-only leak fix: Bun.file(fd).writer() on a borrowed pipe/tty/file fd never fired on_close, so the must_be_kept_alive_until_eof self-ref taken on the first pending write was never released and each writer leaked a native FileSink for the process lifetime. The fix is two-pronged: (1) a new dup_borrowed_pipe_for_uv() helper DuplicateHandles a borrowed pipe/tty fd so the writer owns the handle libuv adopts and can safely uv_close it, and (2) WindowsStreamingWriter::end() is relaxed to let close() run for borrowed File/SyncFile sources, where close() already honours !owns_fd via detach_borrowed_fd(). Both Windows sink-creation sites in Blob.rs (get_writer and pipe_readable_stream_to_blob) now use the helper and additionally close the fd if start() itself fails. New Windows-only subprocess tests assert liveCount() doesn't grow and the caller's fd stays open.

Security risks

None. No untrusted input parsing; the change is fd-ownership bookkeeping on Windows.

Level of scrutiny

High. This is native memory-safety-adjacent code — fd/HANDLE ownership, libuv handle lifecycle, and refcount release ordering on Windows, which REVIEW.md calls out as the most-blocked category. The end() change alters behaviour for every WindowsStreamingWriter with !owns_fd and a File/SyncFile source; I verified the only setter of owns_fd = false on this type is the two Blob sites updated here (shell IOWriter uses WindowsBufferedWriter, whose end() is untouched), and that the stdout/stderr start_sync path lands in SyncFile and so is covered. But a maintainer familiar with the Windows pipe lifecycle should confirm the dup-then-uv_pipe_open pattern doesn't interact badly with any existing consumer, and decide sequencing with #35993 which touches the same get_writer hunk.

Other factors

The PR has good test coverage (both pipe and file variants, asserting both no-leak and fd-stays-open), and the author reports the relevant existing suites pass on Windows x64 debug plus rust:check-all on all targets. Both prior review nits (temp-file cleanup in the child, wiring cp.on('error', reject)) have been addressed in follow-up commits. The explicit overlap with in-flight #35993 is another reason for a human to sequence the merges.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both prior nits are addressed — nothing further from the bug-hunting pass. Deferring to a human familiar with the Windows libuv path: this changes fd-ownership semantics in WindowsStreamingWriter::end() and adds a HANDLE dup ahead of uv_pipe_open, which is squarely in the native memory-safety / lifetime category and only verifiable on Windows CI. Also flagging the stated overlap with #35993 on the get_writer hunk so the two land in a coordinated order.

What was reviewed:

  • end() now calls close() for borrowed Source::File/SyncFile; confirmed BaseWindowsPipeWriter::close() routes that case through detach_borrowed_fd() and does not close the caller's fd.
  • dup_borrowed_pipe_for_uv uses bun_sys::dup + make_lib_uv_owned_for_syscall(.., CloseOnFail), so a failed uv-wrap doesn't leak the dup.
  • FileSink::init still receives the original fd (JS-visible via get_fd()), only the writer's start() gets the dup — checked this split is intentional.
  • New writer_fd.close() on start() failure is gated on owns_fd, so a caller-borrowed non-pipe fd is not closed.
Extended reasoning...

Overview

The PR fixes a Windows-only native FileSink leak when Bun.file(fd).writer() is created on a borrowed (caller-supplied) fd. Three files: a one-line semantic change to WindowsStreamingWriter::end() in src/io/PipeWriter.rs letting close() run for borrowed File/SyncFile sources; a new dup_borrowed_pipe_for_uv() helper in src/runtime/webcore/Blob.rs applied at both Windows sink-creation sites (get_writer and pipe_readable_stream_to_blob) so borrowed pipe/tty handles are DuplicateHandle'd before uv_pipe_open adopts them; and a new Windows-only leak-regression test in filesink.test.ts covering both pipe and file borrowed fds.

Security risks

None identified. No untrusted-input parsing, no auth/crypto surface. The change is confined to internal fd-ownership bookkeeping and libuv handle lifecycle on Windows.

Level of scrutiny

High — this is exactly the class REVIEW.md flags as the most-blocked category (native memory safety, ref-count balance, fd ownership). The fix reasons about who owns the HANDLE that uv_pipe_open adopts, when on_close fires vs. early-returns, and whether close() for each Source variant honours !owns_fd. I traced BaseWindowsPipeWriter::close() and confirmed the File/SyncFile arm branches on owns_fd() into detach_borrowed_fd(), and that the Pipe/Tty arm would uv_close the handle unconditionally (justifying both the early-return remaining for that case and the dup making it unreachable from Blob). The dup helper composes bun_sys::dup with make_lib_uv_owned_for_syscall(CloseOnFail), matching existing usage in src/sys/tmp.rs. The new close-on-start()-failure paths are correctly gated on owns_fd. Nothing looked wrong, but this is subtle enough — and Windows-only, so the author could not run the tests locally — that a maintainer who knows this layer should sign off.

Other factors

Both nits I raised earlier (temp-file hermeticity, wiring cp.on('error', reject)) were addressed in 97d0227 and bf0facf. The author explicitly calls out overlap with #35993 on the identical get_writer hunk and the end() condition; a human should decide the merge order. The test asserts both the leak fix (liveCount() doesn't grow) and the negative contract (fstatSync(fd) still succeeds → caller's fd stays open), and follows the existing bounded-poll pattern in the same file. CI build #83426 for the latest commit is still in progress at the time of this review.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: all Windows lanes (x64 and aarch64, build + test) passed on both builds #83414 and #83426, which is where this diff actually runs.

The remaining red is unrelated to this Windows-only change:

  • test/js/bun/http/serve.test.ts "request body backpressure > releases a paused request body when the handler responds without reading it" fails on macOS on both builds and is also red on main build #83238; reported for main-break triage.
  • Build #83414 additionally had ld.lld: Invalid record on linux-aarch64 (LTO bitcode corruption); #83426 built that lane cleanly.

Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant