FileReader: release the io-ref in on_cancel when close() doesn't reach on_reader_done - #36076
FileReader: release the io-ref in on_cancel when close() doesn't reach on_reader_done#36076robobun wants to merge 5 commits into
Conversation
…h on_reader_done test-stdin-from-file-spawn.js panicked on linux x64-asan with assertion failed: !(self.done.get() && self.waiting_for_on_reader_done.get()) in FileReader::finalize_detach. on_cancel sets done=true and calls reader().close(); the expectation is that close() synchronously reaches on_reader_done which clears waiting_for_on_reader_done and releases the io-ref taken by from_pipe/on_start. PollOrFd::close_impl skips the callback when the handle's fd is already invalid (and close_handle is a no-op when CLOSE_HANDLE is unset), so on_cancel can return with both flags set and the io-ref stranded. Release it in on_cancel; idempotent with on_reader_done's own clear.
|
Warning Review limit reached
Next review available in: 22 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughChangesFileReader cancellation cleanup
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
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 `@test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts`:
- Around line 473-476: Remove the duplicate declarations of the result variable
in the surrounding test block, retaining a single let result binding with the
existing { iters, protectedAfterCancel, aliveAfterCancel } type before the try
statement.
- Around line 467-486: Add an explicit, non-strict assertion that the captured
stderr in the spawn test is acceptable before the existing exitCode assertion,
while allowing expected debug/ASAN/--smol noise. Keep stdout parsing and the
existing result assertions unchanged, and place the stderr validation in the
flow around proc.exited.
🪄 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: 2855fa7e-23b5-43dd-9322-e35e866dfe12
📒 Files selected for processing (2)
src/runtime/webcore/FileReader.rstest/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts
There was a problem hiding this comment.
Beyond the inline finding, I also checked whether the new on_cancel decrement can race a later on_reader_done and double-decrement — both sites gate on and clear waiting_for_on_reader_done, so they are mutually exclusive. The Windows deferred-done() path (DEFER_DONE_CALLBACK) was also examined for a post-decrement UAF: it only applies to Source::File/SyncFile, not the from_pipe subprocess path, and the later on_reader_done sees waiting == false and skips its own decrement.
Extended reasoning...
This PR touches unsafe refcount lifecycle code in FileReader (Source::decrement_count under unsafe), which per the repo's review guidelines is the most-blocked category and warrants human sign-off. The fix itself reads correctly — waiting_for_on_reader_done == true implies a prior increment_count, so ref_count >= 2 and the decrement cannot free the box mid-&self; and both on_reader_done and the new guard check-and-clear the same flag, so at most one decrement fires. The inline finding about the new test likely passing on a pre-fix build is the actionable item; the PR description already acknowledges the actual interleaving was not reproducible locally.
|
Updated 4:41 AM PT - Jul 27th, 2026
✅ @robobun, your commit 8a260d7b8c8b4b979503ee89e7cbbbd2cd0be26a passed in 🧪 To try this PR locally: bunx bun-pr 36076That installs a local version of the PR into your bun-36076 --bun |
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/spawn/spawn-stdout-filereader-gc-uaf.test.ts`:
- Around line 396-403: Remove the implementation-history and future-change
rationale comment above the post-cancel invariant in the test; retain only a
tracked issue URL if one exists, following the repository’s regression-test
comment convention.
🪄 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: 86646784-e9f3-4569-b956-565928d99e90
📒 Files selected for processing (2)
src/runtime/webcore/FileReader.rstest/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts
|
Status: CI running (build 83280). The diff is ready for review. The panic this targets is a single sighting and not reproducible locally (800+ debug+asan runs under GC=1 and 4-way parallel stress; details in the PR body). A code trace of every Needs a maintainer to judge whether the analysis warrants merging without a fail-before reproduction. |
What
test/js/node/test/parallel/test-stdin-from-file-spawn.jspanicked on the linux x64-asan lane (build 83189) with:in
FileReader::finalize_detach. The assertion says aFileReadercannot reach its JS finalizer with bothdoneandwaiting_for_on_reader_doneset:waitingmeans the io-ref taken byfrom_pipe/on_startis still held (which upgrades the JS wrapper toStrong, so it should not be collectable), anddonemeanson_cancelhas run and nothing will ever callon_reader_doneto release that ref.Change
FileReader::on_cancelnow releases the io-ref itself afterclose()returns.on_cancelis the only place that setsdone = true(other thanfinalize_detach), so once it runs no more io will fire and the io-ref is its responsibility. Todayclose()always reacheson_reader_donefor every in-treefrom_pipeproducer (subprocess pipes have a valid fd andCLOSE_HANDLEset, traced below), so this block is a no-op on current main; the release is idempotent withon_reader_done's own check-and-clear.waiting == trueimplies a priorincrement_count, soref_count >= 2and the decrement cannot free the box mid-&self.Why
This is defensive hardening, not a root-cause fix. I traced every producer of
waiting_for_on_reader_done = trueand every transition to an invalid reader fd:from_pipe(ReadableStream.rs:420) setswaitingafterPosixBufferedReader::from()transferred a handle whose fd came fromSubprocessPipeReader::start()→reader.start(fd, true), which storeshandle = PollOrFd::Fd(fd).on_start's lazy path (FileReader.rs:417) setswaitingonly whenpollablewith adebug_assert!(opened.fd.is_valid())guard; on error it clearswaitingbefore returning.on_start's non-lazy path (FileReader.rs:438) setswaitingonly whenPOLLABLE, which is inserted afterstart()stored a real fd.PollOrFd::close_implskips its callback only whenget_fd() == INVALID, which for a livePollnever holds (the fd is set at construction and invalidated only insideclose_implitself); every path that reacheshandle.close(cb)withwaiting == truehas a valid fd and firesdone()→on_reader_done().CLOSE_HANDLEdefaults on and is transferred wholesale byfrom(); the only removers areFileResponseStreamand the shellIOReader, neither of which go throughfrom_pipe.So no in-tree path today produces
(waiting = true, fd invalid)aton_cancelentry. The CI panic is a single sighting; 800+ local debug+asan runs underBUN_GARBAGE_COLLECTOR_LEVEL=1and 4-way parallel stress did not reproduce it, and the release-asan artifact is not fetchable from this environment to symbolize the stack. Build 83189's branch is 3 commits behind main (merge-base 6c12afd) and the reporter's diff is unrelated (fs.cpnon-UTF8 names).close()is not contract-bound to dispatchon_reader_donefor every reader state, and relying on it for the io-ref release means a future reader change (or a state I have not found) can strand the ref silently. Havingon_cancelown the release makes thedone && waitingstate structurally impossible throughon_cancel, which is the only producer ofdone = true.Verification
test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.tsgains a post-cancel invariant check: after cancelling afrom_pipeFileReaderthat never received data, noFileInternalReadableStreamSourceremains Strong-protected and the wrappers are collectable. On current main this is satisfied by the existingclose()→on_reader_donepath, so the test is a guard against regressing that invariant rather than a reproduction of the CI panic. The three existing lifetime-invariant tests in that file,spawn-unread-stdout-gc.test.ts,native-source-onclose-leak.test.ts,process-stdin.test.ts, andspawn-streaming-stdout.test.tsall pass underbun bd.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/test/parallel/test-stdin-from-file-spawn.js test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts