Fix/engine hardening (#249)

* fix(dom): read insert_before prev_sibling after detach to avoid a sibling cycle

insert_before captured the reference node's prev_sibling BEFORE detaching the
node being inserted. When page JS inserts a node before its own immediate
previous sibling (a no-op reorder frameworks do constantly), detach moves that
pointer, so the stale prev_id spliced new.next_sibling = new. The resulting
sibling self-cycle made descendants() inner next_sibling walk loop forever in
native Rust, uninterruptible by tokio or the V8 watchdog. This hung ebay.com,
found by a 1500-page crash/hang sweep.

Read prev_sibling after detach so it reflects the post-detach tree. Cap the inner
sibling-collection loops in descendants() and log when a descendants cap fires,
as defense in depth so a sibling cycle from any path cannot hang the engine. Add
test_insert_before_previous_sibling_no_cycle.

ebay.com now renders (192K chars) instead of hanging. Verified: 33/33 obscura-dom
tests, obstacle course 33/33.

* fix(cli): settle async --eval work before reading the page (#248)

--eval ran after the selector check and returned its value immediately, so an
expression that only kicks off async work (e.g. fetch().then(t => DOM write))
returned the still-pending Promise as {} and any --selector/--dump were ignored.

When --eval is combined with --selector and/or --dump, run the eval to kick off
its async work, settle the event loop again so it completes, then read the page
(selector wait + dump) instead of returning the Promise. A bare --eval still
returns its value unchanged, so synchronous expressions are not affected, and the
eval stays bounded by evaluate_with_timeout.

dump becomes Option<DumpFormat> so an explicit --dump is distinguishable from the
default. Verified: the repro now returns the fetched content, a plain
JSON.stringify eval is unchanged, 37 + 16 cli tests pass.
This commit is contained in:
SGavrl authored and GitHub committed 2026-06-05 21:07:08 +02:00
1 parent 98b62e53e8
commit 32ddf93885
2 files changed
+101 -27

No files matched your search

+58 -7
View File
@@ -306,14 +306,10 @@ impl DomTree {
if existing_id == new_sibling_id {
return;
}
let (parent_id, prev_id) = {
let parent_id = {
let inner = self.inner.borrow();
let node = match inner.nodes.get(existing_id.index()).and_then(|n| n.as_ref()) {
Some(n) => n,
None => return,
};
match node.parent {
Some(p) => (p, node.prev_sibling),
match inner.nodes.get(existing_id.index()).and_then(|n| n.as_ref()).and_then(|n| n.parent) {
Some(p) => p,
None => return,
}
};
@@ -349,6 +345,17 @@ impl DomTree {
self.detach(new_sibling_id);
// Read existing's prev AFTER detaching new. If new was existing's
// immediate previous sibling, detach moved that pointer; using the
// pre-detach value would splice new.next_sibling = new (a self-cycle)
// and hang every later sibling walk. This is what hung ebay.com.
let prev_id = {
let inner = self.inner.borrow();
inner.nodes.get(existing_id.index())
.and_then(|n| n.as_ref())
.and_then(|n| n.prev_sibling)
};
let mut inner = self.inner.borrow_mut();
if let Some(Some(node)) = inner.nodes.get_mut(new_sibling_id.index()) {
@@ -496,6 +503,10 @@ impl DomTree {
let mut children_to_push = Vec::new();
while let Some(child_id) = first {
children_to_push.push(child_id);
if children_to_push.len() > inner.nodes.len() {
eprintln!("obscura: sibling-chain cap hit at node {} - cycle", node_id.index());
break;
}
first = inner.nodes.get(child_id.index())
.and_then(|n| n.as_ref())
.and_then(|n| n.next_sibling);
@@ -512,6 +523,11 @@ impl DomTree {
// than grow the stack and result forever and wedge the engine. On a
// valid tree this bound is never reached, so the hot path is unchanged.
if result.len() > inner.nodes.len() {
eprintln!(
"obscura: descendants() cap hit at node {} ({} nodes) - tree has a cycle",
node_id.index(),
inner.nodes.len()
);
break;
}
@@ -521,6 +537,10 @@ impl DomTree {
let mut children_to_push = Vec::new();
while let Some(child_id) = child {
children_to_push.push(child_id);
if children_to_push.len() > inner.nodes.len() {
eprintln!("obscura: sibling-chain cap hit at node {} - cycle", current.index());
break;
}
child = inner.nodes.get(child_id.index())
.and_then(|n| n.as_ref())
.and_then(|n| n.next_sibling);
@@ -853,6 +873,37 @@ mod tests {
assert_eq!(tree.descendants(doc).len(), before);
}
#[test]
fn test_insert_before_previous_sibling_no_cycle() {
// Inserting a node before its own immediate previous sibling is a no-op
// reorder that frameworks do constantly. It used to splice
// next_sibling = self via a prev_id captured before detach, hanging every
// later sibling walk (this hung ebay.com). The result must stay a
// well-formed [a, b] with no cycle.
let tree = DomTree::new();
let doc = tree.document();
let mk = |n: &str| {
tree.new_node(NodeData::Element {
name: QualName::new(None, ns!(html), LocalName::from(n)),
attrs: vec![],
template_contents: None,
mathml_annotation_xml_integration_point: false,
})
};
let parent = mk("div");
let a = mk("a");
let b = mk("b");
tree.append_child(doc, parent);
tree.append_child(parent, a);
tree.append_child(parent, b); // parent -> [a, b]
// a is already b's previous sibling; this reorder must not create a cycle.
tree.insert_before(b, a);
let kids = tree.descendants(parent);
assert_eq!(kids, vec![a, b], "order preserved, no cycle");
}
#[test]
fn test_append_text_merges() {
let tree = DomTree::new();