Summary
Frame.removeNode unlinks a node from its parent and updates ID maps / disconnect callbacks, but never frees the Node (or Element) memory. Across the entire src/browser/ tree there are zero call sites that pass a *Node or *Element to Factory.destroy(). Once a Node is allocated, it lives until the Page is destroyed.
For short-lived browse sessions this is invisible. For long-lived sessions that mutate the DOM repeatedly (virtualized lists, infinite scroll, SPAs with route changes, automation that interacts repeatedly with the same page) memory grows monotonically.
Where it happens
src/browser/Frame.zig:2899 (Frame.removeNode):
pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpts) void {
// ... unlink from parent's children list ...
const children = parent._children.?;
switch (children.*) {
.one => |n| {
parent._children = null;
self._factory.destroy(children); // destroys the Children container
},
.list => |list| {
list.remove(&child._child_link);
// ...
self._factory.destroy(list); // destroys the list container
},
}
child._parent = null;
child._child_link = .{};
// ... update live ranges, ID maps, slot assignments ...
// ... fire disconnectedCallback for custom elements ...
// ... unhook style sheets if a <style> is being removed ...
// No call to self._factory.destroy(child) anywhere in this function
// or in any function it calls. The child Node struct stays allocated.
}
I grepped the whole src/browser/ tree for _factory.destroy(.*[Nn]ode) and _factory.destroy(.*Element): zero hits. The only destroy call sites are for small auxiliary structs (the Children container, MessagePort, CSS prop entries, attributes, iterators, X25519 private keys, WorkerGlobalScope) -- never a Node or Element.
Reproducer (synthetic, no external deps)
const initial = document.querySelectorAll('*').length;
for (let i = 0; i < 10000; i++) {
const div = document.createElement('div');
document.body.appendChild(div);
document.body.removeChild(div);
}
const after = document.querySelectorAll('*').length;
console.log({ initial, after, delta: after - initial });
// Lightpanda: delta is 0 (correct: removed nodes are no longer connected)
// BUT process RSS has grown by ~10000 * sizeof(Element + EventTarget + Node + HtmlElement)
getElementsByTagName('*') only sees connected nodes, so the visible DOM count is correct. The leak is in pmap / RSS, not in DOM-API observability.
Reproducer (realistic, what we hit)
A long-lived Puppeteer + Lightpanda session that drives variant rendering across many product pages (the workload that exposed this for us). After ~50 pages of interactive variant rendering, the Lightpanda process accumulates several hundred MB of Element/Node memory beyond what the live DOM accounts for.
This is somewhat masked by our specific case (sibling issue I'm also filing about CSS-in-JS <style> accumulation) because there the <style> elements stay connected and dominate the symptom. But even on pages without that issue, repeated removeChild will leak.
Suggested fix directions
-
Per-<style>-element destroy hook, narrow scope. Doesn't fix the general leak but catches the case our sibling issue tickles.
-
Track detached subtrees and reclaim them on next macrotask boundary. When removeNode is called and the subtree won't be reattached (opts.will_be_reconnected == false), walk the detached subtree and destroy() each Node + its prototypes. The current code already walks the subtree for disconnectedCallback / ID-map cleanup at lines ~2986-3006 -- adding destroy at the end of that walk is a small change. The catch: any JS-side handle to a detached node would become a use-after-free, so this needs the existing reference-counting (RC(u8)) on Nodes, or a "do not destroy if JS still holds it" check.
-
Per-document arena for Node allocations, reset on full document teardown only. Trades freshness-of-reclaim for simplicity. Not a real fix for long-running pages but bounds the worst case to "one page lifetime".
(2) is the proper fix. (1) is a quick partial mitigation if (2) is far away.
Possibly related
Environment
- Lightpanda:
1.0.0-nightly.6240+37391687
- Repo HEAD at investigation time:
2f3a426fb0d3
- OS: macOS 14, arm64
Happy to provide a minimal Zig test that exercises the leak (just create + destroy 10k elements and check _factory._slab allocator stats before/after) if that would help land the fix.
Summary
Frame.removeNodeunlinks a node from its parent and updates ID maps / disconnect callbacks, but never frees theNode(orElement) memory. Across the entiresrc/browser/tree there are zero call sites that pass a*Nodeor*ElementtoFactory.destroy(). Once a Node is allocated, it lives until the Page is destroyed.For short-lived browse sessions this is invisible. For long-lived sessions that mutate the DOM repeatedly (virtualized lists, infinite scroll, SPAs with route changes, automation that interacts repeatedly with the same page) memory grows monotonically.
Where it happens
src/browser/Frame.zig:2899(Frame.removeNode):I grepped the whole
src/browser/tree for_factory.destroy(.*[Nn]ode)and_factory.destroy(.*Element): zero hits. The only destroy call sites are for small auxiliary structs (theChildrencontainer,MessagePort, CSS prop entries, attributes, iterators, X25519 private keys, WorkerGlobalScope) -- never a Node or Element.Reproducer (synthetic, no external deps)
getElementsByTagName('*')only sees connected nodes, so the visible DOM count is correct. The leak is inpmap/RSS, not in DOM-API observability.Reproducer (realistic, what we hit)
A long-lived Puppeteer + Lightpanda session that drives variant rendering across many product pages (the workload that exposed this for us). After ~50 pages of interactive variant rendering, the Lightpanda process accumulates several hundred MB of Element/Node memory beyond what the live DOM accounts for.
This is somewhat masked by our specific case (sibling issue I'm also filing about CSS-in-JS
<style>accumulation) because there the<style>elements stay connected and dominate the symptom. But even on pages without that issue, repeatedremoveChildwill leak.Suggested fix directions
Per-
<style>-element destroy hook, narrow scope. Doesn't fix the general leak but catches the case our sibling issue tickles.Track detached subtrees and reclaim them on next macrotask boundary. When
removeNodeis called and the subtree won't be reattached (opts.will_be_reconnected == false), walk the detached subtree anddestroy()each Node + its prototypes. The current code already walks the subtree fordisconnectedCallback/ ID-map cleanup at lines ~2986-3006 -- adding destroy at the end of that walk is a small change. The catch: any JS-side handle to a detached node would become a use-after-free, so this needs the existing reference-counting (RC(u8)) on Nodes, or a "do not destroy if JS still holds it" check.Per-document arena for Node allocations, reset on full document teardown only. Trades freshness-of-reclaim for simplicity. Not a real fix for long-running pages but bounds the worst case to "one page lifetime".
(2) is the proper fix. (1) is a quick partial mitigation if (2) is far away.
Possibly related
MutationRecord, etc.) but Nodes/Elements aren't on that path.Environment
1.0.0-nightly.6240+373916872f3a426fb0d3Happy to provide a minimal Zig test that exercises the leak (just create + destroy 10k elements and check
_factory._slaballocator stats before/after) if that would help land the fix.