From 140bedf39fef858771bd4127e75caa7482ed3a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 24 Sep 2026 15:39:16 +0200 Subject: [PATCH 1/3] mem: AXNode tree, query and isIgnore walks -> iterative Follow-up to the SemanticTree change: the CDP accessibility writer still recursed once per DOM level in writeNodeChildren (getFullAXTree / getPartialAXTree), walkQuery (queryAXTree) and isIgnore (chains of generic div/span containers). In a debug build, a 100k-deep chain overflows the 8MB main-thread stack in the tree writer and in isIgnore. The tree and query walks now share an allocation-free pre-order Walker that counts aria-hidden ancestors instead of threading the flag through the recursion. isIgnore splits into a shallow per-node verdict (ignoreSelf) and a TreeWalker pass over generic containers that skips ignored subtrees. Output is byte-identical to the recursive version. --- src/server/cdp/AXNode.zig | 176 ++++++++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 64 deletions(-) diff --git a/src/server/cdp/AXNode.zig b/src/server/cdp/AXNode.zig index af0fc17e0..f98eb2db0 100644 --- a/src/server/cdp/AXNode.zig +++ b/src/server/cdp/AXNode.zig @@ -82,13 +82,11 @@ pub const Writer = struct { fn toJSON(self: *const Writer, w: anytype) !void { try w.beginArray(); if (self.filter != null) { - try self.walkQuery(self.root.dom, false, w); + try self.walkQuery(self.root.dom, w); } else { const root = AXNode.fromNode(self.root.dom); const root_hidden = if (self.root.dom.is(DOMNode.Element)) |el| isHidden(el, self.frame, .{}) else false; - if (try self.writeNode(self.root.id, root, false, root_hidden, w)) { - try self.writeNodeChildren(root, false, w); - } + try self.writeTree(root, root_hidden, w); } return w.endArray(); } @@ -118,28 +116,27 @@ pub const Writer = struct { try w.write(s); } - fn writeNodeChildren(self: *const Writer, parent: AXNode, in_aria_hidden: bool, w: anytype) !void { - // Add ListMarker for listitem elements - if (parent.dom.is(DOMNode.Element)) |parent_el| { - if (parent_el.getTag() == .li) { - try self.writeListMarker(parent.dom, w); + fn writeTree(self: *const Writer, root: AXNode, root_hidden: bool, w: anytype) !void { + var walker: Walker = .init(root.dom); + var axn = root; + var descend = try self.writeNode(self.root.id, root, false, root_hidden, w); + while (true) { + if (descend) { + if (axn.dom.is(DOMNode.Element)) |el| { + if (el.getTag() == .li) { + try self.writeListMarker(axn.dom, w); + } + } } - } - const child_in_aria_hidden = in_aria_hidden or blk: { - const parent_el = parent.dom.is(DOMNode.Element) orelse break :blk false; - break :blk hasAriaHiddenTrue(parent_el); - }; - - var it = parent.dom.childrenIterator(); - const ignore_text = ignoreText(parent.dom); - while (it.next()) |dom_node| { + const dom_node = walker.next(descend) orelse return; + descend = false; switch (dom_node._type) { .cdata => { if (dom_node.is(DOMNode.CData.Text) == null) { continue; } - if (ignore_text) { + if (ignoreText(dom_node._parent.?)) { continue; } }, @@ -147,8 +144,8 @@ pub const Writer = struct { // Prune hidden subtrees entirely (display:none, // visibility:hidden, aria-hidden, hidden, inert). Matches // Chromium: these elements aren't exposed to the AX tree. - const child_el = dom_node.as(DOMNode.Element); - if (child_in_aria_hidden or isHidden(child_el, self.frame, .{ .ancestors = false })) { + const el = dom_node.as(DOMNode.Element); + if (walker.inAriaHidden() or isHidden(el, self.frame, .{ .ancestors = false })) { continue; } }, @@ -156,10 +153,8 @@ pub const Writer = struct { } const node = try self.registry.register(dom_node); - const axn = AXNode.fromNode(node.dom); - if (try self.writeNode(node.id, axn, child_in_aria_hidden, false, w)) { - try self.writeNodeChildren(axn, child_in_aria_hidden, w); - } + axn = AXNode.fromNode(node.dom); + descend = try self.writeNode(node.id, axn, walker.inAriaHidden(), false, w); } } @@ -591,7 +586,7 @@ pub const Writer = struct { } // Skip hidden element children so childIds matches the - // subtree-pruning done in writeNodeChildren. + // subtree-pruning done in writeTree. if (child.is(DOMNode.Element)) |child_el| { if (child_in_aria_hidden or isHidden(child_el, self.frame, .{ .ancestors = false })) { continue; @@ -648,25 +643,21 @@ pub const Writer = struct { // Query-mode walk. Visits every node under `root` (including AX-ignored // ones, per the queryAXTree spec) and defers emission to emitMatch. - fn walkQuery(self: *const Writer, node: *DOMNode, in_aria_hidden: bool, w: anytype) !void { - const axn = AXNode.fromNode(node); - try self.emitMatch(axn, in_aria_hidden, w); + fn walkQuery(self: *const Writer, root: *DOMNode, w: anytype) !void { + var walker: Walker = .init(root); + var node = root; + while (true) { + const axn = AXNode.fromNode(node); + try self.emitMatch(axn, walker.inAriaHidden(), w); - // ,