From 33ad79db13b1ef120b76b06d47263976eda4dad5 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Dec 2025 16:15:12 +0300 Subject: [PATCH 1/4] add an overwriting ring buffer implementation --- src/ring_buffer.zig | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/ring_buffer.zig diff --git a/src/ring_buffer.zig b/src/ring_buffer.zig new file mode 100644 index 000000000..0ecb83d6b --- /dev/null +++ b/src/ring_buffer.zig @@ -0,0 +1,80 @@ +const std = @import("std"); + +/// Overwriting ring buffer implementation. +/// Useful if you're not interested with stale data. +pub fn Overwriting( + comptime T: type, + comptime backing: union(enum) { array: usize, slice }, +) type { + switch (comptime backing) { + .array => |size| if (size == 0) @panic("invalid ring buffer size"), + else => {}, + } + + return struct { + const Self = @This(); + + /// Storage. + buffer: switch (backing) { + .array => |size| [size]T, + .slice => []T, + }, + /// Next write index. + write_idx: usize = 0, + /// Length of items ring currently have. + count: usize = 0, + + fn initArray() Self { + return .{ .buffer = undefined }; + } + + fn initSlice(allocator: std.mem.Allocator, size: usize) !Self { + if (size == 0) return error.InvalidSize; + return .{ .buffer = try allocator.alloc(T, size) }; + } + + pub const init = switch (backing) { + .array => initArray, + .slice => initSlice, + }; + + /// Puts an item. + pub fn put(self: *Self, item: T) void { + self.buffer[self.write_idx] = item; + // Wrapping addition. + self.write_idx = (self.write_idx + 1) % self.buffer.len; + self.count = @min(self.count + 1, self.buffer.len); + } + + /// Returns the oldest item. + pub fn get(self: *Self) ?T { + // No items. + if (self.count == 0) { + return null; + } + + const read_idx = (self.write_idx + self.buffer.len - self.count) % self.buffer.len; + const item = self.buffer[read_idx]; + self.count -= 1; + return item; + } + + /// Returns slices to items in ring buffer. + /// In order to avoid memcpy at this level, this function returns + /// 2 slices. Second slice will be empty if items can be represented + /// in a contigious way. + pub fn slice(self: *const Self) struct { first: []const T, second: []const T } { + if (self.count == 0) { + return .{ .first = &.{}, .second = &.{} }; + } + + const read_idx = (self.write_idx + self.buffer.len - self.count) % self.buffer.len; + + if (read_idx < self.write_idx) { + return .{ .first = self.buffer[read_idx..self.write_idx], .second = &.{} }; + } + + return .{ .first = self.buffer[read_idx..], .second = self.buffer[0..self.write_idx] }; + } + }; +} From 93cb13d7c75f9db27fd789cb70f65a3a8fd27be8 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 19 Dec 2025 20:34:10 +0300 Subject: [PATCH 2/4] core performance observer logic Heavily based on MutationObserver and IntersectionObserver. --- src/browser/Page.zig | 31 ++++ src/browser/js/Context.zig | 10 ++ src/browser/webapi/Performance.zig | 45 ++++-- src/browser/webapi/PerformanceObserver.zig | 167 ++++++++++++++++++--- 4 files changed, 220 insertions(+), 33 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0fd81e81a..ea18f87a4 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -54,6 +54,7 @@ const ShadowRoot = @import("webapi/ShadowRoot.zig"); const Performance = @import("webapi/Performance.zig"); const Screen = @import("webapi/Screen.zig"); const HtmlScript = @import("webapi/Element.zig").Html.Script; +const PerformanceObserver = @import("webapi/PerformanceObserver.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); @@ -108,6 +109,9 @@ _intersection_observers: std.ArrayList(*IntersectionObserver) = .{}, _intersection_check_scheduled: bool = false, _intersection_delivery_scheduled: bool = false, +// List of active PerformanceObservers. +_performance_observers: std.ArrayList(*PerformanceObserver) = .{}, + // Lookup for customized built-in elements. Maps element pointer to definition. _customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{}, _customized_builtin_connected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{}, @@ -245,6 +249,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._notified_network_idle = .init; self._notified_network_almost_idle = .init; + self._performance_observers = .{}; self._mutation_observers = .{}; self._mutation_delivery_scheduled = false; self._mutation_delivery_depth = 0; @@ -841,6 +846,32 @@ pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Elemen return null; } +pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void { + return self._performance_observers.append(self.arena, observer); +} + +pub fn unregisterPerformanceObserver(self: *Page, observer: *PerformanceObserver) void { + for (self._performance_observers.items, 0..) |perf_observer, i| { + if (perf_observer == observer) { + _ = self._performance_observers.swapRemove(i); + return; + } + } +} + +/// Updates performance observers with the new entry. +/// This doesn't emit callbacks but rather fills the queues of observers; +/// microtask queue runs them periodically. +pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void { + for (self._performance_observers.items) |observer| { + if (observer.interested(entry)) { + observer._entries.append(self.arena, entry) catch |err| { + log.err(.page, "notifyPerformanceObservers", .{ .err = err }); + }; + } + } +} + pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void { try self._mutation_observers.append(self.arena, observer); } diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 63a6d7515..25cce2597 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1964,6 +1964,16 @@ fn zigJsonToJs(isolate: v8.Isolate, v8_context: v8.Context, value: std.json.Valu } // Microtasks +pub fn queuePerformanceDelivery(self: *Context) !void { + self.isolate.enqueueMicrotask(struct { + fn run(data: ?*anyopaque) callconv(.c) void { + const page: *Page = @ptrCast(@alignCast(data.?)); + _ = page; + @panic("TODO"); + } + }, self.page); +} + pub fn queueMutationDelivery(self: *Context) !void { self.isolate.enqueueMicrotask(struct { fn run(data: ?*anyopaque) callconv(.c) void { diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 5253373d2..0bbab23bc 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -11,7 +11,7 @@ const std = @import("std"); const Performance = @This(); _time_origin: u64, -_entries: std.ArrayListUnmanaged(*Entry) = .{}, +_entries: std.ArrayList(*Entry) = .{}, /// Get high-resolution timestamp in microseconds, rounded to 5μs increments /// to match browser behavior (prevents fingerprinting) @@ -42,9 +42,16 @@ pub fn getTimeOrigin(self: *const Performance) f64 { return @as(f64, @floatFromInt(self._time_origin)) / 1000.0; } -pub fn mark(self: *Performance, name: []const u8, _options: ?Mark.Options, page: *Page) !*Mark { +pub fn mark( + self: *Performance, + name: []const u8, + _options: ?Mark.Options, + page: *Page, +) !*Mark { const m = try Mark.init(name, _options, page); try self._entries.append(page.arena, m._proto); + // Notify about the change. + try page.notifyPerformanceObservers(m._proto); return m; } @@ -230,21 +237,40 @@ pub const Entry = struct { _name: []const u8, _start_time: f64 = 0.0, - const Type = union(enum) { + pub const Type = union(Enum) { element, event, first_input, - largest_contentful_paint, - layout_shift, - long_animation_frame, + @"largest-contentful-paint", + @"layout-shift", + @"long-animation-frame", longtask, measure: *Measure, navigation, paint, resource, taskattribution, - visibility_state, + @"visibility-state", mark: *Mark, + + pub const Enum = enum(u8) { + element = 1, // Changing this affect PerformanceObserver's behavior. + event = 2, + first_input = 3, + @"largest-contentful-paint" = 4, + @"layout-shift" = 5, + @"long-animation-frame" = 6, + longtask = 7, + measure = 8, + navigation = 9, + paint = 10, + resource = 11, + taskattribution = 12, + @"visibility-state" = 13, + mark = 14, + // If we ever have types more than 16, we have to update entry + // table of PerformanceObserver too. + }; }; pub fn getDuration(self: *const Entry) f64 { @@ -253,11 +279,6 @@ pub const Entry = struct { pub fn getEntryType(self: *const Entry) []const u8 { return switch (self._type) { - .first_input => "first-input", - .largest_contentful_paint => "largest-contentful-paint", - .layout_shift => "layout-shift", - .long_animation_frame => "long-animation-frame", - .visibility_state => "visibility-state", else => |t| @tagName(t), }; } diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index cd77ad188..795c9f5b6 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -16,41 +16,144 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -const js = @import("../js/js.zig"); +const std = @import("std"); -const Entry = @import("Performance.zig").Entry; +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Performance = @import("Performance.zig"); -// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver +/// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver const PerformanceObserver = @This(); -pub fn init(callback: js.Function) PerformanceObserver { - _ = callback; - return .{}; +/// Emitted when there are events with same interests. +_callback: js.Function, +/// The threshold to deliver `PerformanceEventTiming` entries. +_duration_threshold: f64, +/// Entry types we're looking for are encoded as bit flags. +_interests: u16, +/// Entries this observer hold. +/// Don't mutate these; other observers may hold pointers to them. +_entries: std.ArrayList(*Performance.Entry), + +const DefaultDurationThreshold: f64 = 104; + +/// Creates a new PerformanceObserver object with the given observer callback. +pub fn init(callback: js.Function, page: *Page) !*PerformanceObserver { + return page._factory.create(PerformanceObserver{ + ._callback = callback, + ._duration_threshold = DefaultDurationThreshold, + ._interests = 0, + ._entries = .{}, + }); } -const ObserverOptions = struct { - buffered: ?bool = null, - durationThreshold: ?f64 = null, +// We don't have to mark this as public but the declarations have to be public; +// otherwise @typeInfo don't allow accessing them. +// +// Note that we also use this to report supported entry types. +//const Interest = struct { +// pub const element = 0x01; +// pub const event = 0x02; +// pub const @"first-input" = 0x04; +// pub const @"largest-contentful-paint" = 0x08; +// pub const @"layout-shift" = 0x010; +// pub const @"long-animation-frame" = 0x020; +// pub const longtask = 0x040; +// pub const mark = 0x080; +// pub const measure = 0x0100; +// pub const navigation = 0x0200; +// pub const pant = 0x0400; +// pub const resource = 0x0800; +// pub const @"visibility-state" = 0x01000; +//}; + +const ObserveOptions = struct { + buffered: bool = false, + durationThreshold: f64 = DefaultDurationThreshold, entryTypes: ?[]const []const u8 = null, type: ?[]const u8 = null, }; -pub fn observe(self: *const PerformanceObserver, opts_: ?ObserverOptions) void { - _ = self; - _ = opts_; - return; +/// TODO: Support `buffered` option. +pub fn observe( + self: *PerformanceObserver, + maybe_options: ?ObserveOptions, + page: *Page, +) !void { + const options: ObserveOptions = maybe_options orelse .{}; + // Update threshold. + self._duration_threshold = @max(@floor(options.durationThreshold / 8) * 8, 16); + + const entry_types: []const []const u8 = blk: { + // More likely. + if (options.type) |entry_type| { + // Can't have both. + if (options.entryTypes != null) { + return error.TypeError; + } + + break :blk &.{entry_type}; + } + + if (options.entryTypes) |entry_types| { + break :blk entry_types; + } + + return error.TypeError; + }; + + // Update entries. + var interests: u16 = 0; + for (entry_types) |entry_type| { + const fields = @typeInfo(Performance.Entry.Type.Enum).@"enum".fields; + inline for (fields) |field| { + if (std.mem.eql(u8, field.name, entry_type)) { + const flag = @as(u16, 1) << @as(u16, field.value); + interests |= flag; + } + } + } + + // Nothing has updated; no need to go further. + if (interests == 0) { + return; + } + + // If we had no interests before, it means Page is not aware of + // this observer. + if (self._interests == 0) { + try page.registerPerformanceObserver(self); + } + + // Update interests. + self._interests = interests; } pub fn disconnect(self: *PerformanceObserver) void { _ = self; } -pub fn takeRecords(_: *const PerformanceObserver) []const Entry { - return &.{}; +/// Returns the current list of PerformanceEntry objects +/// stored in the performance observer, emptying it out. +pub fn takeRecords(self: *PerformanceObserver, page: *Page) ![]*Performance.Entry { + const records = try page.call_arena.dupe(*Performance.Entry, self._entries.items); + self._entries.clearRetainingCapacity(); + return records; } -pub fn getSupportedEntryTypes(_: *const PerformanceObserver) [][]const u8 { - return &.{}; +/// Returns true if observer interested with given entry. +pub fn interested( + self: *const PerformanceObserver, + entry: *const Performance.Entry, +) bool { + // TODO. + _ = self; + _ = entry; + return true; +} + +pub fn getSupportedEntryTypes(_: *const PerformanceObserver) []const []const u8 { + return &.{ "mark", "measure" }; } pub const JsApi = struct { @@ -60,13 +163,35 @@ pub const JsApi = struct { pub const name = "PerformanceObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const empty_with_no_proto = true; }; - pub const constructor = bridge.constructor(PerformanceObserver.init, .{}); + pub const constructor = bridge.constructor(PerformanceObserver.init, .{ .dom_exception = true }); - pub const observe = bridge.function(PerformanceObserver.observe, .{}); + pub const observe = bridge.function(PerformanceObserver.observe, .{ .dom_exception = true }); pub const disconnect = bridge.function(PerformanceObserver.disconnect, .{}); - pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{}); + pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{ .dom_exception = true }); pub const supportedEntryTypes = bridge.accessor(PerformanceObserver.getSupportedEntryTypes, null, .{ .static = true }); }; + +/// List of performance events that were explicitly +/// observed via the observe() method. +/// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserverEntryList +pub const EntryList = struct { + _entries: []*Performance.Entry, + + pub fn getEntries(self: *const EntryList) []*Performance.Entry { + return self._entries; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(EntryList); + + pub const Meta = struct { + pub const name = "PerformanceEntryList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + + pub const getEntries = bridge.function(EntryList.getEntries, .{}); + }; + }; +}; From d698c69a0f89f82e3d67aca16d6ad58e8977aca0 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 22 Dec 2025 11:47:00 +0300 Subject: [PATCH 3/4] proper `interested` function --- src/browser/webapi/PerformanceObserver.zig | 27 +++------------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index 795c9f5b6..dbaed6d46 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -47,26 +47,6 @@ pub fn init(callback: js.Function, page: *Page) !*PerformanceObserver { }); } -// We don't have to mark this as public but the declarations have to be public; -// otherwise @typeInfo don't allow accessing them. -// -// Note that we also use this to report supported entry types. -//const Interest = struct { -// pub const element = 0x01; -// pub const event = 0x02; -// pub const @"first-input" = 0x04; -// pub const @"largest-contentful-paint" = 0x08; -// pub const @"layout-shift" = 0x010; -// pub const @"long-animation-frame" = 0x020; -// pub const longtask = 0x040; -// pub const mark = 0x080; -// pub const measure = 0x0100; -// pub const navigation = 0x0200; -// pub const pant = 0x0400; -// pub const resource = 0x0800; -// pub const @"visibility-state" = 0x01000; -//}; - const ObserveOptions = struct { buffered: bool = false, durationThreshold: f64 = DefaultDurationThreshold, @@ -146,10 +126,9 @@ pub fn interested( self: *const PerformanceObserver, entry: *const Performance.Entry, ) bool { - // TODO. - _ = self; - _ = entry; - return true; + const index = @as(u16, @intFromEnum(entry._type)); + const flag = @as(u16, 1) << index; + return self._interests & flag != 0; } pub fn getSupportedEntryTypes(_: *const PerformanceObserver) []const []const u8 { From a0fbdd0be481aaa9913d5db19ef34f24422eec16 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 22 Dec 2025 13:19:00 +0300 Subject: [PATCH 4/4] don't prefer microtask queue for execution This still needs investigation. Spec doesn't refer usage of microtask queue for this, yet the current behavior doesn't match to Firefox and Chrome. --- src/browser/Page.zig | 19 ++++++++++++++++--- src/browser/js/Context.zig | 10 ---------- src/browser/webapi/PerformanceObserver.zig | 17 +++++++++++++---- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index ea18f87a4..7b49ac581 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -109,8 +109,10 @@ _intersection_observers: std.ArrayList(*IntersectionObserver) = .{}, _intersection_check_scheduled: bool = false, _intersection_delivery_scheduled: bool = false, -// List of active PerformanceObservers. +/// List of active PerformanceObservers. +/// Contrary to MutationObserver and IntersectionObserver, these are regular tasks. _performance_observers: std.ArrayList(*PerformanceObserver) = .{}, +_performance_delivery_scheduled: bool = false, // Lookup for customized built-in elements. Maps element pointer to definition. _customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{}, @@ -772,6 +774,16 @@ pub fn tick(self: *Page) void { _ = self.scheduler.run() catch |err| { log.err(.page, "tick", .{ .err = err }); }; + + // Dispatch performance observer events. + for (self._performance_observers.items) |observer| { + if (observer.hasRecords()) { + observer.dispatch(self) catch |err| { + log.err(.page, "tcik", .{ .err = err }); + }; + } + } + self.js.runMicrotasks(); } @@ -860,8 +872,7 @@ pub fn unregisterPerformanceObserver(self: *Page, observer: *PerformanceObserver } /// Updates performance observers with the new entry. -/// This doesn't emit callbacks but rather fills the queues of observers; -/// microtask queue runs them periodically. +/// This doesn't emit callbacks but rather fills the queues of observers. pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void { for (self._performance_observers.items) |observer| { if (observer.interested(entry)) { @@ -870,6 +881,8 @@ pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void }; } } + + self._performance_delivery_scheduled = true; } pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 25cce2597..63a6d7515 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1964,16 +1964,6 @@ fn zigJsonToJs(isolate: v8.Isolate, v8_context: v8.Context, value: std.json.Valu } // Microtasks -pub fn queuePerformanceDelivery(self: *Context) !void { - self.isolate.enqueueMicrotask(struct { - fn run(data: ?*anyopaque) callconv(.c) void { - const page: *Page = @ptrCast(@alignCast(data.?)); - _ = page; - @panic("TODO"); - } - }, self.page); -} - pub fn queueMutationDelivery(self: *Context) !void { self.isolate.enqueueMicrotask(struct { fn run(data: ?*anyopaque) callconv(.c) void { diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index dbaed6d46..be920c1ac 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -121,18 +121,27 @@ pub fn takeRecords(self: *PerformanceObserver, page: *Page) ![]*Performance.Entr return records; } +pub fn getSupportedEntryTypes(_: *const PerformanceObserver) []const []const u8 { + return &.{ "mark", "measure" }; +} + /// Returns true if observer interested with given entry. pub fn interested( self: *const PerformanceObserver, entry: *const Performance.Entry, ) bool { - const index = @as(u16, @intFromEnum(entry._type)); - const flag = @as(u16, 1) << index; + const flag = @as(u16, 1) << @intCast(@intFromEnum(entry._type)); return self._interests & flag != 0; } -pub fn getSupportedEntryTypes(_: *const PerformanceObserver) []const []const u8 { - return &.{ "mark", "measure" }; +pub inline fn hasRecords(self: *const PerformanceObserver) bool { + return self._entries.items.len > 0; +} + +/// Runs the PerformanceObserver's callback with records; emptying it out. +pub fn dispatch(self: *PerformanceObserver, page: *Page) !void { + const records = try self.takeRecords(page); + _ = try self._callback.call(void, .{records}); } pub const JsApi = struct {