Skip to content

rewriter: stop scanning end-tag handlers once none are active (cherry-pick from #3) - #5

Open
robobun wants to merge 4 commits into
bunfrom
robobun/end-tag-handlers-linear
Open

robobun wants to merge 4 commits into
bunfrom
robobun/end-tag-handlers-linear

Conversation

@robobun

@robobun robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

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 call on_end_tag cost n^2/2 item visits: in Bun, "<a>".repeat(n) + "</a>".repeat(n) with element.onEndTag(() => {}) takes 0.69 s at n = 32768, 2.4 s at 65536, 5.3 s at 100000 (release). The same document without onEndTag is linear (91 ms at 100000).

Fix

Stop the reverse walk once self.user_count is 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 have user_count == 0 and were never called.

The suspension path is unaffected: a handler is removed before it is called, so resume_suspended_token finds 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.

robobun and others added 4 commits August 2, 2026 02:58
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants