Skip to content

HTMLRewriter: throw 'Response body already used' for a disturbed body - #36816

Open
robobun wants to merge 1 commit into
mainfrom
farm/d4b53504/htmlrewriter-disturbed-body-panic
Open

robobun wants to merge 1 commit into
mainfrom
farm/d4b53504/htmlrewriter-disturbed-body-panic

Conversation

@robobun

@robobun robobun commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator

HTMLRewriter.transform() on a Response whose body stream has been partially read (getReader() + read() + releaseLock()) used to abort the process with internal error: entered unreachable code in ValueBufferer::buffer_locked_body_value. #36733 replaced that code path and it now throws ERR_STREAM_ALREADY_FINISHED from wire_input's is_disturbed guard, so the crash is gone; this PR is the residual consistency fix plus coverage.

const resp = new Response(new Blob(["<p>a</p><p>b</p>"]));
const rd = resp.body.getReader();
await rd.read();
rd.releaseLock();
// resp.bodyUsed === true
new HTMLRewriter().on("p", { element() {} }).transform(resp);
// before: Error "Stream already used, please create a new one" (ERR_STREAM_ALREADY_FINISHED)
// after:  TypeError "Response body already used" (ERR_INVALID_ARG_TYPE)

Fix

transform_ checks is_disturbed on a Locked body's stream alongside the existing Value::Used check, so a disturbed body throws the same Response body already used TypeError as .text()/.json()/.clone() and as a body consumed by a previous transform() (the latter is already asserted by the transform() marks the input body used test at the bottom of the file).

Tests

  • (from Bun.file().stream()): subprocess coverage for the originally reported shape (transform rewrites a Response(Bun.file(path).stream()) body).
  • throws on a disturbed Response body: partially reads a Blob body and a Bun.file body before transform() and asserts TypeError: Response body already used. Fails on main without the transform_ check (throws Error/ERR_STREAM_ALREADY_FINISHED instead), passes with it.

Full html-rewriter.test.js suite: 124 pass, 0 fail.


