fs: reject async iterator with ERR_STREAM_PREMATURE_CLOSE when destroy() is called during iteration#30920
fs: reject async iterator with ERR_STREAM_PREMATURE_CLOSE when destroy() is called during iteration#30920robobun wants to merge 6 commits into
Conversation
When destroy() is called during for-await iteration of a fs.ReadStream, the in-flight fs.read callback could still fire after destroy() had run. Bun unconditionally called this.push(null) (EOF) in that callback, which set _readableState.ended = true. end-of-stream's onclose handler then saw isReadableFinished(stream, false) === true and completed cleanly instead of emitting ERR_STREAM_PREMATURE_CLOSE, so the async iterator resolved instead of rejecting. Match Node's behavior and short-circuit the _read callback when this.destroyed is set. Fixes #30919
|
Updated 2:15 AM PT - May 18th, 2026
✅ @robobun, your commit c3d113fe2e4c53978ec8e2d05b9acb9322b905e5 passed in 🧪 To try this PR locally: bunx bun-pr 30920That installs a local version of the PR into your bun-30920 --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:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughReadStream._read now ignores fs.read results if the stream was destroyed mid-read; _destroy closes the file descriptor immediately. Tests added verify async-iterator rejection on destroy and autoClose/close semantics. ChangesReadStream Async Iteration Destruction
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
darwin-x64-build-rust exited with -1 (agent/infra), every other lane passed including darwin-x64-build-cpp and all other *-build-rust lanes. Diff is TS-only, no Rust code touched.
|
CI update: two back-to-back darwin build-stage flakes on unrelated lanes.
Diff is TypeScript-only ( Spent my one re-roll budget. Handing off for a maintainer re-roll / merge. |
…, autoClose} Commit #16754 removed the kIsPerformingIO/kIoDone symbols but mechanically substituted kReadStreamFastPath in the _destroy check, producing a once-listener for an event that is never emitted. With `{ start, autoClose: true }` (both documented fs options) that branch was taken, so _destroy never invoked cb, 'close' never fired, and the fd leaked. Drop the dead branch. The real reason Node waits on kIoDone — protecting the fd from close() while a read() is outstanding on the thread-pool — is already handled in the _read callback by the destroyed guard added above, plus Bun can cancel in-flight reads where Node (on Windows) cannot. Also adjacent-addresses review feedback on #30920.
|
CI update on 0385d1b (the adjacent fd-leak fix): Failures are on unrelated tests that dont touch fs/streams:
Every other lane passed (60 steps passed, 0 step-level failures). Diff is TypeScript-only: Already spent my one re-roll budget on earlier darwin flakes. Needs a maintainer re-roll / merge. |
There was a problem hiding this comment.
LGTM — the fd-leak fix in 0385d1b addresses my earlier comment, and the remaining inline note is just a comment-wording nit.
Extended reasoning...
Overview
This PR touches src/js/internal/fs/streams.ts (two small changes to ReadStream.prototype._read and _destroy) and adds two regression tests in test/js/node/fs/fs.test.ts. The _read change adds an if (this.destroyed) return guard in the fs.read callback — a direct port of Node's behavior — so a late-arriving read result can't push(null) and suppress ERR_STREAM_PREMATURE_CLOSE for async iterators. The _destroy change (added after my earlier review comment) drops a dead once(kReadStreamFastPath, ...) branch that listened for an event nothing emits, fixing an fd leak / missing 'close' for { start: 0, autoClose: true }.
Security risks
None. This is Node-compat stream-state bookkeeping in pure TypeScript; no auth, crypto, parsing, or untrusted-input surface is touched. The fd-lifecycle change makes the { start: 0, autoClose: true } path behave identically to the default path (immediate close()), which is strictly safer than the previous behavior of leaking the fd forever.
Level of scrutiny
Moderate — fd lifecycle in fs streams is worth a careful read, which I gave it. Key observation: _destroy already called close(this, err, cb) immediately on the common createReadStream(path) path before this PR (because kReadStreamFastPath requires an explicit start: 0 to be truthy). So the simplified _destroy is not a behavioral change for the default case; it just unifies the rare opted-in path with the existing default and removes a listener that could never fire. The "close fd while a thread-pool read may be in flight" concern is therefore pre-existing (since #16754) and orthogonal to this PR.
Other factors
- My prior inline comment (pre-existing fd leak via the never-emitted
kReadStreamFastPathlistener) was addressed in 0385d1b with both a fix and a regression test. - Both behavioral changes have targeted regression tests; the PR description shows fail-before/pass-after locally and that the broader
test-fs-read-stream*/test-stream-readable-*Node parallel suites still pass. - CI red is two darwin build-stage flakes (build-cpp / build-rust agent failures) on a TypeScript-only diff — unrelated infra, as the author noted.
- The one remaining inline finding is a nit: the new
_destroycomment's "on Windows" qualifier is inaccurate (Node'skIsPerformingIO/kIoDonepair runs on all platforms). It's documentary only and doesn't affect runtime behavior. kReadStreamFastPathis now write-only (assigned in the constructor, only read in a commented-outpipeblock). Harmless dead state; fine to leave for a follow-up cleanup.
The 'on Windows' qualifier was wrong — libuv runs regular-file I/O on a blocking thread pool on every platform, so Node's kIsPerformingIO/kIoDone pair isn't Windows-specific. The JS-level push(null)-after-destroy race this PR closes is separate from the kernel-level close-while-reading concern, and Bun has always had the latter behavior on the common path.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Delete remaining references to kReadStreamFastPath. It is no longer used. Including the comment.
Per maintainer request. The previous commit removed the last live reader (the broken once-listener in _destroy), leaving only: - the symbol declaration and its 4-line scaffolding comment - a constructor assignment no code consumed - a ReadStream.prototype.pipe override whose body was a commented-out stub that just delegated to Readable.prototype.pipe Remove all of them. The pipe override can come back in one line if and when the planned createReadStream().pipe(res) fast path is actually implemented.
Left over from the kReadStreamFastPath cleanup — the old fast-path assignment was the only reader. Stream encoding is still applied via Readable.$call(this, options).
There was a problem hiding this comment.
Nothing further from me — all four prior threads are resolved and c3d113f addresses the last nit. Deferring final sign-off to a maintainer since this touches fs.ReadStream destroy/fd-lifecycle semantics (in particular the now-unconditional immediate close() in _destroy, which matches what the common path has done since #16754 but is worth a human glance).
Extended reasoning...
Overview
The PR fixes #30919 by adding an if (this.destroyed) return guard in ReadStream._read's fs callback so a late-arriving read result can't push(null) after destroy() and suppress ERR_STREAM_PREMATURE_CLOSE for async iterators / finished(). In response to my earlier review it also (a) drops the dead once(kReadStreamFastPath, ...) branch in _destroy that leaked fds for { start: 0, autoClose: true }, (b) removes the now-unused kReadStreamFastPath symbol, constructor assignment, and no-op pipe override, and (c) drops the orphaned encoding destructure. Two regression tests added in test/js/node/fs/fs.test.ts.
Security risks
None identified. No user-controlled input reaches new code paths; the change only short-circuits a callback and removes dead code. No injection, auth, or data-exposure surface.
Level of scrutiny
Moderate-to-high. fs.ReadStream is a core Node-compat API and the changes affect destroy/close ordering and fd lifecycle. Each individual edit is small and well-reasoned — the destroyed guard mirrors Node exactly, and the _destroy simplification makes a rare edge case match the behavior the default createReadStream(path) path has already had since #16754 — but stream-destruction semantics are subtle enough that a maintainer should confirm the reasoning, particularly that closing the fd immediately while a thread-pool read may still be in flight is acceptable for Bun (Node defers via kIsPerformingIO/kIoDone; the author's position is that Bun's common path has already done immediate close without issue).
Other factors
I've reviewed this PR across four rounds; every comment was addressed and all threads are resolved. The bug-hunting system found nothing on the latest revision. CI failures noted in the thread are unrelated infra flakes (darwin build agents, heap-snapshot SIGKILL, a flaky-tagged worker_threads test). No CODEOWNERS entry covers these files. The removed pipe override was a pure no-op delegating to Readable.prototype.pipe, so its removal is behaviorally inert. Net: I have no remaining concerns, but this isn't a mechanical/config change, so deferring rather than approving.
Fixes #30919.
Repro
Node: prints
got chunkthenerror: ERR_STREAM_PREMATURE_CLOSE.Bun (before): prints
got chunkthencompleted— the async iterator resolves instead of rejecting.Cause
for awaitis pull-based: yielding a chunk returns control to user code while the next_read()is still in flight on the fs thread pool. Whenstream.destroy()runs synchronously in the iterator body, the pending read callback still fires. Bun'sReadStream._readcallback unconditionally calledthis.push(null)on EOF (bytesRead === 0), even if the stream was already destroyed.push(null)flips_readableState.endedtotrue. Thenend-of-stream'sonclosereadswhere
isReadableFinished(stream, false)returns!!(rState.endEmitted || (strict === false && rState.ended === true && rState.length === 0))— so withended=trueBun skipped the premature-close error.Node guards the same fs read callback with
if (this.destroyed) return;. This PR adds the same guard.Fix
src/js/internal/fs/streams.ts— inReadStream._read's fs callback, short-circuit whenthis.destroyed:Test
test/js/node/fs/fs.test.ts— adds a regression test in thecreateReadStreamdescribe block that matches the issue repro. Fails on unpatched Bun (iterator resolves with no error) and passes after the fix.Verification
All other
createReadStream,fs.ReadStream, and Node'stest-fs-read-stream*/test-stream-readable-*parallel tests still pass locally.