Repository navigation
Conversation
A text handler that changes nothing rewrote the document. With a text handler active, each text chunk is decoded for the handler, and the decoded text is what gets written back. In a UTF-8 document the decode puts U+FFFD in place of malformed input, so `<p>caf\xE9</p>` left as `<p>caf\xEF\xBF\xBD</p>` although no handler touched the chunk. Comments, tags and doctypes keep their raw bytes and write those back when they are not modified. The decoder now says which chunks are lossy (`decode_to_str` already reports whether it replaced anything) and hands their source bytes to the dispatcher. A lossy chunk that no handler edited, replaced or removed is written from those bytes. Every other chunk is written from its text as before. In a UTF-8 document that text is the source, so well-formed input takes the path it took before and `TextChunk` stays 80 bytes. The decoder holds up to 3 bytes of an incomplete sequence across `write()` calls. Those bytes belong to the chunk that shows their character. The bookkeeping is settled before the handler runs, so a handler that suspends cannot lose them. A suspended lossy chunk gets a copy of its source bytes, because the input buffer is gone by the time it resumes. Handlers get the same chunks with the same text as before. Documents in other encodings are written as before. This differs from upstream lol-html and from Cloudflare Workers, which write the U+FFFD.
`TextChunk::into_bytes_from_source` took the chunk by value and was compiled into this crate. In a ThinLTO build shaped like Bun's, that made every text chunk about 20 instructions slower, in well-formed documents too. The function brought the drop glue of the mutations into this crate. A crate built with `-Zshare-generics` then linked to that copy, and the drop of a chunk was no longer inlined into `Token::into_bytes`. `TextChunk::write_from_source` borrows the chunk and is `#[inline]`, so it is compiled with its callers. It returns `false` for a chunk that a handler edited, and the caller serializes that chunk as before. New tests tie the function to `impl_serialize!`. A lossy chunk and a well-formed one get the same content around them for every kind of mutation, and an error from that content fails the rewrite for both. Two more tests state what holds when other handlers change the document: text handlers that change nothing change no byte of the output. The comment on `account_source_bytes` now says that its rule about what the decoder holds is an observed behaviour of encoding_rs, and names the test that checks it.
Collaborator
Author
|
Status: parked draft. It waits for a maintainer's choice between (a), (b) and (c) in the description. Reproduced on Bun 1.4.3 and on main: const page = Buffer.from("<p>caf\xE9</p>", "latin1");
const out = await new HTMLRewriter().on("p", { text() {} }).transform(new Response(page)).arrayBuffer();
console.log(Buffer.from(out).toString("hex"));
// 3c703e636166efbfbd3c2f703e, and 3c703e636166e93c2f703e with this changeworkerd 1.20261015.1 prints the first line too. |
This was referenced Oct 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Parked draft. It needs a maintainer's choice:
Settingsflag that is off by default.Revives oven-sh/bun#35933.
Problem
<p>caf\xE9</p>leaves as<p>caf\xEF\xBF\xBD</p>. Without a text handler it passes unchanged.TextChunk::serialize_self(tokens/text_chunk.rs) writes the decoded text, which has U+FFFD for malformed input. Tags and comments write their raw bytes.Fix
TextDecoderkeepshad_replacementsfromdecode_to_str. A chunk that got a U+FFFD is written from its source bytes, unless a handler replaced, removed or edited it.cargo test --features=_integration_test. 32 of 32 seeded faults fail a test.Background
HtmlRewriter::resume()). Thewrite()input is gone by then, so a parked chunk with a U+FFFD keeps a copy of its source.TextChunk(HTMLRewriter: pass text bytes through unchanged when a text handler only observes bun#35933): 24 more bytes per chunk and an allocation per split sequence.Downsides
before()/after()content can land inside a character.Notes
Why it is parked. A review of this PR found that the change does what it says, and that it moves Bun away from the implementation that Bun's docs name as the compatibility target. On the repro above, workerd 1.20261015.1 and stock lol-html 3.0.1 write the same
EF BF BDthat Bun 1.4.3 writes. No report from a user was found in the trackers of Bun, lol-html or workerd. The symptom comes from a fuzz rule ("a handler that only reads leaves the bytes as they were") that the reference does not keep. So the choice between (a), (b) and (c) belongs to a maintainer. The charset change of (c) is separate work and does not build on this PR.The 150 rows. 5 inputs (windows-1252 text, Shift_JIS text, ill-formed UTF-8, a sequence cut short by the end tag, well-formed UTF-8), 5 Content-Type labels (none,
text/html,charset=utf-8,charset=windows-1252,charset=shift_jis), 6 handlers (none, element, text read-only, document text read-only, textbefore/after, textreplace). Each row is the output bytes oftransform(new Response(bytes, { headers })).The first two lines are real binaries. The last two are a model: a Rust program that builds the same
Settingsas Bun's binding and takes the encoding from the label. With UTF-8 for every document the model gives the output of the real binaries on 150 of 150 rows, for both pins. The 15 rows that this change makes equal are rows with a legacy label, and the charset change makes the same 15 equal. The 36 rows are the 4 inputs that are not valid UTF-8, under the 3 labels that mean UTF-8, with the 3 text handlers that keep the chunk: there workerd writes U+FFFD and this change writes the source bytes.Branches. The Bun side is branch
robobun/72e68030/htmlrewriter-text-source-bytesin oven-sh/bun, with no PR. This PR is based onbun(v3.0.1): e1a306d to 42ec40c, two commits. Bun pins the v2.7.2 lineage today, so the same two commits are onrobobun/72e68030/text-source-writeback-v2.7.2(725ce49 to cfc777a). The two differ only where v3 differs: the updated upstream testtest_flush_before_charset_switch, andTextDecoder::new. The Bun branch pins cfc777a and adds 33 tests totest/js/workerd/html-rewriter.test.js(31 of them fail on Bun 1.4.3, the other 2 are controls).Held bytes. The decoder holds up to 3 bytes of an incomplete sequence between
write()calls. They belong to the chunk that shows their character, and they are written with it. The bookkeeping is settled before the handler runs, so a handler that suspends cannot lose them.What a handler sees. Nothing changes for handlers.
as_str()has the U+FFFD as before, and the chunks are the same chunks.before()andafter()content goes around the source bytes.replace(),remove(),set_str()andas_mut_str()write what they wrote before. A document in another encoding is written as before.What other handlers do. Text handlers that change nothing change no byte of the output, whatever other handlers do. Two tests state it (
handlers_that_change_nothing_change_no_byte_whatever_other_handlers_do,bytes_that_a_removed_token_separated_are_written_side_by_side). One effect follows. When a handler removes a tag, a comment or a chunk that stood between two malformed fragments, the bytes on both sides become neighbours, and they can make a character that no text handler was given:b.remove_and_keep_content()on<p>\xE2<b>\x82</b>\xAC</p>writes<p>\xE2\x82\xAC</p>. That is also the output without a text handler, before and after this change.Bodies that are not HTML. 2,048 random bytes with a PNG signature through a document text handler that changes nothing: Bun 1.4.3 writes 3,760 bytes, this change writes the 2,048 bytes. workerd 1.20261015.1 writes the same 3,760 bytes, and no charset change helps there.
Measurements in release builds of Bun. Bun at 924257d44, ThinLTO, three builds that differ in lol-html: 725ce49, the first commit, and the by-reference form of the second commit (its lol-html differs from cfc777a in a variable name and in where
Ok(true)stands). User-mode instructions of the process for onetransform()of a Response, as the slope between 200 and 1,200 rewrites, median of 7 runs. The last column is the spread of the 7 runs of the base.The release binary grows by 8,192 bytes (106,198,792 to 106,206,984).
Measurements in a harness. Bun's toolchain (
nightly-2026-09-15) and Bun's release flags for linux-x64,-Zbuild-std, ThinLTO by lld, and two crates that both use the rewriter, asbun_bundlerandbun_runtimedo. User-mode instructions of one rewrite with a Rust handler that does nothing, minimum of 5.The sign of the first line depends on what the link inlines. With one crate that uses the rewriter, the same flags give 13.0 fewer instructions per chunk on v3.0.1 and 12.0 fewer on v2.7.2. The table is the worse shape. An earlier version of this body gave "13.8 fewer per chunk" from a build without LTO. That number does not describe a build that Bun ships.
Allocations, from a counting allocator: valid page 990 before and after, 1,000 split characters 2,008 and 2,008, 1,000 malformed chunks 9 and 9. With every chunk suspended, 8,009 and 9,009: one copy per parked chunk with malformed input.
Sizes:
TextChunk80 B andToken136 B before and after.TextDecoder128 B to 160 B, so a rewriter grows by 32 B.Why
write_from_sourceborrows and is#[inline]. The first commit hadinto_bytes_from_source(mut self, ..), compiled into this crate. That cost about 20 instructions for every chunk of every document. The function brought<Vec<StringChunk> as Drop>::drop, a part of the drop of the mutations, into this crate. Bun builds with-Zshare-generics, so the crates that use the rewriter linked to that copy. The drop of aMutationsthen looked cheap to the inliner and went into the drop of aTextChunk, which became too large to inline intoToken::into_bytes. The LLVM inline remarks show it: cost 190 before, 420 after, threshold 250. The second commit borrows the chunk and compiles the function with its callers.Fuzz. 200,000 random documents with damaged UTF-8, cut into random writes, through handlers that change nothing. 0 outputs differ from the input, with sync handlers and with handlers that suspend at random. On the base 167,895 differ. The result is the same with the 1,024-byte decode buffer and with a 13-byte one. The chunks that handlers get are the same as on the base in all 14 handler configurations (200,000 documents each). With handlers that insert, replace or edit, the output decodes to the same text as the output of the base. When a handler removes a chunk, the bytes on both sides of it become neighbours, and the decoded text can differ there. A shorter form of this fuzz runs in the test suite.
LOL_HTML_FUZZ_DOCUMENTS=200000runs the long form.Seeded faults. 32 single faults, each in one place of the new code. Every one makes at least one test fail on both branches. They cover the tail scan,
may_hold, the held bytes, the parked source,text_edited, the encoding gate, the fast check, the order inresume_dispatch, the content around a chunk (order, replacement, errors), and whatwrite_from_sourcereturns and what its callers do with it.Overlap with #3. Commit 6e9ace3 of #3 keeps a text node on the UTF-8 fast path. It merges with this change without a conflict, and then
chunks_account_for_every_source_bytefails: its reference model describes the chunks from before 6e9ace3. The PR that lands second has to update the model. No production path conflicts.Found on the way, not changed here. When a handler suspends on each chunk of a long text lexeme,
TextFeedSuspensioncopies the undecoded rest of the lexeme each time. One 16 MB text node with a byte that is not UTF-8 near its start takes 10.2 s in Bun 1.4.3 with a handler that awaitssetImmediate, and 19 ms with a sync handler. This PR adds one more copy of at most 1,024 bytes per parked chunk with malformed input, next to it.If the ruling is (a). Rebase on the
buntip after #9, #11 and 6e9ace3. Pin the merged commit after oven-sh/bun#43266 and drop the v2.7.2 port. Name this second difference from upstream in the fork README and in the header ofscripts/build/deps/lolhtml.ts. Add text-handler cases tobench/snippets/rewriter.mjs. Once Bun gives the charset to lol-html, a page with a legacy label is decoded in that encoding and this change no longer applies to it: the docs sentence on the Bun branch has to say so.Draft of an issue for cloudflare/lol-html (not filed)
Title: A text handler that changes nothing rewrites malformed bytes to U+FFFD
With a text handler registered, the rewriter writes every text chunk back from its decoded text, whether or not a handler changed the chunk. In a UTF-8 document the decoder puts U+FFFD in place of malformed input, so those bytes change although no handler touched them. Tags, comments and doctypes write their raw bytes when nothing modified them, so an element or comment handler that changes nothing leaves the document as it was, byte for byte.
This is v3.0.1 (608cc4a).
TextChunk::serialize_selfwrites the decoded text (src/rewritable_units/tokens/text_chunk.rs:332), andTextDecoder::feed_textdrops thehad_replacementsresult ofdecode_to_str(src/rewritable_units/text_decoder.rs:91).Where it shows:
src/rewriter/mod.rsstate the contract assink_output + unfed_remaining_bytes == original_html, and say "The handlers used are no-ops, so serialized tokens are byte-for-byte identical to the original input". That holds for theelement!andcomments!handlers those tests use. Withdoc_text!(|_| Ok(()))added and<p>caf\xE9</p>in front of the huge comment oftest_bail_out_reconstruct_huge_comment, the reconstructed response starts with<p>caf\xEF\xBF\xBD</p>.0..=255come out as 512 bytes throughdoc_text!(|_| Ok(())), and unchanged through an element handler.Is the write-back of the decoded text intended for a chunk that no handler changed? Cloudflare Workers has this behaviour today (workerd 1.20261015.1), and so does Bun, so a change would be visible there. A patch exists (this PR). It can be the default, or a
Settingsflag that defaults to today's output.