[stamp-90s] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/workerd/html-rewriter.test.js
bun test v1.4.0 (519cef19e)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [16.68ms]
(pass) HTMLRewriter > error inside element handler rejects the body [12.81ms]
(pass) HTMLRewriter > error inside element handler (string) [7.87ms]
(pass) HTMLRewriter > async error without a real await inside element handler rejects the body [12.00ms]
(pass) HTMLRewriter > fast async error inside element handler rejects the body [23.72ms]
(pass) HTMLRewriter > slow async error inside element handler rejects the body [11.02ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [113.76ms]
(pass) HTMLRewriter > async handlers suspend the rewrite > runs async element handlers strictly in document order [611.88ms]
(pass) HTMLRewriter > async handlers suspend the rewrite > mutations made before and after the await both land [20.61ms]
(pass) HTMLRewriter > async handlers suspend the rewrite > an attribute iterator from before the await keeps working [17.90ms]
(pass) HTMLRewriter > async ha
... (truncated)

release without fix: BUILD FAILED (no junit output)
bun test v1.4.0-canary.1 (1498d7b77)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [0.12ms]
27 |   // rejects its output body.
28 |   it("error inside element handler rejects the body", async () => {
29 |     const res = new HTMLRewriter()
30 |       .on("div", {
31 |         element(element) {
32 |           throw new Error("test");
                         ^
error: test
      at element (/workspace/bun/test/js/workerd/html-rewriter.test.js:32:21)
      at <anonymous> (/workspace/bun/test/js/workerd/html-rewriter.test.js:35:8)
(fail) HTMLRewriter > error inside element handler rejects the body [0.46ms]
(pass) HTMLRewriter > error inside element handler (string) [0.09ms]
50 | 
51 |   it("async error without a real await inside element handler rejects the body", async () => {
52 |     const res = new HTMLRewriter()
53 |       .on("div", {
54 |         async element(element) {
55 |           throw new Error("test");
                         ^
error: test
      at element (/workspace/bun/test/js/workerd/html-rewriter.test.js:55:21)
      at <anonymous> (/workspace/bun/test/js/workerd/html-rewriter.test.js:58:8)
50 | 
51 |   it("async erro
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/workerd/html-rewriter.test.js
bun test v1.4.0 (519cef19e)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [12.94ms]
(pass) HTMLRewriter > error inside element handler rejects the body [7.72ms]
(pass) HTMLRewriter > error inside element handler (string) [4.73ms]
(pass) HTMLRewriter > async error without a real await inside element handler rejects the body [7.37ms]
(pass) HTMLRewriter > fast async error inside element handler rejects the body [21.76ms]
(pass) HTMLRewriter > slow async error inside element handler rejects the body [10.34ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [114.47ms]
(pass) HTMLRewriter > async handlers suspend the rewrite > runs async element handlers strictly in document order [634.00ms]
(pass) HTMLRewriter > async handlers suspend the rewrite > mutations made before and after the await both land [20.71ms]
(pass) HTMLRewriter > async handlers suspend the rewrite > an attribute iterator from before the await keeps working [17.21ms]
(pass) HTMLRewriter > async hand
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     519cef19e3
  features     baseline

22 deps, 108 codegen, 1171 objects in 839ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [9.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [5.00ms]
[4/1234] gen ErrorCode+*.h
[5/1234] gen bindgenv2
[6/1234] fetch tinycc
[tinycc] up to date
[7/1234] fetch picohttpparser
[picohttpparser] up to date
[8/1234] fetch zlib
[zlib] up to date
[9/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConst
... (truncated)
diff hotspot
src/runtime/api/html_rewriter.rs      |  8 ++++++++
 test/js/workerd/html-rewriter.test.js | 37 +++++++++++++++++++++++++++++++++++
 2 files changed, 45 insertions(+)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/runtime/api/html_rewriter.rs           6      2      0
test/js/workerd/html-rewriter.test.js      7     14      0

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

HTMLRewriter now rejects disturbed locked response bodies. Body buffering also materializes Blob and File stream sources. Regression tests cover Bun.file streams and reused Blob or File bodies.

Changes

HTMLRewriter body handling

Layer / File(s) Summary
Body buffering and Blob/File materialization
src/runtime/webcore/Body.rs
ValueBufferer rejects locked or disturbed streams and converts Blob or File sources through to_any_blob before buffering.
HTMLRewriter checks and regression coverage
src/runtime/api/html_rewriter.rs, test/js/workerd/html-rewriter.test.js
HTMLRewriter.transform() rejects disturbed locked bodies. Isolated tests cover Bun.file(...).stream() and reused Blob or File response bodies.

Possibly related PRs

  • oven-sh/bun#36733: Modifies the same HTMLRewriter and body buffering paths.
  • oven-sh/bun#35855: Handles disturbed and used body streams in related Body.rs and HTMLRewriter paths.
  • oven-sh/bun#36499: Adds locked and disturbed stream checks in related body-handling paths.

Suggested reviewers: jarred-sumner

🚥 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 identifies the main change: HTMLRewriter now throws a used-body error for disturbed response bodies.
Description check ✅ Passed The description explains the fix, expected error, implementation details, regression tests, and verification results.

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

@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 checked that ValueBufferer has no callers other than HTMLRewriter (so the new is_disturbed guard in buffer_locked_body_value can't regress other body consumers), and traced the recursive self.run(value, None) after re-tagging *value to a Blob variant — it hits the Value::Blob | InternalBlob | WTFStringImpl arm and cannot re-enter Locked. Deferring to a human on the memory-ownership side of the new to_any_blob recovery path in Body.rs.

Extended reasoning...

The change replaces a user-reachable unreachable!() with a proper TypeError plus a defense-in-depth recovery path, and ValueBufferer is HTMLRewriter-only so blast radius is contained. The subprocess tests follow harness conventions and assert exact error text. The one open question worth a maintainer glance is the stream.to_any_blob → self.readable_stream_ref.deinit() → recursive run() sequence: it looks correct (the stream's done() runs inside to_any_blob, and the extracted AnyBlob owns its store), but it's a new control-flow shape in body-buffering code that touches store ownership.

Comment thread test/js/workerd/html-rewriter.test.js Outdated
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Body.rs Outdated

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

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 `@src/runtime/webcore/Body.rs`:
- Around line 2487-2492: In the readable_stream handling path, validate
stream.is_locked(self.global) and stream.is_disturbed(self.global) before
assigning Value::Used. Ensure a failed transform() on a locked but unread stream
leaves the Response body usable after the lock is released, and add regression
coverage for that scenario.
🪄 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: bdb6681f-8bd0-4b1b-8a7d-c486cd584eca

📥 Commits

Reviewing files that changed from the base of the PR and between 074656d and b419349.

📒 Files selected for processing (3)
  • src/runtime/api/html_rewriter.rs
  • src/runtime/webcore/Body.rs
  • test/js/workerd/html-rewriter.test.js

Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Body.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.

LGTM — narrow crash fix replacing a user-reachable unreachable!() with the same "Body already used" error that .text()/.json()/.clone() already return.

What was reviewed:

  • buffer_locked_body_value: the new is_disturbed guard mirrors every other body consumer; the Source::Blob|File arm now recovers via to_any_blob and re-enters run() on the existing Value::Blob path (bounded, one level), falling back to UnsupportedStreamType — strictly safer than the panic it replaces.
  • transform_: the pre-check reuses get_body_readable_stream (already called downstream in BufferOutputSink::init) and matches the adjacent Value::Used error path.
  • readable_stream_ref ownership: deinit() on the recovery path, Drop on the error path — no leak.
  • Tests: subprocess-based, drain stdout/stderr/exited concurrently, assert exact TypeError message for both Blob and Bun.file bodies; the earlier timeout and comment-length nits were addressed in b191979 / b419349.
Extended reasoning...

Overview

Three files: src/runtime/api/html_rewriter.rs (+9), src/runtime/webcore/Body.rs (+20/-4), test/js/workerd/html-rewriter.test.js (+63). The fix stops HTMLRewriter.transform() from aborting the process when handed a Response whose body stream has been partially read then had its reader released — a state where the stream is disturbed but not locked, and its Blob/File source has already detached its store. Previously this reached an unreachable!() in ValueBufferer::buffer_locked_body_value; now it throws the same "Response body already used" TypeError that .text()/.json()/.clone() produce for the same input.

Security risks

None. No untrusted-input parsing, no new allocation sizing, no auth/crypto/permissions surface. The change converts a process-aborting panic into a catchable TypeError, which is strictly a robustness improvement (a reachable panic on user input is a DoS per REVIEW.md).

Level of scrutiny

Medium. Body.rs is core Request/Response plumbing, but the change is confined to one match arm that was previously unreachable!() plus one added disjunct on an existing guard. I traced the new recursion self.run(value, None) after setting *value = Value::Blob/InternalBlob/WTFStringImpl: run() calls to_blob_if_possible() (no-op for these variants) then matches the Blob arm and invokes on_finished_buffering synchronously — one bounded hop into an existing, well-exercised path. On the Err(UnsupportedStreamType) fallback, readable_stream_ref is released by ValueBufferer::drop (line 2261). to_any_blob takes &mut self (hence the mut stream binding change), and its own is_disturbed check is redundant with the guard added at line 2490 — belt-and-suspenders, not a correctness dependency.

Other factors

All prior review feedback is addressed: my timeout nit (b191979), both comment-cop flags and my comment-length nit (02ce98d, b419349), and CodeRabbit's "consumed before rejecting" finding was withdrawn as pre-existing behavior out of scope for this crash fix. The PR body notes #36697 will replace ValueBufferer entirely; this is the minimal interim fix. Tests follow harness conventions (bunEnv/bunExe, concurrent pipe drain, stdout/stderr asserted before exitCode, tmpdirSync matching the neighboring (from file) test), and the PR evidence shows they fail on main (subprocess aborts) and pass with the fix on both debug+ASAN and release.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts or close

transform() on a Response whose body stream has been partially read
(getReader() + read() + releaseLock()) used to abort with
unreachable!() in ValueBufferer::buffer_locked_body_value; #36733
replaced that path and it now throws ERR_STREAM_ALREADY_FINISHED from
wire_input's is_disturbed guard.

Check is_disturbed in transform_ alongside the existing Value::Used
check so a disturbed body throws the same 'Response body already used'
TypeError as .text()/.json()/.clone() and as a body consumed by a
previous transform(). Adds coverage for Bun.file().stream() bodies and
the partial-read-before-transform scenario.
@robobun
robobun force-pushed the farm/d4b53504/htmlrewriter-disturbed-body-panic branch from b419349 to 519cef1 Compare August 4, 2026 04:28
@robobun robobun changed the title HTMLRewriter: throw on disturbed Response body instead of hitting unreachable!() HTMLRewriter: throw 'Response body already used' for a disturbed body Aug 4, 2026
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on 87e168f (#36733). That PR removed ValueBufferer and the unreachable!() entirely, so the crash is already fixed on main; the remaining delta here is the transform_ disturbed check so a partially-read body throws TypeError: Response body already used (matching .text()/.json()/.clone() and the existing transform() marks the input body used test) instead of ERR_STREAM_ALREADY_FINISHED from wire_input. Body.rs changes dropped; test rewritten in-process. 124/124 in html-rewriter.test.js. Happy to close instead if the error-message consistency isn't worth it.

@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 — narrow, pattern-following fix with all prior feedback addressed.

What was reviewed:

  • The is_disturbed guard in transform_ mirrors the identical Locked → get_body_readable_stream → is_disturbed idiom used across Body.rs (~6 sites) and fetch.rs; the error message matches the adjacent Value::Used check.
  • Post-rebase onto #36733 the Body.rs / ValueBufferer changes were correctly dropped (that code no longer exists); begin_transform line ~1100 already guards disturbed streams, so the in-process test no longer risks a crash — verifier confirmed the subprocess→in-process conversion is sound.
  • Timeout override, comment-length, and CodeRabbit's locked-body ordering concern were all addressed/withdrawn; Jarred's "fix conflicts" request appears satisfied by the rebase.
Extended reasoning...

Overview

After rebasing onto #36733 (which replaced ValueBufferer with the SinkHandle streaming path), this PR is now just 9 lines in src/runtime/api/html_rewriter.rs plus two tests. The Rust change adds an early is_disturbed check in transform_() so that transforming a Response whose body has been partially read throws a synchronous TypeError: Response body already used — matching .text(), .json(), .clone(), and the adjacent Value::Used branch — instead of returning a Response whose body later rejects with the less consistent ERR_STREAM_ALREADY_FINISHED from the pipe's line-1100 guard.

Security risks

None. This adds an earlier rejection on an already-detected invalid state; no new input parsing, no allocation, no unsafe. is_disturbed is already used in a non-throwing boolean context at html_rewriter.rs:1100 and throughout Body.rs.

Level of scrutiny

Low. The three-line nested-if is a verbatim copy of the pattern at Body.rs:1786-1790, 1920-1924, 1970-1974, 2025-2029, 2076-2080 and fetch.rs:1240-1244. The error string reuses the exact wording from the Value::Used branch four lines above. Tests follow the file's existing conventions (tmpdirSync, bunExe -e subprocess, it.each).

Other factors

All prior review threads are resolved: the 15s timeout was dropped (b191979), the long Body.rs comment was removed (b419349, and that file is no longer in the diff anyway), and CodeRabbit withdrew its locked-body ordering finding after the author showed it was pre-existing and out of scope. The bug-hunting system found nothing; two candidate concerns about the in-process test conversion were verified as non-issues (post-rebase, without the fix the test fails cleanly rather than aborting the process, since the unreachable!() no longer exists). Jarred's only outstanding request was to fix merge conflicts, which the rebase did.

This branch has not been deployed

No deployments
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.

2 participants