Skip to content

webcore(FileReader): release onStart() ref in onReaderError - #30116

Closed
robobun wants to merge 3 commits into
mainfrom
farm/a699758e/filereader-onreadererror-refcount
Closed

robobun wants to merge 3 commits into
mainfrom
farm/a699758e/filereader-onreadererror-refcount

Conversation

@robobun

@robobun robobun commented May 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

FileReader.onStart() increments the parent Source refcount and sets waiting_for_onReaderDone = true so the native source outlives its JS wrapper while I/O is in flight. The paired decrementCount() lived only in onReaderDone().

On POSIX, when a read syscall fails (ECONNRESET, EIO, EBADF, …), PosixBufferedReader.onError invokes only vtable.onReaderError and never follows up with done()/onReaderDone. FileReader.onReaderError rejected the pending pull but never cleared waiting_for_onReaderDone or called decrementCount(). After the stream errors and JS drops the wrapper, finalize() drops the refcount from 2→1, it never reaches 0, so FileReader.deinit never runs — the dup'd fd, its FilePoll, and any buffered bytes leak forever. The leaked poll's keep-alive ref also prevents the event loop from exiting.

Refcount trace:

  • onStart() → parent().incrementCount() (1→2), waiting_for_onReaderDone = true
  • success: onReaderDone() → decrementCount() (2→1) → JS finalize() → decrementCount() (1→0) → deinit
  • error: onReaderError() → (nothing) → JS finalize() → decrementCount() (2→1) → stays at 1 forever

Terminal.onReaderError and FileResponseStream.onReaderError already release their reader ref in the error path; FileReader was the outlier. Same class of bug as #30055 (subprocess PipeReader).

Fix

Mirror onReaderDone in the error path: after rejecting the pending pull, clear waiting_for_onReaderDone and call parent().decrementCount().

Repro / test

Bun.listen server calls socket.terminate() on the accepted connection (closes with SO_LINGER{1,0} → RST), so the client's next recv() on the fd dup'd by Bun.file(fd).stream().getReader() returns ECONNRESET, reliably hitting PosixBufferedReader.onError → FileReader.onReaderError.

(net.Socket.resetAndDestroy() was tried first but does not actually send RST in Bun — it uses .fast_shutdown, which surfaces as clean EOF on the peer.)

Verification

# without fix
error: expect(received).toBeLessThan(expected)
Expected: < 5
Received: 50

# with fix
(pass) Bun.file(fd).stream() does not leak fds when read fails (ECONNRESET)

The FileReader.onReaderError change here is the same as item 6 in #29440 (which bundles it with several Windows BufferedReader fixes and the close_jsvalue Strong-cycle change). This PR adds a POSIX regression test for the leak; #29440 has no test/ coverage for this path. Whichever lands first, the other reduces to a trivial merge.

@coderabbitai

coderabbitai Bot commented May 2, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c53dcba1-a47b-4f98-8759-e09d72393ff8

📥 Commits

Reviewing files that changed from the base of the PR and between 0a7bed5 and 73ff63d.

📒 Files selected for processing (2)
  • src/runtime/webcore/FileReader.zig
  • test/js/bun/util/bun-file-fd-read.test.ts

Walkthrough

A fix for file descriptor leaks in FileReader.zig prevents unreleased reference counts on error-only code paths, paired with a regression test that verifies file descriptor cleanup on stream read failures in Linux and macOS environments.

Changes

FileReader Error Handling and FD Leak Prevention

Layer / File(s) Summary
Core Implementation
src/runtime/webcore/FileReader.zig
onReaderError now checks the waiting_for_onReaderDone flag; if set, it clears the flag and decrements the parent refcount to release the stream source on error paths where only onError is called and onReaderDone never fires.
Regression Test
test/js/bun/util/bun-file-fd-read.test.ts
New Linux/macOS test added that spawns a subprocess to exercise Bun.file(fd).stream() on ECONNRESET failure. Subprocess measures file descriptors before and after repeated read attempts, asserts errors occur in the majority of iterations, and verifies the leaked FD count remains below a threshold of 5.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'webcore(FileReader): release onStart() ref in onReaderError' accurately captures the main change—fixing a refcount leak by releasing the onStart() reference in the error path.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the problem, fix, reproduction steps, and verification results. All required template sections are addressed in detail.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 2/5 reviews remaining, refill in 25 minutes and 56 seconds.

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

@github-actions github-actions Bot added the claude label May 2, 2026
@robobun

robobun commented May 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:06 PM PT - May 4th, 2026

❌ @robobun, your commit 73ff63d has 4 failures in Build #51267 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30116

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

bun-30116 --bun

@github-actions

github-actions Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. createReadStream seems to crash the program/make the file handles hang permanently #7957 - createReadStream hits EMFILE from leaked dup'd fds; matches the FileReader refcount leak on error path
  2. stream on sliced Bunfile doesn't work #18192 - Bun.file().slice().stream() hangs on files >640K when slice boundary is mid-read; consistent with onReaderError not releasing the keep-alive ref
  3. Freeze interaction with Bun.file().slice().stream() #21175 - Same hang pattern as stream on sliced Bunfile doesn't work #18192 with .slice().stream() freezing on large files when slice doesn't reach EOF

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #7957
Fixes #18192
Fixes #21175

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. io: fix WindowsBufferedReader.deinit source handling + ReadableStreamSource Strong cycle #29440 - Contains the identical FileReader.onReaderError refcount fix (clearing waiting_for_onReaderDone and calling decrementCount()), bundled with additional WindowsBufferedReader and ReadableStreamSource fixes

