Repository navigation
Conversation
When a text handler suspends on a chunk, `TextDecoder` copies the undecoded rest of the lexeme, because the input of `write()` is gone when the resume runs. `resume_feed` then ran the decoder loop over that copy, and a chunk that suspended again copied the rest again. A handler that suspends on every chunk of one N-byte lexeme made N / 1024 suspensions, and each copied N / 2 bytes on average. One 4 MiB text node allocated 8.59 GB. `resume_feed` now lends its bytes to the loop and leaves its record in the `suspended` slot. A chunk that suspends again finds the record there and only moves `offset`. The same text node allocates 8.72 MB. The parked buffer keeps its size until the lexeme ends. Both places that record a suspension call one cold helper, `park`. It holds the only copy. It records nothing for an error that is not a suspension: such an error ends the rewrite, so nothing reads the record. The path that never suspends has no new statement, and `TextFeedSuspension` keeps its size.
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.
Problem
raw_input.get(read..).unwrap_or_default().to_vec()inTextDecoder::feed_decoder_loop(src/rewritable_units/text_decoder.rs:193).resume_feedruns that loop over the parked copy, so the next suspension copies the rest again.Fix
resume_feedlends its bytes to the loop and leaves its record in thesuspendedslot. A chunk that suspends again only movesoffset. The 4 MiB lexeme now allocates 8.72 MB.#[cold]helper,park, holds the only copy. It records suspensions, not errors.cargo test --features=_integration_test. Of 5 new tests, 2 fail on the unfixed code.Background
<that opens no tag ends it, and so does the end of awrite().write()input is gone whenresume()runs, so a suspension copies the undecoded rest.Dispatcher(one fact, two structs) and theTransformStreamarena (a branch on everywrite()).Downsides
Notes
Origin. Found by measurement of Bun's
HTMLRewriter. #12 mentions it under "Found on the way, not changed here". No user reported it. The copy came with the suspension commit (230e863 here, oven-sh/bun#36733, Bun 1.4.0).Which text takes the decoder. Valid UTF-8 text with no stray
<, in onewrite(), is one chunk and is not affected. These three bodies each give about 4,100 chunks for 4 MiB of text. The times are CPU for onetransform()in a release build of Bun with a handler that awaitssetImmediateon every chunk, before and after this change (single runs):<p>, the byte 0xE9, text<p>a <, text (valid UTF-8)<script>withi<n, text (valid UTF-8)A handler whose promise settles in the microtask checkpoint does not suspend and is not affected.
Allocation. A counting global allocator around one rewrite (release build, the handler returns
SuspensionRequeston every chunk unless stated):On the v2.7.2 lineage (725ce49) the rows that suspend differ from these by less than 500 bytes.
The path that never suspends. Its source does not change: the two
if res.is_err()arms callparkand no longer build the record inline. User-mode instructions (perf_event_open,instructions:u) of a harness with a sync text handler: 3.00 fewer per small text node (4,044.2 before), 1.99 fewer per 1024-byte chunk. With Bun's flags (-Ctarget-cpu=haswell, frame pointers, static relocation, ThinLTO): 4.0 more per text node and 2.0 fewer per chunk.TextFeedSuspensionis 40 bytes before and after, andHtmlRewriteris 1,144 bytes before and after. TheTextDecoderfunctions are 1,707 bytes before and 1,949 after,parkincluded.Tests.
rest_of_lexeme_is_copied_once: every suspension of one lexeme parks the same buffer (address and length) with a larger offset.suspended_feed_yields_the_same_chunksandtext_suspension_in_the_middle_of_a_lexeme: the chunks,last_in_text_node, the source ranges and the output equal the run that never suspends. The handler suspends on every 1st, 2nd, 3rd or 7th chunk. In the second test a second text handler and an element handler suspend too, over 6 documents: a byte that is not UTF-8 first, a valid prefix first, a<that opens no tag, 3-byte characters, a text node over three writes, and a text node that ends inend().handler_error_parks_nothingandsuspension_record_size.offsetfield, so they do not compile on the unfixed code. With those 2 lines removed,rest_of_lexeme_is_copied_onceandhandler_error_parks_nothingfail on their assertions, and every other test passes.resume_feedfeeds an empty slice, the offset goes back to 0, the offset is one byte late, the offset is one chunk early, the buffer is not put back,parkrecords every error, the rest is copied again, the slot holds the old offset, the read prefix is drained at every suspension, and the first copy has the right length and the wrong bytes.Overlap with #3. #3 contains 6e9ace3, which keeps a valid UTF-8 text node on the fast path: a later lexeme is then one chunk, while no handler suspends. After a suspension,
resume_feedruns the decoder loop, the loop creates the streaming decoder, and the later lexemes arrive in 1024-byte chunks. A handler that suspends then sees other chunks than a handler that does not. This commit merges into 6166e4c without a textual conflict, andsuspended_feed_yields_the_same_chunksandtext_suspension_in_the_middle_of_a_lexemethen fail on that difference. Whichever lands second has to keep the fast path inresume_feedwhen no input is left to decode.Overlap with #12. #12 changes
feed_text, the decoder loop andresume_dispatch, and keeps bothto_vec()calls. Whichever lands second routes its arm throughpark.CI. The four jobs of this PR fail in "Set up job", before any step runs. #10 changes the workflows. Run here by hand:
cargo fmt -- --check,cargo test --features=_integration_test, andcargo clippy --all --all-targets --features=_integration_test, which reports the same 3 warnings asbun.Other branches with the same commit.
robobun/463f6c50/text-suspension-linear-v2.7.2: fefbf98 on 725ce49, the commit that Bun pins today. It differs in one context line and in how the test builds aTextDecoder.robobun/463f6c50/text-suspension-linear-on-lazy-open-name-counts: 5f83589 on 0e6a4b1, the head of selectors_vm: keep the open-name counts only while the stack is deep #11. HTMLRewriter: make an async text handler on a long text node linear bun#45912 pins it. That PR is stacked on HTMLRewriter: match nested and compound :not() selectors (lol-html v3.0.1) bun#43266, which moves Bun to this branch's lineage.