From 67c7e7c423c5d1acab1872c630cae61e2bc45211 Mon Sep 17 00:00:00 2001 From: Josef Guenther Date: Tue, 13 Oct 2026 19:40:41 -0700 Subject: [PATCH] Fix quadratic parsing of deeply nested markup Many tree builder steps search the stack of open elements. "Has an element in scope" runs on most start and end tags in body, and a few other steps look for an element by name or for the nearest element of some kind. Each search walks down from the top of the stack, and when nothing matches it walks all the way to the bottom. So on deeply nested markup every token costs time in proportion to the nesting depth, and parsing takes quadratic time. A few hundred kilobytes of nested ), ) => { - if !self.in_scope_named(default_scope, tag.name.clone()) { + if !self.in_scope_named(DefaultScope, tag.name.clone()) { self.unexpected(&tag); } else { self.generate_implied_end_tags(cursory_implied_end); @@ -636,7 +638,10 @@ where .parse_error(Borrowed("Null form element pointer on ")); return ProcessResult::Done; }; - if !self.in_scope(default_scope, |n| self.sink.same_node(&node, &n)) { + let in_scope = self.position_in_open_elems(&node).is_some_and(|position| { + self.open_elems.borrow().in_scope(DefaultScope, position) + }); + if !in_scope { self.sink .parse_error(Borrowed("Form element not in scope on ")); return ProcessResult::Done; @@ -649,7 +654,7 @@ where .parse_error(Borrowed("Bad open element on ")); } } else { - if !self.in_scope_named(default_scope, local_name!("form")) { + if !self.in_scope_named(DefaultScope, local_name!("form")) { self.sink .parse_error(Borrowed("Form element not in scope on ")); return ProcessResult::Done; @@ -666,22 +671,20 @@ where // FIXME: This branch does not exist like this in the specification, because it should run for // implicitly closed option tags too. See https://github.com/servo/html5ever/issues/712. Token::Tag(tag @ tag!()) => { - let option_in_stack = self - .open_elems - .borrow() - .iter() - .find(|elem| self.html_elem_named(elem, local_name!("option"))) - .cloned(); + let option_in_stack = { + let open_elems = self.open_elems.borrow(); + open_elems + .positions_named(&local_name!("option")) + .first() + .map(|position| (position, open_elems[position].clone())) + }; self.process_end_tag_in_body(tag); - if let Some(option) = option_in_stack { - if !self - .open_elems - .borrow() - .iter() - .any(|elem| self.sink.same_node(elem, &option)) - { + // Processing the end tag only pops elements, so the option is still open if + // the stack still reaches its position. + if let Some((position, option)) = option_in_stack { + if position >= self.open_elems.borrow().len() { self.sink .maybe_clone_an_option_into_selectedcontent(&option); } @@ -691,7 +694,7 @@ where }, Token::Tag(tag!(

)) => { - if !self.in_scope_named(button_scope, local_name!("p")) { + if !self.in_scope_named(ButtonScope, local_name!("p")) { self.sink.parse_error(Borrowed("No

tag to close")); self.insert_phantom(local_name!("p")); } @@ -701,9 +704,9 @@ where Token::Tag(tag @ tag!( | | )) => { let in_scope = if tag.name == local_name!("li") { - self.in_scope_named(list_item_scope, tag.name.clone()) + self.in_scope_named(ListItemScope, tag.name.clone()) } else { - self.in_scope_named(default_scope, tag.name.clone()) + self.in_scope_named(DefaultScope, tag.name.clone()) }; if in_scope { self.generate_implied_end_except(tag.name.clone()); @@ -715,7 +718,15 @@ where }, Token::Tag(tag @ tag!( | | | | | )) => { - if self.in_scope(default_scope, |n| self.elem_in(&n, heading_tag)) { + let headings = [ + local_name!("h1"), + local_name!("h2"), + local_name!("h3"), + local_name!("h4"), + local_name!("h5"), + local_name!("h6"), + ]; + if self.in_scope_any_named(DefaultScope, &headings) { self.generate_implied_end_tags(cursory_implied_end); if !self.current_node_named(tag.name) { self.sink.parse_error(Borrowed("Closing wrong heading tag")); @@ -745,7 +756,7 @@ where Token::Tag(tag @ tag!()) => { self.reconstruct_active_formatting_elements(); - if self.in_scope_named(default_scope, local_name!("nobr")) { + if self.in_scope_named(DefaultScope, local_name!("nobr")) { self.sink.parse_error(Borrowed("Nested ")); self.adoption_agency(local_name!("nobr")); self.reconstruct_active_formatting_elements(); @@ -773,7 +784,7 @@ where }, Token::Tag(tag @ tag!( | | )) => { - if !self.in_scope_named(default_scope, tag.name.clone()) { + if !self.in_scope_named(DefaultScope, tag.name.clone()) { self.unexpected(&tag); } else { self.generate_implied_end_tags(cursory_implied_end); @@ -822,7 +833,7 @@ where self.unexpected(&tag); } - if self.in_scope_named(default_scope, local_name!("select")) { + if self.in_scope_named(DefaultScope, local_name!("select")) { self.unexpected(&tag); self.pop_until_named(local_name!("select")); } @@ -846,10 +857,10 @@ where Token::Tag(tag @ tag!(


)) => { self.close_p_element_in_button_scope(); - if self.in_scope_named(default_scope, local_name!("select")) { + if self.in_scope_named(DefaultScope, local_name!("select")) { self.generate_implied_end_tags(cursory_implied_end); - if self.in_scope_named(default_scope, local_name!("option")) - || self.in_scope_named(default_scope, local_name!("optgroup")) + if self.in_scope_named(DefaultScope, local_name!("option")) + || self.in_scope_named(DefaultScope, local_name!("optgroup")) { self.sink.parse_error(Borrowed("hr in option")); } @@ -900,7 +911,7 @@ where ) { self.unexpected(&tag); - } else if self.in_scope_named(default_scope, local_name!("select")) { + } else if self.in_scope_named(DefaultScope, local_name!("select")) { self.unexpected(&tag); self.pop_until_named(local_name!("select")); } else { @@ -913,9 +924,9 @@ where }, Token::Tag(tag @ tag!()) => { - if self.in_scope_named(default_scope, local_name!("select")) { + if self.in_scope_named(DefaultScope, local_name!("select")) { self.generate_implied_end_tags(cursory_implied_end); - if self.in_scope_named(default_scope, local_name!("option")) - || self.in_scope_named(default_scope, local_name!("optgroup")) + if self.in_scope_named(DefaultScope, local_name!("option")) + || self.in_scope_named(DefaultScope, local_name!("optgroup")) { self.sink.parse_error(Borrowed("nested options")); } @@ -945,7 +956,7 @@ where }, Token::Tag(tag @ tag!( | )) => { - if self.in_scope_named(default_scope, local_name!("ruby")) { + if self.in_scope_named(DefaultScope, local_name!("ruby")) { self.generate_implied_end_tags(cursory_implied_end); } if !self.current_node_named(local_name!("ruby")) { @@ -956,7 +967,7 @@ where }, Token::Tag(tag @ tag!( | )) => { - if self.in_scope_named(default_scope, local_name!("ruby")) { + if self.in_scope_named(DefaultScope, local_name!("ruby")) { self.generate_implied_end_except(local_name!("rtc")); } if !self.current_node_named(local_name!("rtc")) @@ -1077,7 +1088,7 @@ where Token::Tag(tag!()) => { self.unexpected(&token); - if self.in_scope_named(table_scope, local_name!("table")) { + if self.in_scope_named(TableScope, local_name!("table")) { self.pop_until_named(local_name!("table")); ProcessResult::Reprocess(self.reset_insertion_mode(), token) } else { @@ -1086,7 +1097,7 @@ where }, Token::Tag(tag!(
)) => { - if self.in_scope_named(table_scope, local_name!("table")) { + if self.in_scope_named(TableScope, local_name!("table")) { self.pop_until_named(local_name!("table")); self.mode.set(self.reset_insertion_mode()); } else { @@ -1175,7 +1186,7 @@ where tag @ tag!( | | | | | | | | | | ), ) => { - if self.in_scope_named(table_scope, local_name!("caption")) { + if self.in_scope_named(TableScope, local_name!("caption")) { self.generate_implied_end_tags(cursory_implied_end); self.expect_to_close(local_name!("caption")); self.clear_active_formatting_to_marker(); @@ -1266,7 +1277,7 @@ where }, Token::Tag(tag @ tag!( | | )) => { - if self.in_scope_named(table_scope, tag.name.clone()) { + if self.in_scope_named(TableScope, tag.name.clone()) { self.pop_until_current(table_body_context); self.pop(); self.mode.set(InsertionMode::InTable); @@ -1279,8 +1290,12 @@ where Token::Tag( tag!( | | | | | | ), ) => { - declare_tag_set!(table_outer = "table" "tbody" "tfoot"); - if self.in_scope(table_scope, |e| self.elem_in(&e, table_outer)) { + let table_outer = [ + local_name!("table"), + local_name!("tbody"), + local_name!("tfoot"), + ]; + if self.in_scope_any_named(TableScope, &table_outer) { self.pop_until_current(table_body_context); self.pop(); ProcessResult::Reprocess(InsertionMode::InTable, token) @@ -1310,7 +1325,7 @@ where }, Token::Tag(tag!()) => { - if self.in_scope_named(table_scope, local_name!("tr")) { + if self.in_scope_named(TableScope, local_name!("tr")) { self.pop_until_current(table_row_context); let node = self.pop(); self.assert_named(&node, local_name!("tr")); @@ -1324,7 +1339,7 @@ where Token::Tag( tag!( | | | | | | | ), ) => { - if self.in_scope_named(table_scope, local_name!("tr")) { + if self.in_scope_named(TableScope, local_name!("tr")) { self.pop_until_current(table_row_context); let node = self.pop(); self.assert_named(&node, local_name!("tr")); @@ -1335,8 +1350,8 @@ where }, Token::Tag(tag @ tag!( | | )) => { - if self.in_scope_named(table_scope, tag.name.clone()) { - if self.in_scope_named(table_scope, local_name!("tr")) { + if self.in_scope_named(TableScope, tag.name.clone()) { + if self.in_scope_named(TableScope, local_name!("tr")) { self.pop_until_current(table_row_context); let node = self.pop(); self.assert_named(&node, local_name!("tr")); @@ -1360,7 +1375,7 @@ where // https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-intd InsertionMode::InCell => match token { Token::Tag(tag @ tag!( | )) => { - if self.in_scope_named(table_scope, tag.name.clone()) { + if self.in_scope_named(TableScope, tag.name.clone()) { self.generate_implied_end_tags(cursory_implied_end); self.expect_to_close(tag.name); self.clear_active_formatting_to_marker(); @@ -1374,7 +1389,8 @@ where Token::Tag( tag!( | | | | | | | | ), ) => { - if self.in_scope(table_scope, |n| self.elem_in(&n, td_th)) { + if self.in_scope_any_named(TableScope, &[local_name!("td"), local_name!("th")]) + { self.close_the_cell(); ProcessResult::Reprocess(InsertionMode::InRow, token) } else { @@ -1387,7 +1403,7 @@ where }, Token::Tag(tag @ tag!( | | | | )) => { - if self.in_scope_named(table_scope, tag.name.clone()) { + if self.in_scope_named(TableScope, tag.name.clone()) { self.close_the_cell(); ProcessResult::Reprocess(InsertionMode::InRow, Token::Tag(tag)) } else { @@ -1650,36 +1666,43 @@ where // FIXME(#118): in SVG Token::Tag(tag @ tag!()) => { - let mut first = true; - let mut stack_idx = self.open_elems.borrow().len() - 1; - loop { - if stack_idx == 0 { - return ProcessResult::Done; - } + let current_idx = self.open_elems.borrow().len() - 1; + if current_idx == 0 { + return ProcessResult::Done; + } - let html; - let eq; - { - let open_elems = self.open_elems.borrow(); - let node_name = self.sink.elem_name(&open_elems[stack_idx]); - html = *node_name.ns() == ns!(html); - eq = node_name.local_name().eq_ignore_ascii_case(&tag.name); - } - if !first && html { + let eq = self + .sink + .elem_name(&self.current_node()) + .local_name() + .eq_ignore_ascii_case(&tag.name); + if eq { + self.open_elems.borrow_mut().truncate(current_idx); + return ProcessResult::Done; + } + self.unexpected(&tag); + + // Below the current node, look for a foreign element with the tag's name, as far + // as the topmost HTML element, and never at the first element of the stack. + let (html_idx, eq_idx) = { + let open_elems = self.open_elems.borrow(); + let below_current = + |positions: Positions| positions.iter().rev().find(|&i| i < current_idx); + ( + below_current(open_elems.positions_in(TrackedSet::Html)), + below_current(open_elems.positions_foreign_named(&tag.name)), + ) + }; + match eq_idx { + Some(eq_idx) if eq_idx > 0 && html_idx.is_none_or(|i| eq_idx > i) => { + self.open_elems.borrow_mut().truncate(eq_idx); + ProcessResult::Done + }, + _ if html_idx.is_some_and(|i| i > 0) => { let mode = self.mode.get(); - return self.step(mode, Token::Tag(tag)); - } - - if eq { - self.open_elems.borrow_mut().truncate(stack_idx); - return ProcessResult::Done; - } - - if first { - self.unexpected(&tag); - first = false; - } - stack_idx -= 1; + self.step(mode, Token::Tag(tag)) + }, + _ => ProcessResult::Done, } }, diff --git a/html5ever/src/tree_builder/tag_sets.rs b/html5ever/src/tree_builder/tag_sets.rs index 47fece1c..e9d61e7e 100644 --- a/html5ever/src/tree_builder/tag_sets.rs +++ b/html5ever/src/tree_builder/tag_sets.rs @@ -86,6 +86,16 @@ declare_tag_set!(pub special_tag = "ul" "wbr" "xmp"); //§ END +declare_tag_set!(pub special_except_address_div_p = [special_tag] - "address" "div" "p"); + +declare_tag_set!(pub reset_insertion_mode_stop = + "td" "th" "tr" "tbody" "thead" "tfoot" "caption" "colgroup" "table" "template" "head" "body" + "frameset" "html"); + +declare_tag_set!(pub body_end_ok = + "dd" "dt" "li" "optgroup" "option" "p" "rp" "rt" "tbody" "td" "tfoot" "th" + "thead" "tr" "body" "html"); + pub(crate) fn mathml_text_integration_point(p: ExpandedName) -> bool { matches!( p, diff --git a/html5ever/tests/tree_builder.rs b/html5ever/tests/tree_builder.rs new file mode 100644 index 00000000..8c4fd49e --- /dev/null +++ b/html5ever/tests/tree_builder.rs @@ -0,0 +1,138 @@ +use html5ever::interface::{ElementFlags, NodeOrText, QuirksMode}; +use html5ever::tendril::TendrilSink; +use html5ever::{ + parse_document, tree_builder::TreeSink, Attribute, ExpandedName, ParseOpts, QualName, +}; +use markup5ever::tendril::StrTendril; +use std::borrow::Cow; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; + +/// A sink that builds no tree, and counts how often the tree builder asks it for an element's +/// name or compares two nodes. The tree builder does both for each element it looks at in the +/// stack of open elements. +#[derive(Default)] +struct Sink { + next_id: Cell, + names: RefCell>, + lookups: Cell, +} + +impl TreeSink for Sink { + type Handle = usize; + type Output = usize; + type ElemName<'a> = ExpandedName<'a>; + fn finish(self) -> usize { + self.lookups.get() + } + + fn get_document(&self) -> usize { + 0 + } + + fn get_template_contents(&self, target: &usize) -> usize { + target + 1 + } + + fn same_node(&self, x: &usize, y: &usize) -> bool { + self.lookups.set(self.lookups.get() + 1); + x == y + } + + fn elem_name(&self, target: &usize) -> ExpandedName<'_> { + self.lookups.set(self.lookups.get() + 1); + self.names + .borrow() + .get(target) + .expect("not an element") + .expanded() + } + + fn create_element(&self, name: QualName, _: Vec, _: ElementFlags) -> usize { + let id = self.next_id.get() + 2; + self.next_id.set(id); + // Leaked, to keep the sink small. + self.names + .borrow_mut() + .insert(id, Box::leak(Box::new(name))); + id + } + + fn create_comment(&self, _text: StrTendril) -> usize { + let id = self.next_id.get() + 2; + self.next_id.set(id); + id + } + + fn create_pi(&self, _target: StrTendril, _value: StrTendril) -> usize { + unimplemented!() + } + + fn append_before_sibling(&self, _sibling: &usize, _new_node: NodeOrText) {} + + fn append_based_on_parent_node( + &self, + _element: &usize, + _prev_element: &usize, + _new_node: NodeOrText, + ) { + } + + fn parse_error(&self, _msg: Cow<'static, str>) {} + fn set_quirks_mode(&self, _mode: QuirksMode) {} + fn append(&self, _parent: &usize, _child: NodeOrText) {} + + fn append_doctype_to_document(&self, _: StrTendril, _: StrTendril, _: StrTendril) {} + fn add_attrs_if_missing(&self, _target: &usize, _attrs: Vec) {} + fn remove_from_parent(&self, _target: &usize) {} + fn reparent_children(&self, _node: &usize, _new_parent: &usize) {} + fn mark_script_already_started(&self, _node: &usize) {} +} + +/// How many elements the tree builder looks at, per token, while parsing `html`. +fn lookups_per_token(html: &str, tokens: usize) -> usize { + let lookups = parse_document(Sink::default(), ParseOpts::default()).one(html); + lookups / tokens +} + +/// Deeply nested markup, followed by tokens that each make the tree builder look for something +/// in the stack of open elements. Each shape is `(prefix, nested, tokens)`: `nested` and then +/// `tokens` are repeated `n` times. +const SHAPES: &[(&str, &str, &str)] = &[ + // https://github.com/servo/html5ever/issues/788 + ("", "
", "
"), + ("", "
  • ", ""), + ("", "", ""), + // Has an element in scope. + ("", "
    ", "
  • "), + ("", "
    ", ""), + ("", "
    ", "

    "), + ("", "
    ", ""), + ("", "
    ", ""), + ("

    ", "
    ", ""), + ("

    "), + ("", "

    ", ""), + ("
    ", "
    ", ""), + ("", "", ""), + ("", "
    ", "
    "), + ("", "
    ", ""), + ("", "", ""), +]; + +#[test] +fn stack_lookups_do_not_grow_with_depth() { + // Walking the stack would look at about n / 2 elements per token. + let n = 2000; + for &(prefix, nested, tokens) in SHAPES { + let html = format!("{prefix}{}{}", nested.repeat(n), tokens.repeat(n)); + let lookups = lookups_per_token(&html, 2 * n); + assert!( + lookups < 20, + "{prefix}{nested}…{tokens}…: {lookups} lookups per token" + ); + } +}