Repository navigation
Conversation
A handler can return Err(SuspensionRequest) to park the current rewritable unit on the heap and exit write()/end() with the non-poisoning RewritingError::Suspended; HtmlRewriter::resume() continues from a StateMachineBookmark. - take_owned() on each rewritable unit deep-copies borrowed byte slices so the unit outlives the write() stack frame - Arena::shift advances a start offset instead of memmoving the tail (a suspended rewrite re-feeds its unconsumed tail on every resume) - the pending captured-text flush is hoisted from Dispatcher::handle_tag into the lexer actions so every suspension point has a uniform shape - end_mut() is the non-consuming end() for a rewriter that may suspend in a document-end handler Bun uses this so an async JS content handler can suspend the rewrite and resume it from a promise reaction instead of spinning a nested event loop inside write(). See oven-sh/bun#36733.
The upstream ci.yml triggers on push: branches: [master] only, so an additive bun.yml runs cargo test --features=integration_test on pushes/PRs to bun without touching ci.yml (keeps rebases onto upstream clean).
…epth
The selector-VM open-element stack had two per-token scans that go
quadratic (or worse) on pathological nesting:
* Stack::pop_up_to did a full rposition() over the open stack for
every end tag. A stray end tag that matches nothing still walked
the whole stack, so N unclosed opens plus N stray closes was O(N^2):
"<a></b>".repeat(100_000) (~700 KB) burned ~25s of CPU.
* try_exec_hereditary_jumps_without_attrs walked every ancestor and
re-executed its hereditary_jumps for every start tag. For a selector
like "div div" over N nested <div>s every item carries the same
address range, so the walk is O(depth) per tag and O(N^2) overall;
k-arm chains ("div div div") compound to O(N^k).
Both paths are reachable from HtmlRewriter with any element-handler
registration, so untrusted HTML can pin a CPU core via sub-MB input.
Memory stays linear so max_allowed_memory_usage does not bound it.
Changes:
* Stack now keeps open_name_counts: HashMap<LocalName, usize> so
pop_up_to rejects a name not on the stack in O(1). When the name is
present the rposition scan length equals the subsequent drain
length, so total scan work over a document is bounded by total
pushes.
* Stack now keeps active_hereditary_jumps: Vec<(AddressRange, usize)>,
the distinct hereditary-jump ranges currently contributed by any
open item, each paired with the shallowest depth that introduced it.
Descendant matching iterates this flat list (bounded by program
size, not depth) and pop_up_to prunes it by depth. The per-item
has_ancestor_with_hereditary_jumps flag and the
HereditaryJumpPtr.stack_offset resume coordinate are removed.
* LocalName gets a manual Hash impl that ASCII-lowercases the Bytes
arm so it agrees with the existing case-insensitive PartialEq.
Without this, keying a HashMap on LocalName is unsound for any name
that falls off LocalNameHash (custom elements, >12 chars, chars
outside a-zA-Z1-6). This also fixes the same latent contract
violation in TypedChildCounterMap.
Re-executing the same address range against the same start tag was
already a no-op after the matched-id set union in add_execution_branch,
so dedup-once execution is observationally equivalent.
do_for_each_active_and_remove walked every registered end-tag handler on every end tag, i.e. O(elements currently open) per end tag. Registered handlers belong to open elements in stack order and the ones an end tag activates are always the most recently registered (an end tag pops a suffix of the open-element stack; end_tag_handler_idx already relies on removals only happening at the tail), so walking from the end and stopping once the active count is spent visits only the handlers that actually run. Documents that leave many elements open in the tokenizer's view -- long lists and tables with omitted </li>/</td>/</tr>/</p>, which is valid HTML -- otherwise go quadratic as soon as anything inside them registers an end-tag handler: 100k `<li><a>x</a>` with an on_end_tag handler took about three seconds.
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.
This is a cherry-pick of a4856ba from #3 (author: @sosukesuzuki) onto
bun, on its own, so that Bun can pin it without the rest of #3. If #3 merges first, this PR is redundant and can be closed. If this merges first, #3 rebases clean (the patch is identical).Problem
HandlerVec::do_for_each_active_and_remove(src/rewriter/handlers_dispatcher.rs) walks every registered end-tag handler on every captured end tag. A registered handler belongs to an element that is still open. So n nested elements that each callon_end_tagcost n^2/2 item visits: in Bun,"<a>".repeat(n) + "</a>".repeat(n)withelement.onEndTag(() => {})takes 0.69 s at n = 32768, 2.4 s at 65536, 5.3 s at 100000 (release). The same document withoutonEndTagis linear (91 ms at 100000).Fix
Stop the reverse walk once
self.user_countis zero. Handlers are pushed in open-element-stack order and an end tag pops a suffix of that stack, so the handlers it activates are always the most recently pushed ones. The walk now visits only the handlers that run. Behavior is unchanged: the items it skips haveuser_count == 0and were never called.The suspension path is unaffected: a handler is removed before it is called, so
resume_suspended_tokenfinds the remaining active handlers at the tail again.cargo test: 172 + 34 pass.Bun side: oven-sh/bun#42234 pins this commit and adds the regression test.