From c3f1314fead5a0424d1f112aace78eb76629e230 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 24 Sep 2026 17:44:18 +0800 Subject: [PATCH 1/6] mem: replace slab allocator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slab allocator was meant to enable efficient re-use of freed memory. In reality, the allocations made to it are rarely freed: ``` │ site │ allocs │ freed │ │ reddit /r/programming │ 50,462 │ 4.6% │ │ wikipedia article │ 8,557 │ 3.4% │ │ youtube │ 4,913 │ 9.5% │ │ bbc news │ 4,734 │ 1.1% │ │ github repo │ 4,117 │ 3.3% │ │ react.dev │ 3,640 │ 0.3% │ │ HN │ 1,313 │ 0.1% │ ``` And, just because allocations are freed doesn't meant that memory gets to be re-used. reddit has HTMLCollection 1120 total HTMLCollection allocation, with a peak inflight of 862, so the majority of frees weren't useful. I think the ArenaPool came after the slab, and it became the preferred (but not exclusive) mechanism for managing eagerly freed memory. This replaces the slab allocator with a simpler recycler. Unlike the slab, it doesn't degrade in performance as the # of allocations grow (310ns/op vs 17ns/op at 500K items) and it doesn't leak memory (the parent allocator *is* the page's arena, so the Slab's bitset growth will leak unless it can grow in place). --- src/RecyclingAllocator.zig | 271 +++++++++ src/browser/Factory.zig | 42 +- src/browser/Frame.zig | 8 +- src/browser/webapi/DataTransfer.zig | 2 +- src/slab.zig | 862 ---------------------------- 5 files changed, 294 insertions(+), 891 deletions(-) create mode 100644 src/RecyclingAllocator.zig delete mode 100644 src/slab.zig diff --git a/src/RecyclingAllocator.zig b/src/RecyclingAllocator.zig new file mode 100644 index 000000000..4e2c44ef6 --- /dev/null +++ b/src/RecyclingAllocator.zig @@ -0,0 +1,271 @@ +// 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 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 deinit(self: *RecyclingAllocator) void { + self.free_lists.deinit(self.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 std.debug.runtime_safety) { + // 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.recycling.deinit(); + 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 cdb69fdf8..0969c485a 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/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/slab.zig b/src/slab.zig deleted file mode 100644 index 21e060c7b..000000000 --- a/src/slab.zig +++ /dev/null @@ -1,862 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; - -const Allocator = std.mem.Allocator; -const Alignment = std.mem.Alignment; - -const Slab = struct { - alignment: Alignment, - item_size: usize, - max_slot_count: usize, - - bitset: std.bit_set.DynamicBitSetUnmanaged, - chunks: std.ArrayList([]u8), - - pub fn init( - allocator: Allocator, - alignment: Alignment, - item_size: usize, - max_slot_count: usize, - ) !Slab { - return .{ - .alignment = alignment, - .item_size = item_size, - .bitset = try .initFull(allocator, 0), - .chunks = .empty, - .max_slot_count = max_slot_count, - }; - } - - pub fn deinit(self: *Slab, allocator: Allocator) void { - self.bitset.deinit(allocator); - - for (self.chunks.items) |chunk| { - allocator.rawFree(chunk, self.alignment, @returnAddress()); - } - - self.chunks.deinit(allocator); - } - - inline fn calculateChunkSize(self: *Slab, chunk_index: usize) usize { - const safe_index: u6 = @intCast(@min(std.math.maxInt(u6), chunk_index)); - const exponential = @as(usize, 1) << safe_index; - return @min(exponential, self.max_slot_count); - } - - inline fn toBitsetIndex(self: *Slab, chunk_index: usize, slot_index: usize) usize { - var offset: usize = 0; - for (0..chunk_index) |i| { - const chunk_size = self.calculateChunkSize(i); - offset += chunk_size; - } - return offset + slot_index; - } - - inline fn toChunkAndSlotIndices(self: *Slab, bitset_index: usize) struct { usize, usize } { - var offset: usize = 0; - var chunk_index: usize = 0; - - while (chunk_index < self.chunks.items.len) : (chunk_index += 1) { - const chunk_size = self.calculateChunkSize(chunk_index); - if (bitset_index < offset + chunk_size) { - return .{ chunk_index, bitset_index - offset }; - } - - offset += chunk_size; - } - - unreachable; - } - - fn alloc(self: *Slab, allocator: Allocator) ![]u8 { - if (self.bitset.findFirstSet()) |index| { - const chunk_index, const slot_index = self.toChunkAndSlotIndices(index); - - // if we have a free slot - self.bitset.unset(index); - - const chunk = self.chunks.items[chunk_index]; - const offset = slot_index * self.item_size; - return chunk.ptr[offset..][0..self.item_size]; - } else { - const old_capacity = self.bitset.bit_length; - - // if we have don't have a free slot - try self.allocateChunk(allocator); - - const first_slot_index = old_capacity; - self.bitset.unset(first_slot_index); - - const new_chunk = self.chunks.items[self.chunks.items.len - 1]; - return new_chunk.ptr[0..self.item_size]; - } - } - - fn free(self: *Slab, ptr: [*]u8) void { - const addr = @intFromPtr(ptr); - - for (self.chunks.items, 0..) |chunk, i| { - const chunk_start = @intFromPtr(chunk.ptr); - const chunk_end = chunk_start + chunk.len; - - if (addr >= chunk_start and addr < chunk_end) { - const offset = addr - chunk_start; - const slot_index = offset / self.item_size; - - const bitset_index = self.toBitsetIndex(i, slot_index); - assert(!self.bitset.isSet(bitset_index)); - - self.bitset.set(bitset_index); - return; - } - } - - unreachable; - } - - fn allocateChunk(self: *Slab, allocator: Allocator) !void { - const next_chunk_size = self.calculateChunkSize(self.chunks.items.len); - const chunk_len = self.item_size * next_chunk_size; - - const chunk_ptr = allocator.rawAlloc( - chunk_len, - self.alignment, - @returnAddress(), - ) orelse return error.FailedChildAllocation; - - const chunk = chunk_ptr[0..chunk_len]; - try self.chunks.append(allocator, chunk); - - const new_capacity = self.bitset.bit_length + next_chunk_size; - try self.bitset.resize(allocator, new_capacity, true); - } - - const Stats = struct { - key: SlabKey, - item_size: usize, - chunk_count: usize, - total_slots: usize, - slots_in_use: usize, - slots_free: usize, - bytes_allocated: usize, - bytes_in_use: usize, - bytes_free: usize, - utilization_ratio: f64, - }; - - fn getStats(self: *const Slab, key: SlabKey) Stats { - const total_slots = self.bitset.bit_length; - const free_slots = self.bitset.count(); - const used_slots = total_slots - free_slots; - const bytes_allocated = total_slots * self.item_size; - const bytes_in_use = used_slots * self.item_size; - - const utilization_ratio = if (bytes_allocated > 0) - @as(f64, @floatFromInt(bytes_in_use)) / @as(f64, @floatFromInt(bytes_allocated)) - else - 0.0; - - return .{ - .key = key, - .item_size = self.item_size, - .chunk_count = self.chunks.items.len, - .total_slots = total_slots, - .slots_in_use = used_slots, - .slots_free = free_slots, - .bytes_allocated = bytes_allocated, - .bytes_in_use = bytes_in_use, - .bytes_free = free_slots * self.item_size, - .utilization_ratio = utilization_ratio, - }; - } -}; - -const SlabKey = struct { - size: usize, - alignment: Alignment, -}; - -pub const SlabAllocator = struct { - const Self = @This(); - - child_allocator: Allocator, - max_slot_count: usize, - - slabs: std.ArrayHashMapUnmanaged(SlabKey, Slab, struct { - const Context = @This(); - - pub fn hash(_: Context, key: SlabKey) u32 { - var hasher = std.hash.Wyhash.init(0); - std.hash.autoHash(&hasher, key.size); - std.hash.autoHash(&hasher, key.alignment); - return @truncate(hasher.final()); - } - - pub fn eql(_: Context, a: SlabKey, b: SlabKey, _: usize) bool { - return a.size == b.size and a.alignment == b.alignment; - } - }, false) = .empty, - - pub fn init(child_allocator: Allocator, max_slot_count: usize) Self { - assert(std.math.isPowerOfTwo(max_slot_count)); - - return .{ - .child_allocator = child_allocator, - .slabs = .empty, - .max_slot_count = max_slot_count, - }; - } - - pub fn deinit(self: *Self) void { - for (self.slabs.values()) |*slab| { - slab.deinit(self.child_allocator); - } - - self.slabs.deinit(self.child_allocator); - } - - const ResetKind = enum { - /// Free all chunks and release all memory. - clear, - /// Keep all chunks, reset trees to reuse memory. - retain_capacity, - }; - - /// This clears all of the stored memory, freeing the currently used chunks. - pub fn reset(self: *Self, kind: ResetKind) void { - switch (kind) { - .clear => { - for (self.slabs.values()) |*slab| { - for (slab.chunks.items) |chunk| { - self.child_allocator.free(chunk); - } - - slab.chunks.clearAndFree(self.child_allocator); - slab.bitset.deinit(self.child_allocator); - } - - self.slabs.clearAndFree(self.child_allocator); - }, - .retain_capacity => { - for (self.slabs.values()) |*slab| { - slab.bitset.setAll(); - } - }, - } - } - - const Stats = struct { - total_allocated_bytes: usize, - bytes_in_use: usize, - bytes_free: usize, - slab_count: usize, - total_chunks: usize, - total_slots: usize, - slots_in_use: usize, - slots_free: usize, - fragmentation_ratio: f64, - utilization_ratio: f64, - slabs: []const Slab.Stats, - - pub fn print(self: *const Stats, stream: *std.Io.Writer) !void { - try stream.print("\n", .{}); - try stream.print("\n=== Slab Allocator Statistics ===\n", .{}); - try stream.print("Overall Memory:\n", .{}); - try stream.print(" Total allocated: {} bytes ({d:.2} MB)\n", .{ - self.total_allocated_bytes, - @as(f64, @floatFromInt(self.total_allocated_bytes)) / 1_048_576.0, - }); - try stream.print(" In use: {} bytes ({d:.2} MB)\n", .{ - self.bytes_in_use, - @as(f64, @floatFromInt(self.bytes_in_use)) / 1_048_576.0, - }); - try stream.print(" Free: {} bytes ({d:.2} MB)\n", .{ - self.bytes_free, - @as(f64, @floatFromInt(self.bytes_free)) / 1_048_576.0, - }); - - try stream.print("\nOverall Structure:\n", .{}); - try stream.print(" Slab Count: {}\n", .{self.slab_count}); - try stream.print(" Total chunks: {}\n", .{self.total_chunks}); - try stream.print(" Total slots: {}\n", .{self.total_slots}); - try stream.print(" Slots in use: {}\n", .{self.slots_in_use}); - try stream.print(" Slots free: {}\n", .{self.slots_free}); - - try stream.print("\nOverall Efficiency:\n", .{}); - try stream.print(" Utilization: {d:.1}%\n", .{self.utilization_ratio * 100.0}); - try stream.print(" Fragmentation: {d:.1}%\n", .{self.fragmentation_ratio * 100.0}); - - if (self.slabs.len > 0) { - try stream.print("\nPer-Slab Breakdown:\n", .{}); - try stream.print( - " {s:>5} | {s:>4} | {s:>6} | {s:>6} | {s:>6} | {s:>10} | {s:>6}\n", - .{ "Size", "Algn", "Chunks", "Slots", "InUse", "Bytes", "Util%" }, - ); - try stream.print( - " {s:-<5}-+-{s:-<4}-+-{s:-<6}-+-{s:-<6}-+-{s:-<6}-+-{s:-<10}-+-{s:-<6}\n", - .{ "", "", "", "", "", "", "" }, - ); - - for (self.slabs) |slab| { - try stream.print(" {d:5} | {d:4} | {d:6} | {d:6} | {d:6} | {d:10} | {d:5.1}%\n", .{ - slab.key.size, - @intFromEnum(slab.key.alignment), - slab.chunk_count, - slab.total_slots, - slab.slots_in_use, - slab.bytes_allocated, - slab.utilization_ratio * 100.0, - }); - } - } - } - }; - - fn getStats(self: *Self, a: std.mem.Allocator) !Stats { - var slab_stats: std.ArrayList(Slab.Stats) = try .initCapacity(a, self.slabs.entries.len); - errdefer slab_stats.deinit(a); - - var stats = Stats{ - .total_allocated_bytes = 0, - .bytes_in_use = 0, - .bytes_free = 0, - .slab_count = self.slabs.count(), - .total_chunks = 0, - .total_slots = 0, - .slots_in_use = 0, - .slots_free = 0, - .fragmentation_ratio = 0.0, - .utilization_ratio = 0.0, - .slabs = &.{}, - }; - - var it = self.slabs.iterator(); - while (it.next()) |entry| { - const key = entry.key_ptr.*; - const slab = entry.value_ptr; - const slab_stat = slab.getStats(key); - - slab_stats.appendAssumeCapacity(slab_stat); - - stats.total_allocated_bytes += slab_stat.bytes_allocated; - stats.bytes_in_use += slab_stat.bytes_in_use; - stats.bytes_free += slab_stat.bytes_free; - stats.total_chunks += slab_stat.chunk_count; - stats.total_slots += slab_stat.total_slots; - stats.slots_in_use += slab_stat.slots_in_use; - stats.slots_free += slab_stat.slots_free; - } - - if (stats.total_allocated_bytes > 0) { - stats.fragmentation_ratio = @as(f64, @floatFromInt(stats.bytes_free)) / - @as(f64, @floatFromInt(stats.total_allocated_bytes)); - stats.utilization_ratio = @as(f64, @floatFromInt(stats.bytes_in_use)) / - @as(f64, @floatFromInt(stats.total_allocated_bytes)); - } - - stats.slabs = try slab_stats.toOwnedSlice(a); - return stats; - } - - const vtable = Allocator.VTable{ - .alloc = alloc, - .free = free, - .remap = Allocator.noRemap, - .resize = Allocator.noResize, - }; - - pub fn allocator(self: *Self) Allocator { - return .{ - .ptr = self, - .vtable = &vtable, - }; - } - - fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 { - const self: *Self = @ptrCast(@alignCast(ctx)); - _ = ret_addr; - - const aligned_len = std.mem.alignForward(usize, len, alignment.toByteUnits()); - - const list_gop = self.slabs.getOrPut( - self.child_allocator, - SlabKey{ .size = aligned_len, .alignment = alignment }, - ) catch return null; - - if (!list_gop.found_existing) { - list_gop.value_ptr.* = Slab.init( - self.child_allocator, - alignment, - aligned_len, - self.max_slot_count, - ) catch return null; - } - - const list = list_gop.value_ptr; - const buf = list.alloc(self.child_allocator) catch return null; - return buf[0..len].ptr; - } - - fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void { - const self: *Self = @ptrCast(@alignCast(ctx)); - _ = ret_addr; - - const ptr = memory.ptr; - const len = memory.len; - const aligned_len = std.mem.alignForward(usize, len, alignment.toByteUnits()); - const list = self.slabs.getPtr(.{ .size = aligned_len, .alignment = alignment }).?; - list.free(ptr); - } -}; - -const testing = std.testing; - -const TestSlabAllocator = SlabAllocator; - -test "slab allocator - basic allocation and free" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate some memory - const ptr1 = try allocator.alloc(u8, 100); - try testing.expect(ptr1.len == 100); - - // Write to it to ensure it's valid - @memset(ptr1, 42); - try testing.expectEqual(@as(u8, 42), ptr1[50]); - - // Free it - allocator.free(ptr1); -} - -test "slab allocator - multiple allocations" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - const ptr1 = try allocator.alloc(u8, 64); - const ptr2 = try allocator.alloc(u8, 128); - const ptr3 = try allocator.alloc(u8, 256); - - // Ensure they don't overlap - const addr1 = @intFromPtr(ptr1.ptr); - const addr2 = @intFromPtr(ptr2.ptr); - const addr3 = @intFromPtr(ptr3.ptr); - - try testing.expect(addr1 + 64 <= addr2 or addr2 + 128 <= addr1); - try testing.expect(addr2 + 128 <= addr3 or addr3 + 256 <= addr2); - - allocator.free(ptr1); - allocator.free(ptr2); - allocator.free(ptr3); -} - -test "slab allocator - no coalescing (different size classes)" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate two blocks of same size - const ptr1 = try allocator.alloc(u8, 128); - const ptr2 = try allocator.alloc(u8, 128); - - // Free them (no coalescing in slab allocator) - allocator.free(ptr1); - allocator.free(ptr2); - - // Can't allocate larger block from these freed 128-byte blocks - const ptr3 = try allocator.alloc(u8, 256); - - // ptr3 will be from a different size class, not coalesced from ptr1+ptr2 - const addr1 = @intFromPtr(ptr1.ptr); - const addr3 = @intFromPtr(ptr3.ptr); - - // They should NOT be adjacent (different size classes) - try testing.expect(addr3 < addr1 or addr3 >= addr1 + 256); - - allocator.free(ptr3); -} - -test "slab allocator - reuse freed memory" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - const ptr1 = try allocator.alloc(u8, 64); - const addr1 = @intFromPtr(ptr1.ptr); - allocator.free(ptr1); - - // Allocate same size, should reuse from same slab - const ptr2 = try allocator.alloc(u8, 64); - const addr2 = @intFromPtr(ptr2.ptr); - - try testing.expectEqual(addr1, addr2); - allocator.free(ptr2); -} - -test "slab allocator - multiple size classes" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate various sizes - each creates a new slab - var ptrs: [10][]u8 = undefined; - const sizes = [_]usize{ 24, 40, 64, 88, 128, 144, 200, 256, 512, 1000 }; - - for (&ptrs, sizes) |*ptr, size| { - ptr.* = try allocator.alloc(u8, size); - @memset(ptr.*, 0xFF); - } - - // Should have created multiple slabs - try testing.expect(slab_alloc.slabs.count() >= 10); - - // Free all - for (ptrs) |ptr| { - allocator.free(ptr); - } -} - -test "slab allocator - various sizes" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Test different sizes (not limited to powers of 2!) - const sizes = [_]usize{ 8, 16, 24, 32, 40, 64, 88, 128, 144, 256 }; - - for (sizes) |size| { - const ptr = try allocator.alloc(u8, size); - try testing.expect(ptr.len == size); - @memset(ptr, @intCast(size & 0xFF)); - allocator.free(ptr); - } -} - -test "slab allocator - exact sizes (no rounding)" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Odd sizes stay exact (unlike buddy which rounds to power of 2) - const ptr1 = try allocator.alloc(u8, 100); - const ptr2 = try allocator.alloc(u8, 200); - const ptr3 = try allocator.alloc(u8, 50); - - // Exact sizes! - try testing.expect(ptr1.len == 100); - try testing.expect(ptr2.len == 200); - try testing.expect(ptr3.len == 50); - - allocator.free(ptr1); - allocator.free(ptr2); - allocator.free(ptr3); -} - -test "slab allocator - chunk allocation" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate many items of same size to force multiple chunks - var ptrs: [100][]u8 = undefined; - for (&ptrs) |*ptr| { - ptr.* = try allocator.alloc(u8, 64); - } - - // Should have allocated multiple chunks (32 items per chunk) - const slab = slab_alloc.slabs.getPtr(.{ .size = 64, .alignment = Alignment.@"1" }).?; - try testing.expect(slab.chunks.items.len > 1); - - // Free all - for (ptrs) |ptr| { - allocator.free(ptr); - } -} - -test "slab allocator - reset with retain_capacity" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate some memory - const ptr1 = try allocator.alloc(u8, 128); - const ptr2 = try allocator.alloc(u8, 256); - _ = ptr1; - _ = ptr2; - - const slabs_before = slab_alloc.slabs.count(); - const slab_128 = slab_alloc.slabs.getPtr(.{ .size = 128, .alignment = Alignment.@"1" }).?; - const chunks_before = slab_128.chunks.items.len; - - // Reset but keep chunks - slab_alloc.reset(.retain_capacity); - - try testing.expectEqual(slabs_before, slab_alloc.slabs.count()); - try testing.expectEqual(chunks_before, slab_128.chunks.items.len); - - // Should be able to allocate again - const ptr3 = try allocator.alloc(u8, 512); - allocator.free(ptr3); -} - -test "slab allocator - reset with clear" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate some memory - const ptr1 = try allocator.alloc(u8, 128); - _ = ptr1; - - try testing.expect(slab_alloc.slabs.count() > 0); - - // Reset and free everything - slab_alloc.reset(.clear); - - try testing.expectEqual(@as(usize, 0), slab_alloc.slabs.count()); - - // Should still work after reset - const ptr2 = try allocator.alloc(u8, 256); - allocator.free(ptr2); -} - -test "slab allocator - stress test" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - var prng = std.Random.DefaultPrng.init(0); - const random = prng.random(); - - var ptrs: std.ArrayList([]u8) = .empty; - - defer { - for (ptrs.items) |ptr| { - allocator.free(ptr); - } - ptrs.deinit(allocator); - } - - // Random allocations and frees - var i: usize = 0; - while (i < 100) : (i += 1) { - if (random.boolean() and ptrs.items.len > 0) { - // Free a random allocation - const index = random.uintLessThan(usize, ptrs.items.len); - allocator.free(ptrs.swapRemove(index)); - } else { - // Allocate random size (8 to 512) - const size = random.uintAtMost(usize, 504) + 8; - const ptr = try allocator.alloc(u8, size); - try ptrs.append(allocator, ptr); - - // Write to ensure it's valid - @memset(ptr, @intCast(i & 0xFF)); - } - } -} - -test "slab allocator - alignment" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - const ptr1 = try allocator.create(u64); - const ptr2 = try allocator.create(u32); - const ptr3 = try allocator.create([100]u8); - - allocator.destroy(ptr1); - allocator.destroy(ptr2); - allocator.destroy(ptr3); -} - -test "slab allocator - no resize support" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - const slice = try allocator.alloc(u8, 100); - @memset(slice, 42); - - // Resize should fail (not supported) - try testing.expect(!allocator.resize(slice, 90)); - try testing.expect(!allocator.resize(slice, 200)); - - allocator.free(slice); -} - -test "slab allocator - fragmentation pattern" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate 10 items - var items: [10][]u8 = undefined; - for (&items) |*item| { - item.* = try allocator.alloc(u8, 64); - @memset(item.*, 0xFF); - } - - // Free every other one - allocator.free(items[0]); - allocator.free(items[2]); - allocator.free(items[4]); - allocator.free(items[6]); - allocator.free(items[8]); - - // Allocate new items - should reuse freed slots - const new1 = try allocator.alloc(u8, 64); - const new2 = try allocator.alloc(u8, 64); - const new3 = try allocator.alloc(u8, 64); - - // Should get some of the freed slots back - const addrs = [_]usize{ - @intFromPtr(items[0].ptr), - @intFromPtr(items[2].ptr), - @intFromPtr(items[4].ptr), - @intFromPtr(items[6].ptr), - @intFromPtr(items[8].ptr), - }; - - const new1_addr = @intFromPtr(new1.ptr); - var found = false; - for (addrs) |addr| { - if (new1_addr == addr) found = true; - } - try testing.expect(found); - - // Cleanup - allocator.free(items[1]); - allocator.free(items[3]); - allocator.free(items[5]); - allocator.free(items[7]); - allocator.free(items[9]); - allocator.free(new1); - allocator.free(new2); - allocator.free(new3); -} - -test "slab allocator - many small allocations" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate 1000 small items - var ptrs: std.ArrayList([]u8) = .empty; - defer { - for (ptrs.items) |ptr| { - allocator.free(ptr); - } - ptrs.deinit(allocator); - } - - var i: usize = 0; - while (i < 1000) : (i += 1) { - const ptr = try allocator.alloc(u8, 24); - try ptrs.append(allocator, ptr); - } - - // Should have created multiple chunks - const slab = slab_alloc.slabs.getPtr(.{ .size = 24, .alignment = Alignment.@"1" }).?; - try testing.expect(slab.chunks.items.len > 1); -} - -test "slab allocator - zero waste for exact sizes" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // These sizes have zero internal fragmentation (unlike buddy) - const sizes = [_]usize{ 24, 40, 56, 88, 144, 152, 184, 232, 648 }; - - for (sizes) |size| { - const ptr = try allocator.alloc(u8, size); - - // Exact size returned! - try testing.expectEqual(size, ptr.len); - - @memset(ptr, 0xFF); - allocator.free(ptr); - } -} - -test "slab allocator - different size classes don't interfere" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Allocate size 64 - const ptr_64 = try allocator.alloc(u8, 64); - const addr_64 = @intFromPtr(ptr_64.ptr); - allocator.free(ptr_64); - - // Allocate size 128 - should NOT reuse size-64 slot - const ptr_128 = try allocator.alloc(u8, 128); - const addr_128 = @intFromPtr(ptr_128.ptr); - - try testing.expect(addr_64 != addr_128); - - // Allocate size 64 again - SHOULD reuse original slot - const ptr_64_again = try allocator.alloc(u8, 64); - const addr_64_again = @intFromPtr(ptr_64_again.ptr); - - try testing.expectEqual(addr_64, addr_64_again); - - allocator.free(ptr_128); - allocator.free(ptr_64_again); -} - -test "slab allocator - 16-byte alignment" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - // Request 16-byte aligned memory - const ptr = try allocator.alignedAlloc(u8, .@"16", 152); - defer allocator.free(ptr); - - // Verify alignment - const addr = @intFromPtr(ptr.ptr); - try testing.expect(addr % 16 == 0); - - // Make sure we can use it - @memset(ptr, 0xFF); -} - -test "slab allocator - various alignments" { - var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); - defer slab_alloc.deinit(); - - const allocator = slab_alloc.allocator(); - - const alignments = [_]std.mem.Alignment{ .@"1", .@"2", .@"4", .@"8", .@"16" }; - - inline for (alignments) |alignment| { - const ptr = try allocator.alignedAlloc(u8, alignment, 100); - defer allocator.free(ptr); - - const addr = @intFromPtr(ptr.ptr); - const align_value = alignment.toByteUnits(); - try testing.expect(addr % align_value == 0); - } -} 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 2/6] 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); - // ,