diff --git a/src/RecyclingAllocator.zig b/src/RecyclingAllocator.zig new file mode 100644 index 000000000..663d8c10b --- /dev/null +++ b/src/RecyclingAllocator.zig @@ -0,0 +1,267 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Allocator for the Factory. Almost everything allocated here lives until the +// child arena goes: DOM nodes are never destroyed. The frees that do happen +// are mostly GC-finalized wrappers (collections, iterators), in batches. +// +// So an allocation is a plain allocation from the child (an arena), and a freed +// slot goes on the free list for its class, to be handed to the next +// allocation of that class. The free lists are only looked at once something +// has been freed. Nothing is ever returned to the child. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const Allocator = std.mem.Allocator; +const Alignment = std.mem.Alignment; + +const RecyclingAllocator = @This(); + +child_allocator: Allocator, + +// Slots currently sitting on a free list. While 0, alloc skips the lookup. +free_slots: usize = 0, +free_lists: std.array_hash_map.Custom(Class, ?[*]u8, Class.Context, false) = .empty, + +// Every slot can hold the free-list link. Alignments below usize's share a +// class, e.g. a 34-byte string and a 40-byte node recycle each other. +const Class = packed struct(u64) { + alignment: Alignment, + size: std.meta.Int(.unsigned, 64 - @bitSizeOf(Alignment)), + + fn of(len: usize, alignment: Alignment) Class { + const class_alignment = Alignment.max(alignment, .of(usize)); + return .{ + .size = @intCast(class_alignment.forward(@max(len, @sizeOf(usize)))), + .alignment = class_alignment, + }; + } + + const Context = struct { + pub fn hash(_: Context, class: Class) u32 { + // A few dozen classes per page: a multiply spreads them well enough. + return @truncate((@as(u64, @bitCast(class)) *% 0x9e3779b97f4a7c15) >> 32); + } + + pub fn eql(_: Context, a: Class, b: Class, _: usize) bool { + return a == b; + } + }; +}; + +const Link = *align(1) ?[*]u8; + +pub fn init(child_allocator: Allocator) RecyclingAllocator { + return .{ .child_allocator = child_allocator }; +} + +pub fn allocator(self: *RecyclingAllocator) Allocator { + return .{ + .ptr = self, + .vtable = &.{ + .alloc = alloc, + .free = free, + .remap = Allocator.noRemap, + .resize = Allocator.noResize, + }, + }; +} + +fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 { + const self: *RecyclingAllocator = @ptrCast(@alignCast(ctx)); + const class = Class.of(len, alignment); + + if (self.free_slots > 0) { + if (self.free_lists.getPtr(class)) |head| { + if (head.*) |slot| { + head.* = @as(Link, @ptrCast(slot)).*; + self.free_slots -= 1; + return slot; + } + } + } + + return self.child_allocator.rawAlloc(class.size, class.alignment, ret_addr); +} + +fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, _: usize) void { + const self: *RecyclingAllocator = @ptrCast(@alignCast(ctx)); + const class = Class.of(memory.len, alignment); + + // On OOM the slot just isn't recycled; the child still owns it. + const gop = self.free_lists.getOrPut(self.child_allocator, class) catch return; + if (gop.found_existing == false) { + gop.value_ptr.* = null; + } + + const slot = memory.ptr; + if (comptime lp.IS_DEBUG) { + // Make a use-after-free read garbage rather than the old object. + @memset(slot[0..class.size], undefined); + } + @as(Link, @ptrCast(slot)).* = gop.value_ptr.*; + gop.value_ptr.* = slot; + self.free_slots += 1; +} + +const testing = @import("testing.zig"); +const TestAllocator = struct { + arena: std.heap.ArenaAllocator, + recycling: RecyclingAllocator, + + fn init(self: *TestAllocator) void { + self.arena = .init(testing.allocator); + self.recycling = .init(self.arena.allocator()); + } + + fn deinit(self: *TestAllocator) void { + self.arena.deinit(); + } +}; + +test "RecyclingAllocator: allocation without frees never touches the free lists" { + var t: TestAllocator = undefined; + t.init(); + defer t.deinit(); + const recycler = t.recycling.allocator(); + + for (0..100) |i| { + const ptr = try recycler.alloc(u8, 24 + i); + @memset(ptr, 42); + } + + try testing.expectEqual(0, t.recycling.free_lists.count()); +} + +test "RecyclingAllocator: reuses freed memory of the same class" { + var t: TestAllocator = undefined; + t.init(); + defer t.deinit(); + const recycler = t.recycling.allocator(); + + const ptr1 = try recycler.alloc(u8, 64); + recycler.free(ptr1); + + const ptr2 = try recycler.alloc(u8, 64); + try testing.expect(ptr1.ptr == ptr2.ptr); + + // free list is LIFO + const a = try recycler.alloc(u8, 64); + const b = try recycler.alloc(u8, 64); + recycler.free(a); + recycler.free(b); + try testing.expectEqual(2, t.recycling.free_slots); + try testing.expect(b.ptr == (try recycler.alloc(u8, 64)).ptr); + try testing.expect(a.ptr == (try recycler.alloc(u8, 64)).ptr); + try testing.expectEqual(0, t.recycling.free_slots); +} + +test "RecyclingAllocator: different classes don't interfere" { + var t: TestAllocator = undefined; + t.init(); + defer t.deinit(); + const recycler = t.recycling.allocator(); + + const ptr_64 = try recycler.alloc(u8, 64); + recycler.free(ptr_64); + + const ptr_128 = try recycler.alloc(u8, 128); + try testing.expect(ptr_64.ptr != ptr_128.ptr); + + try testing.expect(ptr_64.ptr == (try recycler.alloc(u8, 64)).ptr); +} + +test "RecyclingAllocator: sizes share a class after rounding" { + var t: TestAllocator = undefined; + t.init(); + defer t.deinit(); + const recycler = t.recycling.allocator(); + + // 34 and 40 bytes both round to 40, even at alignment 1 + const small = try recycler.alloc(u8, 34); + recycler.free(small); + const ptr = try recycler.alignedAlloc(u8, .@"8", 40); + try testing.expect(small.ptr == ptr.ptr); + + // anything smaller than a pointer still fits the free-list link + const tiny = try recycler.alloc(u8, 1); + recycler.free(tiny); + try testing.expect(tiny.ptr == (try recycler.alloc(u8, 3)).ptr); + + // u64 and u32 share the 8-byte class + const int64 = try recycler.create(u64); + recycler.destroy(int64); + try testing.expectEqual(@intFromPtr(int64), @intFromPtr(try recycler.create(u32))); +} + +test "RecyclingAllocator: never hands out a slot with weaker alignment" { + var t: TestAllocator = undefined; + t.init(); + defer t.deinit(); + const recycler = t.recycling.allocator(); + + const ptr8 = try recycler.alignedAlloc(u8, .@"8", 64); + recycler.free(ptr8); + + const ptr32 = try recycler.alignedAlloc(u8, .@"32", 64); + try testing.expect(std.mem.isAligned(@intFromPtr(ptr32.ptr), 32)); + try testing.expect(ptr8.ptr != ptr32.ptr); +} + +test "RecyclingAllocator: no resize support" { + var t: TestAllocator = undefined; + t.init(); + defer t.deinit(); + const recycler = t.recycling.allocator(); + + const slice = try recycler.alloc(u8, 100); + try testing.expect(!recycler.resize(slice, 90)); + try testing.expect(!recycler.resize(slice, 200)); +} + +test "RecyclingAllocator: stress" { + var t: TestAllocator = undefined; + t.init(); + defer t.deinit(); + const recycler = t.recycling.allocator(); + + var prng = std.Random.DefaultPrng.init(0); + const random = prng.random(); + + var live: std.ArrayList([]u8) = .empty; + defer live.deinit(testing.allocator); + + var frees: usize = 0; + for (0..2000) |_| { + if (random.boolean() and live.items.len > 0) { + const slice = live.swapRemove(random.uintLessThan(usize, live.items.len)); + // still holds what we wrote: the slot wasn't handed out twice + for (slice) |b| try testing.expectEqual(@as(u8, @truncate(slice.len)), b); + recycler.free(slice); + frees += 1; + } else { + const slice = try recycler.alloc(u8, random.uintAtMost(usize, 256) + 1); + @memset(slice, @truncate(slice.len)); + try live.append(testing.allocator, slice); + } + } + + // some frees were handed back out + try testing.expect(t.recycling.free_slots < frees); +} diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 56b7015f1..dd3280c8b 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -21,7 +21,7 @@ const lp = @import("lightpanda"); const reflect = @import("reflect.zig"); -const SlabAllocator = @import("../slab.zig").SlabAllocator; +const RecyclingAllocator = @import("../RecyclingAllocator.zig"); const Page = @import("Page.zig"); const Frame = @import("Frame.zig"); @@ -52,7 +52,7 @@ const Factory = @This(); _page: *Page, _arena: Allocator, -_slab: SlabAllocator, +_recycling: RecyclingAllocator, _documents: std.ArrayList(u32) = .empty, // ids of the documents _we_ created _document_registry: *DocumentRegistry, // &browser.documents @@ -60,7 +60,7 @@ pub fn init(page: *Page, arena: Allocator, document_registry: *DocumentRegistry) return .{ ._page = page, ._arena = arena, - ._slab = SlabAllocator.init(arena, 128), + ._recycling = .init(arena), ._document_registry = document_registry, }; } @@ -72,7 +72,7 @@ pub fn deinit(self: *Factory) void { } pub fn storageAllocator(self: *Factory) Allocator { - return self._slab.allocator(); + return self._recycling.allocator(); } fn registerDocument(self: *Factory, doc: *Document) !u32 { @@ -83,7 +83,7 @@ fn registerDocument(self: *Factory, doc: *Document) !u32 { // this is a root object pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { - return self.eventTargetWithAllocator(self._slab.allocator(), child); + return self.eventTargetWithAllocator(self._recycling.allocator(), child); } pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) { @@ -324,7 +324,7 @@ pub fn abstractRange(_: *const Factory, arena: *lp.Arena, child: anytype, frame: } pub fn domRect(self: *Factory, rect: DOMRectReadOnly.Data) !*DOMRect { - const chain = try PrototypeChain(&.{ DOMRectReadOnly, DOMRect }).allocate(self._slab.allocator()); + const chain = try PrototypeChain(&.{ DOMRectReadOnly, DOMRect }).allocate(self._recycling.allocator()); const base = chain.get(0); base.* = .{ @@ -341,7 +341,7 @@ pub fn domRect(self: *Factory, rect: DOMRectReadOnly.Data) !*DOMRect { pub fn node(self: *Factory, owner: *const Document, child: anytype) !*@TypeOf(child) { comptime assert(@TypeOf(child) != Document); - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, @TypeOf(child) }, ).createOwned(allocator, owner._index, child); @@ -363,7 +363,7 @@ pub fn genericDocument(self: *Factory, opts: DocumentOpts) !*Document { // the start. fn documentChain(self: *Factory, comptime types: []const type, opts: DocumentOpts) !PrototypeChain(types) { comptime assert(types[1] == Node and types[2] == Document); - const chain = try PrototypeChain(types).allocate(self._slab.allocator()); + const chain = try PrototypeChain(types).allocate(self._recycling.allocator()); const doc = chain.get(2); const index = try self.registerDocument(doc); @@ -391,7 +391,7 @@ pub fn cdataNode(self: *Factory, owner: *const Document, cd: Node.CData, leaf: a const types = comptime prototypeTypes(@TypeOf(leaf)); comptime assert(types[0] == EventTarget and types[1] == Node and types[2] == Node.CData); - const chain = try PrototypeChain(types).allocate(self._slab.allocator()); + const chain = try PrototypeChain(types).allocate(self._recycling.allocator()); chain.setRoot(); chain.setMiddle(1); chain.get(1)._owner = owner._index; @@ -475,7 +475,7 @@ fn hasStoredProto(comptime T: type) bool { // any field that must point at another chain member, patching the latter on // the result. pub fn chained(self: *Factory, values: anytype) !*ChainedLeaf(@TypeOf(values)) { - return chainedWithAllocator(self._slab.allocator(), values); + return chainedWithAllocator(self._recycling.allocator(), values); } pub fn chainedWithAllocator(allocator: Allocator, values: anytype) !*ChainedLeaf(@TypeOf(values)) { @@ -516,28 +516,28 @@ pub fn document(self: *Factory, child: anytype) !*@TypeOf(child) { } pub fn documentFragment(self: *Factory, owner: *const Document, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Node.DocumentFragment, @TypeOf(child) }, ).createOwned(allocator, owner._index, child); } pub fn element(self: *Factory, owner: *const Document, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Element, @TypeOf(child) }, ).createOwned(allocator, owner._index, child); } pub fn htmlElement(self: *Factory, owner: *const Document, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Element, Element.Html, @TypeOf(child) }, ).createOwned(allocator, owner._index, child); } pub fn htmlMediaElement(self: *Factory, owner: *const Document, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Element, Element.Html, Element.Html.Media, @TypeOf(child) }, ).createOwned(allocator, owner._index, child); @@ -545,7 +545,7 @@ pub fn htmlMediaElement(self: *Factory, owner: *const Document, child: anytype) pub fn svgElement(self: *Factory, owner: *const Document, tag_name: []const u8, child: anytype) !*@TypeOf(child) { const types = comptime svgPrototypeTypes(@TypeOf(child)); - const chain = try PrototypeChain(types).allocate(self._slab.allocator()); + const chain = try PrototypeChain(types).allocate(self._recycling.allocator()); chain.setRoot(); inline for (1..types.len - 1) |i| { @@ -583,17 +583,17 @@ pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) ! pub fn idbOpenRequest(self: *Factory, child: anytype) !*@TypeOf(child) { return try AutoPrototypeChain( &.{ EventTarget, IDBRequest, @TypeOf(child) }, - ).create(self._slab.allocator(), child); + ).create(self._recycling.allocator(), child); } pub fn taskSignal(self: *Factory, child: anytype) !*@TypeOf(child) { return try AutoPrototypeChain( &.{ EventTarget, AbortSignal, @TypeOf(child) }, - ).create(self._slab.allocator(), child); + ).create(self._recycling.allocator(), child); } pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); const TextTrackCue = @import("webapi/media/TextTrackCue.zig"); return try AutoPrototypeChain( @@ -623,7 +623,7 @@ pub fn destroy(self: *Factory, value: anytype) void { } pub fn destroyStandalone(self: *Factory, value: anytype) void { - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); allocator.destroy(value); } @@ -634,7 +634,7 @@ fn destroyChain( old_align: std.mem.Alignment, ) void { const S = reflect.Struct(@TypeOf(value)); - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); // aligns the old size to the alignment of this element const current_size = std.mem.alignForward(usize, old_size, @alignOf(S)); @@ -656,7 +656,7 @@ fn destroyChain( } fn createT(self: *Factory, comptime T: type) !*T { - const allocator = self._slab.allocator(); + const allocator = self._recycling.allocator(); return try allocator.create(T); } diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index b24b7140b..55bc76786 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -161,7 +161,7 @@ _http_owner: HttpClient.Owner, // List of active live ranges (for mutation updates per DOM spec) _live_ranges: std.DoublyLinkedList = .{}, // Live NodeIterators for the DOM pre-removing steps. Iterators are -// slab-allocated (frame lifetime) and never unlinked. +// factory-allocated (frame lifetime) and never unlinked. _live_node_iterators: std.DoublyLinkedList = .{}, // List of open BroadcastChannels, used to route postMessage between same-named @@ -453,12 +453,6 @@ pub fn deinit(self: *Frame) void { if (comptime lp.IS_DEBUG) { log.debug(.frame, "frame.deinit", .{ .url = self.url, .type = self._type }); - - // Uncomment if you want slab statistics to print. - // const stats = self._factory._slab.getStats(self.arena) catch unreachable; - // var buffer: [256]u8 = undefined; - // var stream = std.Io.File.stderr().writerStreaming(lp.io, &buffer).interface; - // stats.print(&stream) catch unreachable; } self._parse_state.deinit(self); diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 18f743bdc..429cec0a0 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -240,7 +240,6 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa const activity = http_client.activity(); const total_http_activity = activity.http; - const total_network_activity = activity.total(); const network_idle = activity.idle(); const is_done = browser.hasMacrotasks() == false and network_idle; @@ -295,7 +294,8 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa condition.status = .complete; }, .pre, .raw, .text, .image, .download => { - if (total_network_activity == 0) { + // Includes pending: another client may hold every connection. + if (network_idle) { condition.status = .complete; } else { want_http_tick = true; diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 0606cba31..e825644d8 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -795,6 +795,7 @@ pub const ToolError = error{ InvalidParams, NodeNotFound, NavigationFailed, + NavigationTimeout, Cancelled, Timeout, InternalError, @@ -807,6 +808,7 @@ pub fn errorMessage(err: ToolError) []const u8 { return switch (err) { error.NodeNotFound => "NodeNotFound: the selector or backendNodeId matched nothing on the current page. Re-inspect the page (tree/interactiveElements) for fresh node ids, or omit backendNodeId to target the document root.", error.FrameNotLoaded => "FrameNotLoaded: no page is loaded — call goto (or pass a url) first.", + error.NavigationTimeout => "NavigationTimeout: no response arrived before the timeout, so the page is empty. Other sessions may be holding every connection (see --http-max-concurrent); retry goto or close idle sessions.", else => @errorName(err), }; } @@ -2456,6 +2458,7 @@ fn performGoto(session: *lp.Session, registry: *NodeRegistry, url: [:0]const u8, // re-fetch frame, navigate might have changed it. const frame = page.frame() orelse return ToolError.NavigationFailed; if (frame._last_navigate_error != null) return ToolError.NavigationFailed; + if (result == .timeout and frame._parse_state == .pre) return ToolError.NavigationTimeout; return result; } @@ -2703,6 +2706,32 @@ test "tree and nodeDetails read the node's own frame" { try std.testing.expect(std.mem.indexOf(u8, details.text, "child-label") != null); } +test "goto: a navigation stuck waiting for a connection is an error" { + var registry: NodeRegistry = .init(std.testing.allocator); + defer registry.deinit(); + + const network = &testing.test_app.network; + var held: std.ArrayList(*@import("../network/http.zig").Connection) = .empty; + defer held.deinit(std.testing.allocator); + defer for (held.items) |conn| network.releaseConnection(conn); + while (network.getConnection()) |conn| try held.append(std.testing.allocator, conn); + + const session = testing.test_session; + defer if (session.primaryPage()) |page| page.close(); + + const aa = testing.arena_allocator; + const args = try std.json.parseFromSliceLeaky(std.json.Value, aa, + \\{"url":"http://localhost:9582/src/browser/tests/mcp_actions.html","timeout":300} + , .{}); + try std.testing.expectError(error.NavigationTimeout, call(aa, session, ®istry, "goto", args, .{})); + + for (held.items) |conn| network.releaseConnection(conn); + held.clearRetainingCapacity(); + + const r = try call(aa, session, ®istry, "goto", args, .{}); + try std.testing.expectEqualStrings("Navigated successfully.", r.text); +} + test "parseValue: zero-filled optional backendNodeId treated as omitted" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); diff --git a/src/browser/webapi/DataTransfer.zig b/src/browser/webapi/DataTransfer.zig index 39c2a34f5..9be567b3f 100644 --- a/src/browser/webapi/DataTransfer.zig +++ b/src/browser/webapi/DataTransfer.zig @@ -53,7 +53,7 @@ _arena: *lp.Arena, _rc: lp.RC = .{}, _items: std.ArrayList(*DataTransferItem) = .empty, _item_list: *DataTransferItemList, -// FileList lives on the factory slab and is frame-tracked, so each File ref it +// FileList lives on the factory allocator and is frame-tracked, so each File ref it // holds is released at frame teardown (same path as ``). _files: *FileList, _drop_effect: []const u8 = "none", diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index c6edfe97e..5a0ba03ca 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -166,7 +166,7 @@ fn dispatchBrowserTool( error.FrameNotLoaded => .FrameNotLoaded, error.NodeNotFound, error.InvalidParams => .InvalidParams, error.Cancelled => .Cancelled, - error.Timeout => .Timeout, + error.Timeout, error.NavigationTimeout => .Timeout, error.NavigationFailed, error.InternalError, error.OutOfMemory => .InternalError, }; return server.sendError(id, code, browser_tools.errorMessage(err)); diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 245d4255e..5f62fb0bf 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -802,7 +802,6 @@ pub fn _tick(self: *Client, timeout_ms: u32, mode: DrainMode) !bool { // we're about to tell our caller not to call us again without it // doing some work (e.g. running tasks). Let's assert that we were // right in doing that, else we'll likely introduce latency. - std.debug.assert(self.pending_queue.first == null); std.debug.assert(self.delayed_queue.first == null); std.debug.assert(self.dispatch_queue.first == null); std.debug.assert(self.ws_dispatch_queue.first == null); diff --git a/src/server/cdp/AXNode.zig b/src/server/cdp/AXNode.zig index af0fc17e0..b336bd9cf 100644 --- a/src/server/cdp/AXNode.zig +++ b/src/server/cdp/AXNode.zig @@ -80,15 +80,17 @@ pub const Writer = struct { } fn toJSON(self: *const Writer, w: anytype) !void { + const cache_arena = try self.frame.getArena(.medium, "AXNode.IgnoreCache"); + defer cache_arena.release(); + var ignore_cache: IgnoreCache = .{ .allocator = cache_arena.allocator() }; + try w.beginArray(); if (self.filter != null) { - try self.walkQuery(self.root.dom, false, w); + try self.walkQuery(self.root.dom, &ignore_cache, 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, &ignore_cache, w); } return w.endArray(); } @@ -118,28 +120,17 @@ 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); - } - } - - 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| { + fn writeTree(self: *const Writer, root: AXNode, root_hidden: bool, ignore_cache: *IgnoreCache, w: anytype) !void { + var walker: Walker = .init(root.dom); + var descend = try self.writeNode(self.root.id, root, false, root_hidden, ignore_cache, w); + while (walker.next(descend)) |dom_node| { + 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 +138,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 +147,7 @@ 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); - } + descend = try self.writeNode(node.id, .fromNode(dom_node), walker.inAriaHidden(), false, ignore_cache, w); } } @@ -495,8 +483,7 @@ pub const Writer = struct { } // write a node. returns true if children must be written. - fn writeNode(self: *const Writer, id: u32, axn: AXNode, in_aria_hidden: bool, hidden: bool, w: anytype) !bool { - // ignore empty texts + fn writeNode(self: *const Writer, id: u32, axn: AXNode, in_aria_hidden: bool, hidden: bool, ignore_cache: *IgnoreCache, w: anytype) !bool { try w.beginObject(); try w.objectField("nodeId"); @@ -511,7 +498,7 @@ pub const Writer = struct { try w.objectField("role"); try self.writeAXValue(.{ .role = resolved.role }, w); - const ignore = axn.isIgnore(self.frame, in_aria_hidden, hidden); + const ignore = try axn.isIgnore(self.frame, in_aria_hidden, hidden, ignore_cache); try w.objectField("ignored"); try w.write(ignore); @@ -591,7 +578,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; @@ -606,6 +593,14 @@ pub const Writer = struct { try w.endObject(); + if (write_children) { + if (n.is(DOMNode.Element)) |el| { + if (el.getTag() == .li) { + try self.writeListMarker(n, w); + } + } + } + return write_children; } @@ -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); - - // ,