fetch: protect stream error JSValue stored in ByteStream pending result#29745
fetch: protect stream error JSValue stored in ByteStream pending result#29745robobun wants to merge 4 commits into
Conversation
|
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)
WalkthroughCentralizes stream error storage into Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/bun.js/webcore/ByteStream.zig (1)
445-452: 🧹 Nitpick | 🔵 TrivialConsider adding
ensureStillAlive()for consistency withfulfillPromise.The error is extracted via
toJSWeak, thendeinit()unprotects it, followed by use on line 451. Comparing with thefulfillPromisepattern instreams.zig(lines 528-535), that code callsensureStillAlive()before unprotecting:const js_err, const was_strong = err.toJSWeak(globalThis); js_err.ensureStillAlive(); // <-- called before unprotect if (was_strong == .Strong) js_err.unprotect();While this is likely safe in practice (no GC-triggering operations between deinit and use, and the local variable keeps the value alive), adding
ensureStillAlive()would make the pattern consistent:🛡️ Suggested defensive improvement
if (this.pending.result == .err) { const err, _ = this.pending.result.err.toJSWeak(globalThis); + err.ensureStillAlive(); this.pending.result.deinit(); this.pending.result = .{ .done = {} };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/bun.js/webcore/ByteStream.zig` around lines 445 - 452, The error-handling path in the ByteStream code extracts the JS error via this.pending.result.err.toJSWeak(globalThis) then calls this.pending.result.deinit() and returns the error without calling ensureStillAlive(), which differs from the fulfillPromise pattern; update the error branch to call ensureStillAlive() on the js error returned by toJSWeak (similar to how streams.zig's fulfillPromise does) before unprotecting/deiniting the pending result so the JS value is defensively kept alive (reference: this.pending.result, toJSWeak, ensureStillAlive, deinit, fulfillPromise).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/bun.js/webcore/ByteStream.zig`:
- Around line 445-452: The error-handling path in the ByteStream code extracts
the JS error via this.pending.result.err.toJSWeak(globalThis) then calls
this.pending.result.deinit() and returns the error without calling
ensureStillAlive(), which differs from the fulfillPromise pattern; update the
error branch to call ensureStillAlive() on the js error returned by toJSWeak
(similar to how streams.zig's fulfillPromise does) before unprotecting/deiniting
the pending result so the JS value is defensively kept alive (reference:
this.pending.result, toJSWeak, ensureStillAlive, deinit, fulfillPromise).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ab35a805-a58d-44c8-a230-791b86e07eda
📒 Files selected for processing (1)
src/bun.js/webcore/ByteStream.zig
|
Independently reproduced and arrived at the same root cause from a separate report. For reference, branch See also #29822 which covers the s3 producer and several of the same consumers — these two PRs together cover essentially the same ground. |
ByteStream.append / onData stored stream.err directly into pending.result when there was no pending pull or buffer action. If the error was a .JSValue, it was never protect()'d, even though Result.deinit, fulfillPromise and toBufferedValue all treat that variant as strong and unprotect it on consumption. The only GC root for the error in that window was the owning Response's body.value.Error Strong. If the user kept the ReadableStream but dropped the Response, the Response (and its Strong) was collected, leaving pending.result.err.JSValue pointing at a freed JSCell. The next readableStreamToText()/toBufferedValue() would then reject with a collected value, tripping a StructureID assertion in debug builds and producing a use-after-free in release. Protect the JSValue when storing it in pending.result (and deinit the previous result to keep the protect/unprotect balance when the error is delivered twice via FetchTasklet and Body.toErrorInstance).
setPendingError protects the stored .JSValue, but onCancel only deinitialized pending.result when there was a prior onPull (view != .zero), and deinit skipped it entirely once done was already set. On the 'error arrives with no pending read, then stream is cancelled/destroyed' path the protect() was never balanced. Hoist the result cleanup in onCancel so it runs unconditionally, and add an else branch to deinit covering the case where onPull/onCancel marked the stream done without consuming a stored error.
Result.deinit() unprotects the .JSValue but does not clear the union tag, so toBufferedValue left pending.result still tagged .err after consuming it. The new else branch in ByteStream.deinit would then deinit (and unprotect) the same value a second time. Reset to .done after deinit, matching toAnyBlob/onCancel.
On regression the JSC 'ASSERTION FAILED: decontaminate()' goes to stderr while stdout stays empty; capture and assert on stderr (minus the benign ASAN startup warning) so the diagnostic appears in CI output instead of just 'expected "" to start with PASS'.
9e26f14 to
9cf788d
Compare
There was a problem hiding this comment.
No further issues found, but given this is JSC protect/unprotect balancing on a hot path (and the open question of reconciling this storage-site approach with the producer-side approach in #29822 / the referenced branch), I'd like a human to sign off on the overall direction.
Extended reasoning...
Overview
This PR adds ByteStream.setPendingError which protect()s a StreamError.JSValue before storing it in pending.result, fixing a use-after-free where the error's only GC root (the owning Response's body Strong) could be collected while the ByteStream still held a raw pointer. To keep the protect/unprotect ledger balanced it also: hoists pending.result.deinit() out of the view != .zero guard in onCancel, adds an else branch in deinit() to release a stored error when done was already set, and resets pending.result = .done after deinit() in toBufferedValue. A subprocess-isolated regression test reproduces the original GC crash.
Security risks
None in the conventional sense (no auth/injection/data-exposure surface). The risk class here is memory safety: an unbalanced protect leaks; an unbalanced unprotect can free an unrelated cell at a reused address, i.e. a deferred UAF. Two such imbalances were caught and fixed during this review cycle, which is precisely why I don't think this should be auto-approved.
Level of scrutiny
High. ByteStream is the core fetch response body stream; every streamed HTTP response flows through it. JSC protect/unprotect bookkeeping is notoriously easy to get subtly wrong, and the consequences (heap corruption, intermittent crashes) are hard to diagnose after the fact.
Other factors
All three of my earlier inline comments (cancel-path leak, toBufferedValue double-unprotect, stderr surfacing in the test) have been addressed and resolved. The current bug-hunting pass found nothing. What tips this toward human review is the author's own 2026-05-03 note: an alternative branch takes the protect-at-producer approach (FetchTasklet, s3/client, Body.toStreamError) and additionally balances several synchronous-consume/discard paths, and #29822 overlaps substantially. Choosing storage-site vs. producer-site protection — and deciding how this PR and #29822 should land relative to each other — is a design call best made by a maintainer.
Problem
ByteStream.append/onDatastoredstream.errdirectly intopending.resultwhen there was no pending pull and no buffer action. When the error is aStreamError.JSValue, that rawJSValuewas neverprotect()'d, even though every consumer (Result.deinit,fulfillPromise,toBufferedValue) already treats that variant as strong and unprotects it.The only GC root for the error in that window is the
Strongheld in the owningResponse'sbody.value.Error. If user code keeps theReadableStreambut drops theResponse, theResponseis collected along with thatStrong, andpending.result.err.JSValueis left pointing at a freed JSCell. The nextBun.readableStreamToText(body)→toBufferedValuewould reject a promise with that collected cell.This addresses the TODO at
streams.zig:225(// TODO: use an explicit jsc.Strong.Optional here.) at the storage site rather than changing the union type.Repro
Debug build before fix:
Fix
Add
ByteStream.setPendingErrorwhichprotect()s the.JSValuevariant before storing it inpending.result, anddeinit()s the previous result first (the error is delivered twice on this path — once fromFetchTasklet.onBodyReceiveddirectly, once fromBody.toErrorInstance— so the old protected value must be released). All existing consumers already unprotect on consumption, so the balance is maintained.Verification
New test in
test/js/web/fetch/fetch.stream.test.tsspawns a subprocess that reproduces the exact sequence (headers → terminate → drop Response → GC →readableStreamToText):ECONNRESET, printsPASS ECONNRESET→ test passes