From 8e59ce9e9f75e37a673ddf5a342572ed1cd0f419 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Wed, 4 Mar 2026 21:55:50 +0000 Subject: [PATCH 01/34] Prepare global NetworkRuntime module --- build.zig.zon | 1 - src/App.zig | 16 +- src/Notification.zig | 2 +- src/Server.zig | 7 +- src/browser/Browser.zig | 2 +- .../Client.zig => browser/HttpClient.zig} | 48 +- src/browser/Page.zig | 16 +- src/browser/ScriptManager.zig | 17 +- src/browser/webapi/net/Fetch.zig | 8 +- src/browser/webapi/net/Headers.zig | 4 +- src/browser/webapi/net/Request.zig | 8 +- src/browser/webapi/net/Response.zig | 4 +- src/browser/webapi/net/XMLHttpRequest.zig | 17 +- src/cdp/cdp.zig | 2 +- src/cdp/domains/fetch.zig | 17 +- src/cdp/domains/network.zig | 2 +- src/http/Http.zig | 89 -- src/lightpanda.zig | 3 +- src/main_legacy_test.zig | 2 +- src/mcp/Server.zig | 4 +- src/{browser => network}/Robots.zig | 0 src/network/Runtime.zig | 166 ++++ src/network/http.zig | 610 ++++++++++++ src/{Net.zig => network/websocket.zig} | 909 +++--------------- src/telemetry/lightpanda.zig | 6 +- src/testing.zig | 6 +- 26 files changed, 985 insertions(+), 981 deletions(-) rename src/{http/Client.zig => browser/HttpClient.zig} (97%) delete mode 100644 src/http/Http.zig rename src/{browser => network}/Robots.zig (100%) create mode 100644 src/network/Runtime.zig create mode 100644 src/network/http.zig rename src/{Net.zig => network/websocket.zig} (53%) diff --git a/build.zig.zon b/build.zig.zon index b7525c77..eb3812a8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,7 +7,6 @@ .v8 = .{ .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz", .hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy", - }, //.v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/App.zig b/src/App.zig index 2d930fd6..7faee4c8 100644 --- a/src/App.zig +++ b/src/App.zig @@ -25,21 +25,19 @@ const Config = @import("Config.zig"); const Snapshot = @import("browser/js/Snapshot.zig"); const Platform = @import("browser/js/Platform.zig"); const Telemetry = @import("telemetry/telemetry.zig").Telemetry; -const RobotStore = @import("browser/Robots.zig").RobotStore; -pub const Http = @import("http/Http.zig"); +const Network = @import("network/Runtime.zig"); pub const ArenaPool = @import("ArenaPool.zig"); const App = @This(); -http: Http, +network: Network, config: *const Config, platform: Platform, snapshot: Snapshot, telemetry: Telemetry, allocator: Allocator, arena_pool: ArenaPool, -robots: RobotStore, app_dir_path: ?[]const u8, shutdown: bool = false, @@ -50,8 +48,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { app.* = .{ .config = config, .allocator = allocator, - .robots = RobotStore.init(allocator), - .http = undefined, + .network = undefined, .platform = undefined, .snapshot = undefined, .app_dir_path = undefined, @@ -59,8 +56,8 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { .arena_pool = undefined, }; - app.http = try Http.init(allocator, &app.robots, config); - errdefer app.http.deinit(); + app.network = try Network.init(allocator, config); + errdefer app.network.deinit(); app.platform = try Platform.init(); errdefer app.platform.deinit(); @@ -90,8 +87,7 @@ pub fn deinit(self: *App) void { self.app_dir_path = null; } self.telemetry.deinit(); - self.robots.deinit(); - self.http.deinit(); + self.network.deinit(); self.snapshot.deinit(); self.platform.deinit(); self.arena_pool.deinit(); diff --git a/src/Notification.zig b/src/Notification.zig index 186cc04e..e025820a 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -21,7 +21,7 @@ const lp = @import("lightpanda"); const log = @import("log.zig"); const Page = @import("browser/Page.zig"); -const Transfer = @import("http/Client.zig").Transfer; +const Transfer = @import("browser/HttpClient.zig").Transfer; const Allocator = std.mem.Allocator; diff --git a/src/Server.zig b/src/Server.zig index bd990560..026dbda9 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -30,9 +30,8 @@ const log = @import("log.zig"); const App = @import("App.zig"); const Config = @import("Config.zig"); const CDP = @import("cdp/cdp.zig").CDP; -const Net = @import("Net.zig"); -const Http = @import("http/Http.zig"); -const HttpClient = @import("http/Client.zig"); +const Net = @import("network/websocket.zig"); +const HttpClient = @import("browser/HttpClient.zig"); const Server = @This(); @@ -283,7 +282,7 @@ pub const Client = struct { log.info(.app, "client connected", .{ .ip = client_address }); } - const http = try app.http.createClient(allocator); + const http = try HttpClient.init(allocator, &app.network); errdefer http.deinit(); return .{ diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 503306d3..8f8c4aa2 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -24,7 +24,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const js = @import("js/js.zig"); const log = @import("../log.zig"); const App = @import("../App.zig"); -const HttpClient = @import("../http/Client.zig"); +const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; diff --git a/src/http/Client.zig b/src/browser/HttpClient.zig similarity index 97% rename from src/http/Client.zig rename to src/browser/HttpClient.zig index 5701de27..e74c049c 100644 --- a/src/http/Client.zig +++ b/src/browser/HttpClient.zig @@ -17,19 +17,19 @@ // along with this program. If not, see . const std = @import("std"); -const lp = @import("lightpanda"); - -const log = @import("../log.zig"); const builtin = @import("builtin"); +const posix = std.posix; -const Net = @import("../Net.zig"); +const lp = @import("lightpanda"); +const log = @import("../log.zig"); +const Net = @import("../network/http.zig"); +const Network = @import("../network/Runtime.zig"); const Config = @import("../Config.zig"); const URL = @import("../browser/URL.zig"); const Notification = @import("../Notification.zig"); const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar; -const Robots = @import("../browser/Robots.zig"); +const Robots = @import("../network/Robots.zig"); const RobotStore = Robots.RobotStore; -const posix = std.posix; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; @@ -77,8 +77,7 @@ queue: TransferQueue, // The main app allocator allocator: Allocator, -// Reference to the App-owned Robot Store. -robot_store: *RobotStore, +network: *Network, // Queue of requests that depend on a robots.txt. // Allows us to fetch the robots.txt just once. pending_robots_queue: std.StringHashMapUnmanaged(std.ArrayList(Request)) = .empty, @@ -97,8 +96,6 @@ http_proxy: ?[:0]const u8 = null, // CDP. use_proxy: bool, -config: *const Config, - cdp_client: ?CDPClient = null, // libcurl can monitor arbitrary sockets, this lets us use libcurl to poll @@ -121,14 +118,14 @@ pub const CDPClient = struct { const TransferQueue = std.DoublyLinkedList; -pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, config: *const Config) !*Client { +pub fn init(allocator: Allocator, network: *Network) !*Client { var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator); errdefer transfer_pool.deinit(); const client = try allocator.create(Client); errdefer allocator.destroy(client); - var handles = try Net.Handles.init(allocator, ca_blob, config); + var handles = try Net.Handles.init(allocator, network.ca_blob, network.config); errdefer handles.deinit(allocator); // Set transfer callbacks on each connection. @@ -136,7 +133,7 @@ pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, try conn.setCallbacks(Transfer.headerCallback, Transfer.dataCallback); } - const http_proxy = config.httpProxy(); + const http_proxy = network.config.httpProxy(); client.* = .{ .queue = .{}, @@ -144,10 +141,9 @@ pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, .intercepted = 0, .handles = handles, .allocator = allocator, - .robot_store = robot_store, + .network = network, .http_proxy = http_proxy, .use_proxy = http_proxy != null, - .config = config, .transfer_pool = transfer_pool, }; @@ -170,7 +166,7 @@ pub fn deinit(self: *Client) void { } pub fn newHeaders(self: *const Client) !Net.Headers { - return Net.Headers.init(self.config.http_headers.user_agent_header); + return Net.Headers.init(self.network.config.http_headers.user_agent_header); } pub fn abort(self: *Client) void { @@ -255,12 +251,12 @@ pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus { } pub fn request(self: *Client, req: Request) !void { - if (self.config.obeyRobots()) { + if (self.network.config.obeyRobots()) { const robots_url = try URL.getRobotsUrl(self.allocator, req.url); errdefer self.allocator.free(robots_url); // If we have this robots cached, we can take a fast path. - if (self.robot_store.get(robots_url)) |robot_entry| { + if (self.network.robot_store.get(robots_url)) |robot_entry| { defer self.allocator.free(robots_url); switch (robot_entry) { @@ -401,18 +397,18 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void { switch (ctx.status) { 200 => { if (ctx.buffer.items.len > 0) { - const robots: ?Robots = ctx.client.robot_store.robotsFromBytes( - ctx.client.config.http_headers.user_agent, + const robots: ?Robots = ctx.client.network.robot_store.robotsFromBytes( + ctx.client.network.config.http_headers.user_agent, ctx.buffer.items, ) catch blk: { log.warn(.browser, "failed to parse robots", .{ .robots_url = ctx.robots_url }); // If we fail to parse, we just insert it as absent and ignore. - try ctx.client.robot_store.putAbsent(ctx.robots_url); + try ctx.client.network.robot_store.putAbsent(ctx.robots_url); break :blk null; }; if (robots) |r| { - try ctx.client.robot_store.put(ctx.robots_url, r); + try ctx.client.network.robot_store.put(ctx.robots_url, r); const path = URL.getPathname(ctx.req.url); allowed = r.isAllowed(path); } @@ -421,12 +417,12 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void { 404 => { log.debug(.http, "robots not found", .{ .url = ctx.robots_url }); // If we get a 404, we just insert it as absent. - try ctx.client.robot_store.putAbsent(ctx.robots_url); + try ctx.client.network.robot_store.putAbsent(ctx.robots_url); }, else => { log.debug(.http, "unexpected status on robots", .{ .url = ctx.robots_url, .status = ctx.status }); // If we get an unexpected status, we just insert as absent. - try ctx.client.robot_store.putAbsent(ctx.robots_url); + try ctx.client.network.robot_store.putAbsent(ctx.robots_url); }, } @@ -609,7 +605,7 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer { .req = req, .ctx = req.ctx, .client = self, - .max_response_size = self.config.httpMaxResponseSize(), + .max_response_size = self.network.config.httpMaxResponseSize(), }; return transfer; } @@ -706,7 +702,7 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr } var header_list = req.headers; - try conn.secretHeaders(&header_list, &self.config.http_headers); // Add headers that must be hidden from intercepts + try conn.secretHeaders(&header_list, &self.network.config.http_headers); // Add headers that must be hidden from intercepts try conn.setHeaders(&header_list); // Add cookies. diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0cc7d8d9..1b50c7b7 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -62,8 +62,8 @@ const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); -const Http = App.Http; -const Net = @import("../Net.zig"); +const Net = @import("../network/http.zig"); +const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; const timestamp = @import("../datetime.zig").timestamp; @@ -396,7 +396,7 @@ pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 { // Add comon headers for a request: // * cookies // * referer -pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *Http.Headers) !void { +pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *Net.Headers) !void { try self.requestCookie(.{}).headersForRequest(temp, url, headers); // Build the referer @@ -828,7 +828,7 @@ fn notifyParentLoadComplete(self: *Page) void { parent.iframeCompletedLoading(self.iframe.?); } -fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool { +fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { var self: *Page = @ptrCast(@alignCast(transfer.ctx)); // would be different than self.url in the case of a redirect @@ -850,7 +850,7 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool { return true; } -fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { +fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { var self: *Page = @ptrCast(@alignCast(transfer.ctx)); if (self._parse_state == .pre) { @@ -3052,7 +3052,7 @@ pub const NavigateReason = enum { pub const NavigateOpts = struct { cdp_id: ?i64 = null, reason: NavigateReason = .address_bar, - method: Http.Method = .GET, + method: Net.Method = .GET, body: ?[]const u8 = null, header: ?[:0]const u8 = null, force: bool = false, @@ -3062,7 +3062,7 @@ pub const NavigateOpts = struct { pub const NavigatedOpts = struct { cdp_id: ?i64 = null, reason: NavigateReason = .address_bar, - method: Http.Method = .GET, + method: Net.Method = .GET, }; const NavigationType = enum { @@ -3307,7 +3307,7 @@ const RequestCookieOpts = struct { is_http: bool = true, is_navigation: bool = false, }; -pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) Http.Client.RequestCookie { +pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) HttpClient.RequestCookie { return .{ .jar = &self._session.cookie_jar, .origin = self.url, diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 0466f125..6f55f43b 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -21,7 +21,8 @@ const lp = @import("lightpanda"); const builtin = @import("builtin"); const log = @import("../log.zig"); -const Http = @import("../http/Http.zig"); +const HttpClient = @import("HttpClient.zig"); +const net_http = @import("../network/http.zig"); const String = @import("../string.zig").String; const js = @import("js/js.zig"); @@ -60,7 +61,7 @@ ready_scripts: std.DoublyLinkedList, shutdown: bool = false, -client: *Http.Client, +client: *HttpClient, allocator: Allocator, buffer_pool: BufferPool, @@ -88,7 +89,7 @@ importmap: std.StringHashMapUnmanaged([:0]const u8), // event). page_notified_of_completion: bool, -pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager { +pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptManager { return .{ .page = page, .async_scripts = .{}, @@ -141,7 +142,7 @@ fn clearList(list: *std.DoublyLinkedList) void { } } -pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers { +pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !net_http.Headers { var headers = try self.client.newHeaders(); try self.page.headersForRequest(self.page.arena, url, &headers); return headers; @@ -675,11 +676,11 @@ pub const Script = struct { self.manager.script_pool.destroy(self); } - fn startCallback(transfer: *Http.Transfer) !void { + fn startCallback(transfer: *HttpClient.Transfer) !void { log.debug(.http, "script fetch start", .{ .req = transfer }); } - fn headerCallback(transfer: *Http.Transfer) !bool { + fn headerCallback(transfer: *HttpClient.Transfer) !bool { const self: *Script = @ptrCast(@alignCast(transfer.ctx)); const header = &transfer.response_header.?; self.status = header.status; @@ -746,14 +747,14 @@ pub const Script = struct { return true; } - fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void { + fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *Script = @ptrCast(@alignCast(transfer.ctx)); self._dataCallback(transfer, data) catch |err| { log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len }); return err; }; } - fn _dataCallback(self: *Script, _: *Http.Transfer, data: []const u8) !void { + fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void { try self.source.remote.appendSlice(self.manager.allocator, data); } diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 35fce366..699cc9c4 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -19,7 +19,7 @@ const std = @import("std"); const log = @import("../../../log.zig"); -const Http = @import("../../../http/Http.zig"); +const HttpClient = @import("../../HttpClient.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); @@ -90,7 +90,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { return resolver.promise(); } -fn httpStartCallback(transfer: *Http.Transfer) !void { +fn httpStartCallback(transfer: *HttpClient.Transfer) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); if (comptime IS_DEBUG) { log.debug(.http, "request start", .{ .url = self._url, .source = "fetch" }); @@ -98,7 +98,7 @@ fn httpStartCallback(transfer: *Http.Transfer) !void { self._response._transfer = transfer; } -fn httpHeaderDoneCallback(transfer: *Http.Transfer) !bool { +fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const arena = self._response._arena; @@ -148,7 +148,7 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !bool { return true; } -fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { +fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); try self._buf.appendSlice(self._response._arena, data); } diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index e5462d82..2c9879ab 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -86,8 +86,8 @@ pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void { } // TODO: do we really need 2 different header structs?? -const Http = @import("../../../http/Http.zig"); -pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *Http.Headers) !void { +const net_http = @import("../../../network/http.zig"); +pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *net_http.Headers) !void { for (self._list._entries.items) |entry| { const merged = try std.mem.concatWithSentinel(allocator, u8, &.{ entry.name.str(), ": ", entry.value.str() }, 0); try http_headers.add(merged); diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 4316ddbb..b5c12e46 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -19,7 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); -const Http = @import("../../../http/Http.zig"); +const net_http = @import("../../../network/http.zig"); const URL = @import("../URL.zig"); const Page = @import("../../Page.zig"); @@ -29,7 +29,7 @@ const Allocator = std.mem.Allocator; const Request = @This(); _url: [:0]const u8, -_method: Http.Method, +_method: net_http.Method, _headers: ?*Headers, _body: ?[]const u8, _arena: Allocator, @@ -107,14 +107,14 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { }); } -fn parseMethod(method: []const u8, page: *Page) !Http.Method { +fn parseMethod(method: []const u8, page: *Page) !net_http.Method { if (method.len > "propfind".len) { return error.InvalidMethod; } const lower = std.ascii.lowerString(&page.buf, method); - const method_lookup = std.StaticStringMap(Http.Method).initComptime(.{ + const method_lookup = std.StaticStringMap(net_http.Method).initComptime(.{ .{ "get", .GET }, .{ "post", .POST }, .{ "delete", .DELETE }, diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index ba1a754d..278fd344 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -18,7 +18,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); -const Http = @import("../../../http/Http.zig"); +const HttpClient = @import("../../HttpClient.zig"); const Page = @import("../../Page.zig"); const Headers = @import("Headers.zig"); @@ -44,7 +44,7 @@ _type: Type, _status_text: []const u8, _url: [:0]const u8, _is_redirected: bool, -_transfer: ?*Http.Transfer = null, +_transfer: ?*HttpClient.Transfer = null, const InitOpts = struct { status: u16 = 200, diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index c1380908..763694ed 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -20,7 +20,8 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const log = @import("../../../log.zig"); -const Http = @import("../../../http/Http.zig"); +const HttpClient = @import("../../HttpClient.zig"); +const net_http = @import("../../../network/http.zig"); const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); @@ -38,10 +39,10 @@ const XMLHttpRequest = @This(); _page: *Page, _proto: *XMLHttpRequestEventTarget, _arena: Allocator, -_transfer: ?*Http.Transfer = null, +_transfer: ?*HttpClient.Transfer = null, _url: [:0]const u8 = "", -_method: Http.Method = .GET, +_method: net_http.Method = .GET, _request_headers: *Headers, _request_body: ?[]const u8 = null, @@ -341,7 +342,7 @@ pub fn getResponseXML(self: *XMLHttpRequest, page: *Page) !?*Node.Document { }; } -fn httpStartCallback(transfer: *Http.Transfer) !void { +fn httpStartCallback(transfer: *HttpClient.Transfer) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); if (comptime IS_DEBUG) { log.debug(.http, "request start", .{ .method = self._method, .url = self._url, .source = "xhr" }); @@ -349,13 +350,13 @@ fn httpStartCallback(transfer: *Http.Transfer) !void { self._transfer = transfer; } -fn httpHeaderCallback(transfer: *Http.Transfer, header: Http.Header) !void { +fn httpHeaderCallback(transfer: *HttpClient.Transfer, header: net_http.Header) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value }); try self._response_headers.append(self._arena, joined); } -fn httpHeaderDoneCallback(transfer: *Http.Transfer) !bool { +fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const header = &transfer.response_header.?; @@ -405,7 +406,7 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !bool { return true; } -fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { +fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); try self._response_data.appendSlice(self._arena, data); @@ -517,7 +518,7 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void { ); } -fn parseMethod(method: []const u8) !Http.Method { +fn parseMethod(method: []const u8) !net_http.Method { if (std.ascii.eqlIgnoreCase(method, "get")) { return .GET; } diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 8783e5a0..b0dd591e 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -28,7 +28,7 @@ const js = @import("../browser/js/js.zig"); const App = @import("../App.zig"); const Browser = @import("../browser/Browser.zig"); const Session = @import("../browser/Session.zig"); -const HttpClient = @import("../http/Client.zig"); +const HttpClient = @import("../browser/HttpClient.zig"); const Page = @import("../browser/Page.zig"); const Incrementing = @import("id.zig").Incrementing; const Notification = @import("../Notification.zig"); diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index beed6d76..310479b2 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -23,7 +23,8 @@ const id = @import("../id.zig"); const log = @import("../../log.zig"); const network = @import("network.zig"); -const Http = @import("../../http/Http.zig"); +const HttpClient = @import("../../browser/HttpClient.zig"); +const net_http = @import("../../network/http.zig"); const Notification = @import("../../Notification.zig"); pub fn processMessage(cmd: anytype) !void { @@ -49,7 +50,7 @@ pub fn processMessage(cmd: anytype) !void { // Stored in CDP pub const InterceptState = struct { allocator: Allocator, - waiting: std.AutoArrayHashMapUnmanaged(u32, *Http.Transfer), + waiting: std.AutoArrayHashMapUnmanaged(u32, *HttpClient.Transfer), pub fn init(allocator: Allocator) !InterceptState { return .{ @@ -62,11 +63,11 @@ pub const InterceptState = struct { return self.waiting.count() == 0; } - pub fn put(self: *InterceptState, transfer: *Http.Transfer) !void { + pub fn put(self: *InterceptState, transfer: *HttpClient.Transfer) !void { return self.waiting.put(self.allocator, transfer.id, transfer); } - pub fn remove(self: *InterceptState, request_id: u32) ?*Http.Transfer { + pub fn remove(self: *InterceptState, request_id: u32) ?*HttpClient.Transfer { const entry = self.waiting.fetchSwapRemove(request_id) orelse return null; return entry.value; } @@ -75,7 +76,7 @@ pub const InterceptState = struct { self.waiting.deinit(self.allocator); } - pub fn pendingTransfers(self: *const InterceptState) []*Http.Transfer { + pub fn pendingTransfers(self: *const InterceptState) []*HttpClient.Transfer { return self.waiting.values(); } }; @@ -221,7 +222,7 @@ fn continueRequest(cmd: anytype) !void { url: ?[]const u8 = null, method: ?[]const u8 = null, postData: ?[]const u8 = null, - headers: ?[]const Http.Header = null, + headers: ?[]const net_http.Header = null, interceptResponse: bool = false, })) orelse return error.InvalidParams; @@ -246,7 +247,7 @@ fn continueRequest(cmd: anytype) !void { try transfer.updateURL(try arena.dupeZ(u8, url)); } if (params.method) |method| { - transfer.req.method = std.meta.stringToEnum(Http.Method, method) orelse return error.InvalidParams; + transfer.req.method = std.meta.stringToEnum(net_http.Method, method) orelse return error.InvalidParams; } if (params.headers) |headers| { @@ -323,7 +324,7 @@ fn fulfillRequest(cmd: anytype) !void { const params = (try cmd.params(struct { requestId: []const u8, // "INT-{d}" responseCode: u16, - responseHeaders: ?[]const Http.Header = null, + responseHeaders: ?[]const net_http.Header = null, binaryResponseHeaders: ?[]const u8 = null, body: ?[]const u8 = null, responsePhrase: ?[]const u8 = null, diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index b353dc76..a2a36bbe 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -24,7 +24,7 @@ const CdpStorage = @import("storage.zig"); const id = @import("../id.zig"); const URL = @import("../../browser/URL.zig"); -const Transfer = @import("../../http/Client.zig").Transfer; +const Transfer = @import("../../browser/HttpClient.zig").Transfer; const Notification = @import("../../Notification.zig"); const Mime = @import("../../browser/Mime.zig"); diff --git a/src/http/Http.zig b/src/http/Http.zig deleted file mode 100644 index 778a1be4..00000000 --- a/src/http/Http.zig +++ /dev/null @@ -1,89 +0,0 @@ -// 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 . - -const std = @import("std"); -const Net = @import("../Net.zig"); - -const ENABLE_DEBUG = Net.ENABLE_DEBUG; -pub const Client = @import("Client.zig"); -pub const Transfer = Client.Transfer; - -pub const Method = Net.Method; -pub const Header = Net.Header; -pub const Headers = Net.Headers; - -const Config = @import("../Config.zig"); -const RobotStore = @import("../browser/Robots.zig").RobotStore; - -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; - -// Client.zig does the bulk of the work and is loosely tied to a browser Page. -// But we still need something above Client.zig for the "utility" http stuff -// we need to do, like telemetry. The most important thing we want from this -// is to be able to share the ca_blob, which can be quite large - loading it -// once for all http connections is a win. -const Http = @This(); - -arena: ArenaAllocator, -allocator: Allocator, -config: *const Config, -ca_blob: ?Net.Blob, -robot_store: *RobotStore, - -pub fn init(allocator: Allocator, robot_store: *RobotStore, config: *const Config) !Http { - try Net.globalInit(); - errdefer Net.globalDeinit(); - - if (comptime ENABLE_DEBUG) { - std.debug.print("curl version: {s}\n\n", .{Net.curl_version()}); - } - - var arena = ArenaAllocator.init(allocator); - errdefer arena.deinit(); - - var ca_blob: ?Net.Blob = null; - if (config.tlsVerifyHost()) { - ca_blob = try Net.loadCerts(allocator); - } - - return .{ - .arena = arena, - .allocator = allocator, - .config = config, - .ca_blob = ca_blob, - .robot_store = robot_store, - }; -} - -pub fn deinit(self: *Http) void { - if (self.ca_blob) |ca_blob| { - const data: [*]u8 = @ptrCast(ca_blob.data); - self.allocator.free(data[0..ca_blob.len]); - } - Net.globalDeinit(); - self.arena.deinit(); -} - -pub fn createClient(self: *Http, allocator: Allocator) !*Client { - return Client.init(allocator, self.ca_blob, self.robot_store, self.config); -} - -pub fn newConnection(self: *Http) !Net.Connection { - return Net.Connection.init(self.ca_blob, self.config); -} diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 26bc23f0..e10d9e20 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -34,6 +34,7 @@ pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); +const HttpClient = @import("browser/HttpClient.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { @@ -43,7 +44,7 @@ pub const FetchOpts = struct { writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { - const http_client = try app.http.createClient(app.allocator); + const http_client = try HttpClient.init(app.allocator, &app.network); defer http_client.deinit(); const notification = try Notification.init(app.allocator); diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 11c7588e..a6d1593f 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -46,7 +46,7 @@ pub fn main() !void { var test_arena = std.heap.ArenaAllocator.init(allocator); defer test_arena.deinit(); - const http_client = try app.http.createClient(allocator); + const http_client = try lp.HttpClient.init(allocator, &app.network); defer http_client.deinit(); var browser = try lp.Browser.init(app, .{ .http_client = http_client }); diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 80da4b56..caed9eef 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -3,7 +3,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const App = @import("../App.zig"); -const HttpClient = @import("../http/Client.zig"); +const HttpClient = @import("../browser/HttpClient.zig"); const testing = @import("../testing.zig"); const protocol = @import("protocol.zig"); const router = @import("router.zig"); @@ -23,7 +23,7 @@ mutex: std.Thread.Mutex = .{}, aw: std.io.Writer.Allocating, pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self { - const http_client = try app.http.createClient(allocator); + const http_client = try HttpClient.init(allocator, &app.network); errdefer http_client.deinit(); const notification = try lp.Notification.init(allocator); diff --git a/src/browser/Robots.zig b/src/network/Robots.zig similarity index 100% rename from src/browser/Robots.zig rename to src/network/Robots.zig diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig new file mode 100644 index 00000000..8de03760 --- /dev/null +++ b/src/network/Runtime.zig @@ -0,0 +1,166 @@ +// 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 . + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const lp = @import("lightpanda"); +const Config = @import("../Config.zig"); +const libcurl = @import("../sys/libcurl.zig"); + +const net_http = @import("http.zig"); +const RobotStore = @import("Robots.zig").RobotStore; + +const Runtime = @This(); + +allocator: Allocator, + +config: *const Config, +ca_blob: ?net_http.Blob, +robot_store: RobotStore, + +fn globalInit() void { + libcurl.curl_global_init(.{ .ssl = true }) catch |err| { + lp.assert(false, "curl global init", .{ .err = err }); + }; +} + +fn globalDeinit() void { + libcurl.curl_global_cleanup(); +} + +var global_init_once = std.once(globalInit); +var global_deinit_once = std.once(globalDeinit); + +pub fn init(allocator: Allocator, config: *const Config) !Runtime { + global_init_once.call(); + errdefer global_deinit_once.call(); + + var ca_blob: ?net_http.Blob = null; + if (config.tlsVerifyHost()) { + ca_blob = try loadCerts(allocator); + } + + return .{ + .allocator = allocator, + .config = config, + .ca_blob = ca_blob, + .robot_store = RobotStore.init(allocator), + }; +} + +pub fn deinit(self: *Runtime) void { + if (self.ca_blob) |ca_blob| { + const data: [*]u8 = @ptrCast(ca_blob.data); + self.allocator.free(data[0..ca_blob.len]); + } + + global_deinit_once.call(); +} + +pub fn newConnection(self: *Runtime) !net_http.Connection { + return net_http.Connection.init(self.ca_blob, self.config); +} + +// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is +// what Zig has), with lines wrapped at 64 characters and with a basic header +// and footer +const LineWriter = struct { + col: usize = 0, + inner: std.ArrayList(u8).Writer, + + pub fn writeAll(self: *LineWriter, data: []const u8) !void { + var writer = self.inner; + + var col = self.col; + const len = 64 - col; + + var remain = data; + if (remain.len > len) { + col = 0; + try writer.writeAll(data[0..len]); + try writer.writeByte('\n'); + remain = data[len..]; + } + + while (remain.len > 64) { + try writer.writeAll(remain[0..64]); + try writer.writeByte('\n'); + remain = data[len..]; + } + try writer.writeAll(remain); + self.col = col + remain.len; + } +}; + +// TODO: on BSD / Linux, we could just read the PEM file directly. +// This whole rescan + decode is really just needed for MacOS. On Linux +// bundle.rescan does find the .pem file(s) which could be in a few different +// places, so it's still useful, just not efficient. +fn loadCerts(allocator: Allocator) !libcurl.CurlBlob { + var bundle: std.crypto.Certificate.Bundle = .{}; + try bundle.rescan(allocator); + defer bundle.deinit(allocator); + + const bytes = bundle.bytes.items; + if (bytes.len == 0) { + lp.log.warn(.app, "No system certificates", .{}); + return .{ + .len = 0, + .flags = 0, + .data = bytes.ptr, + }; + } + + const encoder = std.base64.standard.Encoder; + var arr: std.ArrayList(u8) = .empty; + + const encoded_size = encoder.calcSize(bytes.len); + const buffer_size = encoded_size + + (bundle.map.count() * 75) + // start / end per certificate + extra, just in case + (encoded_size / 64) // newline per 64 characters + ; + try arr.ensureTotalCapacity(allocator, buffer_size); + errdefer arr.deinit(allocator); + var writer = arr.writer(allocator); + + var it = bundle.map.valueIterator(); + while (it.next()) |index| { + const cert = try std.crypto.Certificate.der.Element.parse(bytes, index.*); + + try writer.writeAll("-----BEGIN CERTIFICATE-----\n"); + var line_writer = LineWriter{ .inner = writer }; + try encoder.encodeWriter(&line_writer, bytes[index.*..cert.slice.end]); + try writer.writeAll("\n-----END CERTIFICATE-----\n"); + } + + // Final encoding should not be larger than our initial size estimate + lp.assert(buffer_size > arr.items.len, "Http loadCerts", .{ .estimate = buffer_size, .len = arr.items.len }); + + // Allocate exactly the size needed and copy the data + const result = try allocator.dupe(u8, arr.items); + // Free the original oversized allocation + arr.deinit(allocator); + + return .{ + .len = result.len, + .data = result.ptr, + .flags = 0, + }; +} diff --git a/src/network/http.zig b/src/network/http.zig new file mode 100644 index 00000000..fe2f1034 --- /dev/null +++ b/src/network/http.zig @@ -0,0 +1,610 @@ +// 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 . + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const Config = @import("../Config.zig"); +const libcurl = @import("../sys/libcurl.zig"); + +const log = @import("lightpanda").log; +const assert = @import("lightpanda").assert; + +pub const ENABLE_DEBUG = false; +const IS_DEBUG = builtin.mode == .Debug; + +pub const Blob = libcurl.CurlBlob; +pub const WaitFd = libcurl.CurlWaitFd; +pub const writefunc_error = libcurl.curl_writefunc_error; + +const Error = libcurl.Error; +const ErrorMulti = libcurl.ErrorMulti; +const errorFromCode = libcurl.errorFromCode; +const errorMFromCode = libcurl.errorMFromCode; +const errorCheck = libcurl.errorCheck; +const errorMCheck = libcurl.errorMCheck; + +pub fn curl_version() [*c]const u8 { + return libcurl.curl_version(); +} + +pub const Method = enum(u8) { + GET = 0, + PUT = 1, + POST = 2, + DELETE = 3, + HEAD = 4, + OPTIONS = 5, + PATCH = 6, + PROPFIND = 7, +}; + +pub const Header = struct { + name: []const u8, + value: []const u8, +}; + +pub const Headers = struct { + headers: ?*libcurl.CurlSList, + cookies: ?[*c]const u8, + + pub fn init(user_agent: [:0]const u8) !Headers { + const header_list = libcurl.curl_slist_append(null, user_agent); + if (header_list == null) { + return error.OutOfMemory; + } + return .{ .headers = header_list, .cookies = null }; + } + + pub fn deinit(self: *const Headers) void { + if (self.headers) |hdr| { + libcurl.curl_slist_free_all(hdr); + } + } + + pub fn add(self: *Headers, header: [*c]const u8) !void { + // Copies the value + const updated_headers = libcurl.curl_slist_append(self.headers, header); + if (updated_headers == null) { + return error.OutOfMemory; + } + + self.headers = updated_headers; + } + + fn parseHeader(header_str: []const u8) ?Header { + const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null; + + const name = std.mem.trim(u8, header_str[0..colon_pos], " \t"); + const value = std.mem.trim(u8, header_str[colon_pos + 1 ..], " \t"); + + return .{ .name = name, .value = value }; + } + + pub fn iterator(self: *Headers) Iterator { + return .{ + .header = self.headers, + .cookies = self.cookies, + }; + } + + const Iterator = struct { + header: [*c]libcurl.CurlSList, + cookies: ?[*c]const u8, + + pub fn next(self: *Iterator) ?Header { + const h = self.header orelse { + const cookies = self.cookies orelse return null; + self.cookies = null; + return .{ .name = "Cookie", .value = std.mem.span(@as([*:0]const u8, cookies)) }; + }; + + self.header = h.*.next; + return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data)))); + } + }; +}; + +// In normal cases, the header iterator comes from the curl linked list. +// But it's also possible to inject a response, via `transfer.fulfill`. In that +// case, the resposne headers are a list, []const Http.Header. +// This union, is an iterator that exposes the same API for either case. +pub const HeaderIterator = union(enum) { + curl: CurlHeaderIterator, + list: ListHeaderIterator, + + pub fn next(self: *HeaderIterator) ?Header { + switch (self.*) { + inline else => |*it| return it.next(), + } + } + + const CurlHeaderIterator = struct { + conn: *const Connection, + prev: ?*libcurl.CurlHeader = null, + + pub fn next(self: *CurlHeaderIterator) ?Header { + const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null; + self.prev = h; + + const header = h.*; + return .{ + .name = std.mem.span(header.name), + .value = std.mem.span(header.value), + }; + } + }; + + const ListHeaderIterator = struct { + index: usize = 0, + list: []const Header, + + pub fn next(self: *ListHeaderIterator) ?Header { + const idx = self.index; + if (idx == self.list.len) { + return null; + } + self.index = idx + 1; + return self.list[idx]; + } + }; +}; + +const HeaderValue = struct { + value: []const u8, + amount: usize, +}; + +pub const AuthChallenge = struct { + status: u16, + source: ?enum { server, proxy }, + scheme: ?enum { basic, digest }, + realm: ?[]const u8, + + pub fn parse(status: u16, header: []const u8) !AuthChallenge { + var ac: AuthChallenge = .{ + .status = status, + .source = null, + .realm = null, + .scheme = null, + }; + + const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader; + const hname = header[0..sep]; + const hvalue = header[sep + 2 ..]; + + if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) { + ac.source = .server; + } else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) { + ac.source = .proxy; + } else { + return error.InvalidAuthChallenge; + } + + const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len; + const _scheme = hvalue[0..pos]; + if (std.ascii.eqlIgnoreCase(_scheme, "basic")) { + ac.scheme = .basic; + } else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) { + ac.scheme = .digest; + } else { + return error.UnknownAuthChallengeScheme; + } + + return ac; + } +}; + +pub const ResponseHead = struct { + pub const MAX_CONTENT_TYPE_LEN = 64; + + status: u16, + url: [*c]const u8, + redirect_count: u32, + _content_type_len: usize = 0, + _content_type: [MAX_CONTENT_TYPE_LEN]u8 = undefined, + // this is normally an empty list, but if the response is being injected + // than it'll be populated. It isn't meant to be used directly, but should + // be used through the transfer.responseHeaderIterator() which abstracts + // whether the headers are from a live curl easy handle, or injected. + _injected_headers: []const Header = &.{}, + + pub fn contentType(self: *ResponseHead) ?[]u8 { + if (self._content_type_len == 0) { + return null; + } + return self._content_type[0..self._content_type_len]; + } +}; + +pub const Connection = struct { + easy: *libcurl.Curl, + node: Handles.HandleList.Node = .{}, + + pub fn init( + ca_blob_: ?libcurl.CurlBlob, + config: *const Config, + ) !Connection { + const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; + errdefer libcurl.curl_easy_cleanup(easy); + + // timeouts + try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout()); + try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout()); + + // redirect behavior + try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects()); + try libcurl.curl_easy_setopt(easy, .follow_location, 2); + try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default + + // proxy + const http_proxy = config.httpProxy(); + if (http_proxy) |proxy| { + try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr); + } + + // tls + if (ca_blob_) |ca_blob| { + try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob); + if (http_proxy != null) { + try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob); + } + } else { + assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{}); + + try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false); + try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false); + + if (http_proxy != null) { + try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false); + try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false); + } + } + + // compression, don't remove this. CloudFront will send gzip content + // even if we don't support it, and then it won't be decompressed. + // empty string means: use whatever's available + try libcurl.curl_easy_setopt(easy, .accept_encoding, ""); + + // debug + if (comptime ENABLE_DEBUG) { + try libcurl.curl_easy_setopt(easy, .verbose, true); + + // Sometimes the default debug output hides some useful data. You can + // uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to + // get more control over the data (specifically, the `CURLINFO_TEXT` + // can include useful data). + + // try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback); + } + + return .{ + .easy = easy, + }; + } + + pub fn deinit(self: *const Connection) void { + libcurl.curl_easy_cleanup(self.easy); + } + + pub fn setURL(self: *const Connection, url: [:0]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .url, url.ptr); + } + + // a libcurl request has 2 methods. The first is the method that + // controls how libcurl behaves. This specifically influences how redirects + // are handled. For example, if you do a POST and get a 301, libcurl will + // change that to a GET. But if you do a POST and get a 308, libcurl will + // keep the POST (and re-send the body). + // The second method is the actual string that's included in the request + // headers. + // These two methods can be different - you can tell curl to behave as though + // you made a GET, but include "POST" in the request header. + // + // Here, we're only concerned about the 2nd method. If we want, we'll set + // the first one based on whether or not we have a body. + // + // It's important that, for each use of this connection, we set the 2nd + // method. Else, if we make a HEAD request and re-use the connection, but + // DON'T reset this, it'll keep making HEAD requests. + // (I don't know if it's as important to reset the 1st method, or if libcurl + // can infer that based on the presence of the body, but we also reset it + // to be safe); + pub fn setMethod(self: *const Connection, method: Method) !void { + const easy = self.easy; + const m: [:0]const u8 = switch (method) { + .GET => "GET", + .POST => "POST", + .PUT => "PUT", + .DELETE => "DELETE", + .HEAD => "HEAD", + .OPTIONS => "OPTIONS", + .PATCH => "PATCH", + .PROPFIND => "PROPFIND", + }; + try libcurl.curl_easy_setopt(easy, .custom_request, m.ptr); + } + + pub fn setBody(self: *const Connection, body: []const u8) !void { + const easy = self.easy; + try libcurl.curl_easy_setopt(easy, .post, true); + try libcurl.curl_easy_setopt(easy, .post_field_size, body.len); + try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr); + } + + pub fn setGetMode(self: *const Connection) !void { + try libcurl.curl_easy_setopt(self.easy, .http_get, true); + } + + pub fn setHeaders(self: *const Connection, headers: *Headers) !void { + try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers); + } + + pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .cookie, cookies); + } + + pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void { + try libcurl.curl_easy_setopt(self.easy, .private, ptr); + } + + pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr); + } + + pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr); + } + + pub fn setCallbacks( + self: *const Connection, + comptime header_cb: libcurl.CurlHeaderFunction, + comptime data_cb: libcurl.CurlWriteFunction, + ) !void { + try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy); + try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb); + try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy); + try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb); + } + + pub fn setProxy(self: *const Connection, proxy: ?[*:0]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .proxy, proxy); + } + + pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void { + try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify); + try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify); + if (use_proxy) { + try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify); + try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify); + } + } + + pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 { + var url: [*c]u8 = undefined; + try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url); + return url; + } + + pub fn getResponseCode(self: *const Connection) !u16 { + var status: c_long = undefined; + try libcurl.curl_easy_getinfo(self.easy, .response_code, &status); + if (status < 0 or status > std.math.maxInt(u16)) { + return 0; + } + return @intCast(status); + } + + pub fn getRedirectCount(self: *const Connection) !u32 { + var count: c_long = undefined; + try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count); + return @intCast(count); + } + + pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { + var hdr: ?*libcurl.CurlHeader = null; + libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| { + // ErrorHeader includes OutOfMemory — rare but real errors from curl internals. + // Logged and returned as null since callers don't expect errors. + log.err(.http, "get response header", .{ + .name = name, + .err = err, + }); + return null; + }; + const h = hdr orelse return null; + return .{ + .amount = h.amount, + .value = std.mem.span(h.value), + }; + } + + pub fn getPrivate(self: *const Connection) !*anyopaque { + var private: *anyopaque = undefined; + try libcurl.curl_easy_getinfo(self.easy, .private, &private); + return private; + } + + // These are headers that may not be send to the users for inteception. + pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void { + if (http_headers.proxy_bearer_header) |hdr| { + try headers.add(hdr); + } + } + + pub fn request(self: *const Connection, http_headers: *const Config.HttpHeaders) !u16 { + var header_list = try Headers.init(http_headers.user_agent_header); + defer header_list.deinit(); + try self.secretHeaders(&header_list, http_headers); + try self.setHeaders(&header_list); + + // Add cookies. + if (header_list.cookies) |cookies| { + try self.setCookies(cookies); + } + + try libcurl.curl_easy_perform(self.easy); + return self.getResponseCode(); + } +}; + +pub const Handles = struct { + connections: []Connection, + dirty: HandleList, + in_use: HandleList, + available: HandleList, + multi: *libcurl.CurlM, + performing: bool = false, + + pub const HandleList = std.DoublyLinkedList; + + pub fn init( + allocator: Allocator, + ca_blob: ?libcurl.CurlBlob, + config: *const Config, + ) !Handles { + const count: usize = config.httpMaxConcurrent(); + if (count == 0) return error.InvalidMaxConcurrent; + + const multi = libcurl.curl_multi_init() orelse return error.FailedToInitializeMulti; + errdefer libcurl.curl_multi_cleanup(multi) catch {}; + + try libcurl.curl_multi_setopt(multi, .max_host_connections, config.httpMaxHostOpen()); + + const connections = try allocator.alloc(Connection, count); + errdefer allocator.free(connections); + + var available: HandleList = .{}; + for (0..count) |i| { + connections[i] = try Connection.init(ca_blob, config); + available.append(&connections[i].node); + } + + return .{ + .dirty = .{}, + .in_use = .{}, + .connections = connections, + .available = available, + .multi = multi, + }; + } + + pub fn deinit(self: *Handles, allocator: Allocator) void { + for (self.connections) |*conn| { + conn.deinit(); + } + allocator.free(self.connections); + libcurl.curl_multi_cleanup(self.multi) catch {}; + } + + pub fn hasAvailable(self: *const Handles) bool { + return self.available.first != null; + } + + pub fn get(self: *Handles) ?*Connection { + if (self.available.popFirst()) |node| { + self.in_use.append(node); + return @as(*Connection, @fieldParentPtr("node", node)); + } + return null; + } + + pub fn add(self: *Handles, conn: *const Connection) !void { + try libcurl.curl_multi_add_handle(self.multi, conn.easy); + } + + pub fn remove(self: *Handles, conn: *Connection) void { + if (libcurl.curl_multi_remove_handle(self.multi, conn.easy)) { + self.isAvailable(conn); + } else |err| { + // can happen if we're in a perform() call, so we'll queue this + // for cleanup later. + const node = &conn.node; + self.in_use.remove(node); + self.dirty.append(node); + log.warn(.http, "multi remove handle", .{ .err = err }); + } + } + + pub fn isAvailable(self: *Handles, conn: *Connection) void { + const node = &conn.node; + self.in_use.remove(node); + self.available.append(node); + } + + pub fn perform(self: *Handles) !c_int { + self.performing = true; + defer self.performing = false; + + const multi = self.multi; + var running: c_int = undefined; + try libcurl.curl_multi_perform(self.multi, &running); + + { + const list = &self.dirty; + while (list.first) |node| { + list.remove(node); + const conn: *Connection = @fieldParentPtr("node", node); + if (libcurl.curl_multi_remove_handle(multi, conn.easy)) { + self.available.append(node); + } else |err| { + log.fatal(.http, "multi remove handle", .{ .err = err, .src = "perform" }); + @panic("multi_remove_handle"); + } + } + } + + return running; + } + + pub fn poll(self: *Handles, extra_fds: []libcurl.CurlWaitFd, timeout_ms: c_int) !void { + try libcurl.curl_multi_poll(self.multi, extra_fds, timeout_ms, null); + } + + pub const MultiMessage = struct { + conn: Connection, + err: ?Error, + }; + + pub fn readMessage(self: *Handles) ?MultiMessage { + var messages_count: c_int = 0; + const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null; + return switch (msg.data) { + .done => |err| .{ + .conn = .{ .easy = msg.easy_handle }, + .err = err, + }, + else => unreachable, + }; + } +}; + +fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: *anyopaque) c_int { + const data = raw[0..len]; + switch (msg_type) { + .text => std.debug.print("libcurl [text]: {s}\n", .{data}), + .header_out => std.debug.print("libcurl [req-h]: {s}\n", .{data}), + .header_in => std.debug.print("libcurl [res-h]: {s}\n", .{data}), + // .data_in => std.debug.print("libcurl [res-b]: {s}\n", .{data}), + else => std.debug.print("libcurl ?? {d}\n", .{msg_type}), + } + return 0; +} diff --git a/src/Net.zig b/src/network/websocket.zig similarity index 53% rename from src/Net.zig rename to src/network/websocket.zig index c45707e4..5a5b4747 100644 --- a/src/Net.zig +++ b/src/network/websocket.zig @@ -21,721 +21,10 @@ const builtin = @import("builtin"); const posix = std.posix; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const libcurl = @import("sys/libcurl.zig"); -const log = @import("log.zig"); -const Config = @import("Config.zig"); +const log = @import("lightpanda").log; const assert = @import("lightpanda").assert; - -pub const ENABLE_DEBUG = false; -const IS_DEBUG = builtin.mode == .Debug; - -pub const Blob = libcurl.CurlBlob; -pub const WaitFd = libcurl.CurlWaitFd; -pub const writefunc_error = libcurl.curl_writefunc_error; - -const Error = libcurl.Error; -const ErrorMulti = libcurl.ErrorMulti; -const errorFromCode = libcurl.errorFromCode; -const errorMFromCode = libcurl.errorMFromCode; -const errorCheck = libcurl.errorCheck; -const errorMCheck = libcurl.errorMCheck; - -pub fn curl_version() [*c]const u8 { - return libcurl.curl_version(); -} - -pub const Method = enum(u8) { - GET = 0, - PUT = 1, - POST = 2, - DELETE = 3, - HEAD = 4, - OPTIONS = 5, - PATCH = 6, - PROPFIND = 7, -}; - -pub const Header = struct { - name: []const u8, - value: []const u8, -}; - -pub const Headers = struct { - headers: ?*libcurl.CurlSList, - cookies: ?[*c]const u8, - - pub fn init(user_agent: [:0]const u8) !Headers { - const header_list = libcurl.curl_slist_append(null, user_agent); - if (header_list == null) { - return error.OutOfMemory; - } - return .{ .headers = header_list, .cookies = null }; - } - - pub fn deinit(self: *const Headers) void { - if (self.headers) |hdr| { - libcurl.curl_slist_free_all(hdr); - } - } - - pub fn add(self: *Headers, header: [*c]const u8) !void { - // Copies the value - const updated_headers = libcurl.curl_slist_append(self.headers, header); - if (updated_headers == null) { - return error.OutOfMemory; - } - - self.headers = updated_headers; - } - - fn parseHeader(header_str: []const u8) ?Header { - const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null; - - const name = std.mem.trim(u8, header_str[0..colon_pos], " \t"); - const value = std.mem.trim(u8, header_str[colon_pos + 1 ..], " \t"); - - return .{ .name = name, .value = value }; - } - - pub fn iterator(self: *Headers) Iterator { - return .{ - .header = self.headers, - .cookies = self.cookies, - }; - } - - const Iterator = struct { - header: [*c]libcurl.CurlSList, - cookies: ?[*c]const u8, - - pub fn next(self: *Iterator) ?Header { - const h = self.header orelse { - const cookies = self.cookies orelse return null; - self.cookies = null; - return .{ .name = "Cookie", .value = std.mem.span(@as([*:0]const u8, cookies)) }; - }; - - self.header = h.*.next; - return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data)))); - } - }; -}; - -// In normal cases, the header iterator comes from the curl linked list. -// But it's also possible to inject a response, via `transfer.fulfill`. In that -// case, the resposne headers are a list, []const Http.Header. -// This union, is an iterator that exposes the same API for either case. -pub const HeaderIterator = union(enum) { - curl: CurlHeaderIterator, - list: ListHeaderIterator, - - pub fn next(self: *HeaderIterator) ?Header { - switch (self.*) { - inline else => |*it| return it.next(), - } - } - - const CurlHeaderIterator = struct { - conn: *const Connection, - prev: ?*libcurl.CurlHeader = null, - - pub fn next(self: *CurlHeaderIterator) ?Header { - const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null; - self.prev = h; - - const header = h.*; - return .{ - .name = std.mem.span(header.name), - .value = std.mem.span(header.value), - }; - } - }; - - const ListHeaderIterator = struct { - index: usize = 0, - list: []const Header, - - pub fn next(self: *ListHeaderIterator) ?Header { - const idx = self.index; - if (idx == self.list.len) { - return null; - } - self.index = idx + 1; - return self.list[idx]; - } - }; -}; - -const HeaderValue = struct { - value: []const u8, - amount: usize, -}; - -pub const AuthChallenge = struct { - status: u16, - source: ?enum { server, proxy }, - scheme: ?enum { basic, digest }, - realm: ?[]const u8, - - pub fn parse(status: u16, header: []const u8) !AuthChallenge { - var ac: AuthChallenge = .{ - .status = status, - .source = null, - .realm = null, - .scheme = null, - }; - - const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader; - const hname = header[0..sep]; - const hvalue = header[sep + 2 ..]; - - if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) { - ac.source = .server; - } else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) { - ac.source = .proxy; - } else { - return error.InvalidAuthChallenge; - } - - const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len; - const _scheme = hvalue[0..pos]; - if (std.ascii.eqlIgnoreCase(_scheme, "basic")) { - ac.scheme = .basic; - } else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) { - ac.scheme = .digest; - } else { - return error.UnknownAuthChallengeScheme; - } - - return ac; - } -}; - -pub const ResponseHead = struct { - pub const MAX_CONTENT_TYPE_LEN = 64; - - status: u16, - url: [*c]const u8, - redirect_count: u32, - _content_type_len: usize = 0, - _content_type: [MAX_CONTENT_TYPE_LEN]u8 = undefined, - // this is normally an empty list, but if the response is being injected - // than it'll be populated. It isn't meant to be used directly, but should - // be used through the transfer.responseHeaderIterator() which abstracts - // whether the headers are from a live curl easy handle, or injected. - _injected_headers: []const Header = &.{}, - - pub fn contentType(self: *ResponseHead) ?[]u8 { - if (self._content_type_len == 0) { - return null; - } - return self._content_type[0..self._content_type_len]; - } -}; - -pub fn globalInit() Error!void { - try libcurl.curl_global_init(.{ .ssl = true }); -} - -pub fn globalDeinit() void { - libcurl.curl_global_cleanup(); -} - -pub const Connection = struct { - easy: *libcurl.Curl, - node: Handles.HandleList.Node = .{}, - - pub fn init( - ca_blob_: ?libcurl.CurlBlob, - config: *const Config, - ) !Connection { - const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; - errdefer libcurl.curl_easy_cleanup(easy); - - // timeouts - try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout()); - try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout()); - - // redirect behavior - try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects()); - try libcurl.curl_easy_setopt(easy, .follow_location, 2); - try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default - - // proxy - const http_proxy = config.httpProxy(); - if (http_proxy) |proxy| { - try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr); - } - - // tls - if (ca_blob_) |ca_blob| { - try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob); - if (http_proxy != null) { - try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob); - } - } else { - assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{}); - - try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false); - try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false); - - if (http_proxy != null) { - try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false); - try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false); - } - } - - // compression, don't remove this. CloudFront will send gzip content - // even if we don't support it, and then it won't be decompressed. - // empty string means: use whatever's available - try libcurl.curl_easy_setopt(easy, .accept_encoding, ""); - - // debug - if (comptime ENABLE_DEBUG) { - try libcurl.curl_easy_setopt(easy, .verbose, true); - - // Sometimes the default debug output hides some useful data. You can - // uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to - // get more control over the data (specifically, the `CURLINFO_TEXT` - // can include useful data). - - // try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback); - } - - return .{ - .easy = easy, - }; - } - - pub fn deinit(self: *const Connection) void { - libcurl.curl_easy_cleanup(self.easy); - } - - pub fn setURL(self: *const Connection, url: [:0]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .url, url.ptr); - } - - // a libcurl request has 2 methods. The first is the method that - // controls how libcurl behaves. This specifically influences how redirects - // are handled. For example, if you do a POST and get a 301, libcurl will - // change that to a GET. But if you do a POST and get a 308, libcurl will - // keep the POST (and re-send the body). - // The second method is the actual string that's included in the request - // headers. - // These two methods can be different - you can tell curl to behave as though - // you made a GET, but include "POST" in the request header. - // - // Here, we're only concerned about the 2nd method. If we want, we'll set - // the first one based on whether or not we have a body. - // - // It's important that, for each use of this connection, we set the 2nd - // method. Else, if we make a HEAD request and re-use the connection, but - // DON'T reset this, it'll keep making HEAD requests. - // (I don't know if it's as important to reset the 1st method, or if libcurl - // can infer that based on the presence of the body, but we also reset it - // to be safe); - pub fn setMethod(self: *const Connection, method: Method) !void { - const easy = self.easy; - const m: [:0]const u8 = switch (method) { - .GET => "GET", - .POST => "POST", - .PUT => "PUT", - .DELETE => "DELETE", - .HEAD => "HEAD", - .OPTIONS => "OPTIONS", - .PATCH => "PATCH", - .PROPFIND => "PROPFIND", - }; - try libcurl.curl_easy_setopt(easy, .custom_request, m.ptr); - } - - pub fn setBody(self: *const Connection, body: []const u8) !void { - const easy = self.easy; - try libcurl.curl_easy_setopt(easy, .post, true); - try libcurl.curl_easy_setopt(easy, .post_field_size, body.len); - try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr); - } - - pub fn setGetMode(self: *const Connection) !void { - try libcurl.curl_easy_setopt(self.easy, .http_get, true); - } - - pub fn setHeaders(self: *const Connection, headers: *Headers) !void { - try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers); - } - - pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .cookie, cookies); - } - - pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void { - try libcurl.curl_easy_setopt(self.easy, .private, ptr); - } - - pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr); - } - - pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr); - } - - pub fn setCallbacks( - self: *const Connection, - comptime header_cb: libcurl.CurlHeaderFunction, - comptime data_cb: libcurl.CurlWriteFunction, - ) !void { - try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy); - try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb); - try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy); - try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb); - } - - pub fn setProxy(self: *const Connection, proxy: ?[*:0]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .proxy, proxy); - } - - pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void { - try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify); - try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify); - if (use_proxy) { - try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify); - try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify); - } - } - - pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 { - var url: [*c]u8 = undefined; - try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url); - return url; - } - - pub fn getResponseCode(self: *const Connection) !u16 { - var status: c_long = undefined; - try libcurl.curl_easy_getinfo(self.easy, .response_code, &status); - if (status < 0 or status > std.math.maxInt(u16)) { - return 0; - } - return @intCast(status); - } - - pub fn getRedirectCount(self: *const Connection) !u32 { - var count: c_long = undefined; - try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count); - return @intCast(count); - } - - pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { - var hdr: ?*libcurl.CurlHeader = null; - libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| { - // ErrorHeader includes OutOfMemory — rare but real errors from curl internals. - // Logged and returned as null since callers don't expect errors. - log.err(.http, "get response header", .{ - .name = name, - .err = err, - }); - return null; - }; - const h = hdr orelse return null; - return .{ - .amount = h.amount, - .value = std.mem.span(h.value), - }; - } - - pub fn getPrivate(self: *const Connection) !*anyopaque { - var private: *anyopaque = undefined; - try libcurl.curl_easy_getinfo(self.easy, .private, &private); - return private; - } - - // These are headers that may not be send to the users for inteception. - pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void { - if (http_headers.proxy_bearer_header) |hdr| { - try headers.add(hdr); - } - } - - pub fn request(self: *const Connection, http_headers: *const Config.HttpHeaders) !u16 { - var header_list = try Headers.init(http_headers.user_agent_header); - defer header_list.deinit(); - try self.secretHeaders(&header_list, http_headers); - try self.setHeaders(&header_list); - - // Add cookies. - if (header_list.cookies) |cookies| { - try self.setCookies(cookies); - } - - try libcurl.curl_easy_perform(self.easy); - return self.getResponseCode(); - } -}; - -pub const Handles = struct { - connections: []Connection, - dirty: HandleList, - in_use: HandleList, - available: HandleList, - multi: *libcurl.CurlM, - performing: bool = false, - - pub const HandleList = std.DoublyLinkedList; - - pub fn init( - allocator: Allocator, - ca_blob: ?libcurl.CurlBlob, - config: *const Config, - ) !Handles { - const count: usize = config.httpMaxConcurrent(); - if (count == 0) return error.InvalidMaxConcurrent; - - const multi = libcurl.curl_multi_init() orelse return error.FailedToInitializeMulti; - errdefer libcurl.curl_multi_cleanup(multi) catch {}; - - try libcurl.curl_multi_setopt(multi, .max_host_connections, config.httpMaxHostOpen()); - - const connections = try allocator.alloc(Connection, count); - errdefer allocator.free(connections); - - var available: HandleList = .{}; - for (0..count) |i| { - connections[i] = try Connection.init(ca_blob, config); - available.append(&connections[i].node); - } - - return .{ - .dirty = .{}, - .in_use = .{}, - .connections = connections, - .available = available, - .multi = multi, - }; - } - - pub fn deinit(self: *Handles, allocator: Allocator) void { - for (self.connections) |*conn| { - conn.deinit(); - } - allocator.free(self.connections); - libcurl.curl_multi_cleanup(self.multi) catch {}; - } - - pub fn hasAvailable(self: *const Handles) bool { - return self.available.first != null; - } - - pub fn get(self: *Handles) ?*Connection { - if (self.available.popFirst()) |node| { - self.in_use.append(node); - return @as(*Connection, @fieldParentPtr("node", node)); - } - return null; - } - - pub fn add(self: *Handles, conn: *const Connection) !void { - try libcurl.curl_multi_add_handle(self.multi, conn.easy); - } - - pub fn remove(self: *Handles, conn: *Connection) void { - if (libcurl.curl_multi_remove_handle(self.multi, conn.easy)) { - self.isAvailable(conn); - } else |err| { - // can happen if we're in a perform() call, so we'll queue this - // for cleanup later. - const node = &conn.node; - self.in_use.remove(node); - self.dirty.append(node); - log.warn(.http, "multi remove handle", .{ .err = err }); - } - } - - pub fn isAvailable(self: *Handles, conn: *Connection) void { - const node = &conn.node; - self.in_use.remove(node); - self.available.append(node); - } - - pub fn perform(self: *Handles) !c_int { - self.performing = true; - defer self.performing = false; - - const multi = self.multi; - var running: c_int = undefined; - try libcurl.curl_multi_perform(self.multi, &running); - - { - const list = &self.dirty; - while (list.first) |node| { - list.remove(node); - const conn: *Connection = @fieldParentPtr("node", node); - if (libcurl.curl_multi_remove_handle(multi, conn.easy)) { - self.available.append(node); - } else |err| { - log.fatal(.http, "multi remove handle", .{ .err = err, .src = "perform" }); - @panic("multi_remove_handle"); - } - } - } - - return running; - } - - pub fn poll(self: *Handles, extra_fds: []libcurl.CurlWaitFd, timeout_ms: c_int) !void { - try libcurl.curl_multi_poll(self.multi, extra_fds, timeout_ms, null); - } - - pub const MultiMessage = struct { - conn: Connection, - err: ?Error, - }; - - pub fn readMessage(self: *Handles) ?MultiMessage { - var messages_count: c_int = 0; - const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null; - return switch (msg.data) { - .done => |err| .{ - .conn = .{ .easy = msg.easy_handle }, - .err = err, - }, - else => unreachable, - }; - } -}; - -// TODO: on BSD / Linux, we could just read the PEM file directly. -// This whole rescan + decode is really just needed for MacOS. On Linux -// bundle.rescan does find the .pem file(s) which could be in a few different -// places, so it's still useful, just not efficient. -pub fn loadCerts(allocator: Allocator) !libcurl.CurlBlob { - var bundle: std.crypto.Certificate.Bundle = .{}; - try bundle.rescan(allocator); - defer bundle.deinit(allocator); - - const bytes = bundle.bytes.items; - if (bytes.len == 0) { - log.warn(.app, "No system certificates", .{}); - return .{ - .len = 0, - .flags = 0, - .data = bytes.ptr, - }; - } - - const encoder = std.base64.standard.Encoder; - var arr: std.ArrayList(u8) = .empty; - - const encoded_size = encoder.calcSize(bytes.len); - const buffer_size = encoded_size + - (bundle.map.count() * 75) + // start / end per certificate + extra, just in case - (encoded_size / 64) // newline per 64 characters - ; - try arr.ensureTotalCapacity(allocator, buffer_size); - errdefer arr.deinit(allocator); - var writer = arr.writer(allocator); - - var it = bundle.map.valueIterator(); - while (it.next()) |index| { - const cert = try std.crypto.Certificate.der.Element.parse(bytes, index.*); - - try writer.writeAll("-----BEGIN CERTIFICATE-----\n"); - var line_writer = LineWriter{ .inner = writer }; - try encoder.encodeWriter(&line_writer, bytes[index.*..cert.slice.end]); - try writer.writeAll("\n-----END CERTIFICATE-----\n"); - } - - // Final encoding should not be larger than our initial size estimate - assert(buffer_size > arr.items.len, "Http loadCerts", .{ .estimate = buffer_size, .len = arr.items.len }); - - // Allocate exactly the size needed and copy the data - const result = try allocator.dupe(u8, arr.items); - // Free the original oversized allocation - arr.deinit(allocator); - - return .{ - .len = result.len, - .data = result.ptr, - .flags = 0, - }; -} - -// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is -// what Zig has), with lines wrapped at 64 characters and with a basic header -// and footer -const LineWriter = struct { - col: usize = 0, - inner: std.ArrayList(u8).Writer, - - pub fn writeAll(self: *LineWriter, data: []const u8) !void { - var writer = self.inner; - - var col = self.col; - const len = 64 - col; - - var remain = data; - if (remain.len > len) { - col = 0; - try writer.writeAll(data[0..len]); - try writer.writeByte('\n'); - remain = data[len..]; - } - - while (remain.len > 64) { - try writer.writeAll(remain[0..64]); - try writer.writeByte('\n'); - remain = data[len..]; - } - try writer.writeAll(remain); - self.col = col + remain.len; - } -}; - -fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: *anyopaque) c_int { - const data = raw[0..len]; - switch (msg_type) { - .text => std.debug.print("libcurl [text]: {s}\n", .{data}), - .header_out => std.debug.print("libcurl [req-h]: {s}\n", .{data}), - .header_in => std.debug.print("libcurl [res-h]: {s}\n", .{data}), - // .data_in => std.debug.print("libcurl [res-b]: {s}\n", .{data}), - else => std.debug.print("libcurl ?? {d}\n", .{msg_type}), - } - return 0; -} - -// Zig is in a weird backend transition right now. Need to determine if -// SIMD is even available. -const backend_supports_vectors = switch (builtin.zig_backend) { - .stage2_llvm, .stage2_c => true, - else => false, -}; - -// Websocket messages from client->server are masked using a 4 byte XOR mask -fn mask(m: []const u8, payload: []u8) void { - var data = payload; - - if (!comptime backend_supports_vectors) return simpleMask(m, data); - - const vector_size = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize); - if (data.len >= vector_size) { - const mask_vector = std.simd.repeat(vector_size, @as(@Vector(4, u8), m[0..4].*)); - while (data.len >= vector_size) { - const slice = data[0..vector_size]; - const masked_data_slice: @Vector(vector_size, u8) = slice.*; - slice.* = masked_data_slice ^ mask_vector; - data = data[vector_size..]; - } - } - simpleMask(m, data); -} - -// Used when SIMD isn't available, or for any remaining part of the message -// which is too small to effectively use SIMD. -fn simpleMask(m: []const u8, payload: []u8) void { - for (payload, 0..) |b, i| { - payload[i] = b ^ m[i & 3]; - } -} +const CDP_MAX_MESSAGE_SIZE = @import("../Config.zig").CDP_MAX_MESSAGE_SIZE; const Fragments = struct { type: Message.Type, @@ -763,76 +52,6 @@ const OpCode = enum(u8) { pong = 128 | 10, }; -fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 { - // can't use buf[0..10] here, because the header length - // is variable. If it's just 2 bytes, for example, we need the - // framed message to be: - // h1, h2, data - // If we use buf[0..10], we'd get: - // h1, h2, 0, 0, 0, 0, 0, 0, 0, 0, data - - var header_buf: [10]u8 = undefined; - - // -10 because we reserved 10 bytes for the header above - const header = websocketHeader(&header_buf, .text, buf.items.len - 10); - const start = 10 - header.len; - - const message = buf.items; - @memcpy(message[start..10], header); - return message[start..]; -} - -// makes the assumption that our caller reserved the first -// 10 bytes for the header -fn websocketHeader(buf: []u8, op_code: OpCode, payload_len: usize) []const u8 { - assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len }); - - const len = payload_len; - buf[0] = 128 | @intFromEnum(op_code); // fin | opcode - - if (len <= 125) { - buf[1] = @intCast(len); - return buf[0..2]; - } - - if (len < 65536) { - buf[1] = 126; - buf[2] = @intCast((len >> 8) & 0xFF); - buf[3] = @intCast(len & 0xFF); - return buf[0..4]; - } - - buf[1] = 127; - buf[2] = 0; - buf[3] = 0; - buf[4] = 0; - buf[5] = 0; - buf[6] = @intCast((len >> 24) & 0xFF); - buf[7] = @intCast((len >> 16) & 0xFF); - buf[8] = @intCast((len >> 8) & 0xFF); - buf[9] = @intCast(len & 0xFF); - return buf[0..10]; -} - -fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 { - // from std.ArrayList - var new_capacity = buf.len; - while (true) { - new_capacity +|= new_capacity / 2 + 8; - if (new_capacity >= required_capacity) break; - } - - log.debug(.app, "CDP buffer growth", .{ .from = buf.len, .to = new_capacity }); - - if (allocator.resize(buf, new_capacity)) { - return buf.ptr[0..new_capacity]; - } - const new_buffer = try allocator.alloc(u8, new_capacity); - @memcpy(new_buffer[0..buf.len], buf); - allocator.free(buf); - return new_buffer; -} - // WebSocket message reader. Given websocket message, acts as an iterator that // can return zero or more Messages. When next returns null, any incomplete // message will remain in reader.data @@ -932,7 +151,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type { if (message_len > 125) { return error.ControlTooLarge; } - } else if (message_len > Config.CDP_MAX_MESSAGE_SIZE) { + } else if (message_len > CDP_MAX_MESSAGE_SIZE) { return error.TooLarge; } else if (message_len > self.buf.len) { const len = self.buf.len; @@ -960,7 +179,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type { if (is_continuation) { const fragments = &(self.fragments orelse return error.InvalidContinuation); - if (fragments.message.items.len + message_len > Config.CDP_MAX_MESSAGE_SIZE) { + if (fragments.message.items.len + message_len > CDP_MAX_MESSAGE_SIZE) { return error.TooLarge; } @@ -1086,14 +305,6 @@ pub fn Reader(comptime EXPECT_MASK: bool) type { }; } -// In-place string lowercase -fn toLower(str: []u8) []u8 { - for (str, 0..) |ch, i| { - str[i] = std.ascii.toLower(ch); - } - return str; -} - pub const WsConnection = struct { // CLOSE, 2 length, code const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 @@ -1385,6 +596,118 @@ pub const WsConnection = struct { } }; +fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 { + // can't use buf[0..10] here, because the header length + // is variable. If it's just 2 bytes, for example, we need the + // framed message to be: + // h1, h2, data + // If we use buf[0..10], we'd get: + // h1, h2, 0, 0, 0, 0, 0, 0, 0, 0, data + + var header_buf: [10]u8 = undefined; + + // -10 because we reserved 10 bytes for the header above + const header = websocketHeader(&header_buf, .text, buf.items.len - 10); + const start = 10 - header.len; + + const message = buf.items; + @memcpy(message[start..10], header); + return message[start..]; +} + +// makes the assumption that our caller reserved the first +// 10 bytes for the header +fn websocketHeader(buf: []u8, op_code: OpCode, payload_len: usize) []const u8 { + assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len }); + + const len = payload_len; + buf[0] = 128 | @intFromEnum(op_code); // fin | opcode + + if (len <= 125) { + buf[1] = @intCast(len); + return buf[0..2]; + } + + if (len < 65536) { + buf[1] = 126; + buf[2] = @intCast((len >> 8) & 0xFF); + buf[3] = @intCast(len & 0xFF); + return buf[0..4]; + } + + buf[1] = 127; + buf[2] = 0; + buf[3] = 0; + buf[4] = 0; + buf[5] = 0; + buf[6] = @intCast((len >> 24) & 0xFF); + buf[7] = @intCast((len >> 16) & 0xFF); + buf[8] = @intCast((len >> 8) & 0xFF); + buf[9] = @intCast(len & 0xFF); + return buf[0..10]; +} + +fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 { + // from std.ArrayList + var new_capacity = buf.len; + while (true) { + new_capacity +|= new_capacity / 2 + 8; + if (new_capacity >= required_capacity) break; + } + + log.debug(.app, "CDP buffer growth", .{ .from = buf.len, .to = new_capacity }); + + if (allocator.resize(buf, new_capacity)) { + return buf.ptr[0..new_capacity]; + } + const new_buffer = try allocator.alloc(u8, new_capacity); + @memcpy(new_buffer[0..buf.len], buf); + allocator.free(buf); + return new_buffer; +} + +// In-place string lowercase +fn toLower(str: []u8) []u8 { + for (str, 0..) |ch, i| { + str[i] = std.ascii.toLower(ch); + } + return str; +} + +// Used when SIMD isn't available, or for any remaining part of the message +// which is too small to effectively use SIMD. +fn simpleMask(m: []const u8, payload: []u8) void { + for (payload, 0..) |b, i| { + payload[i] = b ^ m[i & 3]; + } +} + +// Zig is in a weird backend transition right now. Need to determine if +// SIMD is even available. +const backend_supports_vectors = switch (builtin.zig_backend) { + .stage2_llvm, .stage2_c => true, + else => false, +}; + +// Websocket messages from client->server are masked using a 4 byte XOR mask +fn mask(m: []const u8, payload: []u8) void { + var data = payload; + + if (!comptime backend_supports_vectors) return simpleMask(m, data); + + const vector_size = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize); + if (data.len >= vector_size) { + const mask_vector = std.simd.repeat(vector_size, @as(@Vector(4, u8), m[0..4].*)); + while (data.len >= vector_size) { + const slice = data[0..vector_size]; + const masked_data_slice: @Vector(vector_size, u8) = slice.*; + slice.* = masked_data_slice ^ mask_vector; + data = data[vector_size..]; + } + } + simpleMask(m, data); +} + const testing = std.testing; test "mask" { diff --git a/src/telemetry/lightpanda.zig b/src/telemetry/lightpanda.zig index d141e060..75552eeb 100644 --- a/src/telemetry/lightpanda.zig +++ b/src/telemetry/lightpanda.zig @@ -7,9 +7,9 @@ const Allocator = std.mem.Allocator; const log = @import("../log.zig"); const App = @import("../App.zig"); -const Net = @import("../Net.zig"); const Config = @import("../Config.zig"); const telemetry = @import("telemetry.zig"); +const Connection = @import("../network/http.zig").Connection; const URL = "https://telemetry.lightpanda.io"; const MAX_BATCH_SIZE = 20; @@ -20,13 +20,13 @@ pub const LightPanda = struct { allocator: Allocator, mutex: std.Thread.Mutex, cond: Thread.Condition, - connection: Net.Connection, + connection: Connection, config: *const Config, pending: std.DoublyLinkedList, mem_pool: std.heap.MemoryPool(LightPandaEvent), pub fn init(app: *App) !LightPanda { - const connection = try app.http.newConnection(); + const connection = try app.network.newConnection(); errdefer connection.deinit(); try connection.setURL(URL); diff --git a/src/testing.zig b/src/testing.zig index 16b06a35..6752ec2a 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -39,7 +39,7 @@ pub fn reset() void { const App = @import("App.zig"); const js = @import("browser/js/js.zig"); const Config = @import("Config.zig"); -const Client = @import("http/Client.zig"); +const HttpClient = @import("browser/HttpClient.zig"); const Page = @import("browser/Page.zig"); const Browser = @import("browser/Browser.zig"); const Session = @import("browser/Session.zig"); @@ -335,7 +335,7 @@ fn isJsonValue(a: std.json.Value, b: std.json.Value) bool { } pub var test_app: *App = undefined; -pub var test_http: *Client = undefined; +pub var test_http: *HttpClient = undefined; pub var test_browser: Browser = undefined; pub var test_notification: *Notification = undefined; pub var test_session: *Session = undefined; @@ -483,7 +483,7 @@ test "tests:beforeAll" { test_app = try App.init(test_allocator, &test_config); errdefer test_app.deinit(); - test_http = try test_app.http.createClient(test_allocator); + test_http = try HttpClient.init(test_allocator, &test_app.network); errdefer test_http.deinit(); test_browser = try Browser.init(test_app, .{ .http_client = test_http }); From 687f57756244bfa44965e2eca8e112d72676a317 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Thu, 5 Mar 2026 00:16:35 +0000 Subject: [PATCH 02/34] Move accept loop to common runtime --- src/App.zig | 9 ++-- src/Config.zig | 8 +++ src/Server.zig | 107 ++++++++++----------------------------- src/lightpanda.zig | 3 +- src/main.zig | 14 ++---- src/network/Runtime.zig | 109 +++++++++++++++++++++++++++++++++++++++- src/testing.zig | 16 +++--- 7 files changed, 160 insertions(+), 106 deletions(-) diff --git a/src/App.zig b/src/App.zig index 7faee4c8..9039cec5 100644 --- a/src/App.zig +++ b/src/App.zig @@ -39,7 +39,6 @@ telemetry: Telemetry, allocator: Allocator, arena_pool: ArenaPool, app_dir_path: ?[]const u8, -shutdown: bool = false, pub fn init(allocator: Allocator, config: *const Config) !*App { const app = try allocator.create(App); @@ -76,11 +75,11 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { return app; } -pub fn deinit(self: *App) void { - if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) { - return; - } +pub fn shutdown(self: *const App) bool { + return self.network.shutdown.load(.acquire); +} +pub fn deinit(self: *App) void { const allocator = self.allocator; if (self.app_dir_path) |app_dir_path| { allocator.free(app_dir_path); diff --git a/src/Config.zig b/src/Config.zig index 5a4cc58e..a06fcc51 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -31,6 +31,7 @@ pub const RunMode = enum { mcp, }; +pub const MAX_LISTENERS = 16; pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // max message size @@ -153,6 +154,13 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 { }; } +pub fn cdpTimeout(self: *const Config) usize { + return switch (self.mode) { + .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, + else => unreachable, + }; +} + pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, diff --git a/src/Server.zig b/src/Server.zig index 026dbda9..23ddefb5 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -18,8 +18,6 @@ const std = @import("std"); const lp = @import("lightpanda"); -const builtin = @import("builtin"); - const net = std.net; const posix = std.posix; @@ -36,9 +34,7 @@ const HttpClient = @import("browser/HttpClient.zig"); const Server = @This(); app: *App, -shutdown: std.atomic.Value(bool) = .init(false), allocator: Allocator, -listener: ?posix.socket_t, json_version_response: []const u8, // Thread management @@ -47,103 +43,52 @@ clients: std.ArrayList(*Client) = .{}, client_mutex: std.Thread.Mutex = .{}, clients_pool: std.heap.MemoryPool(Client), -pub fn init(app: *App, address: net.Address) !Server { +pub fn init(app: *App, address: net.Address) !*Server { const allocator = app.allocator; const json_version_response = try buildJSONVersionResponse(allocator, address); errdefer allocator.free(json_version_response); - return .{ + const self = try allocator.create(Server); + errdefer allocator.destroy(self); + + self.* = .{ .app = app, - .listener = null, .allocator = allocator, .json_version_response = json_version_response, - .clients_pool = std.heap.MemoryPool(Client).init(app.allocator), + .clients_pool = std.heap.MemoryPool(Client).init(allocator), }; + + try self.app.network.bind(address, self, onAccept); + log.info(.app, "server running", .{ .address = address }); + + return self; } -/// Interrupts the server so that main can complete normally and call all defer handlers. -pub fn stop(self: *Server) void { - if (self.shutdown.swap(true, .release)) { - return; - } - - // Shutdown all active clients +pub fn deinit(self: *Server) void { + // Stop all active clients { self.client_mutex.lock(); defer self.client_mutex.unlock(); + for (self.clients.items) |client| { client.stop(); } } - // Linux and BSD/macOS handle canceling a socket blocked on accept differently. - // For Linux, we use std.shutdown, which will cause accept to return error.SocketNotListening (EINVAL). - // For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (BADF). - if (self.listener) |listener| switch (builtin.target.os.tag) { - .linux => posix.shutdown(listener, .recv) catch |err| { - log.warn(.app, "listener shutdown", .{ .err = err }); - }, - .macos, .freebsd, .netbsd, .openbsd => { - self.listener = null; - posix.close(listener); - }, - else => unreachable, - }; -} - -pub fn deinit(self: *Server) void { - if (!self.shutdown.load(.acquire)) { - self.stop(); - } - self.joinThreads(); - if (self.listener) |listener| { - posix.close(listener); - self.listener = null; - } self.clients.deinit(self.allocator); self.clients_pool.deinit(); self.allocator.free(self.json_version_response); + self.allocator.destroy(self); } -pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void { - const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; - const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); - self.listener = listener; - - try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); - if (@hasDecl(posix.TCP, "NODELAY")) { - try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))); - } - - try posix.bind(listener, &address.any, address.getOsSockLen()); - try posix.listen(listener, self.app.config.maxPendingConnections()); - - log.info(.app, "server running", .{ .address = address }); - while (!self.shutdown.load(.acquire)) { - const socket = posix.accept(listener, null, null, posix.SOCK.NONBLOCK) catch |err| { - switch (err) { - error.SocketNotListening, error.ConnectionAborted => { - log.info(.app, "server stopped", .{}); - break; - }, - error.WouldBlock => { - std.Thread.sleep(10 * std.time.ns_per_ms); - continue; - }, - else => { - log.err(.app, "CDP accept", .{ .err = err }); - std.Thread.sleep(std.time.ns_per_s); - continue; - }, - } - }; - - self.spawnWorker(socket, timeout_ms) catch |err| { - log.err(.app, "CDP spawn", .{ .err = err }); - posix.close(socket); - }; - } +fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { + const self: *Server = @ptrCast(@alignCast(ctx)); + const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout()); + self.spawnWorker(socket, timeout_ms) catch |err| { + log.err(.app, "CDP spawn", .{ .err = err }); + posix.close(socket); + }; } fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void { @@ -172,10 +117,10 @@ fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void self.registerClient(client); defer self.unregisterClient(client); - // Check shutdown after registering to avoid missing stop() signal. - // If stop() already iterated over clients, this client won't receive stop() + // Check shutdown after registering to avoid missing the stop signal. + // If deinit() already iterated over clients, this client won't receive stop() // and would block joinThreads() indefinitely. - if (self.shutdown.load(.acquire)) { + if (self.app.shutdown()) { return; } @@ -212,7 +157,7 @@ fn unregisterClient(self: *Server, client: *Client) void { } fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void { - if (self.shutdown.load(.acquire)) { + if (self.app.shutdown()) { return error.ShuttingDown; } diff --git a/src/lightpanda.zig b/src/lightpanda.zig index e10d9e20..47b8181f 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -18,6 +18,7 @@ const std = @import("std"); pub const App = @import("App.zig"); +pub const Network = @import("network/Runtime.zig"); pub const Server = @import("Server.zig"); pub const Config = @import("Config.zig"); pub const URL = @import("browser/URL.zig"); @@ -34,7 +35,7 @@ pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); -const HttpClient = @import("browser/HttpClient.zig"); +pub const HttpClient = @import("browser/HttpClient.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { diff --git a/src/main.zig b/src/main.zig index dd6a759a..26e29b22 100644 --- a/src/main.zig +++ b/src/main.zig @@ -93,18 +93,14 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { return args.printUsageAndExit(false); }; - // _server is global to handle graceful shutdown. - var server = try lp.Server.init(app, address); - defer server.deinit(); - - try sighandler.on(lp.Server.stop, .{&server}); - - // max timeout of 1 week. - const timeout = if (opts.timeout > 604_800) 604_800_000 else @as(u32, opts.timeout) * 1000; - server.run(address, timeout) catch |err| { + var server = lp.Server.init(app, address) catch |err| { log.fatal(.app, "server run error", .{ .err = err }); return err; }; + defer server.deinit(); + + try sighandler.on(lp.Network.stop, .{&app.network}); + app.network.run(); }, .fetch => |opts| { const url = opts.url; diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index 8de03760..4dc7647d 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -17,8 +17,10 @@ // along with this program. If not, see . const std = @import("std"); +const builtin = @import("builtin"); +const net = std.net; +const posix = std.posix; const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; const lp = @import("lightpanda"); const Config = @import("../Config.zig"); @@ -29,12 +31,24 @@ const RobotStore = @import("Robots.zig").RobotStore; const Runtime = @This(); +const Listener = struct { + socket: posix.socket_t, + ctx: *anyopaque, + onAccept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, +}; + allocator: Allocator, config: *const Config, ca_blob: ?net_http.Blob, robot_store: RobotStore, +pollfds: [Config.MAX_LISTENERS]posix.pollfd = @splat(.{ .fd = -1, .events = 0, .revents = 0 }), +listeners: [Config.MAX_LISTENERS]?Listener = @splat(null), + +shutdown: std.atomic.Value(bool) = .init(false), +listener_count: std.atomic.Value(usize) = .init(0), + fn globalInit() void { libcurl.curl_global_init(.{ .ssl = true }) catch |err| { lp.assert(false, "curl global init", .{ .err = err }); @@ -74,6 +88,99 @@ pub fn deinit(self: *Runtime) void { global_deinit_once.call(); } +pub fn bind( + self: *Runtime, + address: net.Address, + ctx: *anyopaque, + onAccept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, +) !void { + const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; + const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); + errdefer posix.close(listener); + + try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); + if (@hasDecl(posix.TCP, "NODELAY")) { + try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))); + } + + try posix.bind(listener, &address.any, address.getOsSockLen()); + try posix.listen(listener, self.config.maxPendingConnections()); + + for (&self.listeners, &self.pollfds) |*slot, *pfd| { + if (slot.* == null) { + slot.* = .{ + .socket = listener, + .ctx = ctx, + .onAccept = onAccept, + }; + pfd.* = .{ + .fd = listener, + .events = posix.POLL.IN, + .revents = 0, + }; + _ = self.listener_count.fetchAdd(1, .release); + return; + } + } + + return error.TooManyListeners; +} + +pub fn run(self: *Runtime) void { + while (!self.shutdown.load(.acquire) and self.listener_count.load(.acquire) > 0) { + _ = posix.poll(&self.pollfds, -1) catch |err| { + lp.log.err(.app, "poll", .{ .err = err }); + continue; + }; + + for (&self.listeners, &self.pollfds) |*slot, *pfd| { + if (pfd.revents == 0) continue; + pfd.revents = 0; + const listener = slot.* orelse continue; + + const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| { + switch (err) { + error.SocketNotListening, error.ConnectionAborted => { + pfd.* = .{ .fd = -1, .events = 0, .revents = 0 }; + slot.* = null; + _ = self.listener_count.fetchSub(1, .release); + }, + error.WouldBlock => {}, + else => { + lp.log.err(.app, "accept", .{ .err = err }); + }, + } + continue; + }; + + listener.onAccept(listener.ctx, socket); + } + } +} + +pub fn stop(self: *Runtime) void { + self.shutdown.store(true, .release); + + // Linux and BSD/macOS handle canceling a socket blocked on accept differently. + // For Linux, we use posix.shutdown, which will cause accept to return error.SocketNotListening (EINVAL). + // For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (EBADF). + for (&self.listeners, &self.pollfds) |*slot, *pfd| { + if (slot.*) |listener| { + switch (builtin.target.os.tag) { + .linux => posix.shutdown(listener.socket, .recv) catch |err| { + lp.log.warn(.app, "listener shutdown", .{ .err = err }); + }, + .macos, .freebsd, .netbsd, .openbsd => posix.close(listener.socket), + else => unreachable, + } + + pfd.* = .{ .fd = -1, .events = 0, .revents = 0 }; + slot.* = null; + _ = self.listener_count.fetchSub(1, .release); + } + } +} + pub fn newConnection(self: *Runtime) !net_http.Connection { return net_http.Connection.init(self.ca_blob, self.config); } diff --git a/src/testing.zig b/src/testing.zig index 6752ec2a..a398f824 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -460,7 +460,7 @@ const log = @import("log.zig"); const TestHTTPServer = @import("TestHTTPServer.zig"); const Server = @import("Server.zig"); -var test_cdp_server: ?Server = null; +var test_cdp_server: ?*Server = null; var test_cdp_server_thread: ?std.Thread = null; var test_http_server: ?TestHTTPServer = null; var test_http_server_thread: ?std.Thread = null; @@ -509,13 +509,11 @@ test "tests:beforeAll" { } test "tests:afterAll" { - if (test_cdp_server) |*server| { - server.stop(); - } + test_app.network.stop(); if (test_cdp_server_thread) |thread| { thread.join(); } - if (test_cdp_server) |*server| { + if (test_cdp_server) |server| { server.deinit(); } @@ -540,14 +538,14 @@ test "tests:afterAll" { fn serveCDP(wg: *std.Thread.WaitGroup) !void { const address = try std.net.Address.parseIp("127.0.0.1", 9583); - test_cdp_server = try Server.init(test_app, address); - wg.finish(); - - test_cdp_server.?.run(address, 5) catch |err| { + test_cdp_server = Server.init(test_app, address) catch |err| { std.debug.print("CDP server error: {}", .{err}); return err; }; + wg.finish(); + + test_app.network.run(); } fn testHTTPHandler(req: *std.http.Server.Request) !void { From 60350efa10d8d5e03ceb43645d991b76b3594eed Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Tue, 10 Mar 2026 02:23:24 +0000 Subject: [PATCH 03/34] Only one listener in network.Runtime --- src/network/Runtime.zig | 109 +++++++++++++++++++++------------------- src/network/http.zig | 2 +- 2 files changed, 57 insertions(+), 54 deletions(-) diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index 4dc7647d..7dc56303 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -31,6 +31,17 @@ const RobotStore = @import("Robots.zig").RobotStore; const Runtime = @This(); +// stop() and run() synchronize access to the listener field through a syscall +// chain: posix.shutdown -> poll wakeup -> accept error. TSAN cannot infer this +// happens-before relationship, so we annotate it explicitly. +const tsan = if (builtin.sanitize_thread) struct { + extern fn __tsan_acquire(addr: *anyopaque) void; + extern fn __tsan_release(addr: *anyopaque) void; +} else struct { + inline fn __tsan_acquire(_: *anyopaque) void {} + inline fn __tsan_release(_: *anyopaque) void {} +}; + const Listener = struct { socket: posix.socket_t, ctx: *anyopaque, @@ -44,10 +55,9 @@ ca_blob: ?net_http.Blob, robot_store: RobotStore, pollfds: [Config.MAX_LISTENERS]posix.pollfd = @splat(.{ .fd = -1, .events = 0, .revents = 0 }), -listeners: [Config.MAX_LISTENERS]?Listener = @splat(null), +listener: ?Listener = null, shutdown: std.atomic.Value(bool) = .init(false), -listener_count: std.atomic.Value(usize) = .init(0), fn globalInit() void { libcurl.curl_global_init(.{ .ssl = true }) catch |err| { @@ -106,78 +116,71 @@ pub fn bind( try posix.bind(listener, &address.any, address.getOsSockLen()); try posix.listen(listener, self.config.maxPendingConnections()); - for (&self.listeners, &self.pollfds) |*slot, *pfd| { - if (slot.* == null) { - slot.* = .{ - .socket = listener, - .ctx = ctx, - .onAccept = onAccept, - }; - pfd.* = .{ - .fd = listener, - .events = posix.POLL.IN, - .revents = 0, - }; - _ = self.listener_count.fetchAdd(1, .release); - return; - } - } + if (self.listener != null) return error.TooManyListeners; - return error.TooManyListeners; + self.listener = .{ + .socket = listener, + .ctx = ctx, + .onAccept = onAccept, + }; + self.pollfds[0] = .{ + .fd = listener, + .events = posix.POLL.IN, + .revents = 0, + }; } pub fn run(self: *Runtime) void { - while (!self.shutdown.load(.acquire) and self.listener_count.load(.acquire) > 0) { + while (!self.shutdown.load(.acquire)) { + const listener = self.listener orelse return; + _ = posix.poll(&self.pollfds, -1) catch |err| { lp.log.err(.app, "poll", .{ .err = err }); continue; }; - for (&self.listeners, &self.pollfds) |*slot, *pfd| { - if (pfd.revents == 0) continue; - pfd.revents = 0; - const listener = slot.* orelse continue; + tsan.__tsan_acquire(@ptrCast(&self.listener)); - const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| { - switch (err) { - error.SocketNotListening, error.ConnectionAborted => { - pfd.* = .{ .fd = -1, .events = 0, .revents = 0 }; - slot.* = null; - _ = self.listener_count.fetchSub(1, .release); - }, - error.WouldBlock => {}, - else => { - lp.log.err(.app, "accept", .{ .err = err }); - }, - } - continue; - }; + if (self.pollfds[0].revents == 0) continue; + self.pollfds[0].revents = 0; - listener.onAccept(listener.ctx, socket); - } + const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| { + switch (err) { + error.SocketNotListening, error.ConnectionAborted => { + self.pollfds[0] = .{ .fd = -1, .events = 0, .revents = 0 }; + self.listener = null; + }, + error.WouldBlock => {}, + else => { + lp.log.err(.app, "accept", .{ .err = err }); + }, + } + continue; + }; + + listener.onAccept(listener.ctx, socket); } } pub fn stop(self: *Runtime) void { self.shutdown.store(true, .release); + // Wake up poll() by shutting down/closing the listener socket. + // Do NOT modify listener/pollfds here — run() handles cleanup in its + // accept error paths, avoiding data races with poll() and the run loop. + // // Linux and BSD/macOS handle canceling a socket blocked on accept differently. // For Linux, we use posix.shutdown, which will cause accept to return error.SocketNotListening (EINVAL). // For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (EBADF). - for (&self.listeners, &self.pollfds) |*slot, *pfd| { - if (slot.*) |listener| { - switch (builtin.target.os.tag) { - .linux => posix.shutdown(listener.socket, .recv) catch |err| { - lp.log.warn(.app, "listener shutdown", .{ .err = err }); - }, - .macos, .freebsd, .netbsd, .openbsd => posix.close(listener.socket), - else => unreachable, - } - - pfd.* = .{ .fd = -1, .events = 0, .revents = 0 }; - slot.* = null; - _ = self.listener_count.fetchSub(1, .release); + if (self.listener) |listener| { + switch (builtin.target.os.tag) { + .linux => posix.shutdown(listener.socket, .recv) catch |err| { + lp.log.warn(.app, "listener shutdown", .{ .err = err }); + }, + .macos, .freebsd, .netbsd, .openbsd => posix.close(listener.socket), + else => unreachable, } + tsan.__tsan_release(@ptrCast(&self.listener)); } } diff --git a/src/network/http.zig b/src/network/http.zig index fe2f1034..28fd7736 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -369,7 +369,7 @@ pub const Connection = struct { pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void { try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr); } - + pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void { try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr); } From 8f960ab0f744a95e68c5d85146af84d2994f6a5d Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Tue, 10 Mar 2026 02:36:50 +0000 Subject: [PATCH 04/34] Uses posix pipe for shutdown network runtime --- src/network/Runtime.zig | 81 +++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index 7dc56303..a3774650 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -31,17 +31,6 @@ const RobotStore = @import("Robots.zig").RobotStore; const Runtime = @This(); -// stop() and run() synchronize access to the listener field through a syscall -// chain: posix.shutdown -> poll wakeup -> accept error. TSAN cannot infer this -// happens-before relationship, so we annotate it explicitly. -const tsan = if (builtin.sanitize_thread) struct { - extern fn __tsan_acquire(addr: *anyopaque) void; - extern fn __tsan_release(addr: *anyopaque) void; -} else struct { - inline fn __tsan_acquire(_: *anyopaque) void {} - inline fn __tsan_release(_: *anyopaque) void {} -}; - const Listener = struct { socket: posix.socket_t, ctx: *anyopaque, @@ -54,9 +43,12 @@ config: *const Config, ca_blob: ?net_http.Blob, robot_store: RobotStore, -pollfds: [Config.MAX_LISTENERS]posix.pollfd = @splat(.{ .fd = -1, .events = 0, .revents = 0 }), +pollfds: []posix.pollfd, listener: ?Listener = null, +// Wakeup pipe: workers write to [1], main thread polls [0] +wakeup_pipe: [2]posix.fd_t = .{ -1, -1 }, + shutdown: std.atomic.Value(bool) = .init(false), fn globalInit() void { @@ -76,6 +68,15 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime { global_init_once.call(); errdefer global_deinit_once.call(); + const pipe = try posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); + + // 0 is wakeup, 1 is listener + const pollfds = try allocator.alloc(posix.pollfd, 2); + errdefer allocator.free(pollfds); + + @memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); + pollfds[0] = .{ .fd = pipe[0], .events = posix.POLL.IN, .revents = 0 }; + var ca_blob: ?net_http.Blob = null; if (config.tlsVerifyHost()) { ca_blob = try loadCerts(allocator); @@ -86,10 +87,21 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime { .config = config, .ca_blob = ca_blob, .robot_store = RobotStore.init(allocator), + .pollfds = pollfds, + .wakeup_pipe = pipe, }; } pub fn deinit(self: *Runtime) void { + for (&self.wakeup_pipe) |*fd| { + if (fd.* >= 0) { + posix.close(fd.*); + fd.* = -1; + } + } + + self.allocator.free(self.pollfds); + if (self.ca_blob) |ca_blob| { const data: [*]u8 = @ptrCast(ca_blob.data); self.allocator.free(data[0..ca_blob.len]); @@ -123,7 +135,7 @@ pub fn bind( .ctx = ctx, .onAccept = onAccept, }; - self.pollfds[0] = .{ + self.pollfds[1] = .{ .fd = listener, .events = posix.POLL.IN, .revents = 0, @@ -134,20 +146,27 @@ pub fn run(self: *Runtime) void { while (!self.shutdown.load(.acquire)) { const listener = self.listener orelse return; - _ = posix.poll(&self.pollfds, -1) catch |err| { + _ = posix.poll(self.pollfds, -1) catch |err| { lp.log.err(.app, "poll", .{ .err = err }); continue; }; - tsan.__tsan_acquire(@ptrCast(&self.listener)); + // check wakeup socket + if (self.pollfds[0].revents != 0) { + self.pollfds[0].revents = 0; - if (self.pollfds[0].revents == 0) continue; - self.pollfds[0].revents = 0; + // If we were woken up, perhaps everything was cancelled and the iteration can be completed. + if (self.shutdown.load(.acquire)) break; + } + + // check new connections; + if (self.pollfds[1].revents == 0) continue; + self.pollfds[1].revents = 0; const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| { switch (err) { error.SocketNotListening, error.ConnectionAborted => { - self.pollfds[0] = .{ .fd = -1, .events = 0, .revents = 0 }; + self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 }; self.listener = null; }, error.WouldBlock => {}, @@ -160,28 +179,18 @@ pub fn run(self: *Runtime) void { listener.onAccept(listener.ctx, socket); } + + if (self.listener) |listener| { + posix.shutdown(listener.socket, .both) catch |err| { + lp.log.warn(.app, "listener shutdown", .{ .err = err }); + }; + posix.close(listener.socket); + } } pub fn stop(self: *Runtime) void { self.shutdown.store(true, .release); - - // Wake up poll() by shutting down/closing the listener socket. - // Do NOT modify listener/pollfds here — run() handles cleanup in its - // accept error paths, avoiding data races with poll() and the run loop. - // - // Linux and BSD/macOS handle canceling a socket blocked on accept differently. - // For Linux, we use posix.shutdown, which will cause accept to return error.SocketNotListening (EINVAL). - // For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (EBADF). - if (self.listener) |listener| { - switch (builtin.target.os.tag) { - .linux => posix.shutdown(listener.socket, .recv) catch |err| { - lp.log.warn(.app, "listener shutdown", .{ .err = err }); - }, - .macos, .freebsd, .netbsd, .openbsd => posix.close(listener.socket), - else => unreachable, - } - tsan.__tsan_release(@ptrCast(&self.listener)); - } + _ = posix.write(self.wakeup_pipe[1], &.{1}) catch {}; } pub fn newConnection(self: *Runtime) !net_http.Connection { From 2b0c22342517a6492a58013e2571a1a28b4494e3 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Tue, 10 Mar 2026 02:49:15 +0000 Subject: [PATCH 05/34] Some code-review fixes --- src/browser/HttpClient.zig | 7 ++++--- src/browser/Page.zig | 7 +++---- src/network/Runtime.zig | 13 +++++-------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index e74c049c..98292efc 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -36,9 +36,10 @@ const ArenaAllocator = std.heap.ArenaAllocator; const IS_DEBUG = builtin.mode == .Debug; -const Method = Net.Method; -const ResponseHead = Net.ResponseHead; -const HeaderIterator = Net.HeaderIterator; +pub const Method = Net.Method; +pub const Headers = Net.Headers; +pub const ResponseHead = Net.ResponseHead; +pub const HeaderIterator = Net.HeaderIterator; // This is loosely tied to a browser Page. Loading all the , doing // XHR requests, and loading imports all happens through here. Sine the app diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 1b50c7b7..d907fd54 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -62,7 +62,6 @@ const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); -const Net = @import("../network/http.zig"); const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; @@ -396,7 +395,7 @@ pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 { // Add comon headers for a request: // * cookies // * referer -pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *Net.Headers) !void { +pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *HttpClient.Headers) !void { try self.requestCookie(.{}).headersForRequest(temp, url, headers); // Build the referer @@ -3052,7 +3051,7 @@ pub const NavigateReason = enum { pub const NavigateOpts = struct { cdp_id: ?i64 = null, reason: NavigateReason = .address_bar, - method: Net.Method = .GET, + method: HttpClient.Method = .GET, body: ?[]const u8 = null, header: ?[:0]const u8 = null, force: bool = false, @@ -3062,7 +3061,7 @@ pub const NavigateOpts = struct { pub const NavigatedOpts = struct { cdp_id: ?i64 = null, reason: NavigateReason = .address_bar, - method: Net.Method = .GET, + method: HttpClient.Method = .GET, }; const NavigationType = enum { diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index a3774650..0698ac4f 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -61,12 +61,9 @@ fn globalDeinit() void { libcurl.curl_global_cleanup(); } -var global_init_once = std.once(globalInit); -var global_deinit_once = std.once(globalDeinit); - pub fn init(allocator: Allocator, config: *const Config) !Runtime { - global_init_once.call(); - errdefer global_deinit_once.call(); + globalInit(); + errdefer globalDeinit(); const pipe = try posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); @@ -107,14 +104,14 @@ pub fn deinit(self: *Runtime) void { self.allocator.free(data[0..ca_blob.len]); } - global_deinit_once.call(); + globalDeinit(); } pub fn bind( self: *Runtime, address: net.Address, ctx: *anyopaque, - onAccept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, + on_accept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, ) !void { const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); @@ -133,7 +130,7 @@ pub fn bind( self.listener = .{ .socket = listener, .ctx = ctx, - .onAccept = onAccept, + .onAccept = on_accept, }; self.pollfds[1] = .{ .fd = listener, From 7171305972c16a0a0b4f8bb934220c00355066ec Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Tue, 10 Mar 2026 03:16:50 +0000 Subject: [PATCH 06/34] Enable tsan for c libs --- build.zig | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/build.zig b/build.zig index 7e3a2817..14c64d89 100644 --- a/build.zig +++ b/build.zig @@ -53,7 +53,7 @@ pub fn build(b: *Build) !void { mod.addImport("build_config", opts.createModule()); try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path); - try linkCurl(b, mod); + try linkCurl(b, mod, enable_tsan); try linkHtml5Ever(b, mod); break :blk mod; @@ -189,19 +189,19 @@ fn linkHtml5Ever(b: *Build, mod: *Build.Module) !void { mod.addObjectFile(obj); } -fn linkCurl(b: *Build, mod: *Build.Module) !void { +fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void { const target = mod.resolved_target.?; - const curl = buildCurl(b, target, mod.optimize.?); + const curl = buildCurl(b, target, mod.optimize.?, is_tsan); mod.linkLibrary(curl); - const zlib = buildZlib(b, target, mod.optimize.?); + const zlib = buildZlib(b, target, mod.optimize.?, is_tsan); curl.root_module.linkLibrary(zlib); - const brotli = buildBrotli(b, target, mod.optimize.?); + const brotli = buildBrotli(b, target, mod.optimize.?, is_tsan); for (brotli) |lib| curl.root_module.linkLibrary(lib); - const nghttp2 = buildNghttp2(b, target, mod.optimize.?); + const nghttp2 = buildNghttp2(b, target, mod.optimize.?, is_tsan); curl.root_module.linkLibrary(nghttp2); const boringssl = buildBoringSsl(b, target, mod.optimize.?); @@ -218,13 +218,14 @@ fn linkCurl(b: *Build, mod: *Build.Module) !void { } } -fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile { +fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile { const dep = b.dependency("zlib", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, + .sanitize_thread = is_tsan, }); const lib = b.addLibrary(.{ .name = "z", .root_module = mod }); @@ -249,13 +250,14 @@ fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opti return lib; } -fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [3]*Build.Step.Compile { +fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) [3]*Build.Step.Compile { const dep = b.dependency("brotli", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, + .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("c/include")); @@ -311,13 +313,14 @@ fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin return .{ ssl, crypto }; } -fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile { +fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile { const dep = b.dependency("nghttp2", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, + .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("lib/includes")); @@ -362,6 +365,7 @@ fn buildCurl( b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, + is_tsan: bool, ) *Build.Step.Compile { const dep = b.dependency("curl", .{}); @@ -369,6 +373,7 @@ fn buildCurl( .target = target, .optimize = optimize, .link_libc = true, + .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("lib")); mod.addIncludePath(dep.path("include")); From 11fb5f990e1e51407714439430d1a1c451ccd2f1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 10 Mar 2026 20:50:58 +0800 Subject: [PATCH 07/34] Call `resetContextGroup` on page removal Calling it here ensures that the inspector gets reset on internal page navigation. We were seeing intermittent segfaults on a problematic WPT tests (/encoding/legacy-mb-japanese/euc-jp/) which I believe this solves. (The tests are still broken. Because we don't support form targets, they cause the root page to reload in a tight cycle, causing a lot of context creation / destruction, which I thin was the issue. This commit doesn't fix the broken test but it hopefully fixes the crash). Also, clear out the Inspector's default_context when the default context is destroyed. (This was the first thing I did to try to fix the crash, it didn't work, but I believe it's correct). --- src/browser/js/Inspector.zig | 6 ++++++ src/cdp/cdp.zig | 7 ++++++- src/cdp/domains/page.zig | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index a9032e33..aa35be7b 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -130,6 +130,12 @@ pub fn contextCreated( pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void { v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context); + + if (self.default_context) |*dc| { + if (v8.v8__Global__IsEqual(dc, context)) { + self.default_context = null; + } + } } pub fn resetContextGroup(self: *const Inspector) void { diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 8783e5a0..6d14c627 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -459,6 +459,12 @@ pub fn BrowserContext(comptime CDP_T: type) type { } self.isolated_worlds.clearRetainingCapacity(); + // do this before closeSession, since we don't want to process any + // new notification (Or maybe, instead of the deinit above, we just + // rely on those notifications to do our normal cleanup?) + + self.notification.unregisterAll(self); + // If the session has a page, we need to clear it first. The page // context is always nested inside of the isolated world context, // so we need to shutdown the page one first. @@ -466,7 +472,6 @@ pub fn BrowserContext(comptime CDP_T: type) type { self.node_registry.deinit(); self.node_search_list.deinit(); - self.notification.unregisterAll(self); self.notification.deinit(); if (self.http_proxy_changed) { diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index d4b7b301..c8b39d65 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -290,6 +290,10 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void } pub fn pageRemove(bc: anytype) !void { + // Clear all remote object mappings to prevent stale objectIds from being used + // after the context is destroy + bc.inspector_session.inspector.resetContextGroup(); + // The main page is going to be removed, we need to remove contexts from other worlds first. for (bc.isolated_worlds.items) |isolated_world| { try isolated_world.removeContext(); From 6a7f7fdf15e4923bf065afe38fac960a642807db Mon Sep 17 00:00:00 2001 From: egrs Date: Tue, 10 Mar 2026 13:53:27 +0100 Subject: [PATCH 08/34] extend CSS value normalization to cover more properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing properties to isLengthProperty (0→0px) and isTwoValueShorthand (duplicate value collapse) maps based on WPT test failures in css/css-sizing, css/css-align, css/css-scroll-snap, css/css-logical, and others. New length properties: scroll-margin/padding-*, column-width, column-rule-width, grid-column/row-gap, outline, shape-margin, offset-distance, translate, animation-range-*, block-step-size, text-decoration-inset, and *-rule-*-inset (CSS Gaps). New two-value shorthands: scroll-padding-block/inline, scroll-snap-align, background-size, border-image-repeat, mask-repeat/size, contain-intrinsic-size, scale, text-box-edge, animation-range, grid-gap. --- src/browser/tests/css/stylesheet.html | 38 +++++++ .../webapi/css/CSSStyleDeclaration.zig | 102 ++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/src/browser/tests/css/stylesheet.html b/src/browser/tests/css/stylesheet.html index e717e806..ec7928af 100644 --- a/src/browser/tests/css/stylesheet.html +++ b/src/browser/tests/css/stylesheet.html @@ -293,6 +293,28 @@ div.style.top = '0'; testing.expectEqual('0px', div.style.top); + // Scroll properties + div.style.scrollMarginTop = '0'; + testing.expectEqual('0px', div.style.scrollMarginTop); + + div.style.scrollPaddingBottom = '0'; + testing.expectEqual('0px', div.style.scrollPaddingBottom); + + // Multi-column + div.style.columnWidth = '0'; + testing.expectEqual('0px', div.style.columnWidth); + + div.style.columnRuleWidth = '0'; + testing.expectEqual('0px', div.style.columnRuleWidth); + + // Outline shorthand + div.style.outline = '0'; + testing.expectEqual('0px', div.style.outline); + + // Shapes + div.style.shapeMargin = '0'; + testing.expectEqual('0px', div.style.shapeMargin); + // Non-length properties should not be affected div.style.opacity = '0'; testing.expectEqual('0', div.style.opacity); @@ -313,6 +335,12 @@ div.style.alignContent = 'first baseline'; testing.expectEqual('baseline', div.style.alignContent); + div.style.alignSelf = 'first baseline'; + testing.expectEqual('baseline', div.style.alignSelf); + + div.style.justifySelf = 'first baseline'; + testing.expectEqual('baseline', div.style.justifySelf); + // "last baseline" should remain unchanged div.style.alignItems = 'last baseline'; testing.expectEqual('last baseline', div.style.alignItems); @@ -339,6 +367,16 @@ div.style.gap = '10px 20px'; testing.expectEqual('10px 20px', div.style.gap); + + // New shorthands + div.style.overflow = 'hidden hidden'; + testing.expectEqual('hidden', div.style.overflow); + + div.style.scrollSnapAlign = 'start start'; + testing.expectEqual('start', div.style.scrollSnapAlign); + + div.style.overscrollBehavior = 'auto auto'; + testing.expectEqual('auto', div.style.overscrollBehavior); } diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index adc8982a..2430f0c4 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -483,6 +483,21 @@ fn isTwoValueShorthand(name: []const u8) bool { .{ "overflow", {} }, .{ "overscroll-behavior", {} }, .{ "gap", {} }, + .{ "grid-gap", {} }, + // Scroll + .{ "scroll-padding-block", {} }, + .{ "scroll-padding-inline", {} }, + .{ "scroll-snap-align", {} }, + // Background/Mask + .{ "background-size", {} }, + .{ "border-image-repeat", {} }, + .{ "mask-repeat", {} }, + .{ "mask-size", {} }, + // Other + .{ "contain-intrinsic-size", {} }, + .{ "scale", {} }, + .{ "text-box-edge", {} }, + .{ "animation-range", {} }, }); return shorthands.has(name); } @@ -561,17 +576,52 @@ fn isLengthProperty(name: []const u8) bool { .{ "row-gap", {} }, .{ "column-gap", {} }, .{ "flex-basis", {} }, + // Legacy grid aliases + .{ "grid-column-gap", {} }, + .{ "grid-row-gap", {} }, // Outline + .{ "outline", {} }, .{ "outline-width", {} }, .{ "outline-offset", {} }, + // Multi-column + .{ "column-rule-width", {} }, + .{ "column-width", {} }, + // Scroll + .{ "scroll-margin", {} }, + .{ "scroll-margin-top", {} }, + .{ "scroll-margin-right", {} }, + .{ "scroll-margin-bottom", {} }, + .{ "scroll-margin-left", {} }, + .{ "scroll-padding", {} }, + .{ "scroll-padding-top", {} }, + .{ "scroll-padding-right", {} }, + .{ "scroll-padding-bottom", {} }, + .{ "scroll-padding-left", {} }, + // Shapes + .{ "shape-margin", {} }, + // Motion path + .{ "offset-distance", {} }, + // Transforms + .{ "translate", {} }, + // Animations + .{ "animation-range-end", {} }, + .{ "animation-range-start", {} }, // Other .{ "border-spacing", {} }, .{ "text-shadow", {} }, .{ "box-shadow", {} }, .{ "baseline-shift", {} }, .{ "vertical-align", {} }, + .{ "text-decoration-inset", {} }, + .{ "block-step-size", {} }, // Grid lanes .{ "flow-tolerance", {} }, + .{ "column-rule-edge-inset", {} }, + .{ "column-rule-interior-inset", {} }, + .{ "row-rule-edge-inset", {} }, + .{ "row-rule-interior-inset", {} }, + .{ "rule-edge-inset", {} }, + .{ "rule-interior-inset", {} }, }); return length_properties.has(name); @@ -693,3 +743,55 @@ pub const JsApi = struct { pub const removeProperty = bridge.function(CSSStyleDeclaration.removeProperty, .{}); pub const cssFloat = bridge.accessor(CSSStyleDeclaration.getFloat, CSSStyleDeclaration.setFloat, .{}); }; + +const testing = @import("std").testing; + +test "normalizePropertyValue: unitless zero to 0px" { + const cases = .{ + .{ "width", "0", "0px" }, + .{ "height", "0", "0px" }, + .{ "scroll-margin-top", "0", "0px" }, + .{ "scroll-padding-bottom", "0", "0px" }, + .{ "column-width", "0", "0px" }, + .{ "column-rule-width", "0", "0px" }, + .{ "outline", "0", "0px" }, + .{ "shape-margin", "0", "0px" }, + .{ "offset-distance", "0", "0px" }, + .{ "translate", "0", "0px" }, + .{ "grid-column-gap", "0", "0px" }, + .{ "grid-row-gap", "0", "0px" }, + // Non-length properties should NOT normalize + .{ "opacity", "0", "0" }, + .{ "z-index", "0", "0" }, + }; + inline for (cases) |case| { + const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); + try testing.expectEqualStrings(case[2], result); + } +} + +test "normalizePropertyValue: first baseline to baseline" { + const result = try normalizePropertyValue(testing.allocator, "align-items", "first baseline"); + try testing.expectEqualStrings("baseline", result); + + const result2 = try normalizePropertyValue(testing.allocator, "align-self", "last baseline"); + try testing.expectEqualStrings("last baseline", result2); +} + +test "normalizePropertyValue: collapse duplicate two-value shorthands" { + const cases = .{ + .{ "overflow", "hidden hidden", "hidden" }, + .{ "gap", "10px 10px", "10px" }, + .{ "scroll-snap-align", "start start", "start" }, + .{ "scroll-padding-block", "5px 5px", "5px" }, + .{ "background-size", "cover cover", "cover" }, + .{ "overscroll-behavior", "auto auto", "auto" }, + // Different values should NOT collapse + .{ "overflow", "hidden scroll", "hidden scroll" }, + .{ "gap", "10px 20px", "10px 20px" }, + }; + inline for (cases) |case| { + const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); + try testing.expectEqualStrings(case[2], result); + } +} From 5301f7998965e56486f9a0b772bcac9fcad2c127 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 10 Mar 2026 20:58:31 +0800 Subject: [PATCH 09/34] Add Form.action getter/setter --- src/browser/tests/element/html/form.html | 16 ++++++++++++++++ src/browser/webapi/element/html/Form.zig | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index f148fae0..f62cb221 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -23,6 +23,22 @@ } + +
diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 9c0fd2ed..c4fdf260 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -18,7 +18,9 @@ const std = @import("std"); const js = @import("../../../js/js.zig"); +const URL = @import("../../../URL.zig"); const Page = @import("../../../Page.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -85,6 +87,19 @@ pub fn getElements(self: *Form, page: *Page) !*collections.HTMLFormControlsColle }); } +pub fn getAction(self: *Form, page: *Page) ![]const u8 { + const element = self.asElement(); + const action = element.getAttributeSafe(comptime .wrap("action")) orelse return page.url; + if (action.len == 0) { + return page.url; + } + return URL.resolve(page.call_arena, page.base(), action, .{ .encode = true }); +} + +pub fn setAction(self: *Form, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe(comptime .wrap("action"), .wrap(value), page); +} + pub fn getLength(self: *Form, page: *Page) !u32 { const elements = try self.getElements(page); return elements.length(page); @@ -104,6 +119,7 @@ pub const JsApi = struct { pub const name = bridge.accessor(Form.getName, Form.setName, .{}); pub const method = bridge.accessor(Form.getMethod, Form.setMethod, .{}); + pub const action = bridge.accessor(Form.getAction, Form.setAction, .{}); pub const elements = bridge.accessor(Form.getElements, null, .{}); pub const length = bridge.accessor(Form.getLength, null, .{}); pub const submit = bridge.function(Form.submit, .{}); From 4fb2f7474ca3b859fd58c1d8e0f69aca6e632acb Mon Sep 17 00:00:00 2001 From: egrs Date: Tue, 10 Mar 2026 14:08:28 +0100 Subject: [PATCH 10/34] remove incorrect entries from normalization maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove scale, contain-intrinsic-size, animation-range, text-box-edge from isTwoValueShorthand: these have asymmetric or 3-value semantics that make "X X" → "X" collapse incorrect. - Remove line-height from isLengthProperty: bare 0 is the unitless number multiplier, not a length (Chrome serializes as "0" not "0px"). - Fix test: background-size "cover cover" is invalid CSS, use "auto auto". --- src/browser/webapi/css/CSSStyleDeclaration.zig | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 2430f0c4..ebaafbe0 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -493,11 +493,6 @@ fn isTwoValueShorthand(name: []const u8) bool { .{ "border-image-repeat", {} }, .{ "mask-repeat", {} }, .{ "mask-size", {} }, - // Other - .{ "contain-intrinsic-size", {} }, - .{ "scale", {} }, - .{ "text-box-edge", {} }, - .{ "animation-range", {} }, }); return shorthands.has(name); } @@ -567,7 +562,6 @@ fn isLengthProperty(name: []const u8) bool { .{ "border-bottom-right-radius", {} }, // Text .{ "font-size", {} }, - .{ "line-height", {} }, .{ "letter-spacing", {} }, .{ "word-spacing", {} }, .{ "text-indent", {} }, @@ -784,7 +778,7 @@ test "normalizePropertyValue: collapse duplicate two-value shorthands" { .{ "gap", "10px 10px", "10px" }, .{ "scroll-snap-align", "start start", "start" }, .{ "scroll-padding-block", "5px 5px", "5px" }, - .{ "background-size", "cover cover", "cover" }, + .{ "background-size", "auto auto", "auto" }, .{ "overscroll-behavior", "auto auto", "auto" }, // Different values should NOT collapse .{ "overflow", "hidden scroll", "hidden scroll" }, From e610506df4860f3b943e438155c4356d6d3d915e Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 10 Mar 2026 18:14:35 +0300 Subject: [PATCH 11/34] `Local`: initial `compileFunction` --- src/browser/js/Local.zig | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 6a68b332..4b38f3f8 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -115,6 +115,38 @@ pub fn exec(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value { return self.compileAndRun(src, name); } +/// Compiles a function body as function. +/// +/// https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a3a15bb5a7dfc3f998e6ac789e6b4646a +pub fn compileFunction(self: *const Local, function_body: []const u8) !js.Function { + // TODO: Make configurable. + const script_name = self.isolate.initStringHandle("anonymous"); + const script_source = self.isolate.initStringHandle(function_body); + + // Create ScriptOrigin. + var origin: v8.ScriptOrigin = undefined; + v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name); + + // Create ScriptCompilerSource. + var script_compiler_source: v8.ScriptCompilerSource = undefined; + v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &script_compiler_source); + defer v8.v8__ScriptCompiler__Source__DESTRUCT(&script_compiler_source); + + // Compile the function. + const result = v8.v8__ScriptCompiler__CompileFunction( + self.handle, + &script_compiler_source, + 0, + null, + 0, + null, + v8.kNoCompileOptions, + v8.kNoCacheNoReason, + ) orelse return error.CompilationError; + + return .{ .local = self, .handle = result }; +} + pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value { const script_name = self.isolate.initStringHandle(name orelse "anonymous"); const script_source = self.isolate.initStringHandle(src); From 9d2ba52160f2ea5e0a7efff2f8411cbb8c71b567 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 10 Mar 2026 19:15:53 +0300 Subject: [PATCH 12/34] adapt `stringToPersistedFunction` to `compileFunction` This is just a thin wrapper around it now. --- src/browser/js/Context.zig | 26 ++++++++------------------ src/browser/js/Local.zig | 25 ++++++++++++++++++------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 9180223c..367a235a 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -336,28 +336,18 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type return l.toLocal(global); } -// This isn't expected to be called often. It's for converting attributes into -// function calls, e.g. will turn that "doSomething" -// string into a js.Function which looks like: function(e) { doSomething(e) } -// There might be more efficient ways to do this, but doing it this way means -// our code only has to worry about js.Funtion, not some union of a js.Function -// or a string. -pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.Global { +pub fn stringToPersistedFunction( + self: *Context, + function_body: []const u8, + comptime parameter_names: []const []const u8, + extensions: []const v8.Object, +) !js.Function.Global { var ls: js.Local.Scope = undefined; self.localScope(&ls); defer ls.deinit(); - var extra: []const u8 = ""; - const normalized = std.mem.trim(u8, str, &std.ascii.whitespace); - if (normalized.len > 0 and normalized[normalized.len - 1] != ')') { - extra = "(e)"; - } - const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0); - const js_val = try ls.local.compileAndRun(full, null); - if (!js_val.isFunction()) { - return error.StringFunctionError; - } - return try (js.Function{ .local = &ls.local, .handle = @ptrCast(js_val.handle) }).persist(); + const js_function = try ls.local.compileFunction(function_body, parameter_names, extensions); + return js_function.persist(); } pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local, src: []const u8, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) { diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 4b38f3f8..c6b8e880 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -118,16 +118,27 @@ pub fn exec(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value { /// Compiles a function body as function. /// /// https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a3a15bb5a7dfc3f998e6ac789e6b4646a -pub fn compileFunction(self: *const Local, function_body: []const u8) !js.Function { +pub fn compileFunction( + self: *const Local, + function_body: []const u8, + /// We tend to know how many params we'll pass; can remove the comptime if necessary. + comptime parameter_names: []const []const u8, + extensions: []const v8.Object, +) !js.Function { // TODO: Make configurable. const script_name = self.isolate.initStringHandle("anonymous"); const script_source = self.isolate.initStringHandle(function_body); - // Create ScriptOrigin. + var parameter_list: [parameter_names.len]*const v8.String = undefined; + inline for (0..parameter_names.len) |i| { + parameter_list[i] = self.isolate.initStringHandle(parameter_names[i]); + } + + // Create `ScriptOrigin`. var origin: v8.ScriptOrigin = undefined; v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name); - // Create ScriptCompilerSource. + // Create `ScriptCompilerSource`. var script_compiler_source: v8.ScriptCompilerSource = undefined; v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &script_compiler_source); defer v8.v8__ScriptCompiler__Source__DESTRUCT(&script_compiler_source); @@ -136,10 +147,10 @@ pub fn compileFunction(self: *const Local, function_body: []const u8) !js.Functi const result = v8.v8__ScriptCompiler__CompileFunction( self.handle, &script_compiler_source, - 0, - null, - 0, - null, + parameter_list.len, + ¶meter_list, + extensions.len, + @ptrCast(&extensions), v8.kNoCompileOptions, v8.kNoCacheNoReason, ) orelse return error.CompilationError; From ba3da32ce67931e5c373a4ae6ee33ec816862477 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 10 Mar 2026 19:16:20 +0300 Subject: [PATCH 13/34] spread new `stringToPersistedFunction` --- src/browser/webapi/element/Html.zig | 23 +++++++++-------------- src/browser/webapi/element/html/Body.zig | 2 +- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index ea962c9e..40f70b67 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -387,22 +387,17 @@ pub fn getAttributeFunction( } const attr = element.getAttributeSafe(.wrap(@tagName(listener_type))) orelse return null; - const callback = page.js.stringToPersistedFunction(attr) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - // Not a valid expression; log this to find out if its something we should be supporting. - log.warn(.js, "Html.getAttributeFunction", .{ - .expression = attr, - .err = err, - }); - - return null; - }, + const function = page.js.stringToPersistedFunction(attr, &.{"event"}, &.{}) catch |err| { + // Not a valid expression; log this to find out if its something we should be supporting. + log.warn(.js, "Html.getAttributeFunction", .{ + .expression = attr, + .err = err, + }); + return null; }; - try self.setAttributeListener(listener_type, callback, page); - - return callback; + try self.setAttributeListener(listener_type, function, page); + return function; } pub fn hasAttributeFunction(self: *HtmlElement, listener_type: GlobalEventHandler, page: *const Page) bool { diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index 7b5b530e..dccb892d 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -50,7 +50,7 @@ pub const Build = struct { pub fn complete(node: *Node, page: *Page) !void { const el = node.as(Element); const on_load = el.getAttributeSafe(comptime .wrap("onload")) orelse return; - if (page.js.stringToPersistedFunction(on_load)) |func| { + if (page.js.stringToPersistedFunction(on_load, &.{"event"}, &.{})) |func| { page.window._on_load = func; } else |err| { log.err(.js, "body.onload", .{ .err = err, .str = on_load }); From a22040efa9fedc680c7cf5850b8e1c9c9c70fc7f Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 10 Mar 2026 19:16:35 +0300 Subject: [PATCH 14/34] update `body.onload` test --- src/browser/tests/window/body_onload1.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/browser/tests/window/body_onload1.html b/src/browser/tests/window/body_onload1.html index 7eb7ee61..3858810e 100644 --- a/src/browser/tests/window/body_onload1.html +++ b/src/browser/tests/window/body_onload1.html @@ -1,5 +1,5 @@ - + - From d23453ce454c280e26393d93f42b2a2d0bbbc181 Mon Sep 17 00:00:00 2001 From: egrs Date: Tue, 10 Mar 2026 19:59:04 +0100 Subject: [PATCH 15/34] update live ranges after CharacterData and DOM mutations Per DOM spec, all live ranges must have their boundary offsets updated when CharacterData content changes (insertData, deleteData, replaceData, splitText) or when nodes are inserted/removed from the tree. Track live ranges via an intrusive linked list on Page. After each mutation, iterate and adjust start/end offsets per the spec algorithms. Also fix Range.deleteContents loop that read _end_offset on each iteration (now decremented by the range update), and Range.insertNode that double-incremented _end_offset for non-collapsed ranges. Route textContent, nodeValue, and data setters through replaceData so range updates fire consistently. Fixes 9 WPT test files (all now 100%): Range-mutations-insertData, deleteData, replaceData, splitText, appendChild, insertBefore, removeChild, appendData, dataChange (~1330 new passing subtests). --- src/browser/Factory.zig | 6 +- src/browser/Page.zig | 145 ++++++++++++ src/browser/tests/range_mutations.html | 315 +++++++++++++++++++++++++ src/browser/webapi/AbstractRange.zig | 3 + src/browser/webapi/CData.zig | 30 ++- src/browser/webapi/Node.zig | 9 +- src/browser/webapi/Range.zig | 24 +- src/browser/webapi/cdata/Text.zig | 18 +- 8 files changed, 528 insertions(+), 22 deletions(-) create mode 100644 src/browser/tests/range_mutations.html diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index cbc2170d..b0da7a81 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -273,14 +273,16 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator); const doc = page.document.asNode(); - chain.set(0, AbstractRange{ + const abstract_range = chain.get(0); + abstract_range.* = AbstractRange{ ._type = unionInit(AbstractRange.Type, chain.get(1)), ._end_offset = 0, ._start_offset = 0, ._end_container = doc, ._start_container = doc, - }); + }; chain.setLeaf(1, child); + page._live_ranges.append(&abstract_range._range_link); return chain.get(1); } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 014ebb62..95433ce1 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -54,6 +54,7 @@ const Performance = @import("webapi/Performance.zig"); const Screen = @import("webapi/Screen.zig"); const VisualViewport = @import("webapi/VisualViewport.zig"); const PerformanceObserver = @import("webapi/PerformanceObserver.zig"); +const AbstractRange = @import("webapi/AbstractRange.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); @@ -143,6 +144,9 @@ _to_load: std.ArrayList(*Element.Html) = .{}, _script_manager: ScriptManager, +// List of active live ranges (for mutation updates per DOM spec) +_live_ranges: std.DoublyLinkedList = .{}, + // List of active MutationObservers _mutation_observers: std.DoublyLinkedList = .{}, _mutation_delivery_scheduled: bool = false, @@ -2434,6 +2438,12 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts const previous_sibling = child.previousSibling(); const next_sibling = child.nextSibling(); + // Capture child's index before removal for live range updates (DOM spec remove steps 4-7) + const child_index_for_ranges: ?u32 = if (self._live_ranges.first != null) + parent.getChildIndex(child) + else + null; + const children = parent._children.?; switch (children.*) { .one => |n| { @@ -2462,6 +2472,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts child._parent = null; child._child_link = .{}; + // Update live ranges for removal (DOM spec remove steps 4-7) + if (child_index_for_ranges) |idx| { + self.updateRangesForNodeRemoval(parent, child, idx); + } + // Handle slot assignment removal before mutation observers if (child.is(Element)) |el| { // Check if the parent was a shadow host @@ -2609,6 +2624,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } child._parent = parent; + // Update live ranges for insertion (DOM spec insert step 6). + // For .before/.after the child was inserted at a specific position; + // ranges on parent with offsets past that position must be incremented. + // For .append no range update is needed (spec: "if child is non-null"). + if (self._live_ranges.first != null) { + switch (relative) { + .append => {}, + .before, .after => { + if (parent.getChildIndex(child)) |idx| { + self.updateRangesForNodeInsertion(parent, idx); + } + }, + } + } + // Tri-state behavior for mutations: // 1. from_parser=true, parse_mode=document -> no mutations (initial document parse) // 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions) @@ -2867,6 +2897,121 @@ pub fn childListChange( } } +// --- Live range update methods (DOM spec §4.2.3, §4.2.4, §4.7, §4.8) --- + +/// Update all live ranges after a replaceData mutation on a CharacterData node. +/// Per DOM spec: insertData = replaceData(offset, 0, data), +/// deleteData = replaceData(offset, count, ""). +/// All parameters are in UTF-16 code unit offsets. +pub fn updateRangesForCharacterDataReplace(self: *Page, target: *Node, offset: u32, count: u32, data_len: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + + if (ar._start_container == target) { + if (ar._start_offset > offset and ar._start_offset <= offset + count) { + ar._start_offset = offset; + } else if (ar._start_offset > offset + count) { + // Use i64 intermediate to avoid u32 underflow when count > data_len + ar._start_offset = @intCast(@as(i64, ar._start_offset) + @as(i64, data_len) - @as(i64, count)); + } + } + + if (ar._end_container == target) { + if (ar._end_offset > offset and ar._end_offset <= offset + count) { + ar._end_offset = offset; + } else if (ar._end_offset > offset + count) { + ar._end_offset = @intCast(@as(i64, ar._end_offset) + @as(i64, data_len) - @as(i64, count)); + } + } + } +} + +/// Update all live ranges after a splitText operation. +/// Steps 7b-7e of the DOM spec splitText algorithm. +/// Steps 7d-7e complement (not overlap) updateRangesForNodeInsertion: +/// the insert update handles offsets > child_index, while 7d/7e handle +/// offsets == node_index+1 (these are equal values but with > vs == checks). +pub fn updateRangesForSplitText(self: *Page, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + + // Step 7b: ranges on the original node with start > offset move to new node + if (ar._start_container == target and ar._start_offset > offset) { + ar._start_container = new_node; + ar._start_offset = ar._start_offset - offset; + } + // Step 7c: ranges on the original node with end > offset move to new node + if (ar._end_container == target and ar._end_offset > offset) { + ar._end_container = new_node; + ar._end_offset = ar._end_offset - offset; + } + // Step 7d: ranges on parent with start == node_index + 1 increment + if (ar._start_container == parent and ar._start_offset == node_index + 1) { + ar._start_offset += 1; + } + // Step 7e: ranges on parent with end == node_index + 1 increment + if (ar._end_container == parent and ar._end_offset == node_index + 1) { + ar._end_offset += 1; + } + } +} + +/// Update all live ranges after a node insertion. +/// Per DOM spec insert algorithm step 6: only applies when inserting before a +/// non-null reference node. +pub fn updateRangesForNodeInsertion(self: *Page, parent: *Node, child_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + + if (ar._start_container == parent and ar._start_offset > child_index) { + ar._start_offset += 1; + } + if (ar._end_container == parent and ar._end_offset > child_index) { + ar._end_offset += 1; + } + } +} + +/// Update all live ranges after a node removal. +/// Per DOM spec remove algorithm steps 4-7. +pub fn updateRangesForNodeRemoval(self: *Page, parent: *Node, child: *Node, child_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + + // Steps 4-5: ranges whose start/end is an inclusive descendant of child + // get moved to (parent, child_index). + if (isInclusiveDescendantOf(ar._start_container, child)) { + ar._start_container = parent; + ar._start_offset = child_index; + } + if (isInclusiveDescendantOf(ar._end_container, child)) { + ar._end_container = parent; + ar._end_offset = child_index; + } + + // Steps 6-7: ranges on parent at offsets > child_index get decremented. + if (ar._start_container == parent and ar._start_offset > child_index) { + ar._start_offset -= 1; + } + if (ar._end_container == parent and ar._end_offset > child_index) { + ar._end_offset -= 1; + } + } +} + +fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool { + var current: ?*Node = node; + while (current) |n| { + if (n == potential_ancestor) return true; + current = n.parentNode(); + } + return false; +} + // TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void { const previous_parse_mode = self._parse_mode; diff --git a/src/browser/tests/range_mutations.html b/src/browser/tests/range_mutations.html new file mode 100644 index 00000000..3e4efc7e --- /dev/null +++ b/src/browser/tests/range_mutations.html @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index 5f3edc31..231c635d 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -33,6 +33,9 @@ _start_offset: u32, _end_container: *Node, _start_container: *Node, +// Intrusive linked list node for tracking live ranges on the Page. +_range_link: std.DoublyLinkedList.Node = .{}, + pub const Type = union(enum) { range: *Range, // TODO: static_range: *StaticRange, diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 13e075ad..5a74f87d 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -37,7 +37,7 @@ _data: String = .empty, /// Count UTF-16 code units in a UTF-8 string. /// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair), /// everything else produces 1. -fn utf16Len(data: []const u8) usize { +pub fn utf16Len(data: []const u8) usize { var count: usize = 0; var i: usize = 0; while (i < data.len) { @@ -232,14 +232,13 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void { } /// JS bridge wrapper for `data` setter. -/// Handles [LegacyNullToEmptyString]: null → setData(null) → "". -/// Passes everything else (including undefined) through V8 toString, -/// so `undefined` becomes the string "undefined" per spec. +/// Per spec, setting .data runs replaceData(0, this.length, value), +/// which includes live range updates. +/// Handles [LegacyNullToEmptyString]: null → "" per spec. pub fn _setData(self: *CData, value: js.Value, page: *Page) !void { - if (value.isNull()) { - return self.setData(null, page); - } - return self.setData(try value.toZig([]const u8), page); + const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8); + const length = self.getLength(); + try self.replaceData(0, length, new_value, page); } pub fn format(self: *const CData, writer: *std.io.Writer) !void { @@ -281,6 +280,11 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); + // Update live ranges per DOM spec replaceData steps (deleteData = replaceData with data="") + const length = self.getLength(); + const effective_count: u32 = @intCast(@min(count, length - offset)); + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, 0); + const old_data = self._data; const old_value = old_data.str(); if (range.start == 0) { @@ -299,6 +303,10 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void { const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset); + + // Update live ranges per DOM spec replaceData steps (insertData = replaceData with count=0) + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), 0, @intCast(utf16Len(data))); + const old_value = self._data; const existing = old_value.str(); self._data = try String.concat(page.arena, &.{ @@ -312,6 +320,12 @@ pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !v pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); + + // Update live ranges per DOM spec replaceData steps + const length = self.getLength(); + const effective_count: u32 = @intCast(@min(count, length - offset)); + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, @intCast(utf16Len(data))); + const old_value = self._data; const existing = old_value.str(); self._data = try String.concat(page.arena, &.{ diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 15541491..7bfa7cca 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -293,7 +293,8 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { } return el.replaceChildren(&.{.{ .text = data }}, page); }, - .cdata => |c| c._data = try page.dupeSSO(data), + // Per spec, setting textContent on CharacterData runs replaceData(0, length, value) + .cdata => |c| try c.replaceData(0, c.getLength(), data, page), .document => {}, .document_type => {}, .document_fragment => |frag| { @@ -612,7 +613,11 @@ pub fn getNodeValue(self: *const Node) ?String { pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void { switch (self._type) { - .cdata => |c| try c.setData(if (value) |v| v.str() else null, page), + // Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value) + .cdata => |c| { + const new_value: []const u8 = if (value) |v| v.str() else ""; + try c.replaceData(0, c.getLength(), new_value, page); + }, .attribute => |attr| try attr.setValue(value, page), .element => {}, .document => {}, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index f0c904a6..a5789a64 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -322,6 +322,11 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { const container = self._proto._start_container; const offset = self._proto._start_offset; + // Per spec: if range is collapsed, end offset should extend to include + // the inserted node. Capture before insertion since live range updates + // in the insert path will adjust non-collapsed ranges automatically. + const was_collapsed = self._proto.getCollapsed(); + if (container.is(Node.CData)) |_| { // If container is a text node, we need to split it const parent = container.parentNode() orelse return error.InvalidNodeType; @@ -351,9 +356,10 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { _ = try container.insertBefore(node, ref_child, page); } - // Update range to be after the inserted node - if (self._proto._start_container == self._proto._end_container) { - self._proto._end_offset += 1; + // Per spec step 11: if range was collapsed, extend end to include inserted node. + // Non-collapsed ranges are already handled by the live range update in the insert path. + if (was_collapsed and self._proto._start_container == self._proto._end_container) { + self._proto._end_offset = self._proto._start_offset + 1; } } @@ -375,9 +381,12 @@ pub fn deleteContents(self: *Range, page: *Page) !void { ); page.characterDataChange(self._proto._start_container, old_value); } else { - // Delete child nodes in range - var offset = self._proto._start_offset; - while (offset < self._proto._end_offset) : (offset += 1) { + // Delete child nodes in range. + // Capture count before the loop: removeChild triggers live range + // updates that decrement _end_offset on each removal. + const count = self._proto._end_offset - self._proto._start_offset; + var i: u32 = 0; + while (i < count) : (i += 1) { if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| { _ = try self._proto._start_container.removeChild(child, page); } @@ -717,3 +726,6 @@ const testing = @import("../../testing.zig"); test "WebApi: Range" { try testing.htmlRunner("range.html", .{}); } +test "WebApi: Range mutations" { + try testing.htmlRunner("range_mutations.html", .{}); +} diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig index 5eb096f3..e7a338b5 100644 --- a/src/browser/webapi/cdata/Text.zig +++ b/src/browser/webapi/cdata/Text.zig @@ -43,16 +43,26 @@ pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text { const new_node = try page.createTextNode(new_data); const new_text = new_node.as(Text); - const old_data = data[0..byte_offset]; - try self._proto.setData(old_data, page); - - // If this node has a parent, insert the new node right after this one const node = self._proto.asNode(); + + // Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e), + // then truncate original node (step 8). if (node.parentNode()) |parent| { const next_sibling = node.nextSibling(); _ = try parent.insertBefore(new_node, next_sibling, page); + + // splitText-specific range updates (steps 7b-7e) + if (parent.getChildIndex(node)) |node_index| { + page.updateRangesForSplitText(node, new_node, @intCast(offset), parent, node_index); + } } + // Step 8: truncate original node via replaceData(offset, count, ""). + // Use replaceData instead of setData so live range updates fire + // (matters for detached text nodes where steps 7b-7e were skipped). + const length = self._proto.getLength(); + try self._proto.replaceData(offset, length - offset, "", page); + return new_text; } From 7927ad8fcf7a833c1ff57ec763189faa9c3fdb02 Mon Sep 17 00:00:00 2001 From: egrs Date: Tue, 10 Mar 2026 20:27:05 +0100 Subject: [PATCH 16/34] route appendData through replaceData for spec compliance Per DOM spec, appendData(data) is defined as replaceData(length, 0, data). While the range update would be a no-op (offset=length, count=0), routing through replaceData ensures consistent code path and spec compliance. --- src/browser/webapi/CData.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 5a74f87d..4fb6de6f 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -271,9 +271,9 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool { } pub fn appendData(self: *CData, data: []const u8, page: *Page) !void { - const old_value = self._data; - self._data = try String.concat(page.arena, &.{ self._data.str(), data }); - page.characterDataChange(self.asNode(), old_value); + // Per DOM spec, appendData(data) is replaceData(length, 0, data). + const length = self.getLength(); + try self.replaceData(length, 0, data, page); } pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void { From 24cc24ed50f167ab7e5c779949c656fcd9f8908e Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Tue, 10 Mar 2026 23:28:40 +0000 Subject: [PATCH 17/34] Fix Robots deinit --- src/network/Runtime.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index 0698ac4f..0112dc18 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -104,6 +104,8 @@ pub fn deinit(self: *Runtime) void { self.allocator.free(data[0..ca_blob.len]); } + self.robot_store.deinit(); + globalDeinit(); } From 94ce5edd20458b2dcdf03e55f828b7afaa108729 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 07:47:33 +0800 Subject: [PATCH 18/34] Frames on the same origin share v8 data Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/153 In some ways this is an extension of https://github.com/lightpanda-io/browser/pull/1635 but it has more implications with respect to correctness. A js.Context wraps a v8::Context. One of the important thing it adds is the identity_map so that, given a Zig instance we always return the same v8::Object. But imagine code running in a frame. This frame has its own Context, and thus its own identity_map. What happens when that frame does: ```js window.top.frame_loaded = true; ``` From Zig's point of view, `Window.getTop` will return the correct Zig instance. It will return the *Window references by the "root" page. When that instance is passed to the bridge, we'll look for the v8::Object in the Context's `identity_map` but wont' find it. The mapping exists in the root context `identity_map`, but not within this frame. So we create a new v8::Object and now our 1 zig instance has N v8::Objects for every page/frame that tries to access it. This breaks cross-frame scripting which should work, at least to some degree, even when frames are on the same origin. This commit adds a `js.Origin` which contains the `identity_map`, along with our other `v8::Global` storage. The `Env` now contains a `*js.Origin` lookup, mapping an origin string (e.g. lightpanda.io:443) to an *Origin. When a Page's URL is changed, we call `self.js.setOrigin(new_url)` which will then either get or create an origin from the Env's origin lookup map. js.Origin is reference counted so that it remains valid so long as at least 1 frame references them. There's some special handling for null-origins (i.e. about:blank). At the root, null origins get a distinct/isolated Origin. For a frame, the parent's origin is used. Above, we talked about `identity_map`, but a `js.Context` has 8 other fields to track v8 values, e.g. `global_objects`, `global_functions`, `global_values_temp`, etc. These all must be shared by frames on the same origin. So all of these have also been moved to js.Origin. They've also been merged so that we now have 3 fields: `identity_map`, `globals` and `temps`. Finally, when the origin of a context is changed, we set the v8::Context's SecurityToken (to that origin). This is a key part of how v8 allows cross- context access. --- build.zig.zon | 6 +- src/browser/Page.zig | 27 +++-- src/browser/js/Context.zig | 124 ++++++++-------------- src/browser/js/Env.zig | 62 ++++++++++- src/browser/js/Function.zig | 4 +- src/browser/js/Local.zig | 2 +- src/browser/js/Object.zig | 2 +- src/browser/js/Origin.zig | 148 +++++++++++++++++++++++++++ src/browser/js/Promise.zig | 4 +- src/browser/js/PromiseResolver.zig | 2 +- src/browser/js/Value.zig | 4 +- src/browser/js/js.zig | 2 +- src/browser/tests/frames/frames.html | 11 +- src/browser/tests/testing.js | 2 +- src/browser/webapi/URL.zig | 3 +- src/cdp/domains/page.zig | 2 +- src/testing.zig | 9 -- 17 files changed, 295 insertions(+), 119 deletions(-) create mode 100644 src/browser/js/Origin.zig diff --git a/build.zig.zon b/build.zig.zon index eb3812a8..b7f9cf3b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,10 +5,10 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz", - .hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.2.tar.gz", + .hash = "v8-0.0.0-xddH6wx-BABNgL7YIDgbnFgKZuXZ68yZNngNSrV6OjrY", }, - //.v8 = .{ .path = "../zig-v8-fork" }, + // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ // v1.2.0 .url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz", diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 47c63904..9b05c39b 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -190,6 +190,8 @@ _queued_navigation: ?*QueuedNavigation = null, // The URL of the current page url: [:0]const u8 = "about:blank", +origin: ?[]const u8 = null, + // The base url specifies the base URL used to resolve the relative urls. // It is set by a tag. // If null the url must be used. @@ -388,10 +390,6 @@ pub fn getTitle(self: *Page) !?[]const u8 { return null; } -pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 { - return try URL.getOrigin(allocator, self.url); -} - // Add comon headers for a request: // * cookies // * referer @@ -449,7 +447,7 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void { } pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { - const current_origin = (try URL.getOrigin(self.call_arena, self.url)) orelse return false; + const current_origin = self.origin orelse return false; return std.mem.startsWith(u8, url, current_origin); } @@ -472,6 +470,14 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi // page and dispatch the events. if (std.mem.eql(u8, "about:blank", request_url)) { self.url = "about:blank"; + + if (self.parent) |parent| { + self.origin = parent.origin; + } else { + self.origin = null; + } + try self.js.setOrigin(self.origin); + // Assume we parsed the document. // It's important to force a reset during the following navigation. self._parse_state = .complete; @@ -518,6 +524,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi var http_client = session.browser.http_client; self.url = try self.arena.dupeZ(u8, request_url); + self.origin = try URL.getOrigin(self.arena, self.url); self._req_id = req_id; self._navigated_options = .{ @@ -825,9 +832,15 @@ fn notifyParentLoadComplete(self: *Page) void { fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { var self: *Page = @ptrCast(@alignCast(transfer.ctx)); - // would be different than self.url in the case of a redirect const header = &transfer.response_header.?; - self.url = try self.arena.dupeZ(u8, std.mem.span(header.url)); + + const response_url = std.mem.span(header.url); + if (std.mem.eql(u8, response_url, self.url) == false) { + // would be different than self.url in the case of a redirect + self.url = try self.arena.dupeZ(u8, response_url); + self.origin = try URL.getOrigin(self.arena, self.url); + } + try self.js.setOrigin(self.origin); self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 9180223c..065b5e28 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -23,6 +23,7 @@ const log = @import("../../log.zig"); const js = @import("js.zig"); const Env = @import("Env.zig"); const bridge = @import("bridge.zig"); +const Origin = @import("Origin.zig"); const Scheduler = @import("Scheduler.zig"); const Page = @import("../Page.zig"); @@ -74,12 +75,7 @@ call_depth: usize = 0, // context.localScope local: ?*const js.Local = null, -// Serves two purposes. Like `global_objects`, this is used to free -// every Global(Object) we've created during the lifetime of the context. -// More importantly, it serves as an identity map - for a given Zig -// instance, we map it to the same Global(Object). -// The key is the @intFromPtr of the Zig value -identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, +origin: *Origin, // Any type that is stored in the identity_map which has a finalizer declared // will have its finalizer stored here. This is only used when shutting down @@ -87,26 +83,9 @@ identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback), -// Some web APIs have to manage opaque values. Ideally, they use an -// js.Object, but the js.Object has no lifetime guarantee beyond the -// current call. They can call .persist() on their js.Object to get -// a `Global(Object)`. We need to track these to free them. -// This used to be a map and acted like identity_map; the key was -// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without -// a reliable way to know if an object has already been persisted, -// we now simply persist every time persist() is called. -global_values: std.ArrayList(v8.Global) = .empty, -global_objects: std.ArrayList(v8.Global) = .empty, +// Unlike other v8 types, like functions or objects, modules are not shared +// across origins. global_modules: std.ArrayList(v8.Global) = .empty, -global_promises: std.ArrayList(v8.Global) = .empty, -global_functions: std.ArrayList(v8.Global) = .empty, -global_promise_resolvers: std.ArrayList(v8.Global) = .empty, - -// Temp variants stored in HashMaps for O(1) early cleanup. -// Key is global.data_ptr. -global_values_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, -global_promises_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, -global_functions_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, // Our module cache: normalized module specifier => module. module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty, @@ -174,12 +153,6 @@ pub fn deinit(self: *Context) void { // this can release objects self.scheduler.deinit(); - { - var it = self.identity_map.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - } { var it = self.finalizer_callbacks.valueIterator(); while (it.next()) |finalizer| { @@ -188,50 +161,11 @@ pub fn deinit(self: *Context) void { self.finalizer_callback_pool.deinit(); } - for (self.global_values.items) |*global| { - v8.v8__Global__Reset(global); - } - - for (self.global_objects.items) |*global| { - v8.v8__Global__Reset(global); - } - for (self.global_modules.items) |*global| { v8.v8__Global__Reset(global); } - for (self.global_functions.items) |*global| { - v8.v8__Global__Reset(global); - } - - for (self.global_promises.items) |*global| { - v8.v8__Global__Reset(global); - } - - for (self.global_promise_resolvers.items) |*global| { - v8.v8__Global__Reset(global); - } - - { - var it = self.global_values_temp.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - } - - { - var it = self.global_promises_temp.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - } - - { - var it = self.global_functions_temp.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - } + env.releaseOrigin(self.origin); v8.v8__Global__Reset(&self.handle); env.isolate.notifyContextDisposed(); @@ -241,6 +175,38 @@ pub fn deinit(self: *Context) void { v8.v8__MicrotaskQueue__DELETE(self.microtask_queue); } +pub fn setOrigin(self: *Context, key: ?[]const u8) !void { + const env = self.env; + const isolate = env.isolate; + + const origin = try env.getOrCreateOrigin(key); + errdefer env.releaseOrigin(origin); + + try self.origin.transferTo(origin); + self.origin.deinit(env.app); + + self.origin = origin; + + { + var ls: js.Local.Scope = undefined; + self.localScope(&ls); + defer ls.deinit(); + + // Set the V8::Context SecurityToken, which is a big part of what allows + // one context to access another. + const token_local = v8.v8__Global__Get(&origin.security_token, isolate.handle); + v8.v8__Context__SetSecurityToken(ls.local.handle, token_local); + } +} + +pub fn trackGlobal(self: *Context, global: v8.Global) !void { + return self.origin.trackGlobal(global); +} + +pub fn trackTemp(self: *Context, global: v8.Global) !void { + return self.origin.trackTemp(global); +} + pub fn weakRef(self: *Context, obj: anytype) void { const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse { if (comptime IS_DEBUG) { @@ -279,7 +245,7 @@ pub fn release(self: *Context, item: anytype) void { if (@TypeOf(item) == *anyopaque) { // Existing *anyopaque path for identity_map. Called internally from // finalizers - var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse { + var global = self.origin.identity_map.fetchRemove(@intFromPtr(item)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -301,14 +267,14 @@ pub fn release(self: *Context, item: anytype) void { return; } - var map = switch (@TypeOf(item)) { - js.Value.Temp => &self.global_values_temp, - js.Promise.Temp => &self.global_promises_temp, - js.Function.Temp => &self.global_functions_temp, - else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)), - }; + if (comptime IS_DEBUG) { + switch (@TypeOf(item)) { + js.Value.Temp, js.Promise.Temp, js.Function.Temp => {}, + else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)), + } + } - if (map.fetchRemove(item.handle.data_ptr)) |kv| { + if (self.origin.temps.fetchRemove(item.handle.data_ptr)) |kv| { var global = kv.value; v8.v8__Global__Reset(&global); } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 1a86ffd5..9bacc088 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -26,6 +26,7 @@ const App = @import("../../App.zig"); const log = @import("../../log.zig"); const bridge = @import("bridge.zig"); +const Origin = @import("Origin.zig"); const Context = @import("Context.zig"); const Isolate = @import("Isolate.zig"); const Platform = @import("Platform.zig"); @@ -57,6 +58,8 @@ const Env = @This(); app: *App, +allocator: Allocator, + platform: *const Platform, // the global isolate @@ -70,6 +73,9 @@ isolate_params: *v8.CreateParams, context_id: usize, +// Maps origin -> shared Origin contains, for v8 values shared across same-origin Contexts +origins: std.StringHashMapUnmanaged(*Origin) = .empty, + // Global handles that need to be freed on deinit eternal_function_templates: []v8.Eternal, @@ -206,6 +212,7 @@ pub fn init(app: *App, opts: InitOpts) !Env { return .{ .app = app, .context_id = 0, + .allocator = allocator, .contexts = undefined, .context_count = 0, .isolate = isolate, @@ -228,7 +235,17 @@ pub fn deinit(self: *Env) void { ctx.deinit(); } - const allocator = self.app.allocator; + const app = self.app; + const allocator = app.allocator; + + { + var it = self.origins.valueIterator(); + while (it.next()) |value| { + value.*.deinit(app); + } + self.origins.deinit(allocator); + } + if (self.inspector) |i| { i.deinit(allocator); } @@ -272,6 +289,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { // get the global object for the context, this maps to our Window const global_obj = v8.v8__Context__Global(v8_context).?; + { // Store our TAO inside the internal field of the global object. This // maps the v8::Object -> Zig instance. Almost all objects have this, and @@ -287,6 +305,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { }; v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); } + // our window wrapped in a v8::Global var global_global: v8.Global = undefined; v8.v8__Global__New(isolate.handle, global_obj, &global_global); @@ -294,10 +313,14 @@ pub fn createContext(self: *Env, page: *Page) !*Context { const context_id = self.context_id; self.context_id = context_id + 1; + const origin = try self.getOrCreateOrigin(null); + errdefer self.releaseOrigin(origin); + const context = try context_arena.create(Context); context.* = .{ .env = self, .page = page, + .origin = origin, .id = context_id, .isolate = isolate, .arena = context_arena, @@ -309,7 +332,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { .scheduler = .init(context_arena), .finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator), }; - try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global); + try context.origin.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global); // Store a pointer to our context inside the v8 context so that, given // a v8 context, we can get our context out @@ -350,6 +373,41 @@ pub fn destroyContext(self: *Env, context: *Context) void { context.deinit(); } +pub fn getOrCreateOrigin(self: *Env, key_: ?[]const u8) !*Origin { + const key = key_ orelse { + var opaque_origin: [36]u8 = undefined; + @import("../../id.zig").uuidv4(&opaque_origin); + // Origin.init will dupe opaque_origin. It's fine that this doesn't + // get added to self.origins. In fact, it further isolates it. When the + // context is freed, it'll call env.releaseOrigin which will free it. + return Origin.init(self.app, self.isolate, &opaque_origin); + }; + + const gop = try self.origins.getOrPut(self.allocator, key); + if (gop.found_existing) { + const origin = gop.value_ptr.*; + origin.rc += 1; + return origin; + } + + errdefer _ = self.origins.remove(key); + + const origin = try Origin.init(self.app, self.isolate, key); + gop.key_ptr.* = origin.key; + gop.value_ptr.* = origin; + return origin; +} + +pub fn releaseOrigin(self: *Env, origin: *Origin) void { + const rc = origin.rc; + if (rc == 1) { + _ = self.origins.remove(origin.key); + origin.deinit(self.app); + } else { + origin.rc = rc - 1; + } +} + pub fn runMicrotasks(self: *Env) void { if (self.microtask_queues_are_running == false) { const v8_isolate = self.isolate.handle; diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 01243d35..203fd9ab 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -209,9 +209,9 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { - try ctx.global_functions.append(ctx.arena, global); + try ctx.trackGlobal(global); } else { - try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global); + try ctx.trackTemp(global); } return .{ .handle = global }; } diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 6a68b332..305391f5 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -171,7 +171,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, .pointer => |ptr| { const resolved = resolveValue(value); - const gop = try ctx.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr)); + const gop = try ctx.origin.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr)); if (gop.found_existing) { // we've seen this instance before, return the same object return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self); diff --git a/src/browser/js/Object.zig b/src/browser/js/Object.zig index 981f4a2b..fbf036e4 100644 --- a/src/browser/js/Object.zig +++ b/src/browser/js/Object.zig @@ -97,7 +97,7 @@ pub fn persist(self: Object) !Global { var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - try ctx.global_objects.append(ctx.arena, global); + try ctx.trackGlobal(global); return .{ .handle = global }; } diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig new file mode 100644 index 00000000..6cb0b356 --- /dev/null +++ b/src/browser/js/Origin.zig @@ -0,0 +1,148 @@ +// Copyright (C) 2023-2025 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 . + +// Origin represents the shared Zig<->JS bridge state for all contexts within +// the same origin. Multiple contexts (frames) from the same origin share a +// single Origin, ensuring that JS objects maintain their identity across frames. + +const std = @import("std"); +const js = @import("js.zig"); + +const App = @import("../../App.zig"); + +const v8 = js.v8; +const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("build").mode == .Debug; + +const Origin = @This(); + +rc: usize = 1, +arena: Allocator, + +// The key, e.g. lightpanda.io:443 +key: []const u8, + +// Security token - all contexts in this realm must use the same v8::Value instance +// as their security token for V8 to allow cross-context access +security_token: v8.Global, + +// Serves two purposes. Like `global_objects`, this is used to free +// every Global(Object) we've created during the lifetime of the realm. +// More importantly, it serves as an identity map - for a given Zig +// instance, we map it to the same Global(Object). +// The key is the @intFromPtr of the Zig value +identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, + +// Some web APIs have to manage opaque values. Ideally, they use an +// js.Object, but the js.Object has no lifetime guarantee beyond the +// current call. They can call .persist() on their js.Object to get +// a `Global(Object)`. We need to track these to free them. +// This used to be a map and acted like identity_map; the key was +// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without +// a reliable way to know if an object has already been persisted, +// we now simply persist every time persist() is called. +globals: std.ArrayList(v8.Global) = .empty, + +// Temp variants stored in HashMaps for O(1) early cleanup. +// Key is global.data_ptr. +temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, + +pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { + const arena = try app.arena_pool.acquire(); + errdefer app.arena_pool.release(arena); + + var hs: js.HandleScope = undefined; + hs.init(isolate); + defer hs.deinit(); + + const owned_key = try arena.dupe(u8, key); + const token_local = isolate.initStringHandle(owned_key); + var token_global: v8.Global = undefined; + v8.v8__Global__New(isolate.handle, token_local, &token_global); + + const self = try arena.create(Origin); + self.* = .{ + .rc = 1, + .arena = arena, + .key = owned_key, + .globals = .empty, + .temps = .empty, + .security_token = token_global, + }; + return self; +} + +pub fn deinit(self: *Origin, app: *App) void { + v8.v8__Global__Reset(&self.security_token); + + { + var it = self.identity_map.valueIterator(); + while (it.next()) |global| { + v8.v8__Global__Reset(global); + } + } + + for (self.globals.items) |*global| { + v8.v8__Global__Reset(global); + } + + { + var it = self.temps.valueIterator(); + while (it.next()) |global| { + v8.v8__Global__Reset(global); + } + } + + app.arena_pool.release(self.arena); +} + +pub fn trackGlobal(self: *Origin, global: v8.Global) !void { + return self.globals.append(self.arena, global); +} + +pub fn trackTemp(self: *Origin, global: v8.Global) !void { + return self.temps.put(self.arena, global.data_ptr, global); +} + +pub fn transferTo(self: *Origin, dest: *Origin) !void { + const arena = dest.arena; + + try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len); + for (self.globals.items) |obj| { + dest.globals.appendAssumeCapacity(obj); + } + self.globals.clearRetainingCapacity(); + + { + try dest.temps.ensureUnusedCapacity(arena, self.temps.count()); + var it = self.temps.iterator(); + while (it.next()) |kv| { + try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*); + } + self.temps.clearRetainingCapacity(); + } + + { + try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count()); + var it = self.identity_map.iterator(); + while (it.next()) |kv| { + try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*); + } + self.identity_map.clearRetainingCapacity(); + } +} diff --git a/src/browser/js/Promise.zig b/src/browser/js/Promise.zig index afadbe82..98520d4b 100644 --- a/src/browser/js/Promise.zig +++ b/src/browser/js/Promise.zig @@ -62,9 +62,9 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { - try ctx.global_promises.append(ctx.arena, global); + try ctx.trackGlobal(global); } else { - try ctx.global_promises_temp.put(ctx.arena, global.data_ptr, global); + try ctx.trackTemp(global); } return .{ .handle = global }; } diff --git a/src/browser/js/PromiseResolver.zig b/src/browser/js/PromiseResolver.zig index 183effee..f2aac0e0 100644 --- a/src/browser/js/PromiseResolver.zig +++ b/src/browser/js/PromiseResolver.zig @@ -79,7 +79,7 @@ pub fn persist(self: PromiseResolver) !Global { var ctx = self.local.ctx; var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - try ctx.global_promise_resolvers.append(ctx.arena, global); + try ctx.trackGlobal(global); return .{ .handle = global }; } diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 7963ae7c..fbc961ed 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -259,9 +259,9 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { - try ctx.global_values.append(ctx.arena, global); + try ctx.trackGlobal(global); } else { - try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global); + try ctx.trackTemp(global); } return .{ .handle = global }; } diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 9415b717..22651c39 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -161,7 +161,7 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type { var ctx = self.local.ctx; var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - try ctx.global_values.append(ctx.arena, global); + try ctx.trackGlobal(global); return .{ .handle = global }; } diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html index 4e614de9..97bed281 100644 --- a/src/browser/tests/frames/frames.html +++ b/src/browser/tests/frames/frames.html @@ -64,11 +64,12 @@ // child frame's top.parent is itself (root has no parent) testing.expectEqual(window, window[0].top.parent); - // Todo: Context security tokens - // testing.expectEqual(true, window.sub1_loaded); - // testing.expectEqual(true, window.sub2_loaded); - // testing.expectEqual(1, window.sub1_count); - // testing.expectEqual(2, window.sub2_count); + // Cross-frame property access + testing.expectEqual(true, window.sub1_loaded); + testing.expectEqual(true, window.sub2_loaded); + testing.expectEqual(1, window.sub1_count); + // depends on how far the initial load got before it was cancelled. + testing.expectEqual(true, window.sub2_count == 1 || window.sub2_count == 2); }); diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 90434f0f..987ba042 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -118,7 +118,7 @@ BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/', }; - if (!IS_TEST_RUNNER) { + if (window.navigator.userAgent.startsWith("Lightpanda/") == false) { // The page is running in a different browser. Probably a developer making sure // a test is correct. There are a few tweaks we need to do to make this a // seemless, namely around adapting paths/urls. diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 3bc6f586..fda7d2a5 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -243,11 +243,10 @@ pub fn createObjectURL(blob: *Blob, page: *Page) ![]const u8 { var uuid_buf: [36]u8 = undefined; @import("../../id.zig").uuidv4(&uuid_buf); - const origin = (try page.getOrigin(page.call_arena)) orelse "null"; const blob_url = try std.fmt.allocPrint( page.arena, "blob:{s}/{s}", - .{ origin, uuid_buf }, + .{ page.origin orelse "null", uuid_buf }, ); try page._blob_urls.put(page.arena, blob_url, blob); return blob_url; diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index a96ac2b6..6e406c05 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -414,7 +414,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P bc.inspector_session.inspector.contextCreated( &ls.local, "", - try page.getOrigin(arena) orelse "", + page.origin orelse "", aux_data, true, ); diff --git a/src/testing.zig b/src/testing.zig index a398f824..774f76e4 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -414,15 +414,6 @@ fn runWebApiTest(test_file: [:0]const u8) !void { try_catch.init(&ls.local); defer try_catch.deinit(); - // by default, on load, testing.js will call testing.assertOk(). This makes our - // tests work well in a browser. But, for our test runner, we disable that - // and call it explicitly. This gives us better error messages. - ls.local.eval("window._lightpanda_skip_auto_assert = true;", "auto_assert") catch |err| { - const caught = try_catch.caughtOrError(arena_allocator, err); - std.debug.print("disable auto assert failure\nError: {f}\n", .{caught}); - return err; - }; - try page.navigate(url, .{}); _ = test_session.wait(2000); From 753391b7e26d113544f302eb64e7b79adcf350ba Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 08:47:43 +0800 Subject: [PATCH 19/34] Add origins safety cleanup when destroying the context for the root page --- src/browser/Page.zig | 5 +++-- src/browser/js/Env.zig | 32 ++++++++++++++++++++++++++++++-- src/cdp/cdp.zig | 2 +- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 9b05c39b..dbb93792 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -345,11 +345,12 @@ pub fn deinit(self: *Page, abort_http: bool) void { } const session = self._session; - session.browser.env.destroyContext(self.js); + const is_root = self.parent == null; + session.browser.env.destroyContext(self.js, is_root); self._script_manager.shutdown = true; - if (self.parent == null) { + if (is_root) { session.browser.http_client.abort(); } else if (abort_http) { // a small optimization, it's faster to abort _everything_ on the root diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 9bacc088..d64ef649 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -73,7 +73,17 @@ isolate_params: *v8.CreateParams, context_id: usize, -// Maps origin -> shared Origin contains, for v8 values shared across same-origin Contexts +// Maps origin -> shared Origin contains, for v8 values shared across +// same-origin Contexts. There's a mismatch here between our JS model and our +// Browser model. Origins only live as long as the root page of a session exists. +// It would be wrong/dangerous to re-use an Origin across root page navigations. +// But we have no mechanism to capture that lifetime in js. We used to have a +// js.BrowserContext which mapped to a Session (oops, I took it out), but even +// that wouldn't match correctly, because 1 session can have have muliple non- +// concurrent pages. We deal with this in destroyContext by checking if we're +// destroying the root context and, if so, making sure origins is empty. But, if +// we ever add multiple Sessions to a Browser or mulitple Pages to a Session, +// this map will have to live in a new, better scoped, container. origins: std.StringHashMapUnmanaged(*Origin) = .empty, // Global handles that need to be freed on deinit @@ -348,7 +358,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { return context; } -pub fn destroyContext(self: *Env, context: *Context) void { +pub fn destroyContext(self: *Env, context: *Context, is_root: bool) void { for (self.contexts[0..self.context_count], 0..) |ctx, i| { if (ctx == context) { // Swap with last element and decrement count @@ -371,6 +381,24 @@ pub fn destroyContext(self: *Env, context: *Context) void { } context.deinit(); + + if (is_root) { + // When the root is destroyed, the all of our contexts should be gone + // and with them, all of our origins. Keep origins around longer than + // intended would cause issues, so we're going to be defensive here and + // clean things up. + if (comptime IS_DEBUG) { + std.debug.assert(self.context_count == 0); + std.debug.assert(self.origins.count() == 0); + } + + const app = self.app; + var it = self.origins.valueIterator(); + while (it.next()) |value| { + value.*.deinit(app); + } + self.origins.clearRetainingCapacity(); + } } pub fn getOrCreateOrigin(self: *Env, key_: ?[]const u8) !*Origin { diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 78e5ab50..40057b8b 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -759,7 +759,7 @@ const IsolatedWorld = struct { pub fn removeContext(self: *IsolatedWorld) !void { const ctx = self.context orelse return error.NoIsolatedContextToRemove; - self.browser.env.destroyContext(ctx); + self.browser.env.destroyContext(ctx, false); self.context = null; } From 2a103fc94ab3d50aa777ea9fa113b4d7ec58707c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 13:14:57 +0800 Subject: [PATCH 20/34] Use Session as a container for cross-frame resources The introduction of frames means that data is no longer tied to a specific Page or Context. 255b9a91cc409adc685806878b716dc834fb991a introduced Origins for v8 values shared across frames of the same origin. The commit highlighted the lifetime mismatched that we now have with data that can outlive 1 frame. A specific issue with that commit was the finalizers were still Context-owned. But like any other piece of data, that isn't right; aside from modules, nothing should be context-owned. This commit continues where the last left off and moves finalizers from Context to Origin. This is done in a separate commit because it introduces significant changes. Currently, finalizers take a *Page, but that's no longer correct. A value created in one Page, can outlive the Page. We need another container. I original thought to use Origin, but that isn't know to CDP/MCP. Instead, I decide to enhance the Session. Session is now the owner of the page.arena, the page.factory and the page.arena_pool. Finalizers are given a *Session which they can use to release their arena. --- src/browser/EventManager.zig | 4 +- src/browser/Factory.zig | 6 +- src/browser/Page.zig | 94 +++--------- src/browser/Session.zig | 142 +++++++++++++++--- src/browser/js/Context.zig | 89 +---------- src/browser/js/Env.zig | 2 +- src/browser/js/Function.zig | 25 +-- src/browser/js/Local.zig | 7 +- src/browser/js/Origin.zig | 82 +++++++++- src/browser/js/Promise.zig | 25 +-- src/browser/js/Value.zig | 25 +-- src/browser/js/bridge.zig | 26 ++-- src/browser/js/js.zig | 1 + src/browser/webapi/Event.zig | 7 +- src/browser/webapi/EventTarget.zig | 2 +- src/browser/webapi/FileReader.zig | 19 ++- src/browser/webapi/IntersectionObserver.zig | 15 +- src/browser/webapi/MutationObserver.zig | 13 +- src/browser/webapi/Window.zig | 4 +- src/browser/webapi/animation/Animation.zig | 5 +- src/browser/webapi/collections/ChildNodes.zig | 5 +- src/browser/webapi/collections/NodeList.zig | 11 +- src/browser/webapi/collections/iterator.zig | 5 +- src/browser/webapi/encoding/TextDecoder.zig | 5 +- src/browser/webapi/event/CompositionEvent.zig | 5 +- src/browser/webapi/event/CustomEvent.zig | 7 +- src/browser/webapi/event/ErrorEvent.zig | 7 +- src/browser/webapi/event/FocusEvent.zig | 5 +- src/browser/webapi/event/KeyboardEvent.zig | 5 +- src/browser/webapi/event/MessageEvent.zig | 7 +- src/browser/webapi/event/MouseEvent.zig | 5 +- .../NavigationCurrentEntryChangeEvent.zig | 5 +- .../webapi/event/PageTransitionEvent.zig | 5 +- src/browser/webapi/event/PointerEvent.zig | 5 +- src/browser/webapi/event/PopStateEvent.zig | 5 +- src/browser/webapi/event/ProgressEvent.zig | 5 +- .../webapi/event/PromiseRejectionEvent.zig | 9 +- src/browser/webapi/event/TextEvent.zig | 5 +- src/browser/webapi/event/UIEvent.zig | 5 +- src/browser/webapi/event/WheelEvent.zig | 5 +- src/browser/webapi/net/Fetch.zig | 6 +- src/browser/webapi/net/Response.zig | 5 +- src/browser/webapi/net/XMLHttpRequest.zig | 23 +-- src/browser/webapi/selector/List.zig | 5 +- src/cdp/Node.zig | 6 +- src/cdp/domains/dom.zig | 4 +- src/mcp/tools.zig | 3 +- 47 files changed, 427 insertions(+), 334 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 573aa4f9..5588b704 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -205,7 +205,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void { event.acquireRef(); - defer event.deinit(false, self.page); + defer event.deinit(false, self.page._session); if (comptime IS_DEBUG) { log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); @@ -234,7 +234,7 @@ pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, const page = self.page; event.acquireRef(); - defer event.deinit(false, page); + defer event.deinit(false, page._session); if (comptime IS_DEBUG) { log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context }); diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index cbc2170d..c5ede22b 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -48,13 +48,11 @@ const Factory = @This(); _arena: Allocator, _slab: SlabAllocator, -pub fn init(arena: Allocator) !*Factory { - const self = try arena.create(Factory); - self.* = .{ +pub fn init(arena: Allocator) Factory { + return .{ ._arena = arena, ._slab = SlabAllocator.init(arena, 128), }; - return self; } // this is a root object diff --git a/src/browser/Page.zig b/src/browser/Page.zig index dbb93792..60a3f968 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -214,14 +214,6 @@ arena: Allocator, // from JS. Best arena to use, when possible. call_arena: Allocator, -arena_pool: *ArenaPool, -// In Debug, we use this to see if anything fails to release an arena back to -// the pool. -_arena_pool_leak_track: (if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct { - owner: []const u8, - count: usize, -}) else void) = if (IS_DEBUG) .empty else {}, - parent: ?*Page, window: *Window, document: *Document, @@ -248,17 +240,11 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void if (comptime IS_DEBUG) { log.debug(.page, "page.init", .{}); } - const browser = session.browser; - const arena_pool = browser.arena_pool; - const page_arena = if (parent) |p| p.arena else try arena_pool.acquire(); - errdefer if (parent == null) arena_pool.release(page_arena); - - var factory = if (parent) |p| p._factory else try Factory.init(page_arena); - - const call_arena = try arena_pool.acquire(); - errdefer arena_pool.release(call_arena); + const call_arena = try session.getArena(.{ .debug = "call_arena" }); + errdefer session.releaseArena(call_arena); + const factory = &session.factory; const document = (try factory.document(Node.Document.HTMLDocument{ ._proto = undefined, })).asDocument(); @@ -266,10 +252,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void self.* = .{ .js = undefined, .parent = parent, - .arena = page_arena, + .arena = session.page_arena, .document = document, .window = undefined, - .arena_pool = arena_pool, .call_arena = call_arena, ._frame_id = frame_id, ._session = session, @@ -277,7 +262,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._pending_loads = 1, // always 1 for the ScriptManager ._type = if (parent == null) .root else .frame, ._script_manager = undefined, - ._event_manager = EventManager.init(page_arena, self), + ._event_manager = EventManager.init(session.page_arena, self), }; var screen: *Screen = undefined; @@ -305,6 +290,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._visual_viewport = visual_viewport, }); + const browser = session.browser; self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); errdefer self._script_manager.deinit(); @@ -340,11 +326,12 @@ pub fn deinit(self: *Page, abort_http: bool) void { // stats.print(&stream) catch unreachable; } + const session = self._session; + if (self._queued_navigation) |qn| { - self.arena_pool.release(qn.arena); + session.releaseArena(qn.arena); } - const session = self._session; const is_root = self.parent == null; session.browser.env.destroyContext(self.js, is_root); @@ -361,23 +348,7 @@ pub fn deinit(self: *Page, abort_http: bool) void { self._script_manager.deinit(); - if (comptime IS_DEBUG) { - var it = self._arena_pool_leak_track.valueIterator(); - while (it.next()) |value_ptr| { - if (value_ptr.count > 0) { - log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type, .url = self.url }); - if (comptime builtin.is_test) { - @panic("ArenaPool Leak"); - } - } - } - } - - self.arena_pool.release(self.call_arena); - - if (self.parent == null) { - self.arena_pool.release(self.arena); - } + session.releaseArena(self.call_arena); } pub fn base(self: *const Page) [:0]const u8 { @@ -417,34 +388,12 @@ pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, header } } -const GetArenaOpts = struct { - debug: []const u8, -}; -pub fn getArena(self: *Page, comptime opts: GetArenaOpts) !Allocator { - const allocator = try self.arena_pool.acquire(); - if (comptime IS_DEBUG) { - const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr)); - if (gop.found_existing) { - std.debug.assert(gop.value_ptr.count == 0); - } - gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 }; - } - return allocator; +pub fn getArena(self: *Page, comptime opts: Session.GetArenaOpts) !Allocator { + return self._session.getArena(opts); } pub fn releaseArena(self: *Page, allocator: Allocator) void { - if (comptime IS_DEBUG) { - const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; - if (found.count != 1) { - log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type, .url = self.url }); - if (comptime builtin.is_test) { - @panic("ArenaPool Double Free"); - } - return; - } - found.count = 0; - } - return self.arena_pool.release(allocator); + return self._session.releaseArena(allocator); } pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { @@ -586,8 +535,8 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp if (self.canScheduleNavigation(std.meta.activeTag(nt)) == false) { return; } - const arena = try self.arena_pool.acquire(); - errdefer self.arena_pool.release(arena); + const arena = try self._session.getArena(.{ .debug = "scheduleNavigation" }); + errdefer self._session.releaseArena(arena); return self.scheduleNavigationWithArena(arena, request_url, opts, nt); } @@ -626,9 +575,8 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: if (target.parent == null) { try session.navigation.updateEntries(target.url, opts.kind, target, true); } - // doin't defer this, the caller, the caller is responsible for freeing - // it on error - target.arena_pool.release(arena); + // don't defer this, the caller is responsible for freeing it on error + session.releaseArena(arena); return; } @@ -3177,7 +3125,7 @@ pub fn handleClick(self: *Page, target: *Node) !void { pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { const event = keyboard_event.asEvent(); const element = self.window._document._active_element orelse { - keyboard_event.deinit(false, self); + keyboard_event.deinit(false, self._session); return; }; @@ -3253,7 +3201,7 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form // so submit_event is still valid when we check _prevent_default submit_event.acquireRef(); - defer submit_event.deinit(false, self); + defer submit_event.deinit(false, self._session); try self._event_manager.dispatch(form_element.asEventTarget(), submit_event); // If the submit event was prevented, don't submit the form @@ -3267,8 +3215,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form // I don't think this is technically correct, but FormData handles it ok const form_data = try FormData.init(form, submitter_, self); - const arena = try self.arena_pool.acquire(); - errdefer self.arena_pool.release(arena); + const arena = try self._session.getArena(.{ .debug = "submitForm" }); + errdefer self._session.releaseArena(arena); const encoding = form_element.getAttributeSafe(comptime .wrap("enctype")); diff --git a/src/browser/Session.zig b/src/browser/Session.zig index d8a85fa2..42fe8ef5 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const builtin = @import("builtin"); const log = @import("../log.zig"); +const App = @import("../App.zig"); const js = @import("js/js.zig"); const storage = @import("webapi/storage/storage.zig"); @@ -29,20 +30,50 @@ const History = @import("webapi/History.zig"); const Page = @import("Page.zig"); const Browser = @import("Browser.zig"); +const Factory = @import("Factory.zig"); const Notification = @import("../Notification.zig"); const QueuedNavigation = Page.QueuedNavigation; const Allocator = std.mem.Allocator; +const ArenaPool = App.ArenaPool; const IS_DEBUG = builtin.mode == .Debug; -// Session is like a browser's tab. -// It owns the js env and the loader for all the pages of the session. // You can create successively multiple pages for a session, but you must -// deinit a page before running another one. +// deinit a page before running another one. It manages two distinct lifetimes. +// +// The first is the lifetime of the Session itself, where pages are created and +// removed, but share the same cookie jar and navigation history (etc...) +// +// The second is as a container the data needed by the full page hierarchy, i.e. \ +// the root page and all of its frames (and all of their frames.) const Session = @This(); +// These are the fields that remain intact for the duration of the Session browser: *Browser, +arena: Allocator, +history: History, +navigation: Navigation, +storage_shed: storage.Shed, notification: *Notification, +cookie_jar: storage.Cookie.Jar, + +// These are the fields that get reset whenever the Session's page (the root) is reset. +factory: Factory, + +page_arena: Allocator, + +// Shared resources for all pages in this session. +// These live for the duration of the page tree (root + frames). +arena_pool: *ArenaPool, + +// In Debug, we use this to see if anything fails to release an arena back to +// the pool. +_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct { + owner: []const u8, + count: usize, +}) else void = if (IS_DEBUG) .empty else {}, + +page: ?Page, queued_navigation: std.ArrayList(*Page), // Temporary buffer for about:blank navigations during processing. @@ -50,27 +81,24 @@ queued_navigation: std.ArrayList(*Page), // about:blank navigations (which may add to queued_navigation). queued_queued_navigation: std.ArrayList(*Page), -// Used to create our Inspector and in the BrowserContext. -arena: Allocator, - -cookie_jar: storage.Cookie.Jar, -storage_shed: storage.Shed, - -history: History, -navigation: Navigation, - -page: ?Page, - frame_id_gen: u32, pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void { const allocator = browser.app.allocator; - const arena = try browser.arena_pool.acquire(); - errdefer browser.arena_pool.release(arena); + const arena_pool = browser.arena_pool; + + const arena = try arena_pool.acquire(); + errdefer arena_pool.release(arena); + + const page_arena = try arena_pool.acquire(); + errdefer arena_pool.release(page_arena); self.* = .{ .page = null, .arena = arena, + .arena_pool = arena_pool, + .page_arena = page_arena, + .factory = Factory.init(page_arena), .history = .{}, .frame_id_gen = 0, // The prototype (EventTarget) for Navigation is created when a Page is created. @@ -90,9 +118,9 @@ pub fn deinit(self: *Session) void { } self.cookie_jar.deinit(); - const browser = self.browser; - self.storage_shed.deinit(browser.app.allocator); - browser.arena_pool.release(self.arena); + self.storage_shed.deinit(self.browser.app.allocator); + self.arena_pool.release(self.page_arena); + self.arena_pool.release(self.arena); } // NOTE: the caller is not the owner of the returned value, @@ -126,29 +154,84 @@ pub fn removePage(self: *Session) void { self.page = null; self.navigation.onRemovePage(); + self.resetPageResources(); if (comptime IS_DEBUG) { log.debug(.browser, "remove page", .{}); } } +pub const GetArenaOpts = struct { + debug: []const u8, +}; + +pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator { + const allocator = try self.arena_pool.acquire(); + if (comptime IS_DEBUG) { + // Use session's arena (not page_arena) since page_arena gets reset between pages + const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr)); + if (gop.found_existing and gop.value_ptr.count != 0) { + log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner }); + @panic("ArenaPool Double Use"); + } + gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 }; + } + return allocator; +} + +pub fn releaseArena(self: *Session, allocator: Allocator) void { + if (comptime IS_DEBUG) { + const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; + if (found.count != 1) { + log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count }); + if (comptime builtin.is_test) { + @panic("ArenaPool Double Free"); + } + return; + } + found.count = 0; + } + return self.arena_pool.release(allocator); +} + +/// Reset page_arena and factory for a clean slate. +/// Called when root page is removed. +fn resetPageResources(self: *Session) void { + // Check for arena leaks before releasing + if (comptime IS_DEBUG) { + var it = self._arena_pool_leak_track.valueIterator(); + while (it.next()) |value_ptr| { + if (value_ptr.count > 0) { + log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner }); + } + } + self._arena_pool_leak_track.clearRetainingCapacity(); + } + + // Release old page_arena and acquire fresh one + self.frame_id_gen = 0; + self.arena_pool.reset(self.page_arena, 64 * 1024); + self.factory = Factory.init(self.page_arena); +} + pub fn replacePage(self: *Session) !*Page { if (comptime IS_DEBUG) { log.debug(.browser, "replace page", .{}); } lp.assert(self.page != null, "Session.replacePage null page", .{}); + lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{}); var current = self.page.?; const frame_id = current._frame_id; - const parent = current.parent; current.deinit(false); + self.resetPageResources(); self.browser.env.memoryPressureNotification(.moderate); self.page = @as(Page, undefined); const page = &self.page.?; - try Page.init(page, frame_id, self, parent); + try Page.init(page, frame_id, self, null); return page; } @@ -428,12 +511,11 @@ fn processQueuedNavigation(self: *Session) !void { fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void { lp.assert(page.parent != null, "root queued navigation", .{}); - const browser = self.browser; const iframe = page.iframe.?; const parent = page.parent.?; page._queued_navigation = null; - defer browser.arena_pool.release(qn.arena); + defer self.releaseArena(qn.arena); errdefer iframe._window = null; @@ -465,9 +547,21 @@ fn processRootQueuedNavigation(self: *Session) !void { // create a copy before the page is cleared const qn = current_page._queued_navigation.?; current_page._queued_navigation = null; - defer self.browser.arena_pool.release(qn.arena); + + defer self.arena_pool.release(qn.arena); + + // HACK + // Mark as released in tracking BEFORE removePage clears the map. + // We can't call releaseArena() because that would also return the arena + // to the pool, making the memory invalid before we use qn.url/qn.opts. + if (comptime IS_DEBUG) { + if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| { + found.count = 0; + } + } self.removePage(); + self.page = @as(Page, undefined); const new_page = &self.page.?; try Page.init(new_page, frame_id, self, null); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 065b5e28..750cd809 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -27,6 +27,7 @@ const Origin = @import("Origin.zig"); const Scheduler = @import("Scheduler.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const ScriptManager = @import("../ScriptManager.zig"); const v8 = js.v8; @@ -42,6 +43,7 @@ const Context = @This(); id: usize, env: *Env, page: *Page, +session: *Session, isolate: js.Isolate, // Per-context microtask queue for isolation between contexts @@ -77,12 +79,6 @@ local: ?*const js.Local = null, origin: *Origin, -// Any type that is stored in the identity_map which has a finalizer declared -// will have its finalizer stored here. This is only used when shutting down -// if v8 hasn't called the finalizer directly itself. -finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, -finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback), - // Unlike other v8 types, like functions or objects, modules are not shared // across origins. global_modules: std.ArrayList(v8.Global) = .empty, @@ -153,14 +149,6 @@ pub fn deinit(self: *Context) void { // this can release objects self.scheduler.deinit(); - { - var it = self.finalizer_callbacks.valueIterator(); - while (it.next()) |finalizer| { - finalizer.*.deinit(); - } - self.finalizer_callback_pool.deinit(); - } - for (self.global_modules.items) |*global| { v8.v8__Global__Reset(global); } @@ -208,7 +196,7 @@ pub fn trackTemp(self: *Context, global: v8.Global) !void { } pub fn weakRef(self: *Context, obj: anytype) void { - const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse { + const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -219,7 +207,7 @@ pub fn weakRef(self: *Context, obj: anytype) void { } pub fn safeWeakRef(self: *Context, obj: anytype) void { - const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse { + const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -231,7 +219,7 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void { } pub fn strongRef(self: *Context, obj: anytype) void { - const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse { + const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -241,45 +229,6 @@ pub fn strongRef(self: *Context, obj: anytype) void { v8.v8__Global__ClearWeak(&fc.global); } -pub fn release(self: *Context, item: anytype) void { - if (@TypeOf(item) == *anyopaque) { - // Existing *anyopaque path for identity_map. Called internally from - // finalizers - var global = self.origin.identity_map.fetchRemove(@intFromPtr(item)) orelse { - if (comptime IS_DEBUG) { - // should not be possible - std.debug.assert(false); - } - return; - }; - v8.v8__Global__Reset(&global.value); - - // The item has been fianalized, remove it for the finalizer callback so that - // we don't try to call it again on shutdown. - const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse { - if (comptime IS_DEBUG) { - // should not be possible - std.debug.assert(false); - } - return; - }; - self.finalizer_callback_pool.destroy(fc.value); - return; - } - - if (comptime IS_DEBUG) { - switch (@TypeOf(item)) { - js.Value.Temp, js.Promise.Temp, js.Function.Temp => {}, - else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)), - } - } - - if (self.origin.temps.fetchRemove(item.handle.data_ptr)) |kv| { - var global = kv.value; - v8.v8__Global__Reset(&global); - } -} - // Any operation on the context have to be made from a local. pub fn localScope(self: *Context, ls: *js.Local.Scope) void { const isolate = self.isolate; @@ -1005,34 +954,6 @@ pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle); } -pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void) !*FinalizerCallback { - const fc = try self.finalizer_callback_pool.create(); - fc.* = .{ - .ctx = self, - .ptr = ptr, - .global = global, - .finalizerFn = finalizerFn, - }; - return fc; -} - -// == Misc == -// A type that has a finalizer can have its finalizer called one of two ways. -// The first is from V8 via the WeakCallback we give to weakRef. But that isn't -// guaranteed to fire, so we track this in ctx._finalizers and call them on -// context shutdown. -pub const FinalizerCallback = struct { - ctx: *Context, - ptr: *anyopaque, - global: v8.Global, - finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void, - - pub fn deinit(self: *FinalizerCallback) void { - self.finalizerFn(self.ptr, self.ctx.page); - self.ctx.finalizer_callback_pool.destroy(self); - } -}; - // == Profiler == pub fn startCpuProfiler(self: *Context) void { if (comptime !IS_DEBUG) { diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index d64ef649..4318394d 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -330,6 +330,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { context.* = .{ .env = self, .page = page, + .session = page._session, .origin = origin, .id = context_id, .isolate = isolate, @@ -340,7 +341,6 @@ pub fn createContext(self: *Env, page: *Page) !*Context { .microtask_queue = microtask_queue, .script_manager = &page._script_manager, .scheduler = .init(context_arena), - .finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator), }; try context.origin.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global); diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 203fd9ab..bfb5e53d 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -210,10 +210,10 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { try ctx.trackGlobal(global); - } else { - try ctx.trackTemp(global); + return .{ .handle = global, .origin = {} }; } - return .{ .handle = global }; + try ctx.trackTemp(global); + return .{ .handle = global, .origin = ctx.origin }; } pub fn tempWithThis(self: *const Function, value: anytype) !Temp { @@ -226,15 +226,18 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global { return with_this.persist(); } -pub const Temp = G(0); -pub const Global = G(1); +pub const Temp = G(.temp); +pub const Global = G(.global); -fn G(comptime discriminator: u8) type { +const GlobalType = enum(u8) { + temp, + global, +}; + +fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - - // makes the types different (G(0) != G(1)), without taking up space - comptime _: u8 = discriminator, + origin: if (global_type == .temp) *js.Origin else void, const Self = @This(); @@ -252,5 +255,9 @@ fn G(comptime discriminator: u8) type { pub fn isEqual(self: *const Self, other: Function) bool { return v8.v8__Global__IsEqual(&self.handle, other.handle); } + + pub fn release(self: *const Self) void { + self.origin.releaseTemp(self.handle); + } }; } diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 305391f5..84e83b49 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -18,6 +18,7 @@ const std = @import("std"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const log = @import("../../log.zig"); const string = @import("../../string.zig"); @@ -231,10 +232,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, // Instead, we check if the base has finalizer. The assumption // here is that if a resolve type has a finalizer, then the base // should have a finalizer too. - const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); + const fc = try ctx.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); { errdefer fc.deinit(); - try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc); + try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc); } conditionallyReference(value); @@ -1083,7 +1084,7 @@ const Resolved = struct { class_id: u16, prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry, finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null, - finalizer_from_zig: ?*const fn (ptr: *anyopaque, page: *Page) void = null, + finalizer_from_zig: ?*const fn (ptr: *anyopaque, session: *Session) void = null, }; pub fn resolveValue(value: anytype) Resolved { const T = bridge.Struct(@TypeOf(value)); diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig index 6cb0b356..356b75c8 100644 --- a/src/browser/js/Origin.zig +++ b/src/browser/js/Origin.zig @@ -24,10 +24,11 @@ const std = @import("std"); const js = @import("js.zig"); const App = @import("../../App.zig"); +const Session = @import("../Session.zig"); const v8 = js.v8; const Allocator = std.mem.Allocator; -const IS_DEBUG = @import("build").mode == .Debug; +const IS_DEBUG = @import("builtin").mode == .Debug; const Origin = @This(); @@ -62,6 +63,29 @@ globals: std.ArrayList(v8.Global) = .empty, // Key is global.data_ptr. temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, +// Any type that is stored in the identity_map which has a finalizer declared +// will have its finalizer stored here. This is only used when shutting down +// if v8 hasn't called the finalizer directly itself. +finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, +finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback), + +// A type that has a finalizer can have its finalizer called one of two ways. +// The first is from V8 via the WeakCallback we give to weakRef. But that isn't +// guaranteed to fire, so we track this in finalizer_callbacks and call them on +// origin shutdown. +pub const FinalizerCallback = struct { + origin: *Origin, + session: *Session, + ptr: *anyopaque, + global: v8.Global, + finalizerFn: *const fn (ptr: *anyopaque, session: *Session) void, + + pub fn deinit(self: *FinalizerCallback) void { + self.finalizerFn(self.ptr, self.session); + self.origin.finalizer_callback_pool.destroy(self); + } +}; + pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { const arena = try app.arena_pool.acquire(); errdefer app.arena_pool.release(arena); @@ -83,11 +107,21 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { .globals = .empty, .temps = .empty, .security_token = token_global, + .finalizer_callback_pool = .init(arena), }; return self; } pub fn deinit(self: *Origin, app: *App) void { + // Call finalizers before releasing anything + { + var it = self.finalizer_callbacks.valueIterator(); + while (it.next()) |finalizer| { + finalizer.*.deinit(); + } + self.finalizer_callback_pool.deinit(); + } + v8.v8__Global__Reset(&self.security_token); { @@ -119,6 +153,52 @@ pub fn trackTemp(self: *Origin, global: v8.Global) !void { return self.temps.put(self.arena, global.data_ptr, global); } +pub fn releaseTemp(self: *Origin, global: v8.Global) void { + if (self.temps.fetchRemove(global.data_ptr)) |kv| { + var g = kv.value; + v8.v8__Global__Reset(&g); + } +} + +/// Release an item from the identity_map (called after finalizer runs from V8) +pub fn release(self: *Origin, item: *anyopaque) void { + var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + return; + }; + v8.v8__Global__Reset(&global.value); + + // The item has been finalized, remove it from the finalizer callback so that + // we don't try to call it again on shutdown. + const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + return; + }; + self.finalizer_callback_pool.destroy(fc.value); +} + +pub fn createFinalizerCallback( + self: *Origin, + session: *Session, + global: v8.Global, + ptr: *anyopaque, + finalizerFn: *const fn (ptr: *anyopaque, session: *Session) void, +) !*FinalizerCallback { + const fc = try self.finalizer_callback_pool.create(); + fc.* = .{ + .origin = self, + .session = session, + .ptr = ptr, + .global = global, + .finalizerFn = finalizerFn, + }; + return fc; +} + pub fn transferTo(self: *Origin, dest: *Origin) !void { const arena = dest.arena; diff --git a/src/browser/js/Promise.zig b/src/browser/js/Promise.zig index 98520d4b..372d2578 100644 --- a/src/browser/js/Promise.zig +++ b/src/browser/js/Promise.zig @@ -63,21 +63,24 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { try ctx.trackGlobal(global); - } else { - try ctx.trackTemp(global); + return .{ .handle = global, .origin = {} }; } - return .{ .handle = global }; + try ctx.trackTemp(global); + return .{ .handle = global, .origin = ctx.origin }; } -pub const Temp = G(0); -pub const Global = G(1); +pub const Temp = G(.temp); +pub const Global = G(.global); -fn G(comptime discriminator: u8) type { +const GlobalType = enum(u8) { + temp, + global, +}; + +fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - - // makes the types different (G(0) != G(1)), without taking up space - comptime _: u8 = discriminator, + origin: if (global_type == .temp) *js.Origin else void, const Self = @This(); @@ -91,5 +94,9 @@ fn G(comptime discriminator: u8) type { .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)), }; } + + pub fn release(self: *const Self) void { + self.origin.releaseTemp(self.handle); + } }; } diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index fbc961ed..309bdb6b 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -260,10 +260,10 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { try ctx.trackGlobal(global); - } else { - try ctx.trackTemp(global); + return .{ .handle = global, .origin = {} }; } - return .{ .handle = global }; + try ctx.trackTemp(global); + return .{ .handle = global, .origin = ctx.origin }; } pub fn toZig(self: Value, comptime T: type) !T { @@ -310,15 +310,18 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void { return js_str.format(writer); } -pub const Temp = G(0); -pub const Global = G(1); +pub const Temp = G(.temp); +pub const Global = G(.global); -fn G(comptime discriminator: u8) type { +const GlobalType = enum(u8) { + temp, + global, +}; + +fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - - // makes the types different (G(0) != G(1)), without taking up space - comptime _: u8 = discriminator, + origin: if (global_type == .temp) *js.Origin else void, const Self = @This(); @@ -336,5 +339,9 @@ fn G(comptime discriminator: u8) type { pub fn isEqual(self: *const Self, other: Value) bool { return v8.v8__Global__IsEqual(&self.handle, other.handle); } + + pub fn release(self: *const Self) void { + self.origin.releaseTemp(self.handle); + } }; } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index fcbbea21..f95329dc 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -21,11 +21,13 @@ const js = @import("js.zig"); const lp = @import("lightpanda"); const log = @import("../../log.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const v8 = js.v8; const Caller = @import("Caller.zig"); const Context = @import("Context.zig"); +const Origin = @import("Origin.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; @@ -104,24 +106,24 @@ pub fn Builder(comptime T: type) type { return entries; } - pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, page: *Page) void) Finalizer { + pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, session: *Session) void) Finalizer { return .{ .from_zig = struct { - fn wrap(ptr: *anyopaque, page: *Page) void { - func(@ptrCast(@alignCast(ptr)), true, page); + fn wrap(ptr: *anyopaque, session: *Session) void { + func(@ptrCast(@alignCast(ptr)), true, session); } }.wrap, .from_v8 = struct { fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void { const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; - const fc: *Context.FinalizerCallback = @ptrCast(@alignCast(ptr)); + const fc: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr)); - const ctx = fc.ctx; + const origin = fc.origin; const value_ptr = fc.ptr; - if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { - func(@ptrCast(@alignCast(value_ptr)), false, ctx.page); - ctx.release(value_ptr); + if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { + func(@ptrCast(@alignCast(value_ptr)), false, fc.session); + origin.release(value_ptr); } else { // A bit weird, but v8 _requires_ that we release it // If we don't. We'll 100% crash. @@ -413,12 +415,12 @@ pub const Property = struct { }; const Finalizer = struct { - // The finalizer wrapper when called fro Zig. This is only called on - // Context.deinit - from_zig: *const fn (ctx: *anyopaque, page: *Page) void, + // The finalizer wrapper when called from Zig. This is only called on + // Origin.deinit + from_zig: *const fn (ctx: *anyopaque, session: *Session) void, // The finalizer wrapper when called from V8. This may never be called - // (hence why we fallback to calling in Context.denit). If it is called, + // (hence why we fallback to calling in Origin.deinit). If it is called, // it is only ever called after we SetWeak on the Global. from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void, }; diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 22651c39..0c196e5b 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -24,6 +24,7 @@ const string = @import("../../string.zig"); pub const Env = @import("Env.zig"); pub const bridge = @import("bridge.zig"); pub const Caller = @import("Caller.zig"); +pub const Origin = @import("Origin.zig"); pub const Context = @import("Context.zig"); pub const Local = @import("Local.zig"); pub const Inspector = @import("Inspector.zig"); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 2821e9f1..d70042f8 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const EventTarget = @import("EventTarget.zig"); const Node = @import("Node.zig"); const String = @import("../../string.zig").String; @@ -139,9 +140,9 @@ pub fn acquireRef(self: *Event) void { self._rc += 1; } -pub fn deinit(self: *Event, shutdown: bool, page: *Page) void { +pub fn deinit(self: *Event, shutdown: bool, session: *Session) void { if (shutdown) { - page.releaseArena(self._arena); + session.releaseArena(self._arena); return; } @@ -151,7 +152,7 @@ pub fn deinit(self: *Event, shutdown: bool, page: *Page) void { } if (rc == 1) { - page.releaseArena(self._arena); + session.releaseArena(self._arena); } else { self._rc = rc - 1; } diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 31f472c3..715c209a 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -59,7 +59,7 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { event._is_trusted = false; event.acquireRef(); - defer event.deinit(false, page); + defer event.deinit(false, page._session); try page._event_manager.dispatch(self, event); return !event._cancelable or !event._prevent_default; } diff --git a/src/browser/webapi/FileReader.zig b/src/browser/webapi/FileReader.zig index 90e26aa0..da7ec71c 100644 --- a/src/browser/webapi/FileReader.zig +++ b/src/browser/webapi/FileReader.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const EventTarget = @import("EventTarget.zig"); const ProgressEvent = @import("event/ProgressEvent.zig"); const Blob = @import("Blob.zig"); @@ -69,17 +70,15 @@ pub fn init(page: *Page) !*FileReader { }); } -pub fn deinit(self: *FileReader, _: bool, page: *Page) void { - const js_ctx = page.js; +pub fn deinit(self: *FileReader, _: bool, session: *Session) void { + if (self._on_abort) |func| func.release(); + if (self._on_error) |func| func.release(); + if (self._on_load) |func| func.release(); + if (self._on_load_end) |func| func.release(); + if (self._on_load_start) |func| func.release(); + if (self._on_progress) |func| func.release(); - if (self._on_abort) |func| js_ctx.release(func); - if (self._on_error) |func| js_ctx.release(func); - if (self._on_load) |func| js_ctx.release(func); - if (self._on_load_end) |func| js_ctx.release(func); - if (self._on_load_start) |func| js_ctx.release(func); - if (self._on_progress) |func| js_ctx.release(func); - - page.releaseArena(self._arena); + session.releaseArena(self._arena); } fn asEventTarget(self: *FileReader) *EventTarget { diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 9d64de3c..74a5d79e 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -24,6 +24,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; const Allocator = std.mem.Allocator; const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const Element = @import("Element.zig"); const DOMRect = @import("DOMRect.zig"); @@ -91,13 +92,13 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I return self; } -pub fn deinit(self: *IntersectionObserver, shutdown: bool, page: *Page) void { - page.js.release(self._callback); +pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void { + self._callback.release(); if ((comptime IS_DEBUG) and !shutdown) { std.debug.assert(self._observing.items.len == 0); } - page.releaseArena(self._arena); + session.releaseArena(self._arena); } pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void { @@ -137,7 +138,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi while (j < self._pending_entries.items.len) { if (self._pending_entries.items[j]._target == target) { const entry = self._pending_entries.swapRemove(j); - entry.deinit(false, page); + entry.deinit(false, page._session); } else { j += 1; } @@ -157,7 +158,7 @@ pub fn disconnect(self: *IntersectionObserver, page: *Page) void { self._previous_states.clearRetainingCapacity(); for (self._pending_entries.items) |entry| { - entry.deinit(false, page); + entry.deinit(false, page._session); } self._pending_entries.clearRetainingCapacity(); page.js.safeWeakRef(self); @@ -302,8 +303,8 @@ pub const IntersectionObserverEntry = struct { _intersection_ratio: f64, _is_intersecting: bool, - pub fn deinit(self: *const IntersectionObserverEntry, _: bool, page: *Page) void { - page.releaseArena(self._arena); + pub fn deinit(self: *IntersectionObserverEntry, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn getTarget(self: *const IntersectionObserverEntry) *Element { diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index a5dd15dd..b8608381 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -21,6 +21,7 @@ const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); const log = @import("../../log.zig"); @@ -84,13 +85,13 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver { return self; } -pub fn deinit(self: *MutationObserver, shutdown: bool, page: *Page) void { - page.js.release(self._callback); +pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void { + self._callback.release(); if ((comptime IS_DEBUG) and !shutdown) { std.debug.assert(self._observing.items.len == 0); } - page.releaseArena(self._arena); + session.releaseArena(self._arena); } pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void { @@ -171,7 +172,7 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void { page.unregisterMutationObserver(self); self._observing.clearRetainingCapacity(); for (self._pending_records.items) |record| { - record.deinit(false, page); + record.deinit(false, page._session); } self._pending_records.clearRetainingCapacity(); page.js.safeWeakRef(self); @@ -363,8 +364,8 @@ pub const MutationRecord = struct { characterData, }; - pub fn deinit(self: *const MutationRecord, _: bool, page: *Page) void { - page.releaseArena(self._arena); + pub fn deinit(self: *MutationRecord, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn getType(self: *const MutationRecord) []const u8 { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 40265bb5..2bc9fbb0 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -646,9 +646,9 @@ const ScheduleCallback = struct { } fn deinit(self: *ScheduleCallback) void { - self.page.js.release(self.cb); + self.cb.release(); for (self.params) |param| { - self.page.js.release(param); + param.release(); } self.page.releaseArena(self.arena); } diff --git a/src/browser/webapi/animation/Animation.zig b/src/browser/webapi/animation/Animation.zig index 96143952..8d445733 100644 --- a/src/browser/webapi/animation/Animation.zig +++ b/src/browser/webapi/animation/Animation.zig @@ -20,6 +20,7 @@ const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; @@ -61,8 +62,8 @@ pub fn init(page: *Page) !*Animation { return self; } -pub fn deinit(self: *Animation, _: bool, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *Animation, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn play(self: *Animation, page: *Page) !void { diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig index 5ba6256a..9c2bde91 100644 --- a/src/browser/webapi/collections/ChildNodes.zig +++ b/src/browser/webapi/collections/ChildNodes.zig @@ -20,6 +20,7 @@ const std = @import("std"); const Node = @import("../Node.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const GenericIterator = @import("iterator.zig").Entry; // Optimized for node.childNodes, which has to be a live list. @@ -53,8 +54,8 @@ pub fn init(node: *Node, page: *Page) !*ChildNodes { return self; } -pub fn deinit(self: *const ChildNodes, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *const ChildNodes, session: *Session) void { + session.releaseArena(self._arena); } pub fn length(self: *ChildNodes, page: *Page) !u32 { diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 0237a76c..a61cc598 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -21,6 +21,7 @@ const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const ChildNodes = @import("ChildNodes.zig"); @@ -38,7 +39,7 @@ _data: union(enum) { }, _rc: usize = 0, -pub fn deinit(self: *NodeList, _: bool, page: *Page) void { +pub fn deinit(self: *NodeList, _: bool, session: *Session) void { const rc = self._rc; if (rc > 1) { self._rc = rc - 1; @@ -46,8 +47,8 @@ pub fn deinit(self: *NodeList, _: bool, page: *Page) void { } switch (self._data) { - .selector_list => |list| list.deinit(page), - .child_nodes => |cn| cn.deinit(page), + .selector_list => |list| list.deinit(session), + .child_nodes => |cn| cn.deinit(session), else => {}, } } @@ -118,8 +119,8 @@ const Iterator = struct { const Entry = struct { u32, *Node }; - pub fn deinit(self: *Iterator, shutdown: bool, page: *Page) void { - self.list.deinit(shutdown, page); + pub fn deinit(self: *Iterator, shutdown: bool, session: *Session) void { + self.list.deinit(shutdown, session); } pub fn acquireRef(self: *Iterator) void { diff --git a/src/browser/webapi/collections/iterator.zig b/src/browser/webapi/collections/iterator.zig index 3c43f3f8..9fe3354d 100644 --- a/src/browser/webapi/collections/iterator.zig +++ b/src/browser/webapi/collections/iterator.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { const R = reflect(Inner, field); @@ -39,9 +40,9 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { return page._factory.create(Self{ .inner = inner }); } - pub fn deinit(self: *Self, shutdown: bool, page: *Page) void { + pub fn deinit(self: *Self, shutdown: bool, session: *Session) void { if (@hasDecl(Inner, "deinit")) { - self.inner.deinit(shutdown, page); + self.inner.deinit(shutdown, session); } } diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig index 4f23cfcc..736a4008 100644 --- a/src/browser/webapi/encoding/TextDecoder.zig +++ b/src/browser/webapi/encoding/TextDecoder.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; const TextDecoder = @This(); @@ -59,8 +60,8 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { return self; } -pub fn deinit(self: *TextDecoder, _: bool, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *TextDecoder, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn getIgnoreBOM(self: *const TextDecoder) bool { diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig index b98fdd6f..a761e3ba 100644 --- a/src/browser/webapi/event/CompositionEvent.zig +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -20,6 +20,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -53,8 +54,8 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CompositionEvent { return event; } -pub fn deinit(self: *CompositionEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *CompositionEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *CompositionEvent) *Event { diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig index e303d901..eaba2e7d 100644 --- a/src/browser/webapi/event/CustomEvent.zig +++ b/src/browser/webapi/event/CustomEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -72,11 +73,11 @@ pub fn initCustomEvent( self._detail = detail_; } -pub fn deinit(self: *CustomEvent, shutdown: bool, page: *Page) void { +pub fn deinit(self: *CustomEvent, shutdown: bool, session: *Session) void { if (self._detail) |d| { - page.js.release(d); + d.release(); } - self._proto.deinit(shutdown, page); + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *CustomEvent) *Event { diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index 5dd12a26..2d3b857f 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -79,11 +80,11 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool return event; } -pub fn deinit(self: *ErrorEvent, shutdown: bool, page: *Page) void { +pub fn deinit(self: *ErrorEvent, shutdown: bool, session: *Session) void { if (self._error) |e| { - page.js.release(e); + e.release(); } - self._proto.deinit(shutdown, page); + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *ErrorEvent) *Event { diff --git a/src/browser/webapi/event/FocusEvent.zig b/src/browser/webapi/event/FocusEvent.zig index 37065936..f6823c23 100644 --- a/src/browser/webapi/event/FocusEvent.zig +++ b/src/browser/webapi/event/FocusEvent.zig @@ -20,6 +20,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -69,8 +70,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *FocusEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *FocusEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *FocusEvent) *Event { diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig index 6e391812..1d4494b3 100644 --- a/src/browser/webapi/event/KeyboardEvent.zig +++ b/src/browser/webapi/event/KeyboardEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const UIEvent = @import("UIEvent.zig"); @@ -221,8 +222,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *KeyboardEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *KeyboardEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *KeyboardEvent) *Event { diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 34e04518..32ced1d8 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -22,6 +22,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Window = @import("../Window.zig"); const Allocator = std.mem.Allocator; @@ -72,11 +73,11 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool return event; } -pub fn deinit(self: *MessageEvent, shutdown: bool, page: *Page) void { +pub fn deinit(self: *MessageEvent, shutdown: bool, session: *Session) void { if (self._data) |d| { - page.js.release(d); + d.release(); } - self._proto.deinit(shutdown, page); + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *MessageEvent) *Event { diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index c94a37d1..6b032433 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -19,6 +19,7 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -109,8 +110,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { return event; } -pub fn deinit(self: *MouseEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *MouseEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *MouseEvent) *Event { diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index 59e32b06..98cba330 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const NavigationHistoryEntry = @import("../navigation/NavigationHistoryEntry.zig"); @@ -82,8 +83,8 @@ fn initWithTrusted( return event; } -pub fn deinit(self: *NavigationCurrentEntryChangeEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *NavigationCurrentEntryChangeEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *NavigationCurrentEntryChangeEvent) *Event { diff --git a/src/browser/webapi/event/PageTransitionEvent.zig b/src/browser/webapi/event/PageTransitionEvent.zig index eceab4f2..bf747c9a 100644 --- a/src/browser/webapi/event/PageTransitionEvent.zig +++ b/src/browser/webapi/event/PageTransitionEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -65,8 +66,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *PageTransitionEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *PageTransitionEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PageTransitionEvent) *Event { diff --git a/src/browser/webapi/event/PointerEvent.zig b/src/browser/webapi/event/PointerEvent.zig index 82f4874f..c5178faf 100644 --- a/src/browser/webapi/event/PointerEvent.zig +++ b/src/browser/webapi/event/PointerEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const MouseEvent = @import("MouseEvent.zig"); @@ -127,8 +128,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PointerEvent { return event; } -pub fn deinit(self: *PointerEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *PointerEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PointerEvent) *Event { diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig index 45774998..f26c17b6 100644 --- a/src/browser/webapi/event/PopStateEvent.zig +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -66,8 +67,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *PopStateEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *PopStateEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PopStateEvent) *Event { diff --git a/src/browser/webapi/event/ProgressEvent.zig b/src/browser/webapi/event/ProgressEvent.zig index a78982a1..b257f12b 100644 --- a/src/browser/webapi/event/ProgressEvent.zig +++ b/src/browser/webapi/event/ProgressEvent.zig @@ -20,6 +20,7 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -67,8 +68,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *ProgressEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *ProgressEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *ProgressEvent) *Event { diff --git a/src/browser/webapi/event/PromiseRejectionEvent.zig b/src/browser/webapi/event/PromiseRejectionEvent.zig index 957228df..f0a195b9 100644 --- a/src/browser/webapi/event/PromiseRejectionEvent.zig +++ b/src/browser/webapi/event/PromiseRejectionEvent.zig @@ -20,6 +20,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -56,14 +57,14 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*PromiseRejectionEve return event; } -pub fn deinit(self: *PromiseRejectionEvent, shutdown: bool, page: *Page) void { +pub fn deinit(self: *PromiseRejectionEvent, shutdown: bool, session: *Session) void { if (self._reason) |r| { - page.js.release(r); + r.release(); } if (self._promise) |p| { - page.js.release(p); + p.release(); } - self._proto.deinit(shutdown, page); + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PromiseRejectionEvent) *Event { diff --git a/src/browser/webapi/event/TextEvent.zig b/src/browser/webapi/event/TextEvent.zig index 54789c13..fd5e32fb 100644 --- a/src/browser/webapi/event/TextEvent.zig +++ b/src/browser/webapi/event/TextEvent.zig @@ -19,6 +19,7 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -58,8 +59,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*TextEvent { return event; } -pub fn deinit(self: *TextEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *TextEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *TextEvent) *Event { diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index 6d329221..0aa2943b 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -18,6 +18,7 @@ const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -69,8 +70,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent { return event; } -pub fn deinit(self: *UIEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *UIEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn as(self: *UIEvent, comptime T: type) *T { diff --git a/src/browser/webapi/event/WheelEvent.zig b/src/browser/webapi/event/WheelEvent.zig index ee725941..831c4e02 100644 --- a/src/browser/webapi/event/WheelEvent.zig +++ b/src/browser/webapi/event/WheelEvent.zig @@ -19,6 +19,7 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -86,8 +87,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*WheelEvent { return event; } -pub fn deinit(self: *WheelEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *WheelEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *WheelEvent) *Event { diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 699cc9c4..9b0f2f98 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -45,7 +45,7 @@ pub const InitOpts = Request.InitOpts; pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const request = try Request.init(input, options, page); const response = try Response.init(null, .{ .status = 0 }, page); - errdefer response.deinit(true, page); + errdefer response.deinit(true, page._session); const resolver = page.js.local.?.createPromiseResolver(); @@ -184,7 +184,7 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { // clear this. (defer since `self is in the response's arena). defer if (self._owns_response) { - response.deinit(err == error.Abort, self._page); + response.deinit(err == error.Abort, self._page._session); self._owns_response = false; }; @@ -205,7 +205,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void { if (self._owns_response) { var response = self._response; response._transfer = null; - response.deinit(true, self._page); + response.deinit(true, self._page._session); // Do not access `self` after this point: the Fetch struct was // allocated from response._arena which has been released. } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 13048f33..6a926369 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -21,6 +21,7 @@ const js = @import("../../js/js.zig"); const HttpClient = @import("../../HttpClient.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Headers = @import("Headers.zig"); const ReadableStream = @import("../streams/ReadableStream.zig"); const Blob = @import("../Blob.zig"); @@ -77,7 +78,7 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { return self; } -pub fn deinit(self: *Response, shutdown: bool, page: *Page) void { +pub fn deinit(self: *Response, shutdown: bool, session: *Session) void { if (self._transfer) |transfer| { if (shutdown) { transfer.terminate(); @@ -86,7 +87,7 @@ pub fn deinit(self: *Response, shutdown: bool, page: *Page) void { } self._transfer = null; } - page.releaseArena(self._arena); + session.releaseArena(self._arena); } pub fn getStatus(self: *const Response) u16 { diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index b9f053a8..d8d5e369 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -26,6 +26,8 @@ const net_http = @import("../../../network/http.zig"); const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); + const Node = @import("../Node.zig"); const Event = @import("../Event.zig"); const Headers = @import("Headers.zig"); @@ -93,7 +95,7 @@ pub fn init(page: *Page) !*XMLHttpRequest { }); } -pub fn deinit(self: *XMLHttpRequest, shutdown: bool, page: *Page) void { +pub fn deinit(self: *XMLHttpRequest, shutdown: bool, session: *Session) void { if (self._transfer) |transfer| { if (shutdown) { transfer.terminate(); @@ -103,37 +105,36 @@ pub fn deinit(self: *XMLHttpRequest, shutdown: bool, page: *Page) void { self._transfer = null; } - const js_ctx = page.js; if (self._on_ready_state_change) |func| { - js_ctx.release(func); + func.release(); } { const proto = self._proto; if (proto._on_abort) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_error) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_load) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_load_end) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_load_start) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_progress) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_timeout) |func| { - js_ctx.release(func); + func.release(); } } - page.releaseArena(self._arena); + session.releaseArena(self._arena); } fn asEventTarget(self: *XMLHttpRequest) *EventTarget { diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 04055910..1061320d 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -19,6 +19,7 @@ const std = @import("std"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const Part = @import("Selector.zig").Part; @@ -40,8 +41,8 @@ pub const EntryIterator = GenericIterator(Iterator, null); pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); -pub fn deinit(self: *const List, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *const List, session: *Session) void { + session.releaseArena(self._arena); } pub fn collect( diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index 34c58e3a..1260217b 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -406,7 +406,7 @@ test "cdp Node: search list" { { const l1 = try doc.querySelectorAll(.wrap("a"), page); - defer l1.deinit(page); + defer l1.deinit(page._session); const s1 = try search_list.create(l1._nodes); try testing.expectEqual("1", s1.name); try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); @@ -417,7 +417,7 @@ test "cdp Node: search list" { { const l2 = try doc.querySelectorAll(.wrap("#a1"), page); - defer l2.deinit(page); + defer l2.deinit(page._session); const s2 = try search_list.create(l2._nodes); try testing.expectEqual("2", s2.name); try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); @@ -425,7 +425,7 @@ test "cdp Node: search list" { { const l3 = try doc.querySelectorAll(.wrap("#a2"), page); - defer l3.deinit(page); + defer l3.deinit(page._session); const s3 = try search_list.create(l3._nodes); try testing.expectEqual("3", s3.name); try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 2f2befa5..df1d37c2 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -98,7 +98,7 @@ fn performSearch(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const list = try Selector.querySelectorAll(page.window._document.asNode(), params.query, page); - defer list.deinit(page); + defer list.deinit(page._session); const search = try bc.node_search_list.create(list._nodes); @@ -249,7 +249,7 @@ fn querySelectorAll(cmd: anytype) !void { }; const selected_nodes = try Selector.querySelectorAll(node.dom, params.selector, page); - defer selected_nodes.deinit(page); + defer selected_nodes.deinit(page._session); const nodes = selected_nodes._nodes; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 1ca9d120..53d29757 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -92,7 +92,8 @@ const ToolStreamingText = struct { }, .links => { if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| { - defer list.deinit(self.page); + defer list.deinit(self.page._session); + var first = true; for (list._nodes) |node| { if (node.is(Element.Html.Anchor)) |anchor| { From 7d90c3f582eefb574b1be878c588504d08f28f48 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 14:16:46 +0800 Subject: [PATCH 21/34] Move origin lookup to Session With the last commit, this becomes the more logical place to hold this as it ties into the Session's awareness of the root page's lifetime. --- src/browser/Session.zig | 52 ++++++++++++++++++++++++++++ src/browser/js/Context.zig | 6 ++-- src/browser/js/Env.zig | 69 +++----------------------------------- 3 files changed, 59 insertions(+), 68 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 42fe8ef5..9cce0ccf 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -62,6 +62,9 @@ factory: Factory, page_arena: Allocator, +// Origin map for same-origin context sharing. Scoped to the root page lifetime. +origins: std.StringHashMapUnmanaged(*js.Origin) = .empty, + // Shared resources for all pages in this session. // These live for the duration of the page tree (root + frames). arena_pool: *ArenaPool, @@ -194,6 +197,41 @@ pub fn releaseArena(self: *Session, allocator: Allocator) void { return self.arena_pool.release(allocator); } +pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin { + const key = key_ orelse { + var opaque_origin: [36]u8 = undefined; + @import("../id.zig").uuidv4(&opaque_origin); + // Origin.init will dupe opaque_origin. It's fine that this doesn't + // get added to self.origins. In fact, it further isolates it. When the + // context is freed, it'll call session.releaseOrigin which will free it. + return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin); + }; + + const gop = try self.origins.getOrPut(self.arena, key); + if (gop.found_existing) { + const origin = gop.value_ptr.*; + origin.rc += 1; + return origin; + } + + errdefer _ = self.origins.remove(key); + + const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key); + gop.key_ptr.* = origin.key; + gop.value_ptr.* = origin; + return origin; +} + +pub fn releaseOrigin(self: *Session, origin: *js.Origin) void { + const rc = origin.rc; + if (rc == 1) { + _ = self.origins.remove(origin.key); + origin.deinit(self.browser.app); + } else { + origin.rc = rc - 1; + } +} + /// Reset page_arena and factory for a clean slate. /// Called when root page is removed. fn resetPageResources(self: *Session) void { @@ -208,6 +246,20 @@ fn resetPageResources(self: *Session) void { self._arena_pool_leak_track.clearRetainingCapacity(); } + // All origins should have been released when contexts were destroyed + if (comptime IS_DEBUG) { + std.debug.assert(self.origins.count() == 0); + } + // Defensive cleanup in case origins leaked + { + const app = self.browser.app; + var it = self.origins.valueIterator(); + while (it.next()) |value| { + value.*.deinit(app); + } + self.origins.clearRetainingCapacity(); + } + // Release old page_arena and acquire fresh one self.frame_id_gen = 0; self.arena_pool.reset(self.page_arena, 64 * 1024); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 750cd809..ac6ed3c1 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -153,7 +153,7 @@ pub fn deinit(self: *Context) void { v8.v8__Global__Reset(global); } - env.releaseOrigin(self.origin); + self.session.releaseOrigin(self.origin); v8.v8__Global__Reset(&self.handle); env.isolate.notifyContextDisposed(); @@ -167,8 +167,8 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { const env = self.env; const isolate = env.isolate; - const origin = try env.getOrCreateOrigin(key); - errdefer env.releaseOrigin(origin); + const origin = try self.session.getOrCreateOrigin(key); + errdefer self.session.releaseOrigin(origin); try self.origin.transferTo(origin); self.origin.deinit(env.app); diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 4318394d..5bc2fded 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -77,14 +77,6 @@ context_id: usize, // same-origin Contexts. There's a mismatch here between our JS model and our // Browser model. Origins only live as long as the root page of a session exists. // It would be wrong/dangerous to re-use an Origin across root page navigations. -// But we have no mechanism to capture that lifetime in js. We used to have a -// js.BrowserContext which mapped to a Session (oops, I took it out), but even -// that wouldn't match correctly, because 1 session can have have muliple non- -// concurrent pages. We deal with this in destroyContext by checking if we're -// destroying the root context and, if so, making sure origins is empty. But, if -// we ever add multiple Sessions to a Browser or mulitple Pages to a Session, -// this map will have to live in a new, better scoped, container. -origins: std.StringHashMapUnmanaged(*Origin) = .empty, // Global handles that need to be freed on deinit eternal_function_templates: []v8.Eternal, @@ -248,14 +240,6 @@ pub fn deinit(self: *Env) void { const app = self.app; const allocator = app.allocator; - { - var it = self.origins.valueIterator(); - while (it.next()) |value| { - value.*.deinit(app); - } - self.origins.deinit(allocator); - } - if (self.inspector) |i| { i.deinit(allocator); } @@ -323,8 +307,8 @@ pub fn createContext(self: *Env, page: *Page) !*Context { const context_id = self.context_id; self.context_id = context_id + 1; - const origin = try self.getOrCreateOrigin(null); - errdefer self.releaseOrigin(origin); + const origin = try page._session.getOrCreateOrigin(null); + errdefer page._session.releaseOrigin(origin); const context = try context_arena.create(Context); context.* = .{ @@ -383,56 +367,11 @@ pub fn destroyContext(self: *Env, context: *Context, is_root: bool) void { context.deinit(); if (is_root) { - // When the root is destroyed, the all of our contexts should be gone - // and with them, all of our origins. Keep origins around longer than - // intended would cause issues, so we're going to be defensive here and - // clean things up. + // When the root is destroyed, all of our contexts should be gone. + // Origin cleanup happens in Session.resetPageResources. if (comptime IS_DEBUG) { std.debug.assert(self.context_count == 0); - std.debug.assert(self.origins.count() == 0); } - - const app = self.app; - var it = self.origins.valueIterator(); - while (it.next()) |value| { - value.*.deinit(app); - } - self.origins.clearRetainingCapacity(); - } -} - -pub fn getOrCreateOrigin(self: *Env, key_: ?[]const u8) !*Origin { - const key = key_ orelse { - var opaque_origin: [36]u8 = undefined; - @import("../../id.zig").uuidv4(&opaque_origin); - // Origin.init will dupe opaque_origin. It's fine that this doesn't - // get added to self.origins. In fact, it further isolates it. When the - // context is freed, it'll call env.releaseOrigin which will free it. - return Origin.init(self.app, self.isolate, &opaque_origin); - }; - - const gop = try self.origins.getOrPut(self.allocator, key); - if (gop.found_existing) { - const origin = gop.value_ptr.*; - origin.rc += 1; - return origin; - } - - errdefer _ = self.origins.remove(key); - - const origin = try Origin.init(self.app, self.isolate, key); - gop.key_ptr.* = origin.key; - gop.value_ptr.* = origin; - return origin; -} - -pub fn releaseOrigin(self: *Env, origin: *Origin) void { - const rc = origin.rc; - if (rc == 1) { - _ = self.origins.remove(origin.key); - origin.deinit(self.app); - } else { - origin.rc = rc - 1; } } From 7348a68c84301ce3c5d6b10138aff925a3d2b4dc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 17:18:50 +0800 Subject: [PATCH 22/34] merge main --- src/browser/Page.zig | 2 +- src/browser/webapi/css/FontFace.zig | 5 +++-- src/browser/webapi/css/FontFaceSet.zig | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 60a3f968..10fc49a3 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -608,7 +608,7 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: }; if (target._queued_navigation) |existing| { - target.arena_pool.release(existing.arena); + session.releaseArena(existing.arena); } target._queued_navigation = qn; diff --git a/src/browser/webapi/css/FontFace.zig b/src/browser/webapi/css/FontFace.zig index f824259a..f3c4059d 100644 --- a/src/browser/webapi/css/FontFace.zig +++ b/src/browser/webapi/css/FontFace.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; @@ -41,8 +42,8 @@ pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace { return self; } -pub fn deinit(self: *FontFace, _: bool, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *FontFace, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn getFamily(self: *const FontFace) []const u8 { diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig index 6e5cd941..2a4a000d 100644 --- a/src/browser/webapi/css/FontFaceSet.zig +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const FontFace = @import("FontFace.zig"); const Allocator = std.mem.Allocator; @@ -38,8 +39,8 @@ pub fn init(page: *Page) !*FontFaceSet { return self; } -pub fn deinit(self: *FontFaceSet, _: bool, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *FontFaceSet, _: bool, session: *Session) void { + session.releaseArena(self._arena); } // FontFaceSet.ready - returns an already-resolved Promise. From 4cea9aba3c08e45d3eed77c81018d669b83b9dce Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 10 Mar 2026 06:43:22 +0800 Subject: [PATCH 23/34] update v8 dep --- .github/actions/install/action.yml | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 6c98f2e5..d9d7b803 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.3.1' + default: 'v0.3.2' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index 8729f992..e30fbdbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.3.1 +ARG ZIG_V8=v0.3.2 ARG TARGETPLATFORM RUN apt-get update -yq && \ From f6d0e484b022e72b03f48a9f129b99b02919691e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 11 Mar 2026 07:19:59 +0800 Subject: [PATCH 24/34] transfer finalizers on origin change --- src/browser/js/Local.zig | 1 + src/browser/js/Origin.zig | 62 +++++++++++++++++++++++---------------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 84e83b49..78ab9d15 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -226,6 +226,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, // can't figure out how to make that work, since it depends on // the [runtime] `value`. // We need the resolved finalizer, which we have in resolved. + // // The above if statement would be more clear as: // if (resolved.finalizer_from_v8) |finalizer| { // But that's a runtime check. diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig index 356b75c8..486888f1 100644 --- a/src/browser/js/Origin.zig +++ b/src/browser/js/Origin.zig @@ -67,24 +67,6 @@ temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, // will have its finalizer stored here. This is only used when shutting down // if v8 hasn't called the finalizer directly itself. finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, -finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback), - -// A type that has a finalizer can have its finalizer called one of two ways. -// The first is from V8 via the WeakCallback we give to weakRef. But that isn't -// guaranteed to fire, so we track this in finalizer_callbacks and call them on -// origin shutdown. -pub const FinalizerCallback = struct { - origin: *Origin, - session: *Session, - ptr: *anyopaque, - global: v8.Global, - finalizerFn: *const fn (ptr: *anyopaque, session: *Session) void, - - pub fn deinit(self: *FinalizerCallback) void { - self.finalizerFn(self.ptr, self.session); - self.origin.finalizer_callback_pool.destroy(self); - } -}; pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { const arena = try app.arena_pool.acquire(); @@ -107,7 +89,6 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { .globals = .empty, .temps = .empty, .security_token = token_global, - .finalizer_callback_pool = .init(arena), }; return self; } @@ -119,7 +100,6 @@ pub fn deinit(self: *Origin, app: *App) void { while (it.next()) |finalizer| { finalizer.*.deinit(); } - self.finalizer_callback_pool.deinit(); } v8.v8__Global__Reset(&self.security_token); @@ -172,13 +152,14 @@ pub fn release(self: *Origin, item: *anyopaque) void { // The item has been finalized, remove it from the finalizer callback so that // we don't try to call it again on shutdown. - const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse { + const kv = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse { if (comptime IS_DEBUG) { std.debug.assert(false); } return; }; - self.finalizer_callback_pool.destroy(fc.value); + const fc = kv.value; + fc.session.releaseArena(fc.arena); } pub fn createFinalizerCallback( @@ -186,15 +167,18 @@ pub fn createFinalizerCallback( session: *Session, global: v8.Global, ptr: *anyopaque, - finalizerFn: *const fn (ptr: *anyopaque, session: *Session) void, + zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void, ) !*FinalizerCallback { - const fc = try self.finalizer_callback_pool.create(); + const arena = try session.getArena(.{ .debug = "FinalizerCallback" }); + errdefer session.releaseArena(arena); + const fc = try arena.create(FinalizerCallback); fc.* = .{ + .arena = arena, .origin = self, .session = session, .ptr = ptr, .global = global, - .finalizerFn = finalizerFn, + .zig_finalizer = zig_finalizer, }; return fc; } @@ -217,6 +201,16 @@ pub fn transferTo(self: *Origin, dest: *Origin) !void { self.temps.clearRetainingCapacity(); } + { + try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count()); + var it = self.finalizer_callbacks.iterator(); + while (it.next()) |kv| { + kv.value_ptr.*.origin = dest; + try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*); + } + self.finalizer_callbacks.clearRetainingCapacity(); + } + { try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count()); var it = self.identity_map.iterator(); @@ -226,3 +220,21 @@ pub fn transferTo(self: *Origin, dest: *Origin) !void { self.identity_map.clearRetainingCapacity(); } } + +// A type that has a finalizer can have its finalizer called one of two ways. +// The first is from V8 via the WeakCallback we give to weakRef. But that isn't +// guaranteed to fire, so we track this in finalizer_callbacks and call them on +// origin shutdown. +pub const FinalizerCallback = struct { + arena: Allocator, + origin: *Origin, + session: *Session, + ptr: *anyopaque, + global: v8.Global, + zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void, + + pub fn deinit(self: *FinalizerCallback) void { + self.zig_finalizer(self.ptr, self.session); + self.session.releaseArena(self.arena); + } +}; From dc3d2e97906e3d0b6df0b9917c4ca4fbaa7e8bf1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 11 Mar 2026 08:21:35 +0800 Subject: [PATCH 25/34] Remove root context check from Env This was only added [very briefly] when Env managed Origins, which it no longer does. --- src/browser/Page.zig | 5 ++--- src/browser/Session.zig | 2 +- src/browser/js/Env.zig | 10 +--------- src/cdp/cdp.zig | 2 +- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 10fc49a3..811e7e5c 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -332,12 +332,11 @@ pub fn deinit(self: *Page, abort_http: bool) void { session.releaseArena(qn.arena); } - const is_root = self.parent == null; - session.browser.env.destroyContext(self.js, is_root); + session.browser.env.destroyContext(self.js); self._script_manager.shutdown = true; - if (is_root) { + if (self.parent == null) { session.browser.http_client.abort(); } else if (abort_http) { // a small optimization, it's faster to abort _everything_ on the root diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 9cce0ccf..529f0847 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -276,7 +276,7 @@ pub fn replacePage(self: *Session) !*Page { var current = self.page.?; const frame_id = current._frame_id; - current.deinit(false); + current.deinit(true); self.resetPageResources(); self.browser.env.memoryPressureNotification(.moderate); diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 5bc2fded..e8488541 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -342,7 +342,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { return context; } -pub fn destroyContext(self: *Env, context: *Context, is_root: bool) void { +pub fn destroyContext(self: *Env, context: *Context) void { for (self.contexts[0..self.context_count], 0..) |ctx, i| { if (ctx == context) { // Swap with last element and decrement count @@ -365,14 +365,6 @@ pub fn destroyContext(self: *Env, context: *Context, is_root: bool) void { } context.deinit(); - - if (is_root) { - // When the root is destroyed, all of our contexts should be gone. - // Origin cleanup happens in Session.resetPageResources. - if (comptime IS_DEBUG) { - std.debug.assert(self.context_count == 0); - } - } } pub fn runMicrotasks(self: *Env) void { diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 40057b8b..78e5ab50 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -759,7 +759,7 @@ const IsolatedWorld = struct { pub fn removeContext(self: *IsolatedWorld) !void { const ctx = self.context orelse return error.NoIsolatedContextToRemove; - self.browser.env.destroyContext(ctx, false); + self.browser.env.destroyContext(ctx); self.context = null; } From ff26b0d5a462d5d9120b2a01a0a7c3118786267b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 11 Mar 2026 09:21:54 +0800 Subject: [PATCH 26/34] Use arena from ArenaPool for Blob (and File) --- src/browser/Factory.zig | 7 ++- src/browser/webapi/Blob.zig | 87 +++++++++++++++++-------------------- src/browser/webapi/File.zig | 16 +++++-- 3 files changed, 56 insertions(+), 54 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index c5ede22b..582291a3 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -247,16 +247,15 @@ fn eventInit(arena: Allocator, typ: String, value: anytype) !Event { }; } -pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); - +pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) { // Special case: Blob has slice and mime fields, so we need manual setup const chain = try PrototypeChain( &.{ Blob, @TypeOf(child) }, - ).allocate(allocator); + ).allocate(arena); const blob_ptr = chain.get(0); blob_ptr.* = .{ + ._arena = arena, ._type = unionInit(Blob.Type, chain.get(1)), ._slice = "", ._mime = "", diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index aa955ce5..5b8bf81c 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -21,8 +21,12 @@ const Writer = std.Io.Writer; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); + const Mime = @import("../Mime.zig"); +const Allocator = std.mem.Allocator; + /// https://w3c.github.io/FileAPI/#blob-section /// https://developer.mozilla.org/en-US/docs/Web/API/Blob const Blob = @This(); @@ -31,6 +35,8 @@ pub const _prototype_root = true; _type: Type, +_arena: Allocator, + /// Immutable slice of blob. /// Note that another blob may hold a pointer/slice to this, /// so its better to leave the deallocation of it to arena allocator. @@ -69,6 +75,9 @@ pub fn initWithMimeValidation( validate_mime: bool, page: *Page, ) !*Blob { + const arena = try page.getArena(.{ .debug = "Blob" }); + errdefer page.releaseArena(arena); + const options: InitOptions = maybe_options orelse .{}; const mime: []const u8 = blk: { @@ -77,7 +86,7 @@ pub fn initWithMimeValidation( break :blk ""; } - const buf = try page.arena.dupe(u8, t); + const buf = try arena.dupe(u8, t); if (validate_mime) { // Full MIME parsing per MIME sniff spec (for Content-Type headers) @@ -99,7 +108,7 @@ pub fn initWithMimeValidation( const data = blk: { if (maybe_blob_parts) |blob_parts| { - var w: Writer.Allocating = .init(page.arena); + var w: Writer.Allocating = .init(arena); const use_native_endings = std.mem.eql(u8, options.endings, "native"); try writeBlobParts(&w.writer, blob_parts, use_native_endings); @@ -109,11 +118,19 @@ pub fn initWithMimeValidation( break :blk ""; }; - return page._factory.create(Blob{ + const self = try arena.create(Blob); + self.* = .{ + ._arena = arena, ._type = .generic, ._slice = data, ._mime = mime, - }); + }; + return self; +} + +pub fn deinit(self: *Blob, shutdown: bool, session: *Session) void { + _ = shutdown; + session.releaseArena(self._arena); } const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8); @@ -264,57 +281,31 @@ pub fn bytes(self: *const Blob, page: *Page) !js.Promise { /// from a subset of the blob on which it's called. pub fn slice( self: *const Blob, - maybe_start: ?i32, - maybe_end: ?i32, - maybe_content_type: ?[]const u8, + start_: ?i32, + end_: ?i32, + content_type_: ?[]const u8, page: *Page, ) !*Blob { - const mime: []const u8 = blk: { - if (maybe_content_type) |content_type| { - if (content_type.len == 0) { - break :blk ""; - } + const data = self._slice; - break :blk try page.dupeString(content_type); + const start = blk: { + const requested_start = start_ orelse break :blk 0; + if (requested_start < 0) { + break :blk data.len -| @abs(requested_start); } - - break :blk ""; + break :blk @min(data.len, @as(u31, @intCast(requested_start))); }; - const data = self._slice; - if (maybe_start) |_start| { - const start = blk: { - if (_start < 0) { - break :blk data.len -| @abs(_start); - } + const end: usize = blk: { + const requested_end = end_ orelse break :blk data.len; + if (requested_end < 0) { + break :blk @max(start, data.len -| @abs(requested_end)); + } - break :blk @min(data.len, @as(u31, @intCast(_start))); - }; + break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end)))); + }; - const end: usize = blk: { - if (maybe_end) |_end| { - if (_end < 0) { - break :blk @max(start, data.len -| @abs(_end)); - } - - break :blk @min(data.len, @max(start, @as(u31, @intCast(_end)))); - } - - break :blk data.len; - }; - - return page._factory.create(Blob{ - ._type = .generic, - ._slice = data[start..end], - ._mime = mime, - }); - } - - return page._factory.create(Blob{ - ._type = .generic, - ._slice = data, - ._mime = mime, - }); + return Blob.init(&.{data[start..end]}, .{ .type = content_type_ orelse "" }, page); } /// Returns the size of the Blob in bytes. @@ -334,6 +325,8 @@ pub const JsApi = struct { pub const name = "Blob"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(Blob.deinit); }; pub const constructor = bridge.constructor(Blob.init, .{}); diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index a67a8a6f..f41e44bb 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -18,9 +18,11 @@ const std = @import("std"); -const Page = @import("../Page.zig"); -const Blob = @import("Blob.zig"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); + +const Blob = @import("Blob.zig"); const File = @This(); @@ -29,7 +31,13 @@ _proto: *Blob, // TODO: Implement File API. pub fn init(page: *Page) !*File { - return page._factory.blob(File{ ._proto = undefined }); + const arena = try page.getArena(.{ .debug = "File" }); + errdefer page.releaseArena(arena); + return page._factory.blob(arena, File{ ._proto = undefined }); +} + +pub fn deinit(self: *File, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub const JsApi = struct { @@ -39,6 +47,8 @@ pub const JsApi = struct { pub const name = "File"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(File.deinit); }; pub const constructor = bridge.constructor(File.init, .{}); From 68564ca71424cd67e30ae4e37ec3e9f2d1336464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 11 Mar 2026 11:09:19 +0900 Subject: [PATCH 27/34] mcp: add interactiveElements and structuredData tools --- src/mcp/tools.zig | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 53d29757..e7ad5668 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -61,6 +61,30 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "interactiveElements", + .description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting interactive elements." } + \\ } + \\} + ), + }, + .{ + .name = "structuredData", + .description = "Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. If a url is provided, it navigates to that url first.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting structured data." } + \\ } + \\} + ), + }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -124,6 +148,8 @@ const ToolAction = enum { navigate, markdown, links, + interactiveElements, + structuredData, evaluate, }; @@ -132,6 +158,8 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "navigate", .navigate }, .{ "markdown", .markdown }, .{ "links", .links }, + .{ "interactiveElements", .interactiveElements }, + .{ "structuredData", .structuredData }, .{ "evaluate", .evaluate }, }); @@ -157,6 +185,8 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .goto, .navigate => try handleGoto(server, arena, req.id.?, call_params.arguments), .markdown => try handleMarkdown(server, arena, req.id.?, call_params.arguments), .links => try handleLinks(server, arena, req.id.?, call_params.arguments), + .interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments), + .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), } } @@ -211,6 +241,58 @@ fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } +fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + url: ?[:0]const u8 = null, + }; + if (arguments) |args_raw| { + if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { + if (args.url) |u| { + try performGoto(server, u, id); + } + } else |_| {} + } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { + log.err(.mcp, "elements collection failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to collect interactive elements"); + }; + var aw: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(elements, .{}, &aw.writer); + + const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + url: ?[:0]const u8 = null, + }; + if (arguments) |args_raw| { + if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { + if (args.url) |u| { + try performGoto(server, u, id); + } + } else |_| {} + } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| { + log.err(.mcp, "struct data collection failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to collect structured data"); + }; + var aw: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(data, .{}, &aw.writer); + + const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate"); From c891eff664a1506cdab6fb93b74126775ecfc8f4 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Wed, 11 Mar 2026 03:34:27 +0000 Subject: [PATCH 28/34] Use zig allocator for libcurl --- src/network/Runtime.zig | 118 +++++++++++++++++++++++++++++++++++++++- src/sys/libcurl.zig | 51 ++++++++++++++++- 2 files changed, 164 insertions(+), 5 deletions(-) diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index 0112dc18..0e6cc398 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -51,8 +51,120 @@ wakeup_pipe: [2]posix.fd_t = .{ -1, -1 }, shutdown: std.atomic.Value(bool) = .init(false), -fn globalInit() void { - libcurl.curl_global_init(.{ .ssl = true }) catch |err| { +const ZigToCurlAllocator = struct { + // C11 requires malloc to return memory aligned to max_align_t (16 bytes on x86_64). + // We match this guarantee since libcurl expects malloc-compatible alignment. + const alignment = 16; + + const Block = extern struct { + size: usize = 0, + _padding: [alignment - @sizeOf(usize)]u8 = .{0} ** (alignment - @sizeOf(usize)), + + inline fn fullsize(bytes: usize) usize { + return alignment + bytes; + } + + inline fn fromPtr(ptr: *anyopaque) *Block { + const raw: [*]u8 = @ptrCast(ptr); + return @ptrCast(@alignCast(raw - @sizeOf(Block))); + } + + inline fn data(self: *Block) [*]u8 { + const ptr: [*]u8 = @ptrCast(self); + return ptr + @sizeOf(Block); + } + + inline fn slice(self: *Block) []align(alignment) u8 { + const base: [*]align(alignment) u8 = @ptrCast(@alignCast(self)); + return base[0 .. alignment + self.size]; + } + }; + + comptime { + std.debug.assert(@sizeOf(Block) == alignment); + } + + var instance: ?ZigToCurlAllocator = null; + + allocator: Allocator, + + pub fn init(allocator: Allocator) void { + lp.assert(instance == null, "Initialization of curl must happen only once", .{}); + instance = .{ .allocator = allocator }; + } + + pub fn interface() libcurl.CurlAllocator { + return .{ + .free = free, + .strdup = strdup, + .malloc = malloc, + .calloc = calloc, + .realloc = realloc, + }; + } + + fn _allocBlock(size: usize) ?*Block { + const slice = instance.?.allocator.alignedAlloc(u8, .fromByteUnits(alignment), Block.fullsize(size)) catch return null; + const block: *Block = @ptrCast(@alignCast(slice.ptr)); + block.size = size; + return block; + } + + fn _freeBlock(header: *Block) void { + instance.?.allocator.free(header.slice()); + } + + fn malloc(size: usize) ?*anyopaque { + const block = _allocBlock(size) orelse return null; + return @ptrCast(block.data()); + } + + fn calloc(nmemb: usize, size: usize) ?*anyopaque { + const total = nmemb * size; + const block = _allocBlock(total) orelse return null; + const ptr = block.data(); + @memset(ptr[0..total], 0); // for historical reasons, calloc zeroes memory, but malloc does not. + return @ptrCast(ptr); + } + + fn realloc(ptr: ?*anyopaque, size: usize) ?*anyopaque { + const p = ptr orelse return malloc(size); + const block = Block.fromPtr(p); + + const old_size = block.size; + if (size == old_size) return ptr; + + if (instance.?.allocator.resize(block.slice(), alignment + size)) { + block.size = size; + return ptr; + } + + const copy_size = @min(old_size, size); + const new_block = _allocBlock(size) orelse return null; + @memcpy(new_block.data()[0..copy_size], block.data()[0..copy_size]); + _freeBlock(block); + return @ptrCast(new_block.data()); + } + + fn free(ptr: ?*anyopaque) void { + const p = ptr orelse return; + _freeBlock(Block.fromPtr(p)); + } + + fn strdup(str: [*:0]const u8) ?[*:0]u8 { + const len = std.mem.len(str); + const header = _allocBlock(len + 1) orelse return null; + const ptr = header.data(); + @memcpy(ptr[0..len], str[0..len]); + ptr[len] = 0; + return ptr[0..len :0]; + } +}; + +fn globalInit(allocator: Allocator) void { + ZigToCurlAllocator.init(allocator); + + libcurl.curl_global_init(.{ .ssl = true }, ZigToCurlAllocator.interface()) catch |err| { lp.assert(false, "curl global init", .{ .err = err }); }; } @@ -62,7 +174,7 @@ fn globalDeinit() void { } pub fn init(allocator: Allocator, config: *const Config) !Runtime { - globalInit(); + globalInit(allocator); errdefer globalDeinit(); const pipe = try posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 759cba25..f13e999a 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -41,6 +41,20 @@ pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR; +pub const FreeCallback = fn (ptr: ?*anyopaque) void; +pub const StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8; +pub const MallocCallback = fn (size: usize) ?*anyopaque; +pub const CallocCallback = fn (nmemb: usize, size: usize) ?*anyopaque; +pub const ReallocCallback = fn (ptr: ?*anyopaque, size: usize) ?*anyopaque; + +pub const CurlAllocator = struct { + free: FreeCallback, + strdup: StrdupCallback, + malloc: MallocCallback, + calloc: CallocCallback, + realloc: ReallocCallback, +}; + pub const CurlGlobalFlags = packed struct(u8) { ssl: bool = false, _reserved: u7 = 0, @@ -449,8 +463,41 @@ pub const CurlMsg = struct { data: CurlMsgData, }; -pub fn curl_global_init(flags: CurlGlobalFlags) Error!void { - try errorCheck(c.curl_global_init(flags.to_c())); +pub fn curl_global_init(flags: CurlGlobalFlags, comptime curl_allocator: ?CurlAllocator) Error!void { + const alloc = curl_allocator orelse { + return errorCheck(c.curl_global_init(flags.to_c())); + }; + + // The purpose of these wrappers is to hide callconv + // and provide an easy place to add logging when debugging. + const free = struct { + fn cb(ptr: ?*anyopaque) callconv(.c) void { + alloc.free(ptr); + } + }.cb; + const strdup = struct { + fn cb(str: [*c]const u8) callconv(.c) [*c]u8 { + const s: [*:0]const u8 = @ptrCast(str orelse return null); + return @ptrCast(alloc.strdup(s)); + } + }.cb; + const malloc = struct { + fn cb(size: usize) callconv(.c) ?*anyopaque { + return alloc.malloc(size); + } + }.cb; + const calloc = struct { + fn cb(nmemb: usize, size: usize) callconv(.c) ?*anyopaque { + return alloc.calloc(nmemb, size); + } + }.cb; + const realloc = struct { + fn cb(ptr: ?*anyopaque, size: usize) callconv(.c) ?*anyopaque { + return alloc.realloc(ptr, size); + } + }.cb; + + try errorCheck(c.curl_global_init_mem(flags.to_c(), malloc, free, realloc, strdup, calloc)); } pub fn curl_global_cleanup() void { From d2c55da6c95724cae61b836d0089adebf0ceda90 Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 07:26:20 +0100 Subject: [PATCH 29/34] address review: move per-range logic to AbstractRange, simplify collapsed check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the per-range update logic from Page into AbstractRange methods (updateForCharacterDataReplace, updateForSplitText, updateForNodeInsertion, updateForNodeRemoval). Page now just iterates the list and delegates. Remove redundant start_container == end_container check in insertNode — collapsed already implies same container. --- src/browser/Page.zig | 75 ++---------------------- src/browser/webapi/AbstractRange.zig | 85 ++++++++++++++++++++++++++++ src/browser/webapi/Range.zig | 2 +- 3 files changed, 90 insertions(+), 72 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 95433ce1..d053f0ab 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -2907,23 +2907,7 @@ pub fn updateRangesForCharacterDataReplace(self: *Page, target: *Node, offset: u var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; while (it) |link| : (it = link.next) { const ar: *AbstractRange = @fieldParentPtr("_range_link", link); - - if (ar._start_container == target) { - if (ar._start_offset > offset and ar._start_offset <= offset + count) { - ar._start_offset = offset; - } else if (ar._start_offset > offset + count) { - // Use i64 intermediate to avoid u32 underflow when count > data_len - ar._start_offset = @intCast(@as(i64, ar._start_offset) + @as(i64, data_len) - @as(i64, count)); - } - } - - if (ar._end_container == target) { - if (ar._end_offset > offset and ar._end_offset <= offset + count) { - ar._end_offset = offset; - } else if (ar._end_offset > offset + count) { - ar._end_offset = @intCast(@as(i64, ar._end_offset) + @as(i64, data_len) - @as(i64, count)); - } - } + ar.updateForCharacterDataReplace(target, offset, count, data_len); } } @@ -2936,25 +2920,7 @@ pub fn updateRangesForSplitText(self: *Page, target: *Node, new_node: *Node, off var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; while (it) |link| : (it = link.next) { const ar: *AbstractRange = @fieldParentPtr("_range_link", link); - - // Step 7b: ranges on the original node with start > offset move to new node - if (ar._start_container == target and ar._start_offset > offset) { - ar._start_container = new_node; - ar._start_offset = ar._start_offset - offset; - } - // Step 7c: ranges on the original node with end > offset move to new node - if (ar._end_container == target and ar._end_offset > offset) { - ar._end_container = new_node; - ar._end_offset = ar._end_offset - offset; - } - // Step 7d: ranges on parent with start == node_index + 1 increment - if (ar._start_container == parent and ar._start_offset == node_index + 1) { - ar._start_offset += 1; - } - // Step 7e: ranges on parent with end == node_index + 1 increment - if (ar._end_container == parent and ar._end_offset == node_index + 1) { - ar._end_offset += 1; - } + ar.updateForSplitText(target, new_node, offset, parent, node_index); } } @@ -2965,13 +2931,7 @@ pub fn updateRangesForNodeInsertion(self: *Page, parent: *Node, child_index: u32 var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; while (it) |link| : (it = link.next) { const ar: *AbstractRange = @fieldParentPtr("_range_link", link); - - if (ar._start_container == parent and ar._start_offset > child_index) { - ar._start_offset += 1; - } - if (ar._end_container == parent and ar._end_offset > child_index) { - ar._end_offset += 1; - } + ar.updateForNodeInsertion(parent, child_index); } } @@ -2981,37 +2941,10 @@ pub fn updateRangesForNodeRemoval(self: *Page, parent: *Node, child: *Node, chil var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; while (it) |link| : (it = link.next) { const ar: *AbstractRange = @fieldParentPtr("_range_link", link); - - // Steps 4-5: ranges whose start/end is an inclusive descendant of child - // get moved to (parent, child_index). - if (isInclusiveDescendantOf(ar._start_container, child)) { - ar._start_container = parent; - ar._start_offset = child_index; - } - if (isInclusiveDescendantOf(ar._end_container, child)) { - ar._end_container = parent; - ar._end_offset = child_index; - } - - // Steps 6-7: ranges on parent at offsets > child_index get decremented. - if (ar._start_container == parent and ar._start_offset > child_index) { - ar._start_offset -= 1; - } - if (ar._end_container == parent and ar._end_offset > child_index) { - ar._end_offset -= 1; - } + ar.updateForNodeRemoval(parent, child, child_index); } } -fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool { - var current: ?*Node = node; - while (current) |n| { - if (n == potential_ancestor) return true; - current = n.parentNode(); - } - return false; -} - // TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void { const previous_parse_mode = self._parse_mode; diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index 231c635d..e766ac29 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -218,6 +218,91 @@ fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool { return isAncestorOf(potential_ancestor, node); } +/// Update this range's boundaries after a replaceData mutation on target. +/// All parameters are in UTF-16 code unit offsets. +pub fn updateForCharacterDataReplace(self: *AbstractRange, target: *Node, offset: u32, count: u32, data_len: u32) void { + if (self._start_container == target) { + if (self._start_offset > offset and self._start_offset <= offset + count) { + self._start_offset = offset; + } else if (self._start_offset > offset + count) { + // Use i64 intermediate to avoid u32 underflow when count > data_len + self._start_offset = @intCast(@as(i64, self._start_offset) + @as(i64, data_len) - @as(i64, count)); + } + } + + if (self._end_container == target) { + if (self._end_offset > offset and self._end_offset <= offset + count) { + self._end_offset = offset; + } else if (self._end_offset > offset + count) { + self._end_offset = @intCast(@as(i64, self._end_offset) + @as(i64, data_len) - @as(i64, count)); + } + } +} + +/// Update this range's boundaries after a splitText operation. +/// Steps 7b-7e of the DOM spec splitText algorithm. +pub fn updateForSplitText(self: *AbstractRange, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void { + // Step 7b: ranges on the original node with start > offset move to new node + if (self._start_container == target and self._start_offset > offset) { + self._start_container = new_node; + self._start_offset = self._start_offset - offset; + } + // Step 7c: ranges on the original node with end > offset move to new node + if (self._end_container == target and self._end_offset > offset) { + self._end_container = new_node; + self._end_offset = self._end_offset - offset; + } + // Step 7d: ranges on parent with start == node_index + 1 increment + if (self._start_container == parent and self._start_offset == node_index + 1) { + self._start_offset += 1; + } + // Step 7e: ranges on parent with end == node_index + 1 increment + if (self._end_container == parent and self._end_offset == node_index + 1) { + self._end_offset += 1; + } +} + +/// Update this range's boundaries after a node insertion. +pub fn updateForNodeInsertion(self: *AbstractRange, parent: *Node, child_index: u32) void { + if (self._start_container == parent and self._start_offset > child_index) { + self._start_offset += 1; + } + if (self._end_container == parent and self._end_offset > child_index) { + self._end_offset += 1; + } +} + +/// Update this range's boundaries after a node removal. +pub fn updateForNodeRemoval(self: *AbstractRange, parent: *Node, child: *Node, child_index: u32) void { + // Steps 4-5: ranges whose start/end is an inclusive descendant of child + // get moved to (parent, child_index). + if (isInclusiveDescendantOf(self._start_container, child)) { + self._start_container = parent; + self._start_offset = child_index; + } + if (isInclusiveDescendantOf(self._end_container, child)) { + self._end_container = parent; + self._end_offset = child_index; + } + + // Steps 6-7: ranges on parent at offsets > child_index get decremented. + if (self._start_container == parent and self._start_offset > child_index) { + self._start_offset -= 1; + } + if (self._end_container == parent and self._end_offset > child_index) { + self._end_offset -= 1; + } +} + +fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool { + var current: ?*Node = node; + while (current) |n| { + if (n == potential_ancestor) return true; + current = n.parentNode(); + } + return false; +} + pub const JsApi = struct { pub const bridge = js.Bridge(AbstractRange); diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index a5789a64..66ce1c93 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -358,7 +358,7 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { // Per spec step 11: if range was collapsed, extend end to include inserted node. // Non-collapsed ranges are already handled by the live range update in the insert path. - if (was_collapsed and self._proto._start_container == self._proto._end_container) { + if (was_collapsed) { self._proto._end_offset = self._proto._start_offset + 1; } } From 625d424199340bd54ee13d97fb2d79e74d86451d Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 07:27:39 +0100 Subject: [PATCH 30/34] remove ranges from live list on GC finalization Add a weak finalizer to Range that removes its linked list node from Page._live_ranges when V8 garbage-collects the JS Range object. This prevents the list from growing unboundedly and avoids iterating over stale entries during mutation updates. --- src/browser/webapi/Range.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index 66ce1c93..e3b8bc41 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -680,6 +680,10 @@ fn getContainerElement(self: *const Range) ?*Node.Element { return parent.is(Node.Element); } +pub fn deinit(self: *Range, _: bool, page: *Page) void { + page._live_ranges.remove(&self._proto._range_link); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Range); @@ -687,6 +691,8 @@ pub const JsApi = struct { pub const name = "Range"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(Range.deinit); }; // Constants for compareBoundaryPoints From 056b8bb53660883280c86439b90f93a7fb47df17 Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 07:58:31 +0100 Subject: [PATCH 31/34] fix CI: store list pointer on AbstractRange to avoid Page type mismatch The bridge.finalizer resolves Page through its own module graph, which can differ from Range.zig's import in release builds. Store a pointer to the live_ranges list directly on AbstractRange so deinit can remove without accessing Page fields. --- src/browser/Factory.zig | 1 + src/browser/webapi/AbstractRange.zig | 1 + src/browser/webapi/Range.zig | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index b0da7a81..dbdc11ae 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -282,6 +282,7 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil ._start_container = doc, }; chain.setLeaf(1, child); + abstract_range._live_ranges = &page._live_ranges; page._live_ranges.append(&abstract_range._range_link); return chain.get(1); } diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index e766ac29..93bda1fa 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -35,6 +35,7 @@ _start_container: *Node, // Intrusive linked list node for tracking live ranges on the Page. _range_link: std.DoublyLinkedList.Node = .{}, +_live_ranges: *std.DoublyLinkedList = undefined, pub const Type = union(enum) { range: *Range, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index e3b8bc41..213dea2a 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -680,8 +680,8 @@ fn getContainerElement(self: *const Range) ?*Node.Element { return parent.is(Node.Element); } -pub fn deinit(self: *Range, _: bool, page: *Page) void { - page._live_ranges.remove(&self._proto._range_link); +pub fn deinit(self: *Range, _: bool, _: *Page) void { + self._proto._live_ranges.remove(&self._proto._range_link); } pub const JsApi = struct { From 697a2834c23cd6da0491f1953be1c3f93342e9c2 Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 08:04:51 +0100 Subject: [PATCH 32/34] Revert "fix CI: store list pointer on AbstractRange to avoid Page type mismatch" This reverts commit 056b8bb53660883280c86439b90f93a7fb47df17. --- src/browser/Factory.zig | 1 - src/browser/webapi/AbstractRange.zig | 1 - src/browser/webapi/Range.zig | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index dbdc11ae..b0da7a81 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -282,7 +282,6 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil ._start_container = doc, }; chain.setLeaf(1, child); - abstract_range._live_ranges = &page._live_ranges; page._live_ranges.append(&abstract_range._range_link); return chain.get(1); } diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index 93bda1fa..e766ac29 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -35,7 +35,6 @@ _start_container: *Node, // Intrusive linked list node for tracking live ranges on the Page. _range_link: std.DoublyLinkedList.Node = .{}, -_live_ranges: *std.DoublyLinkedList = undefined, pub const Type = union(enum) { range: *Range, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index 213dea2a..e3b8bc41 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -680,8 +680,8 @@ fn getContainerElement(self: *const Range) ?*Node.Element { return parent.is(Node.Element); } -pub fn deinit(self: *Range, _: bool, _: *Page) void { - self._proto._live_ranges.remove(&self._proto._range_link); +pub fn deinit(self: *Range, _: bool, page: *Page) void { + page._live_ranges.remove(&self._proto._range_link); } pub const JsApi = struct { From 25c89c9940bd1241037fe13623cb2b87a11a5c7c Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 08:04:53 +0100 Subject: [PATCH 33/34] Revert "remove ranges from live list on GC finalization" This reverts commit 625d424199340bd54ee13d97fb2d79e74d86451d. --- src/browser/webapi/Range.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index e3b8bc41..66ce1c93 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -680,10 +680,6 @@ fn getContainerElement(self: *const Range) ?*Node.Element { return parent.is(Node.Element); } -pub fn deinit(self: *Range, _: bool, page: *Page) void { - page._live_ranges.remove(&self._proto._range_link); -} - pub const JsApi = struct { pub const bridge = js.Bridge(Range); @@ -691,8 +687,6 @@ pub const JsApi = struct { pub const name = "Range"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const weak = true; - pub const finalizer = bridge.finalizer(Range.deinit); }; // Constants for compareBoundaryPoints From 48ead908503f4d37132d62ee4262b94628c8caff Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 11 Mar 2026 16:29:44 +0800 Subject: [PATCH 34/34] Don't log SocketNotConnected when shutting down listener on non-Linux On BSD, a listening socket isn't considered connected, so this error is expected. Maybe we shouldn't call shutdown at all, but I guess it's safer this way. --- src/network/Runtime.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index 0112dc18..626b7229 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -180,7 +180,13 @@ pub fn run(self: *Runtime) void { } if (self.listener) |listener| { - posix.shutdown(listener.socket, .both) catch |err| { + posix.shutdown(listener.socket, .both) catch |err| blk: { + if (err == error.SocketNotConnected and builtin.os.tag != .linux) { + // This error is normal/expected on BSD/MacOS. We probably + // shouldn't bother calling shutdown at all, but I guess this + // is safer. + break :blk; + } lp.log.warn(.app, "listener shutdown", .{ .err = err }); }; posix.close(listener.socket);