Files
browser/src/lightpanda.zig
Karl Seguin 77b60cebb0 Move finalizers to pure reference counting
Takes https://github.com/lightpanda-io/browser/pull/2024 a step further and
changes all reference counting to be explicit.

Up until this point, finalizers_callback was seen as a fail-safe to make sure
that instances were released no matter what. It exists because v8 might never
call a finalizer, so we need to keep track of finalizables and finalize them
on behalf of v8. BUT, it was used as more than a fallback for v8...it allowed
us to be lazy and acquireRef's in Zig without a matching releaseRef (1), because
why not, the finalizer_callback will handle it.

This commit redefines finalizer_callbacks as strictly being a fallback for v8.
If v8 calls the finalizer, then the finalizer callback is removed (2) - we lose
our fail-safe. This means that every acquireRef must be matched with a
releaseRef. Everything is explicit now. The most obvious impact of this is
that on Page.deinit, we have to releaseRef every MO, IO and blob held by the
page.

This change removes a number of special-cases to deal with various ownership
patterns. For example, Iterators are now properly reference counted and when their
RC reaches 0, they can safely releaseRef on their list. This also elimites
use-after-free potential when 2 RC objects reference each other. This should
eliminate some WPT crashes (e.g. /editing/run/insertimage.html)

(1) - We were only ever lazy about releaseRef during shutdown, so this change
won't result in more aggressive collection.

(2) Since 1 object can be referenced from 0-N IsolatedWorlds, it would be more
accurate to say that the finalizer callback is removed when all referencing
IsolatedWorld finalize it.
2026-04-02 17:04:33 +08:00

273 lines
10 KiB
Zig

// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
pub const App = @import("App.zig");
pub const Network = @import("network/Network.zig");
pub const Server = @import("Server.zig");
pub const Config = @import("Config.zig");
pub const URL = @import("browser/URL.zig");
pub const String = @import("string.zig").String;
pub const Page = @import("browser/Page.zig");
pub const Browser = @import("browser/Browser.zig");
pub const Session = @import("browser/Session.zig");
pub const Notification = @import("Notification.zig");
pub const log = @import("log.zig");
pub const js = @import("browser/js/js.zig");
pub const dump = @import("browser/dump.zig");
pub const markdown = @import("browser/markdown.zig");
pub const SemanticTree = @import("SemanticTree.zig");
pub const CDPNode = @import("cdp/Node.zig");
pub const interactive = @import("browser/interactive.zig");
pub const links = @import("browser/links.zig");
pub const forms = @import("browser/forms.zig");
pub const actions = @import("browser/actions.zig");
pub const structured_data = @import("browser/structured_data.zig");
pub const mcp = @import("mcp.zig");
pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig");
pub const HttpClient = @import("browser/HttpClient.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const FetchOpts = struct {
wait_ms: u32 = 5000,
wait_until: ?Config.WaitUntil = null,
wait_script: ?[:0]const u8 = null,
wait_selector: ?[:0]const u8 = null,
dump: dump.Opts,
dump_mode: ?Config.DumpFormat = null,
writer: ?*std.Io.Writer = null,
};
pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void {
const http_client = try HttpClient.init(app.allocator, &app.network);
defer http_client.deinit();
const notification = try Notification.init(app.allocator);
defer notification.deinit();
var browser = try Browser.init(app, .{ .http_client = http_client });
defer browser.deinit();
var session = try browser.newSession(notification);
const page = try session.createPage();
// // Comment this out to get a profile of the JS code in v8/profile.json.
// // You can open this in Chrome's profiler.
// // I've seen it generate invalid JSON, but I'm not sure why. It
// // happens rarely, and I manually fix the file.
// page.js.startCpuProfiler();
// defer {
// if (page.js.stopCpuProfiler()) |profile| {
// std.fs.cwd().writeFile(.{
// .sub_path = ".lp-cache/cpu_profile.json",
// .data = profile,
// }) catch |err| {
// log.err(.app, "profile write error", .{ .err = err });
// };
// } else |err| {
// log.err(.app, "profile error", .{ .err = err });
// }
// }
// // Comment this out to get a heap V8 heap profil
// page.js.startHeapProfiler();
// defer {
// if (page.js.stopHeapProfiler()) |profile| {
// std.fs.cwd().writeFile(.{
// .sub_path = ".lp-cache/allocating.heapprofile",
// .data = profile.@"0",
// }) catch |err| {
// log.err(.app, "allocating write error", .{ .err = err });
// };
// std.fs.cwd().writeFile(.{
// .sub_path = ".lp-cache/snapshot.heapsnapshot",
// .data = profile.@"1",
// }) catch |err| {
// log.err(.app, "heapsnapshot write error", .{ .err = err });
// };
// } else |err| {
// log.err(.app, "profile error", .{ .err = err });
// }
// }
const encoded_url = try URL.ensureEncoded(page.call_arena, url);
_ = try page.navigate(encoded_url, .{
.reason = .address_bar,
.kind = .{ .push = null },
});
var runner = try session.runner(.{});
var timer = try std.time.Timer.start();
if (opts.wait_until) |wu| {
try runner.wait(.{ .ms = opts.wait_ms, .until = wu });
} else if (opts.wait_selector == null and opts.wait_script == null) {
// We default to .done if both wait_selector and wait_script are null
// This allows the caller to ONLY --wait-selector or ONLY --wait-script
// or combine --wait-until WITH --wait-selector/script
try runner.wait(.{ .ms = opts.wait_ms, .until = .done });
}
if (opts.wait_selector) |selector| {
const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
const remaining = opts.wait_ms -| elapsed;
if (remaining == 0) return error.Timeout;
_ = try runner.waitForSelector(selector, remaining);
}
if (opts.wait_script) |script| {
const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
const remaining = opts.wait_ms -| elapsed;
if (remaining == 0) return error.Timeout;
try runner.waitForScript(script, remaining);
}
const writer = opts.writer orelse return;
if (opts.dump_mode) |mode| {
switch (mode) {
.html => try dump.root(page.window._document, opts.dump, writer, page),
.markdown => try markdown.dump(page.window._document.asNode(), .{}, writer, page),
.semantic_tree, .semantic_tree_text => {
var registry = CDPNode.Registry.init(app.allocator);
defer registry.deinit();
const st: SemanticTree = .{
.dom_node = page.window._document.asNode(),
.registry = &registry,
.page = page,
.arena = page.call_arena,
.prune = (mode == .semantic_tree_text),
};
if (mode == .semantic_tree) {
try std.json.Stringify.value(st, .{}, writer);
} else {
try st.textStringify(writer);
}
},
.wpt => try dumpWPT(page, writer),
}
}
try writer.flush();
}
fn dumpWPT(page: *Page, writer: *std.Io.Writer) !void {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
// return the detailed result.
const dump_script =
\\ JSON.stringify((() => {
\\ const statuses = ['Pass', 'Fail', 'Timeout', 'Not Run', 'Optional Feature Unsupported'];
\\ const parse = (raw) => {
\\ for (const status of statuses) {
\\ const idx = raw.indexOf('|' + status);
\\ if (idx !== -1) {
\\ const name = raw.slice(0, idx);
\\ const rest = raw.slice(idx + status.length + 1);
\\ const message = rest.length > 0 && rest[0] === '|' ? rest.slice(1) : null;
\\ return { name, status, message };
\\ }
\\ }
\\ return { name: raw, status: 'Unknown', message: null };
\\ };
\\ const cases = Object.values(report.cases).map(parse);
\\ return {
\\ url: window.location.href,
\\ status: report.status,
\\ message: report.message,
\\ summary: {
\\ total: cases.length,
\\ passed: cases.filter(c => c.status === 'Pass').length,
\\ failed: cases.filter(c => c.status === 'Fail').length,
\\ timeout: cases.filter(c => c.status === 'Timeout').length,
\\ notrun: cases.filter(c => c.status === 'Not Run').length,
\\ unsupported: cases.filter(c => c.status === 'Optional Feature Unsupported').length
\\ },
\\ cases
\\ };
\\ })(), null, 2)
;
const value = ls.local.exec(dump_script, "dump_script") catch |err| {
const caught = try_catch.caughtOrError(page.call_arena, err);
return writer.print("Caught error trying to access WPT's report: {f}\n", .{caught});
};
try writer.writeAll("== WPT Results==\n");
try writer.writeAll(try value.toStringSliceWithAlloc(page.call_arena));
}
pub inline fn assert(ok: bool, comptime ctx: []const u8, args: anytype) void {
if (!ok) {
if (comptime IS_DEBUG) {
unreachable;
}
assertionFailure(ctx, args);
}
}
noinline fn assertionFailure(comptime ctx: []const u8, args: anytype) noreturn {
@branchHint(.cold);
if (@inComptime()) {
@compileError(std.fmt.comptimePrint("assertion failure: " ++ ctx, args));
}
@import("crash_handler.zig").crash(ctx, args, @returnAddress());
}
// Reference counting helper
pub fn RC(comptime T: type) type {
return struct {
_refs: T = 0,
pub fn init(refs: T) @This() {
return .{ ._refs = refs };
}
pub fn acquire(self: *@This()) void {
self._refs += 1;
}
pub fn release(self: *@This(), value: anytype, session: *Session) void {
if (comptime IS_DEBUG) {
std.debug.assert(self._refs > 0);
}
const refs = self._refs - 1;
self._refs = refs;
if (refs > 0) {
return;
}
value.deinit(session);
}
pub fn format(self: @This(), writer: *std.Io.Writer) !void {
return writer.print("{d}", .{self._refs});
}
};
}
test {
std.testing.refAllDecls(@This());
}