Skip to content

blitz-dom: connecting or removing a deep subtree overflows the stack

Generated from repros/blitz-dom-subtree-walk-recursive/README.md when the site was built at 7aa23e2.

Nothing it can change: niso connects and removes subtrees through DocumentMutator. Its test of 200k-deep trees (crates/niso-quickjs/tests/deep_trees.rs) runs on a 1 GiB stack.

Known production limit. On an 8 MiB stack (the usual main thread), removing a connected subtree more than about 30k levels deep aborts the process with a stack overflow: 30k works and 40k does not. niso_runtime.h says so at niso_dom_new. niso’s own walks over a tree (cloning, serializing, textContent, isEqualNode) keep stacks of their own, so they handle 200k-deep trees on an 8 MiB stack.

BaseDocument::iter_subtree_mut (packages/blitz-dom/src/traversal.rs:152-170) recurses once per level, and process_added_subtree and process_removed_subtree (mutator.rs) walk every subtree that joins or leaves the document with it. Connecting a 100k-deep chain to the body overflows an 8 MiB stack. Browsers keep these trees working: Gecko and Blink walk subtrees iteratively.

connecting and removing a 100000-deep chain on an 8 MiB stack
expected: steps ["built", "connected", "removed"], exit success
actual: steps ["built"], signal: 6 (SIGABRT) (core dumped)
Terminal window
CARGO_TARGET_DIR=~/.capy/work/repro-target cargo run --release

Same code. Blitz main was at b6ace6a0c33e17b654e8e6d0b14a7e04e83756fe when checked (2026-11-09): iter_subtree_mut_inner still calls itself at traversal.rs:167.

None found in a search of DioxusLabs/blitz issues and PRs for “stack overflow”, “deep tree” and “recursion”.

Title: iter_subtree_mut recurses per level, so connecting or removing a deep subtree overflows the stack

Body:

iter_subtree_mut calls iter_subtree_mut_inner once per level of the tree, and process_added_subtree and process_removed_subtree go through it, so appending or removing a deep enough subtree aborts with a stack overflow: a 100k-deep chain on an 8 MiB stack does. Walking with an explicit stack of (node, next child index) pairs (or taking each node’s children list as today and pushing them) keeps it in constant stack.

use std::process::Command;
use blitz_dom::{DocumentConfig, LocalName, QualName, ns};
use blitz_html::HtmlDocument;
const DEPTH: usize = 100_000;
/// Builds a chain of DEPTH divs outside the document, connects it to the body
/// and removes it again, on a thread with an 8 MiB stack (the main thread's
/// usual size).
fn connect_and_remove_chain() {
let mut doc = HtmlDocument::from_html("<!DOCTYPE html><body></body>", DocumentConfig::default());
let html = doc.root_element().id;
let body = *doc.get_node(html).unwrap().children.last().unwrap();
let mut m = doc.mutate();
let mut top = m.create_element(QualName::new(None, ns!(html), LocalName::from("div")), vec![]);
for _ in 1..DEPTH {
let parent = m.create_element(QualName::new(None, ns!(html), LocalName::from("div")), vec![]);
m.append_children(parent, &[top]);
top = parent;
}
eprintln!("built");
m.append_children(body, &[top]);
eprintln!("connected");
m.remove_node(top);
eprintln!("removed");
}
fn main() {
if std::env::args().nth(1).as_deref() == Some("child") {
std::thread::Builder::new().stack_size(8 << 20).spawn(connect_and_remove_chain).unwrap().join().unwrap();
return;
}
let out = Command::new(std::env::current_exe().unwrap()).arg("child").output().unwrap();
let steps: Vec<String> = String::from_utf8_lossy(&out.stderr).lines().filter(|l| ["built", "connected", "removed"].contains(l)).map(str::to_string).collect();
println!("connecting and removing a {DEPTH}-deep chain on an 8 MiB stack");
println!("expected: steps [\"built\", \"connected\", \"removed\"], exit success");
println!("actual: steps {steps:?}, {}", out.status);
if !out.status.success() {
std::process::exit(1);
}
}