Files
obscura/crates/obscura-dom/src/selector.rs
T
SGavrl fd2421940b Perf/cdp fetch cache and cookie alloc (#216)
* perf: cache HTTP clients + selector parsing + exponential wait backoff

Three low-risk wins discovered while reading the hot paths.

1. op_fetch_url cached its reqwest::Client. Previously every JS-side
   fetch(), XHR, and dynamic script load built a fresh client, which
   re-loaded TLS roots and started with an empty connection pool. On
   an asset-heavy page with 30+ subresources this added ~150ms of
   wasted setup with zero connection reuse. The cache is a process-
   wide map keyed by proxy URL (empty string for no-proxy), so the
   first fetch on each proxy pays the build cost once and every
   subsequent fetch reuses the same pool.
   Also bumped pool_idle_timeout to 5 minutes and tcp_keepalive to
   60s so connections stay warm for SPA-heavy workloads that hit
   the same origin dozens of times across a navigation.

2. DomTree::parse_selector now caches parsed selectors in a thread-
   local HashMap (cap 256). querySelector / querySelectorAll re-
   parsed the selector string every call before; batch DOM access
   patterns (an agent scraping a table row-by-row, a framework
   polling for elements) paid the cssparser cost N times.

3. browser_wait_for / browser_wait_for_text now exponentially back
   off (5 -> 10 -> 20 -> ... -> 200 ms). The old fixed 200ms tick
   meant a selector that appeared 30ms after the call still took
   the next full 200ms tick to be detected. Worst case is still
   200ms per check, but the median wait drops sharply for fast
   appearances.

28/28 integration tests pass. Asset-heavy bench (rust-lang/rust,
docs.stripe.com, etc) doesn't regress.

* perf: module loader client cache, fetch concurrency cap, snapshot hide list, cookie alloc

Round two of perf wins after a deep code audit.

1. ES module loader (module_loader.rs) was building a fresh
   reqwest::Client on every dynamic import. Modern SPAs (Next, Vite)
   load 20-50 chunks via dynamic import; each one used to spin up an
   empty connection pool with fresh TLS init. Now reuses the
   process-wide FETCH_CLIENT_CACHE that op_fetch_url already uses
   (the cached_request_client helper). Saves ~30-100ms on chunk-
   heavy first navigations.

2. Script and CSS fetches in execute_scripts used futures::join_all
   with unbounded concurrency. A 100-script page would open 100 TCP
   sockets at once, exhausting the connection pool and the OS
   ephemeral port range. Capped at 16 in-flight (well above the per-
   host pool ceiling real Chrome uses).

3. bootstrap.js __obscura_init() ran Object.keys(globalThis).filter()
   on every page setup to find internals to hide. The set of keys
   is static at snapshot-build time, so we now precompute it once
   (top-level statement just below the function) and __obscura_init
   just iterates the precomputed array. Was 5-40ms per page on heavy
   SPAs (1000+ globals); now ~0.2ms per page.

4. CookieJar::domain_matches allocated 2 lowercase Strings and a
   format! per call. The lookup runs per fetch per cookie domain;
   on a heavily-cookied site with 100 cookies across 30 domains
   that was thousands of allocations per page. Now zero-alloc with
   eq_ignore_ascii_case and char-boundary checks.
2026-06-01 00:00:22 +02:00

724 lines
23 KiB
Rust

use cssparser::{CowRcStr, ToCss};
use html5ever::{LocalName, Namespace};
use precomputed_hash::PrecomputedHash;
use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint};
use selectors::bloom::BloomFilter;
use selectors::context::QuirksMode;
use selectors::matching::{
ElementSelectorFlags, MatchingContext, MatchingForInvalidation, MatchingMode,
NeedsSelectorFlags,
};
use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
use selectors::{Element, OpaqueElement, SelectorList};
use selectors::visitor::SelectorVisitor;
use crate::tree::{DomTree, NodeData, NodeId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObscuraSelector;
impl parser::SelectorImpl for ObscuraSelector {
type ExtraMatchingData<'a> = ();
type AttrValue = CssString;
type Identifier = CssString;
type LocalName = CssLocalName;
type NamespaceUrl = CssNamespace;
type NamespacePrefix = CssString;
type BorrowedLocalName = CssLocalName;
type BorrowedNamespaceUrl = CssNamespace;
type NonTSPseudoClass = PseudoClass;
type PseudoElement = PseudoElement;
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CssString(pub String);
impl<'a> From<&'a str> for CssString {
fn from(s: &'a str) -> Self {
CssString(s.to_string())
}
}
impl AsRef<str> for CssString {
fn as_ref(&self) -> &str {
&self.0
}
}
impl ToCss for CssString {
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
cssparser::serialize_string(&self.0, dest)
}
}
impl PrecomputedHash for CssString {
fn precomputed_hash(&self) -> u32 {
let mut h: u32 = 5381;
for b in self.0.as_bytes() {
h = h.wrapping_mul(33).wrapping_add(*b as u32);
}
h
}
}
impl Default for CssString {
fn default() -> Self {
CssString(String::new())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CssLocalName(pub LocalName);
impl<'a> From<&'a str> for CssLocalName {
fn from(s: &'a str) -> Self {
CssLocalName(LocalName::from(s))
}
}
impl ToCss for CssLocalName {
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
dest.write_str(&self.0)
}
}
impl PrecomputedHash for CssLocalName {
fn precomputed_hash(&self) -> u32 {
self.0.precomputed_hash()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub struct CssNamespace(pub Namespace);
impl PrecomputedHash for CssNamespace {
fn precomputed_hash(&self) -> u32 {
self.0.precomputed_hash()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PseudoClass {
Hover,
Active,
Focus,
Enabled,
Disabled,
Checked,
}
impl parser::NonTSPseudoClass for PseudoClass {
type Impl = ObscuraSelector;
fn is_active_or_hover(&self) -> bool {
matches!(self, PseudoClass::Hover | PseudoClass::Active)
}
fn is_user_action_state(&self) -> bool {
matches!(
self,
PseudoClass::Hover | PseudoClass::Active | PseudoClass::Focus
)
}
fn visit<V>(&self, _visitor: &mut V) -> bool
where
V: SelectorVisitor<Impl = Self::Impl>,
{
true
}
}
impl ToCss for PseudoClass {
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
match self {
PseudoClass::Hover => dest.write_str(":hover"),
PseudoClass::Active => dest.write_str(":active"),
PseudoClass::Focus => dest.write_str(":focus"),
PseudoClass::Enabled => dest.write_str(":enabled"),
PseudoClass::Disabled => dest.write_str(":disabled"),
PseudoClass::Checked => dest.write_str(":checked"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PseudoElement {
Before,
After,
}
impl parser::PseudoElement for PseudoElement {
type Impl = ObscuraSelector;
}
impl ToCss for PseudoElement {
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
match self {
PseudoElement::Before => dest.write_str("::before"),
PseudoElement::After => dest.write_str("::after"),
}
}
}
pub struct ObscuraSelectorParser;
impl<'i> parser::Parser<'i> for ObscuraSelectorParser {
type Impl = ObscuraSelector;
type Error = SelectorParseErrorKind<'i>;
fn parse_non_ts_pseudo_class(
&self,
_location: cssparser::SourceLocation,
name: CowRcStr<'i>,
) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
match name.as_ref() {
"hover" => Ok(PseudoClass::Hover),
"active" => Ok(PseudoClass::Active),
"focus" => Ok(PseudoClass::Focus),
"enabled" => Ok(PseudoClass::Enabled),
"disabled" => Ok(PseudoClass::Disabled),
"checked" => Ok(PseudoClass::Checked),
_ => Err(cssparser::ParseError {
kind: cssparser::ParseErrorKind::Custom(
SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
),
location: _location,
}),
}
}
}
#[derive(Clone, Copy)]
pub struct DomElement<'a> {
pub tree: &'a DomTree,
pub node_id: NodeId,
}
impl<'a> DomElement<'a> {
pub fn new(tree: &'a DomTree, node_id: NodeId) -> Self {
DomElement { tree, node_id }
}
}
impl<'a> std::fmt::Debug for DomElement<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "DomElement({:?})", self.node_id)
}
}
impl<'a> PartialEq for DomElement<'a> {
fn eq(&self, other: &Self) -> bool {
self.node_id == other.node_id
}
}
impl<'a> Eq for DomElement<'a> {}
impl<'a> Element for DomElement<'a> {
type Impl = ObscuraSelector;
fn opaque(&self) -> OpaqueElement {
OpaqueElement::new(self)
}
fn parent_element(&self) -> Option<Self> {
let node = self.tree.get_node(self.node_id)?;
let parent_id = node.parent?;
let parent = self.tree.get_node(parent_id)?;
if parent.is_element() {
Some(DomElement::new(self.tree, parent_id))
} else {
None
}
}
fn parent_node_is_shadow_root(&self) -> bool {
false
}
fn containing_shadow_host(&self) -> Option<Self> {
None
}
fn pseudo_element_originating_element(&self) -> Option<Self> {
None
}
fn is_pseudo_element(&self) -> bool {
false
}
fn prev_sibling_element(&self) -> Option<Self> {
let node = self.tree.get_node(self.node_id)?;
let mut current = node.prev_sibling;
while let Some(sibling_id) = current {
let sibling = self.tree.get_node(sibling_id)?;
if sibling.is_element() {
return Some(DomElement::new(self.tree, sibling_id));
}
current = sibling.prev_sibling;
}
None
}
fn next_sibling_element(&self) -> Option<Self> {
let node = self.tree.get_node(self.node_id)?;
let mut current = node.next_sibling;
while let Some(sibling_id) = current {
let sibling = self.tree.get_node(sibling_id)?;
if sibling.is_element() {
return Some(DomElement::new(self.tree, sibling_id));
}
current = sibling.next_sibling;
}
None
}
fn first_element_child(&self) -> Option<Self> {
let node = self.tree.get_node(self.node_id)?;
let mut current = node.first_child;
while let Some(child_id) = current {
let child = self.tree.get_node(child_id)?;
if child.is_element() {
return Some(DomElement::new(self.tree, child_id));
}
current = child.next_sibling;
}
None
}
fn is_html_element_in_html_document(&self) -> bool {
self.tree
.with_node(self.node_id, |n| {
n.as_element()
.map(|name| name.ns == ns!(html))
.unwrap_or(false)
})
.unwrap_or(false)
}
fn has_local_name(&self, local_name: &CssLocalName) -> bool {
self.tree
.with_node(self.node_id, |n| {
n.as_element()
.map(|name| name.local == local_name.0)
.unwrap_or(false)
})
.unwrap_or(false)
}
fn has_namespace(&self, ns: &CssNamespace) -> bool {
self.tree
.with_node(self.node_id, |n| {
n.as_element()
.map(|name| name.ns == ns.0)
.unwrap_or(false)
})
.unwrap_or(false)
}
fn is_same_type(&self, other: &Self) -> bool {
let self_name = self.tree.with_node(self.node_id, |n| {
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
}).flatten();
let other_name = self.tree.with_node(other.node_id, |n| {
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
}).flatten();
match (self_name, other_name) {
(Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns,
_ => false,
}
}
fn attr_matches(
&self,
ns: &NamespaceConstraint<&CssNamespace>,
local_name: &CssLocalName,
operation: &AttrSelectorOperation<&CssString>,
) -> bool {
self.tree
.with_node(self.node_id, |node| {
let attrs = match node.attrs() {
Some(a) => a,
None => return false,
};
attrs.iter().any(|attr| {
let ns_match = match ns {
NamespaceConstraint::Any => true,
NamespaceConstraint::Specific(expected_ns) => attr.name.ns == expected_ns.0,
};
if !ns_match || attr.name.local != local_name.0 {
return false;
}
operation.eval_str(&attr.value)
})
})
.unwrap_or(false)
}
fn has_attr_in_no_namespace(&self, local_name: &CssLocalName) -> bool {
self.tree
.with_node(self.node_id, |node| {
node.attrs()
.map(|attrs| {
attrs.iter().any(|a| {
a.name.ns == html5ever::ns!()
&& a.name.local == local_name.0
})
})
.unwrap_or(false)
})
.unwrap_or(false)
}
fn match_non_ts_pseudo_class(
&self,
_pc: &PseudoClass,
_context: &mut MatchingContext<'_, Self::Impl>,
) -> bool {
false
}
fn match_pseudo_element(
&self,
_pe: &PseudoElement,
_context: &mut MatchingContext<'_, Self::Impl>,
) -> bool {
false
}
fn apply_selector_flags(&self, _flags: ElementSelectorFlags) {}
fn is_link(&self) -> bool {
self.tree
.with_node(self.node_id, |n| {
n.as_element()
.map(|name| {
matches!(name.local.as_ref(), "a" | "area" | "link")
&& n.get_attribute("href").is_some()
})
.unwrap_or(false)
})
.unwrap_or(false)
}
fn is_html_slot_element(&self) -> bool {
false
}
fn assigned_slot(&self) -> Option<Self> {
None
}
fn has_id(&self, id: &CssString, case_sensitivity: CaseSensitivity) -> bool {
self.tree
.with_node(self.node_id, |n| {
n.get_attribute("id")
.map(|value| case_sensitivity.eq(value.as_bytes(), id.0.as_bytes()))
.unwrap_or(false)
})
.unwrap_or(false)
}
fn has_class(&self, name: &CssString, case_sensitivity: CaseSensitivity) -> bool {
self.tree
.with_node(self.node_id, |n| {
n.get_attribute("class")
.map(|class_attr| {
class_attr
.split_whitespace()
.any(|c| case_sensitivity.eq(c.as_bytes(), name.0.as_bytes()))
})
.unwrap_or(false)
})
.unwrap_or(false)
}
fn has_custom_state(&self, _name: &CssString) -> bool {
false
}
fn imported_part(&self, _name: &CssString) -> Option<CssString> {
None
}
fn is_part(&self, _name: &CssString) -> bool {
false
}
fn is_empty(&self) -> bool {
self.tree
.with_node(self.node_id, |node| {
let mut child = node.first_child;
while let Some(child_id) = child {
if let Some(child_node) = self.tree.get_node(child_id) {
match &child_node.data {
NodeData::Element { .. } => return false,
NodeData::Text { contents } if !contents.is_empty() => return false,
_ => {}
}
child = child_node.next_sibling;
} else {
break;
}
}
true
})
.unwrap_or(true)
}
fn is_root(&self) -> bool {
self.tree
.with_node(self.node_id, |n| {
n.parent
.map(|parent_id| {
self.tree
.with_node(parent_id, |p| p.is_document())
.unwrap_or(false)
})
.unwrap_or(false)
})
.unwrap_or(false)
}
fn ignores_nth_child_selectors(&self) -> bool {
false
}
fn add_element_unique_hashes(&self, _filter: &mut BloomFilter) -> bool {
false
}
}
// Thread-local LRU cache of parsed selectors. Without this every
// querySelector / querySelectorAll re-parses the selector string;
// for batch-heavy DOM access (agent scraping a table, framework
// repeatedly polling for elements) the parse cost adds up to tens
// of ms per page. Cap of 256 entries fits a typical page's distinct
// selectors without unbounded memory growth.
thread_local! {
static SELECTOR_CACHE: std::cell::RefCell<
std::collections::HashMap<String, std::sync::Arc<SelectorList<ObscuraSelector>>>,
> = std::cell::RefCell::new(std::collections::HashMap::with_capacity(64));
}
const SELECTOR_CACHE_CAP: usize = 256;
fn parse_selector_uncached(selector: &str) -> Result<SelectorList<ObscuraSelector>, String> {
let mut parser_input = cssparser::ParserInput::new(selector);
let mut parser = cssparser::Parser::new(&mut parser_input);
SelectorList::parse(&ObscuraSelectorParser, &mut parser, ParseRelative::No)
.map_err(|e| format!("Failed to parse selector '{}': {:?}", selector, e))
}
pub fn parse_selector(selector: &str) -> Result<SelectorList<ObscuraSelector>, String> {
// Hot path: cached. Cold path: parse + insert.
if let Some(cached) = SELECTOR_CACHE.with(|c| c.borrow().get(selector).cloned()) {
return Ok((*cached).clone());
}
let parsed = parse_selector_uncached(selector)?;
let cached = std::sync::Arc::new(parsed.clone());
SELECTOR_CACHE.with(|c| {
let mut cache = c.borrow_mut();
// Crude eviction: if at cap, dump the whole table. A real LRU
// would be more memory-friendly but selectors are small and 256
// is comfortably above a single page's distinct-selector count.
if cache.len() >= SELECTOR_CACHE_CAP {
cache.clear();
}
cache.insert(selector.to_string(), cached);
});
Ok(parsed)
}
impl DomTree {
pub fn query_selector(&self, selector: &str) -> Result<Option<NodeId>, String> {
self.query_selector_from(self.document(), selector)
}
pub fn query_selector_all(&self, selector: &str) -> Result<Vec<NodeId>, String> {
self.query_selector_all_from(self.document(), selector)
}
pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result<Option<NodeId>, String> {
let selector_list = parse_selector(selector)?;
let mut caches = selectors::context::SelectorCaches::default();
let mut context = MatchingContext::new(
MatchingMode::Normal,
None,
&mut caches,
QuirksMode::NoQuirks,
NeedsSelectorFlags::No,
MatchingForInvalidation::No,
);
for desc_id in self.descendants(root) {
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
if is_element {
let element = DomElement::new(self, desc_id);
if selectors::matching::matches_selector_list(
&selector_list,
&element,
&mut context,
) {
return Ok(Some(desc_id));
}
}
}
Ok(None)
}
pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result<Vec<NodeId>, String> {
let selector_list = parse_selector(selector)?;
let mut caches = selectors::context::SelectorCaches::default();
let mut context = MatchingContext::new(
MatchingMode::Normal,
None,
&mut caches,
QuirksMode::NoQuirks,
NeedsSelectorFlags::No,
MatchingForInvalidation::No,
);
let mut results = Vec::new();
for desc_id in self.descendants(root) {
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
if is_element {
let element = DomElement::new(self, desc_id);
if selectors::matching::matches_selector_list(
&selector_list,
&element,
&mut context,
) {
results.push(desc_id);
}
}
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use crate::tree_sink::parse_html;
#[test]
fn test_query_selector_tag() {
let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>");
let result = tree.query_selector("h1").unwrap();
assert!(result.is_some());
let node = tree.get_node(result.unwrap()).unwrap();
assert_eq!(node.as_element().unwrap().local.as_ref(), "h1");
}
#[test]
fn test_query_selector_class() {
let tree =
parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
let result = tree.query_selector(".foo").unwrap();
assert!(result.is_some());
let node = tree.get_node(result.unwrap()).unwrap();
assert_eq!(node.get_attribute("class"), Some("foo bar"));
}
#[test]
fn test_query_selector_id() {
let tree = parse_html(r#"<div id="main">Content</div>"#);
let result = tree.query_selector("#main").unwrap();
assert!(result.is_some());
}
#[test]
fn test_query_selector_all() {
let tree = parse_html("<ul><li>1</li><li>2</li><li>3</li></ul>");
let results = tree.query_selector_all("li").unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn test_query_selector_descendant() {
let tree =
parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
let result = tree.query_selector("#outer span").unwrap();
assert!(result.is_some());
let node = tree.get_node(result.unwrap()).unwrap();
assert_eq!(node.as_element().unwrap().local.as_ref(), "span");
}
#[test]
fn test_query_selector_attribute() {
let tree = parse_html(
r#"<input type="text" name="user"><input type="password" name="pass">"#,
);
let result = tree.query_selector(r#"input[type="password"]"#).unwrap();
assert!(result.is_some());
let node = tree.get_node(result.unwrap()).unwrap();
assert_eq!(node.get_attribute("name"), Some("pass"));
}
#[test]
fn test_query_selector_no_match() {
let tree = parse_html("<div>Hello</div>");
let result = tree.query_selector("span").unwrap();
assert!(result.is_none());
}
#[test]
fn test_query_selector_complex() {
let tree = parse_html(
r#"<div class="container">
<ul class="list">
<li class="item active">First</li>
<li class="item">Second</li>
<li class="item active">Third</li>
</ul>
</div>"#,
);
let results = tree.query_selector_all(".list .item.active").unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_query_selector_all_from_scopes_to_subtree() {
let tree = parse_html(
r#"<div id="a"><span class="x">in a</span></div><div id="b"><span class="x">in b</span></div>"#,
);
let a = tree.get_element_by_id("a").expect("div#a");
let b = tree.get_element_by_id("b").expect("div#b");
// Document-rooted: sees both spans.
assert_eq!(tree.query_selector_all(".x").unwrap().len(), 2);
// Scoped to #a: only its descendant span.
let in_a = tree.query_selector_all_from(a, ".x").unwrap();
assert_eq!(in_a.len(), 1);
let in_b = tree.query_selector_all_from(b, ".x").unwrap();
assert_eq!(in_b.len(), 1);
assert_ne!(in_a[0], in_b[0]);
}
#[test]
fn test_query_selector_from_returns_first_in_subtree_only() {
let tree = parse_html(
r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#,
);
let s = tree.get_element_by_id("s").expect("section#s");
// Scoped to #s: skip the outside paragraph; return the first inside.
let first_in_s = tree.query_selector_from(s, "p").unwrap().expect("a p inside");
assert_eq!(tree.text_content(first_in_s), "first");
}
#[test]
fn test_query_selector_from_excludes_self() {
// The root element itself must not match its own scoped query, per
// the spec: querySelector matches descendants only.
let tree = parse_html(r#"<div id="root" class="x"><span>child</span></div>"#);
let root = tree.get_element_by_id("root").expect("div#root");
// Only descendants are candidates: `.x` on root finds nothing.
assert!(tree.query_selector_from(root, ".x").unwrap().is_none());
// `span` finds the child.
assert!(tree.query_selector_from(root, "span").unwrap().is_some());
}
}