🤖 Generated with Claude Code

Comment thread test/js/bun/util/bun-file-fd-read.test.ts Outdated
Comment thread src/runtime/webcore/FileReader.zig
robobun added 2 commits May 4, 2026 10:24
FileReader.onStart() increments the parent Source refcount and sets
waiting_for_onReaderDone=true so the native source outlives the JS
wrapper while I/O is in flight. The paired decrementCount() lived only
in onReaderDone(). On POSIX, when a read syscall fails (ECONNRESET,
EIO, EBADF, ...), PosixBufferedReader.onError invokes only
vtable.onReaderError and never calls done()/onReaderDone, so the
refcount was never released. JS finalize drops it from 2->1, deinit
never runs, and the dup'd fd + poll + buffered bytes leak forever. The
leaked poll's keep-alive ref also prevented the event loop from
exiting.

Mirror onReaderDone in the error path: after rejecting the pending
pull, clear waiting_for_onReaderDone and call parent().decrementCount()
so the final JS finalize reaches 0 and deinit runs.

Test: Bun.listen server calls socket.terminate() (SO_LINGER{1,0} ->
RST) so the client's recv() on the dup'd fd returns ECONNRESET,
reliably hitting PosixBufferedReader.onError -> FileReader.onReaderError.
50 iterations leaked 50 fds before, 0 after.
isPosix includes FreeBSD, but the fixture's countFds relies on
/proc/self/fd which is not mounted by default there. Match harness
getFDCount() and skip on anything other than Linux/macOS.
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/a699758e/filereader-onreadererror-refcount branch from 75efa4d to 250a6e5 Compare May 4, 2026 10:24

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

LGTM — the FreeBSD gating nit was addressed and the Windows onStart() sibling path is acknowledged as #29440's scope; the 4-line fix mirrors onReaderDone() exactly under the same flag guard.

Extended reasoning...

Overview

The Zig change adds a 4-line guarded decrementCount() at the tail of FileReader.onReaderError (src/runtime/webcore/FileReader.zig), mirroring the identical block already present in onReaderDone(). The test change adds a Linux/macOS-gated regression test that triggers ECONNRESET via socket.terminate() and asserts fd count doesn't grow across 50 iterations.

Security risks

None. This is a resource-leak fix (releasing a refcount that was previously leaked). The decrement is guarded by waiting_for_onReaderDone, which is cleared before decrementing, so it's idempotent with onReaderDone() and cannot double-free. The decrement is the final statement in the function, so even if it drives the refcount to 0 and frees the parent Source, this is not touched afterward — same ordering as onReaderDone(). decrementCount() (ReadableStream.zig:511) panics in debug on underflow, providing additional safety.

Level of scrutiny

Refcount/lifecycle code in core runtime I/O normally warrants careful review, but this change is mechanical: it copies the exact guard+decrement pattern from onReaderDone() into the sibling error callback. The PR description provides a complete refcount trace, cites precedent in Terminal.onReaderError / FileResponseStream.onReaderError / #30055 (subprocess PipeReader), and includes a regression test that verifies both that the error path is exercised (errored > iterations/2) and that fds don't leak.

Other factors

I left two inline comments on earlier revisions: (1) the isPosix/FreeBSD gating nit — fixed in 75efa4d by gating on isLinux || isMacOS; (2) the related Windows-only onStart() synchronous-.err leak — author confirmed it's dead on POSIX and deferred to #29440 for Windows, which is reasonable scoping. Both threads are resolved. No CODEOWNERS match for this path. No new bugs found in this run.

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

LGTM — the flag-guarded decrementCount() exactly mirrors onReaderDone, and both earlier review threads are resolved.

Extended reasoning...

Overview

This PR touches two files: a 4-line logic addition to FileReader.onReaderError in src/runtime/webcore/FileReader.zig (clear waiting_for_onReaderDone and call parent().decrementCount(), mirroring the tail of onReaderDone immediately above it), plus a ~100-line POSIX regression test in test/js/bun/util/bun-file-fd-read.test.ts that provokes ECONNRESET via Bun.listen + socket.terminate() and counts fds before/after 50 iterations.

Security risks

None. This is a resource-leak fix on an error path; no auth, parsing, or untrusted-input handling is involved. The test runs in a spawned subprocess with hard-coded localhost endpoints.

Level of scrutiny

Moderate — refcount/lifecycle code in the native runtime can UAF if wrong. I checked the specifics: the new block is identical to onReaderDone's tail, is guarded by the waiting_for_onReaderDone flag (set only in onStart, cleared before decrementing, so no double-decrement even under re-entrancy via pending.run()), and decrementCount() is the last statement in the function so a 1→0 transition that triggers deinit cannot dereference this afterward. decrementCount itself (ReadableStream.zig:511) panics in debug on underflow, which would have caught any miscount during the included test.

Other factors

Both of my earlier inline comments are resolved: the FreeBSD /proc/self/fd nit was fixed by gating the test on isLinux || isMacOS, and the Windows synchronous-.err sibling leak was explicitly flagged as pre-existing/non-blocking and deferred to #29440. No CODEOWNERS rule covers these paths. The PR description includes a verified before/after refcount trace and test output (50 leaked → pass), and the bug-hunting system found nothing on this revision.

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this fix targets .zig source files, which are no longer compiled now that Bun's runtime has been ported to Rust. The underlying bug is a missing-deinit leak; Rust's Drop handles release automatically in the ported code.

@robobun robobun closed this Jun 23, 2026
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