From 1923877a741ce0b9b539c3e216a4357832314b94 Mon Sep 17 00:00:00 2001 From: gujishh Date: Mon, 13 Apr 2026 00:12:43 +0900 Subject: [PATCH 01/27] Clarify WSL install notes --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1ec482ba..b16cce83 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download chmod a+x ./lightpanda ``` -*For Windows + WSL2* +### Windows (via WSL2) -The Lightpanda browser is compatible to run on windows inside WSL. Follow the Linux instruction for installation from a WSL terminal. -It is recommended to install clients like Puppeteer on the Windows host. +Lightpanda runs inside WSL when invoked from a WSL shell; follow the Linux install steps above in that environment. +Install automation clients such as Puppeteer or Playwright on the Windows host so they can drive Lightpanda through CDP over the mapped ports. **Install from Docker** From 5e5a573a9f5466eb1d1f8d63a562fea1b5f261e5 Mon Sep 17 00:00:00 2001 From: zed Date: Sat, 11 Apr 2026 18:30:29 +0800 Subject: [PATCH 02/27] new: allow CDP change useragent --- src/browser/HttpClient.zig | 39 ++++++++++++++++++++++++++++++-- src/browser/webapi/Navigator.zig | 2 +- src/cdp/CDP.zig | 4 ++++ src/cdp/domains/emulation.zig | 26 ++++++++++++++++++++- 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index a2da34d5..318dae0d 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -115,6 +115,12 @@ tls_verify: bool = true, obey_robots: bool, +// User agent override set via CDP Emulation.setUserAgentOverride. +// When set, takes precedence over the config's http_headers values. +// Both fields are allocated from self.allocator when set, null otherwise. +user_agent_override: ?[:0]const u8 = null, +user_agent_header_override: ?[:0]const u8 = null, + cdp_client: ?CDPClient = null, max_response_size: usize, @@ -177,9 +183,33 @@ pub fn deinit(self: *Client) void { } self.pending_robots_queue.deinit(self.allocator); + self.clearUserAgentOverride(); self.allocator.destroy(self); } +// Set a user agent override. Both the raw UA string and the pre-formatted +// "User-Agent: " header string are allocated from self.allocator. +pub fn setUserAgentOverride(self: *Client, ua: []const u8) !void { + self.clearUserAgentOverride(); + const override = try self.allocator.dupeZ(u8, ua); + errdefer self.allocator.free(override); + const header = try std.fmt.allocPrintSentinel(self.allocator, "User-Agent: {s}", .{ua}, 0); + self.user_agent_override = override; + self.user_agent_header_override = header; +} + +// Clear any user agent override, restoring the default from config. +pub fn clearUserAgentOverride(self: *Client) void { + if (self.user_agent_override) |ua| { + self.allocator.free(ua); + self.user_agent_override = null; + } + if (self.user_agent_header_override) |uah| { + self.allocator.free(uah); + self.user_agent_header_override = null; + } +} + // Enable TLS verification on all connections. pub fn setTlsVerify(self: *Client, verify: bool) !void { // Remove inflight connections check on enable TLS b/c chromiumoxide calls @@ -209,7 +239,12 @@ pub fn changeProxy(self: *Client, proxy: ?[:0]const u8) !void { } pub fn newHeaders(self: *const Client) !http.Headers { - return http.Headers.init(self.network.config.http_headers.user_agent_header); + const ua_header = self.user_agent_header_override orelse self.network.config.http_headers.user_agent_header; + return http.Headers.init(ua_header); +} + +pub fn getUserAgent(self: *const Client) [:0]const u8 { + return self.user_agent_override orelse self.network.config.http_headers.user_agent; } pub fn abort(self: *Client) void { @@ -511,7 +546,7 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void { 200 => { if (ctx.buffer.items.len > 0) { const robots: ?Robots = ctx.client.network.robot_store.robotsFromBytes( - ctx.client.network.config.http_headers.user_agent, + ctx.client.getUserAgent(), ctx.buffer.items, ) catch blk: { log.warn(.browser, "failed to parse robots", .{ .robots_url = ctx.robots_url }); diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 8de0d163..d4389ed3 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -37,7 +37,7 @@ _storage: StorageManager = .{}, pub const init: Navigator = .{}; pub fn getUserAgent(_: *const Navigator, page: *Page) []const u8 { - return page._session.browser.app.config.http_headers.user_agent; + return page._session.browser.http_client.getUserAgent(); } pub fn getLanguages(_: *const Navigator) [2][]const u8 { diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 222098c8..4ee83f66 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -368,6 +368,7 @@ pub const BrowserContext = struct { next_script_id: u32 = 1, http_proxy_changed: bool = false, + user_agent_changed: bool = false, // Extra headers to add to all requests. extra_headers: std.ArrayList([*c]const u8) = .empty, @@ -477,6 +478,9 @@ pub const BrowserContext = struct { log.warn(.http, "changeProxy", .{ .err = err }); }; } + if (self.user_agent_changed) { + browser.http_client.clearUserAgentOverride(); + } self.intercept_state.deinit(); } diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index 4cfcd7be..85a7e895 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -70,6 +70,30 @@ fn setTouchEmulationEnabled(cmd: *CDP.Command) !void { } fn setUserAgentOverride(cmd: *CDP.Command) !void { - log.info(.app, "setUserAgentOverride ignored", .{}); + const params = (try cmd.params(struct { + userAgent: []const u8, + acceptLanguage: ?[]const u8 = null, + platform: ?[]const u8 = null, + })) orelse return error.InvalidParams; + + const ua = params.userAgent; + + // Validate: all characters must be printable ASCII + for (ua) |c| { + if (!std.ascii.isPrint(c)) { + return cmd.sendError(-32602, "User agent contains non-printable characters", .{}); + } + } + + // Reject user agents containing "mozilla" (case-insensitive) + if (std.ascii.indexOfIgnoreCase(ua, "mozilla") != null) { + return cmd.sendError(-32602, "User agent must not contain Mozilla", .{}); + } + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const http_client = cmd.cdp.browser.http_client; + try http_client.setUserAgentOverride(ua); + bc.user_agent_changed = true; + return cmd.sendResult(null, .{}); } From 9fe3a48c3a917c734363e10b8dedb4b759c88f0e Mon Sep 17 00:00:00 2001 From: zed Date: Sat, 11 Apr 2026 23:16:35 +0800 Subject: [PATCH 03/27] test: add tests for setting CDP user agent --- src/Config.zig | 97 ++++++++++++++++++++++++++++++++++ src/cdp/domains/emulation.zig | 99 +++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/src/Config.zig b/src/Config.zig index 2acca63c..20e85205 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -1193,3 +1193,100 @@ fn parseCommonArg( return false; } + +test "HttpHeaders: default user agent" { + const allocator = std.testing.allocator; + var config = Config{ + .mode = .{ .serve = .{} }, + .exec_name = "test", + .http_headers = undefined, + }; + config.http_headers = try HttpHeaders.init(allocator, &config); + defer config.http_headers.deinit(allocator); + + try std.testing.expectEqualStrings("Lightpanda/1.0", config.http_headers.user_agent); + try std.testing.expectEqualStrings("User-Agent: Lightpanda/1.0", config.http_headers.user_agent_header); + try std.testing.expect(config.http_headers.proxy_bearer_header == null); +} + +test "HttpHeaders: custom user agent override" { + const allocator = std.testing.allocator; + const ua = try allocator.dupe(u8, "MyBot/2.0"); + var config = Config{ + .mode = .{ .serve = .{ .common = .{ .user_agent = ua } } }, + .exec_name = "test", + .http_headers = undefined, + }; + config.http_headers = try HttpHeaders.init(allocator, &config); + defer config.http_headers.deinit(allocator); + defer allocator.free(ua); + + try std.testing.expectEqualStrings("MyBot/2.0", config.http_headers.user_agent); + try std.testing.expectEqualStrings("User-Agent: MyBot/2.0", config.http_headers.user_agent_header); +} + +test "HttpHeaders: user agent suffix" { + const allocator = std.testing.allocator; + const suffix = try allocator.dupe(u8, "CustomSuffix/3.0"); + var config = Config{ + .mode = .{ .serve = .{ .common = .{ .user_agent_suffix = suffix } } }, + .exec_name = "test", + .http_headers = undefined, + }; + config.http_headers = try HttpHeaders.init(allocator, &config); + defer config.http_headers.deinit(allocator); + defer allocator.free(suffix); + + try std.testing.expectEqualStrings("Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent); + try std.testing.expectEqualStrings("User-Agent: Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent_header); +} + +test "HttpHeaders: fetch mode default user agent" { + const allocator = std.testing.allocator; + const url = try allocator.dupeZ(u8, "https://example.com"); + defer allocator.free(url); + var config = Config{ + .mode = .{ .fetch = .{ .url = url } }, + .exec_name = "test", + .http_headers = undefined, + }; + config.http_headers = try HttpHeaders.init(allocator, &config); + defer config.http_headers.deinit(allocator); + + try std.testing.expectEqualStrings("Lightpanda/1.0", config.http_headers.user_agent); +} + +test "HttpHeaders: fetch mode custom user agent" { + const allocator = std.testing.allocator; + const url = try allocator.dupeZ(u8, "https://example.com"); + defer allocator.free(url); + const ua = try allocator.dupe(u8, "FetchBot/1.0"); + var config = Config{ + .mode = .{ .fetch = .{ .url = url, .common = .{ .user_agent = ua } } }, + .exec_name = "test", + .http_headers = undefined, + }; + config.http_headers = try HttpHeaders.init(allocator, &config); + defer config.http_headers.deinit(allocator); + defer allocator.free(ua); + + try std.testing.expectEqualStrings("FetchBot/1.0", config.http_headers.user_agent); + try std.testing.expectEqualStrings("User-Agent: FetchBot/1.0", config.http_headers.user_agent_header); +} + +test "HttpHeaders: proxy bearer header" { + const allocator = std.testing.allocator; + const token: [:0]const u8 = try allocator.dupeZ(u8, "secret-token"); + var config = Config{ + .mode = .{ .serve = .{ .common = .{ .proxy_bearer_token = token } } }, + .exec_name = "test", + .http_headers = undefined, + }; + config.http_headers = try HttpHeaders.init(allocator, &config); + defer config.http_headers.deinit(allocator); + defer allocator.free(token); + + try std.testing.expectEqualStrings("Lightpanda/1.0", config.http_headers.user_agent); + try std.testing.expectEqualStrings("Proxy-Authorization: Bearer secret-token", config.http_headers.proxy_bearer_header.?); +} + diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index 85a7e895..16d2a23b 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -97,3 +97,102 @@ fn setUserAgentOverride(cmd: *CDP.Command) !void { return cmd.sendResult(null, .{}); } + +const testing = @import("../testing.zig"); + +test "cdp.Emulation: setUserAgentOverride with valid user agent" { + var ctx = try testing.context(); + defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-UA1" }); + + try ctx.processMessage(.{ + .id = 1, + .method = "Emulation.setUserAgentOverride", + .params = .{ .userAgent = "CustomBot/1.0" }, + }); + + try ctx.expectSentResult(null, .{ .id = 1 }); +} + +test "cdp.Emulation: setUserAgentOverride rejects mozilla" { + var ctx = try testing.context(); + defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-UA2" }); + + try ctx.processMessage(.{ + .id = 2, + .method = "Emulation.setUserAgentOverride", + .params = .{ .userAgent = "Mozilla/5.0 (Windows NT 10.0)" }, + }); + + try ctx.expectSentError(-32602, "User agent must not contain Mozilla", .{ .id = 2 }); +} + +test "cdp.Emulation: setUserAgentOverride rejects mozilla case insensitive" { + var ctx = try testing.context(); + defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-UA3" }); + + try ctx.processMessage(.{ + .id = 3, + .method = "Emulation.setUserAgentOverride", + .params = .{ .userAgent = "MOZILLA/5.0 test" }, + }); + + try ctx.expectSentError(-32602, "User agent must not contain Mozilla", .{ .id = 3 }); +} + +test "cdp.Emulation: setUserAgentOverride rejects non-printable characters" { + var ctx = try testing.context(); + defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-UA4" }); + + try ctx.processMessage(.{ + .id = 4, + .method = "Emulation.setUserAgentOverride", + .params = .{ .userAgent = "Bot/1.0\x01hidden" }, + }); + + try ctx.expectSentError(-32602, "User agent contains non-printable characters", .{ .id = 4 }); +} + +test "cdp.Emulation: setUserAgentOverride with optional params" { + var ctx = try testing.context(); + defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-UA5" }); + + try ctx.processMessage(.{ + .id = 5, + .method = "Emulation.setUserAgentOverride", + .params = .{ + .userAgent = "CustomBot/2.0", + .acceptLanguage = "en-US", + .platform = "Linux", + }, + }); + + try ctx.expectSentResult(null, .{ .id = 5 }); +} + +test "cdp.Emulation: setUserAgentOverride can be called multiple times" { + var ctx = try testing.context(); + defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-UA6" }); + + try ctx.processMessage(.{ + .id = 6, + .method = "Emulation.setUserAgentOverride", + .params = .{ .userAgent = "FirstBot/1.0" }, + }); + + try ctx.expectSentResult(null, .{ .id = 6 }); + + try ctx.processMessage(.{ + .id = 7, + .method = "Emulation.setUserAgentOverride", + .params = .{ .userAgent = "SecondBot/2.0" }, + }); + + try ctx.expectSentResult(null, .{ .id = 7 }); +} + From 05a08f1f978693f4c960806556a3721f0793cbf1 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 13 Apr 2026 10:22:44 +0200 Subject: [PATCH 04/27] cdp: forward Network.setUserAgentOverride to Emulation.setUserAgentOverride --- src/cdp/domains/emulation.zig | 4 ++-- src/cdp/domains/network.zig | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index 16d2a23b..65c7205d 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -69,7 +69,8 @@ fn setTouchEmulationEnabled(cmd: *CDP.Command) !void { return cmd.sendResult(null, .{}); } -fn setUserAgentOverride(cmd: *CDP.Command) !void { +// Emulation.setUserAgentOverride is also called by Network.setUserAgentOverride +pub fn setUserAgentOverride(cmd: *CDP.Command) !void { const params = (try cmd.params(struct { userAgent: []const u8, acceptLanguage: ?[]const u8 = null, @@ -195,4 +196,3 @@ test "cdp.Emulation: setUserAgentOverride can be called multiple times" { try ctx.expectSentResult(null, .{ .id = 7 }); } - diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 575c711f..629f1ab0 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -51,7 +51,7 @@ pub fn processMessage(cmd: *CDP.Command) !void { .enable => return enable(cmd), .disable => return disable(cmd), .setCacheDisabled => return cmd.sendResult(null, .{}), - .setUserAgentOverride => return cmd.sendResult(null, .{}), + .setUserAgentOverride => return @import("emulation.zig").setUserAgentOverride(cmd), .setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd), .deleteCookies => return deleteCookies(cmd), .clearBrowserCookies => return clearBrowserCookies(cmd), From 21e27a257d5141b7cb4df2829b19d038159e6263 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 13 Apr 2026 10:25:55 +0200 Subject: [PATCH 05/27] cdp: add warning for non-implemented params on Emulation.setUserAgentOverride --- src/cdp/domains/emulation.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index 65c7205d..db8d3a41 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -77,6 +77,13 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void { platform: ?[]const u8 = null, })) orelse return error.InvalidParams; + if (params.acceptLanguage) |v| { + log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "acceptLanguage", .value = v }); + } + if (params.platform) |v| { + log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "platform", .value = v }); + } + const ua = params.userAgent; // Validate: all characters must be printable ASCII From 1589445ec01a623241a26be68ff5c77fd78aa314 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 13 Apr 2026 10:36:35 +0200 Subject: [PATCH 06/27] zig fmt --- src/Config.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Config.zig b/src/Config.zig index 20e85205..b0059c32 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -1289,4 +1289,3 @@ test "HttpHeaders: proxy bearer header" { try std.testing.expectEqualStrings("Lightpanda/1.0", config.http_headers.user_agent); try std.testing.expectEqualStrings("Proxy-Authorization: Bearer secret-token", config.http_headers.proxy_bearer_header.?); } - From 920ae57f9ae41f841fd52c0fde7ca222baaef0fa Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 13 Apr 2026 11:16:33 +0200 Subject: [PATCH 07/27] cdp: ignore UA containing Mozilla Instead of returning an error when the UA contains Mozilla, we ignore the option and log an message. --- src/cdp/domains/emulation.zig | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index db8d3a41..7248b76c 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -95,7 +95,11 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void { // Reject user agents containing "mozilla" (case-insensitive) if (std.ascii.indexOfIgnoreCase(ua, "mozilla") != null) { - return cmd.sendError(-32602, "User agent must not contain Mozilla", .{}); + // go-rod client automatically set a Mozilla/ user agent. + // Since we don't want to stop this client to work, let's ignore the + // new user-agent and add a log instead. + log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "userAgent", .value = ua, .info = "User agent must not contain Mozilla" }); + return cmd.sendResult(null, .{}); } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; @@ -122,7 +126,7 @@ test "cdp.Emulation: setUserAgentOverride with valid user agent" { try ctx.expectSentResult(null, .{ .id = 1 }); } -test "cdp.Emulation: setUserAgentOverride rejects mozilla" { +test "cdp.Emulation: setUserAgentOverride ignores mozilla" { var ctx = try testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-UA2" }); @@ -133,10 +137,11 @@ test "cdp.Emulation: setUserAgentOverride rejects mozilla" { .params = .{ .userAgent = "Mozilla/5.0 (Windows NT 10.0)" }, }); - try ctx.expectSentError(-32602, "User agent must not contain Mozilla", .{ .id = 2 }); + try ctx.expectSentResult(null, .{}); + try testing.expect(ctx.cdp().browser_context.?.user_agent_changed == false); } -test "cdp.Emulation: setUserAgentOverride rejects mozilla case insensitive" { +test "cdp.Emulation: setUserAgentOverride ignores mozilla case insensitive" { var ctx = try testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-UA3" }); @@ -147,7 +152,8 @@ test "cdp.Emulation: setUserAgentOverride rejects mozilla case insensitive" { .params = .{ .userAgent = "MOZILLA/5.0 test" }, }); - try ctx.expectSentError(-32602, "User agent must not contain Mozilla", .{ .id = 3 }); + try ctx.expectSentResult(null, .{}); + try testing.expect(ctx.cdp().browser_context.?.user_agent_changed == false); } test "cdp.Emulation: setUserAgentOverride rejects non-printable characters" { From ea67e4166054dea25a1b8d584dee5ddefb28a7fd Mon Sep 17 00:00:00 2001 From: gujishh Date: Mon, 13 Apr 2026 21:09:27 +0900 Subject: [PATCH 08/27] docs: match WSL section formatting --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b16cce83..cb010908 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download chmod a+x ./lightpanda ``` -### Windows (via WSL2) +*For Windows + WSL2* Lightpanda runs inside WSL when invoked from a WSL shell; follow the Linux install steps above in that environment. Install automation clients such as Puppeteer or Playwright on the Windows host so they can drive Lightpanda through CDP over the mapped ports. From 5226df99689da396eac4ac48bea9c5a4eeef8adc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 14 Apr 2026 17:11:54 +0800 Subject: [PATCH 09/27] Tweak tests De-duplicate user agent validation --- src/Config.zig | 136 ++++++++++++---------------------- src/cdp/domains/emulation.zig | 40 +++++----- src/cdp/testing.zig | 1 + 3 files changed, 69 insertions(+), 108 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index b0059c32..c4099c97 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -1098,20 +1098,14 @@ fn parseCommonArg( return error.InvalidArgument; }; - for (str) |c| { - if (!std.ascii.isPrint(c)) { - log.fatal(.app, "not printable character", .{ .arg = opt }); - return error.InvalidArgument; - } - } - - if (std.ascii.indexOfIgnoreCase(str, "mozilla") != null) { + validateUserAgent(str) catch |err| { log.fatal(.app, "invalid value", .{ - .detail = "user-agent can't contain Mozilla", + .detail = "invalid user agent", .arg = opt, + .err = err, }); return error.InvalidArgument; - } + }; common.user_agent = try allocator.dupe(u8, str); return true; @@ -1194,98 +1188,60 @@ fn parseCommonArg( return false; } -test "HttpHeaders: default user agent" { - const allocator = std.testing.allocator; - var config = Config{ - .mode = .{ .serve = .{} }, - .exec_name = "test", - .http_headers = undefined, - }; - config.http_headers = try HttpHeaders.init(allocator, &config); - defer config.http_headers.deinit(allocator); +pub fn validateUserAgent(ua: []const u8) !void { + for (ua) |c| { + if (!std.ascii.isPrint(c)) { + return error.NonPrintable; + } + } - try std.testing.expectEqualStrings("Lightpanda/1.0", config.http_headers.user_agent); - try std.testing.expectEqualStrings("User-Agent: Lightpanda/1.0", config.http_headers.user_agent_header); - try std.testing.expect(config.http_headers.proxy_bearer_header == null); + if (std.ascii.indexOfIgnoreCase(ua, "mozilla") != null) { + return error.Reserved; + } } -test "HttpHeaders: custom user agent override" { - const allocator = std.testing.allocator; - const ua = try allocator.dupe(u8, "MyBot/2.0"); - var config = Config{ - .mode = .{ .serve = .{ .common = .{ .user_agent = ua } } }, - .exec_name = "test", - .http_headers = undefined, - }; - config.http_headers = try HttpHeaders.init(allocator, &config); - defer config.http_headers.deinit(allocator); - defer allocator.free(ua); +const testing = @import("testing.zig"); +test "Config: HttpHeaders - default user agent" { + var config = try Config.init(testing.allocator, "", .{ .serve = .{} }); + defer config.deinit(testing.allocator); - try std.testing.expectEqualStrings("MyBot/2.0", config.http_headers.user_agent); - try std.testing.expectEqualStrings("User-Agent: MyBot/2.0", config.http_headers.user_agent_header); + try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); + try testing.expectEqual("User-Agent: Lightpanda/1.0", config.http_headers.user_agent_header); + try testing.expect(config.http_headers.proxy_bearer_header == null); } -test "HttpHeaders: user agent suffix" { - const allocator = std.testing.allocator; - const suffix = try allocator.dupe(u8, "CustomSuffix/3.0"); - var config = Config{ - .mode = .{ .serve = .{ .common = .{ .user_agent_suffix = suffix } } }, - .exec_name = "test", - .http_headers = undefined, - }; - config.http_headers = try HttpHeaders.init(allocator, &config); - defer config.http_headers.deinit(allocator); - defer allocator.free(suffix); +test "Config: HttpHeaders - custom user agent override" { + var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .user_agent = "MyBot/2.0" } } }); + defer config.deinit(testing.allocator); - try std.testing.expectEqualStrings("Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent); - try std.testing.expectEqualStrings("User-Agent: Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent_header); + try testing.expectEqual("MyBot/2.0", config.http_headers.user_agent); + try testing.expectEqual("User-Agent: MyBot/2.0", config.http_headers.user_agent_header); } -test "HttpHeaders: fetch mode default user agent" { - const allocator = std.testing.allocator; - const url = try allocator.dupeZ(u8, "https://example.com"); - defer allocator.free(url); - var config = Config{ - .mode = .{ .fetch = .{ .url = url } }, - .exec_name = "test", - .http_headers = undefined, - }; - config.http_headers = try HttpHeaders.init(allocator, &config); - defer config.http_headers.deinit(allocator); +test "Config: HttpHeaders - user agent suffix" { + var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .user_agent_suffix = "CustomSuffix/3.0" } } }); + defer config.deinit(testing.allocator); - try std.testing.expectEqualStrings("Lightpanda/1.0", config.http_headers.user_agent); + try testing.expectEqual("Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent); + try testing.expectEqual("User-Agent: Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent_header); } -test "HttpHeaders: fetch mode custom user agent" { - const allocator = std.testing.allocator; - const url = try allocator.dupeZ(u8, "https://example.com"); - defer allocator.free(url); - const ua = try allocator.dupe(u8, "FetchBot/1.0"); - var config = Config{ - .mode = .{ .fetch = .{ .url = url, .common = .{ .user_agent = ua } } }, - .exec_name = "test", - .http_headers = undefined, - }; - config.http_headers = try HttpHeaders.init(allocator, &config); - defer config.http_headers.deinit(allocator); - defer allocator.free(ua); - - try std.testing.expectEqualStrings("FetchBot/1.0", config.http_headers.user_agent); - try std.testing.expectEqualStrings("User-Agent: FetchBot/1.0", config.http_headers.user_agent_header); +test "Config: HttpHeaders - fetch mode default user agent" { + var config = try Config.init(testing.allocator, "", .{ .fetch = .{ .url = "https://example.com" } }); + defer config.deinit(testing.allocator); + try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); } -test "HttpHeaders: proxy bearer header" { - const allocator = std.testing.allocator; - const token: [:0]const u8 = try allocator.dupeZ(u8, "secret-token"); - var config = Config{ - .mode = .{ .serve = .{ .common = .{ .proxy_bearer_token = token } } }, - .exec_name = "test", - .http_headers = undefined, - }; - config.http_headers = try HttpHeaders.init(allocator, &config); - defer config.http_headers.deinit(allocator); - defer allocator.free(token); - - try std.testing.expectEqualStrings("Lightpanda/1.0", config.http_headers.user_agent); - try std.testing.expectEqualStrings("Proxy-Authorization: Bearer secret-token", config.http_headers.proxy_bearer_header.?); +test "Config: HttpHeaders - fetch mode custom user agent" { + var config = try Config.init(testing.allocator, "", .{ .fetch = .{ .url = "https://example.com", .common = .{ .user_agent = "FetchBot/1.0" } } }); + defer config.deinit(testing.allocator); + try testing.expectEqual("FetchBot/1.0", config.http_headers.user_agent); + try testing.expectEqual("User-Agent: FetchBot/1.0", config.http_headers.user_agent_header); +} + +test "Config: HttpHeaders - proxy bearer header" { + var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .proxy_bearer_token = "secret-token" } } }); + defer config.deinit(testing.allocator); + try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); + try testing.expectEqual("Proxy-Authorization: Bearer secret-token", config.http_headers.proxy_bearer_header.?); } diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index 7248b76c..fc7d821d 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -19,6 +19,7 @@ const std = @import("std"); const CDP = @import("../CDP.zig"); const log = @import("../../log.zig"); +const Config = @import("../../Config.zig"); pub fn processMessage(cmd: *CDP.Command) !void { const action = std.meta.stringToEnum(enum { @@ -85,22 +86,13 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void { } const ua = params.userAgent; - - // Validate: all characters must be printable ASCII - for (ua) |c| { - if (!std.ascii.isPrint(c)) { - return cmd.sendError(-32602, "User agent contains non-printable characters", .{}); - } - } - - // Reject user agents containing "mozilla" (case-insensitive) - if (std.ascii.indexOfIgnoreCase(ua, "mozilla") != null) { - // go-rod client automatically set a Mozilla/ user agent. - // Since we don't want to stop this client to work, let's ignore the - // new user-agent and add a log instead. - log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "userAgent", .value = ua, .info = "User agent must not contain Mozilla" }); - return cmd.sendResult(null, .{}); - } + Config.validateUserAgent(ua) catch |err| switch (err) { + error.NonPrintable => return cmd.sendError(-32602, "User agent contains non-printable characters", .{}), + error.Reserved => { + log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "userAgent", .value = ua, .info = "User agent must not contain Mozilla" }); + return cmd.sendResult(null, .{}); + }, + }; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const http_client = cmd.cdp.browser.http_client; @@ -127,6 +119,9 @@ test "cdp.Emulation: setUserAgentOverride with valid user agent" { } test "cdp.Emulation: setUserAgentOverride ignores mozilla" { + const filter: testing.LogFilter = .init(&.{.not_implemented}); + defer filter.deinit(); + var ctx = try testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-UA2" }); @@ -138,10 +133,13 @@ test "cdp.Emulation: setUserAgentOverride ignores mozilla" { }); try ctx.expectSentResult(null, .{}); - try testing.expect(ctx.cdp().browser_context.?.user_agent_changed == false); + try testing.expectEqual(false, ctx.cdp().browser_context.?.user_agent_changed); } test "cdp.Emulation: setUserAgentOverride ignores mozilla case insensitive" { + const filter: testing.LogFilter = .init(&.{.not_implemented}); + defer filter.deinit(); + var ctx = try testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-UA3" }); @@ -153,10 +151,13 @@ test "cdp.Emulation: setUserAgentOverride ignores mozilla case insensitive" { }); try ctx.expectSentResult(null, .{}); - try testing.expect(ctx.cdp().browser_context.?.user_agent_changed == false); + try testing.expectEqual(false, ctx.cdp().browser_context.?.user_agent_changed); } test "cdp.Emulation: setUserAgentOverride rejects non-printable characters" { + const filter: testing.LogFilter = .init(&.{.not_implemented}); + defer filter.deinit(); + var ctx = try testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-UA4" }); @@ -171,6 +172,9 @@ test "cdp.Emulation: setUserAgentOverride rejects non-printable characters" { } test "cdp.Emulation: setUserAgentOverride with optional params" { + const filter: testing.LogFilter = .init(&.{.not_implemented}); + defer filter.deinit(); + var ctx = try testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-UA5" }); diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index a7c7317b..7392b873 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -32,6 +32,7 @@ pub const expectError = base.expectError; pub const expectEqualSlices = base.expectEqualSlices; pub const pageTest = base.pageTest; pub const newString = base.newString; +pub const LogFilter = base.LogFilter; const TestContext = struct { read_at: usize = 0, From e698028e3aa30b306b20ac40c3cd8afafb89a874 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 13 Apr 2026 17:52:24 +0300 Subject: [PATCH 10/27] `Fetch`: cookie jar should only be included for `include` and same origin requests --- src/browser/webapi/net/Fetch.zig | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 1d0776b3..8f10e9a1 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -86,6 +86,24 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { log.debug(.http, "fetch", .{ .url = request._url }); } + const cookie_jar = switch (request._credentials) { + .omit => null, + .include => &page._session.cookie_jar, + .@"same-origin" => blk: { + const page_origin = URL.getOrigin(page.arena, page.url) catch break :blk null; + const req_origin = URL.getOrigin(page.arena, request._url) catch break :blk null; + + const is_same_origin = page_origin != null and req_origin != null and + std.mem.eql(u8, page_origin.?, req_origin.?); + + if (is_same_origin) { + break :blk &page._session.cookie_jar; + } + + break :blk null; + }, + }; + try http_client.request(.{ .ctx = fetch, .url = request._url, @@ -95,7 +113,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { .body = request._body, .headers = headers, .resource_type = .fetch, - .cookie_jar = &page._session.cookie_jar, + .cookie_jar = cookie_jar, .cookie_origin = page.url, .notification = page._session.notification, .start_callback = httpStartCallback, From f9fc858212d04bfeae05232f109aef8bd62564f5 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 14 Apr 2026 13:11:25 +0300 Subject: [PATCH 11/27] `Page.isSameOrigin`: `!bool` -> `bool` --- src/browser/Page.zig | 2 +- src/browser/webapi/History.zig | 2 +- src/browser/webapi/Navigator.zig | 2 +- src/browser/webapi/net/XMLHttpRequest.zig | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 66523574..5e3dcb9f 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -462,7 +462,7 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void { return self._session.releaseArena(allocator); } -pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { +pub fn isSameOrigin(self: *const Page, url: [:0]const u8) bool { const current_origin = self.origin orelse return false; // fastpath diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index 19325b7a..1ccc59f6 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -92,7 +92,7 @@ fn goInner(delta: i32, page: *Page) !void { const entry = page._session.navigation._entries.items[index]; if (entry._url) |url| { - if (try page.isSameOrigin(url)) { + if (page.isSameOrigin(url)) { const target = page.window.asEventTarget(); if (page._event_manager.hasDirectListeners(target, "popstate", page.window._on_popstate)) { const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent(); diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 8de0d163..64e6df10 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -139,7 +139,7 @@ fn validateProtocolHandlerURL(url: [:0]const u8, page: *const Page) !void { if (std.mem.indexOf(u8, url, "%s") == null) { return error.SyntaxError; } - if (try page.isSameOrigin(url) == false) { + if (page.isSameOrigin(url) == false) { return error.SyntaxError; } } diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 2c130ac3..c19393e0 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -245,7 +245,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { var headers = try http_client.newHeaders(); // Only add cookies for same-origin or when withCredentials is true - const cookie_support = self._with_credentials or try page.isSameOrigin(self._url); + const cookie_support = self._with_credentials or page.isSameOrigin(self._url); try self._request_headers.populateHttpHeader(page.call_arena, &headers); if (cookie_support) { From c42e242897084ec3daf1cfdfc1d299aca87c3695 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 14 Apr 2026 13:12:22 +0300 Subject: [PATCH 12/27] `Fetch.init`: prefer `isSameOrigin` for comparison --- src/browser/webapi/net/Fetch.zig | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 8f10e9a1..4a8b7e92 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -89,19 +89,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const cookie_jar = switch (request._credentials) { .omit => null, .include => &page._session.cookie_jar, - .@"same-origin" => blk: { - const page_origin = URL.getOrigin(page.arena, page.url) catch break :blk null; - const req_origin = URL.getOrigin(page.arena, request._url) catch break :blk null; - - const is_same_origin = page_origin != null and req_origin != null and - std.mem.eql(u8, page_origin.?, req_origin.?); - - if (is_same_origin) { - break :blk &page._session.cookie_jar; - } - - break :blk null; - }, + .@"same-origin" => if (page.isSameOrigin(request._url)) &page._session.cookie_jar else null, }; try http_client.request(.{ From eb0af793c29bd91d3f73a075e04cfe84495b5be4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 14 Apr 2026 21:56:51 +0800 Subject: [PATCH 13/27] WS.close returns DOMException Cleanup WS after sending disconnetion events Mapping support for []f32 and []f64 Default max WS connections 8 -> 64 --- src/Config.zig | 2 +- src/browser/js/Local.zig | 18 +++++++ src/browser/js/Value.zig | 8 ++++ src/browser/webapi/net/WebSocket.zig | 71 ++++++++++++++-------------- 4 files changed, 63 insertions(+), 36 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 2acca63c..0e238def 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -130,7 +130,7 @@ pub fn httpMaxResponseSize(self: *const Config) ?usize { pub fn wsMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.ws_max_concurrent orelse 8, + inline .serve, .fetch, .mcp => |opts| opts.common.ws_max_concurrent orelse 64, else => unreachable, }; } diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index a4757221..63a0fe4e 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -857,6 +857,18 @@ fn jsValueToTypedArray(comptime T: type, js_val: js.Value) !?[]T { return ptr[0..num_elements]; } }, + f32 => { + if (js_val.isFloat32Array()) { + const ptr = @as([*]f32, @ptrCast(@alignCast(base))); + return ptr[0..num_elements]; + } + }, + f64 => { + if (js_val.isFloat64Array()) { + const ptr = @as([*]f64, @ptrCast(@alignCast(base))); + return ptr[0..num_elements]; + } + }, else => {}, } return error.InvalidArgument; @@ -985,6 +997,12 @@ fn probeJsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !Pr i64 => if (js_val.isBigInt64Array()) { return .{ .ok = {} }; }, + f32 => if (js_val.isFloat32Array()) { + return .{ .ok = {} }; + }, + f64 => if (js_val.isFloat64Array()) { + return .{ .ok = {} }; + }, else => {}, } return .{ .invalid = {} }; diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index f80727b0..bf1696e7 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -155,6 +155,14 @@ pub fn isBigInt64Array(self: Value) bool { return v8.v8__Value__IsBigInt64Array(self.handle); } +pub fn isFloat32Array(self: Value) bool { + return v8.v8__Value__IsFloat32Array(self.handle); +} + +pub fn isFloat64Array(self: Value) bool { + return v8.v8__Value__IsFloat64Array(self.handle); +} + pub fn isPromise(self: Value) bool { return v8.v8__Value__IsPromise(self.handle); } diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 32be22fb..6869cac3 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -88,20 +88,6 @@ pub const BinaryType = enum { arraybuffer, }; -fn isValidProtocol(protocol: []const u8) bool { - if (protocol.len == 0) return false; - for (protocol) |c| { - // Control characters - if (c <= 31 or c == 127) return false; - // Separators per RFC 2616 - switch (c) { - '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t' => return false, - else => {}, - } - } - return true; -} - pub fn init(url: []const u8, protocols: [][]const u8, page: *Page) !*WebSocket { { if (url.len < 6) { @@ -196,6 +182,18 @@ pub fn deinit(self: *WebSocket, session: *Session) void { session.releaseArena(self._arena); } +pub fn releaseRef(self: *WebSocket, session: *Session) void { + self._rc.release(self, session); +} + +pub fn acquireRef(self: *WebSocket) void { + self._rc.acquire(); +} + +fn asEventTarget(self: *WebSocket) *EventTarget { + return self._proto; +} + // we're being aborted internally (e.g. page shutting down) pub fn kill(self: *WebSocket) void { self.cleanup(); @@ -211,13 +209,15 @@ pub fn disconnected(self: *WebSocket, err_: ?anyerror) void { log.info(.websocket, "disconnected", .{ .url = self._url, .reason = "closed" }); } - self.cleanup(); + defer self.cleanup(); // Use 1006 (abnormal closure) if connection wasn't cleanly closed const code = if (was_clean) self._close_code else 1006; const reason = if (was_clean) self._close_reason else ""; - // Spec requires error event before close on abnormal closure + // Spec requires error event before close on abnormal closure. + // Dispatch events before cleanup since cleanup releases the ref count + // which may free our event handler references. if (!was_clean) { self.dispatchErrorEvent() catch |err| { log.err(.websocket, "error event dispatch failed", .{ .err = err }); @@ -239,18 +239,6 @@ fn cleanup(self: *WebSocket) void { } } -pub fn releaseRef(self: *WebSocket, session: *Session) void { - self._rc.release(self, session); -} - -pub fn acquireRef(self: *WebSocket) void { - self._rc.acquire(); -} - -fn asEventTarget(self: *WebSocket) *EventTarget { - return self._proto; -} - fn queueMessage(self: *WebSocket, msg: Message) !void { const was_empty = self._send_queue.items.len == 0; try self._send_queue.append(self._arena, msg); @@ -263,6 +251,20 @@ fn queueMessage(self: *WebSocket, msg: Message) !void { } } +fn isValidProtocol(protocol: []const u8) bool { + if (protocol.len == 0) return false; + for (protocol) |c| { + // Control characters and non-ASCII + if (c <= 31 or c >= 127) return false; + // Separators per RFC 2616 + switch (c) { + '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t' => return false, + else => {}, + } + } + return true; +} + /// WebSocket send() accepts string, Blob, ArrayBuffer, or TypedArray const SendData = union(enum) { blob: *Blob, @@ -279,17 +281,16 @@ const BinaryData = union(enum) { uint32: []u32, int64: []i64, uint64: []u64, + float32: []f32, + float64: []f64, fn asBuffer(self: BinaryData) []u8 { return switch (self) { .int8 => |b| @as([*]u8, @ptrCast(b.ptr))[0..b.len], .uint8 => |b| b, - .int16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2], - .uint16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2], - .int32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4], - .uint32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4], - .int64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8], - .uint64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8], + inline .int16, .uint16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2], + inline .int32, .uint32, .float32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4], + inline .int64, .uint64, .float64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8], }; } }; @@ -754,7 +755,7 @@ pub const JsApi = struct { pub const onclose = bridge.accessor(WebSocket.getOnClose, WebSocket.setOnClose, .{}); pub const send = bridge.function(WebSocket.send, .{ .dom_exception = true }); - pub const close = bridge.function(WebSocket.close, .{}); + pub const close = bridge.function(WebSocket.close, .{ .dom_exception = true }); }; const testing = @import("../../../testing.zig"); From 2087aa7aaca0b03f1bd18f2f5043b6f3d5927fff Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 14 Apr 2026 22:53:03 +0800 Subject: [PATCH 14/27] acquire reference on document font --- src/browser/Page.zig | 7 ++++++- src/browser/webapi/Document.zig | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 5e3dcb9f..339e5e50 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -385,7 +385,12 @@ pub fn deinit(self: *Page, abort_http: bool) void { observer.releaseRef(session); } - self.window._document._selection.releaseRef(session); + var document = self.window._document; + document._selection.releaseRef(session); + + if (document._fonts) |f| { + f.releaseRef(session); + } } session.browser.env.destroyContext(self.js); diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index cd2d5a4c..39c8a21c 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -454,6 +454,7 @@ pub fn getFonts(self: *Document, page: *Page) !*FontFaceSet { return fonts; } const fonts = try FontFaceSet.init(page); + fonts.acquireRef(); self._fonts = fonts; return fonts; } From 506f52bea2d3f49fa05b5f82795cad589a34471a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 00:04:41 +0800 Subject: [PATCH 15/27] Remove unnecessary flag clear Fetch is owned by response.arena (a) we need to clear the flag before freeing the arena and (b) there's no point in clearing the flag at all, since the memory is freed. --- src/browser/webapi/net/Fetch.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 4a8b7e92..07901487 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -242,7 +242,6 @@ fn httpErrorCallback(ctx: *anyopaque, _: anyerror) void { defer if (self._owns_response) { response.deinit(self._page._session); - self._owns_response = false; }; var ls: js.Local.Scope = undefined; From ada235a8c86b7166e025a0ab6e5ea4362b46c166 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 01:13:21 +0800 Subject: [PATCH 16/27] Various crash fixes 1. Double buffer to_load list so that load callback which register more loadable elements don't invalid the list while we're iterating 2. Switch to debug-only assertion for opaque origin. Not clear how this assertion is failing, but isn't worth failing release builds for it. 3. on worker terminate, don't remove worker from page tracking. This results in a leaking context, which causes numerous problems. 4. On page.init error, cleanly shutdown context --- src/browser/Page.zig | 23 ++++++++++++++++++----- src/browser/js/Context.zig | 8 +++++++- src/browser/webapi/Worker.zig | 2 -- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 5e3dcb9f..d8284f93 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -142,7 +142,11 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{}, /// `load` events that'll be fired before window's `load` event. /// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it. -_to_load: std.ArrayList(*Element.Html) = .{}, +/// Double-buffered so that dispatching load events (which may trigger JS that +/// creates new elements) doesn't invalidate the list while iterating. +_to_load_1: std.ArrayList(*Element.Html) = .{}, +_to_load_2: std.ArrayList(*Element.Html) = .{}, +_to_load: *std.ArrayList(*Element.Html) = undefined, _style_manager: StyleManager, _script_manager: ScriptManager, @@ -280,6 +284,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._script_manager = undefined, ._event_manager = EventManager.init(session.page_arena, self), }; + self._to_load = &self._to_load_1; var screen: *Screen = undefined; var visual_viewport: *VisualViewport = undefined; @@ -320,7 +325,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void .identity_arena = session.page_arena, .call_arena = self.call_arena, }); - errdefer self.js.deinit(); + errdefer browser.env.destroyContext(self.js); document._page = self; @@ -1447,14 +1452,22 @@ pub fn checkIntersections(self: *Page) !void { pub fn dispatchLoad(self: *Page) !void { const has_dom_load_listener = self._event_manager.has_dom_load_listener; - for (self._to_load.items) |html_element| { + + // Swap buffers - new additions during dispatch go to the other buffer + const to_process = self._to_load; + self._to_load = if (self._to_load == &self._to_load_1) + &self._to_load_2 + else + &self._to_load_1; + + for (to_process.items) |html_element| { if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) { const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); try self._event_manager.dispatch(html_element.asEventTarget(), event); } } - // We drained everything. - self._to_load.clearRetainingCapacity(); + + to_process.clearRetainingCapacity(); } pub fn scheduleMutationDelivery(self: *Page) !void { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 575e2f3a..abe10ee6 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -228,7 +228,13 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { const env = self.env; const isolate = env.isolate; - lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc }); + if (comptime IS_DEBUG) { + // A page starts off with an opaque origin. After navigation, setOrigin + // is called. This is the only time setOrigin should be called for that + // page. Therefore, when setOrigin is called, the previous origin should + // have been opaque and its rc should have been 1. + lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc }); + } const origin = try self.session.getOrCreateOrigin(key); diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index d4f7f54b..4ae306e8 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -244,8 +244,6 @@ pub fn terminate(self: *Worker) void { resp.abort(error.Abort); self._http_response = null; } - - self._page.removeWorker(self); } // Posts a message from the page to the worker. From 16686c13af77d13f82524a91e48d38b18eda8f1e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 09:02:38 +0800 Subject: [PATCH 17/27] Add safety check around cache get It should be impossible for a internal cache get to have an incorrect # of internal fields. But we're seeing this exact scenario in production. https://github.com/lightpanda-io/browser/pull/1991 was meant to help with this, but you can do some pretty weird things in JavaScript and it's possible there's some combination of JavaScript which still allows calling these methods on a different receiver. --- src/browser/js/Caller.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 01e88a74..05419752 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -660,6 +660,17 @@ pub const Function = struct { switch (cache) { .internal => |idx| { + // Defensive check: verify object has enough internal fields. + // This guards against edge cases where signature check passes but + // the receiver doesn't have expected internal fields (e.g., global + // proxy vs global object, cross-context scenarios). + if (v8.v8__Object__InternalFieldCount(js_this) <= idx) { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + return false; + } + if (v8.v8__Object__GetInternalField(js_this, idx)) |cached| { // means we can't cache undefined, since we can't tell the // difference between "it isn't in the cache" and "it's From 9654bc9afe3931f611b9cd9f453c65faba255d00 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 10:27:00 +0800 Subject: [PATCH 18/27] Return promise on media.play() --- src/browser/js/Local.zig | 3 +++ src/browser/js/js.zig | 3 +++ src/browser/tests/element/html/media.html | 8 +++++++- src/browser/webapi/element/html/Media.zig | 3 ++- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 63a0fe4e..ce25a0bc 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -412,6 +412,9 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) js.Promise.Temp, js.PromiseResolver.Global, js.Module.Global => return .{ .local = self, .handle = @ptrCast(value.local(self).handle) }, + + js.Undefined => return .{.local = self, .handle = isolate.initUndefined() }, + else => {} } // zig fmt: on diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 74ee0c7a..cda8f9d5 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -319,6 +319,9 @@ pub fn simpleZigValueToJs(isolate: Isolate, value: anytype, comptime fail: bool, return null; } +// marker interface +pub const Undefined = struct {}; + // These are here, and not in Inspector.zig, because Inspector.zig isn't always // included (e.g. in the wpt build). diff --git a/src/browser/tests/element/html/media.html b/src/browser/tests/element/html/media.html index e97a0d67..a0e23f43 100644 --- a/src/browser/tests/element/html/media.html +++ b/src/browser/tests/element/html/media.html @@ -42,11 +42,17 @@ const audio = document.getElementById('audio1'); testing.expectEqual(true, audio.paused); - audio.play(); + var resolved = false; + audio.play().then(() => { + resolved = true; + }); testing.expectEqual(false, audio.paused); audio.pause(); testing.expectEqual(true, audio.paused); + testing.onload(() => { + testing.expectEqual(true, resolved); + }) } diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig index 6d62013f..84a828be 100644 --- a/src/browser/webapi/element/html/Media.zig +++ b/src/browser/webapi/element/html/Media.zig @@ -137,7 +137,7 @@ fn isMaybeSupported(mime_type: []const u8) bool { return false; } -pub fn play(self: *Media, page: *Page) !void { +pub fn play(self: *Media, page: *Page) !js.Promise { const was_paused = self._paused; self._paused = false; self._ready_state = .HAVE_ENOUGH_DATA; @@ -146,6 +146,7 @@ pub fn play(self: *Media, page: *Page) !void { try self.dispatchEvent("play", page); try self.dispatchEvent("playing", page); } + return page.js.local.?.resolvePromise(js.Undefined{}); } pub fn pause(self: *Media, page: *Page) !void { From b15fc158c23b0caefaa5676ef537e739ffa6fff6 Mon Sep 17 00:00:00 2001 From: gujishh Date: Wed, 15 Apr 2026 15:08:53 +0900 Subject: [PATCH 19/27] docs: expand WSL installation guidance --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cb010908..f2bbcc4d 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,12 @@ chmod a+x ./lightpanda *For Windows + WSL2* -Lightpanda runs inside WSL when invoked from a WSL shell; follow the Linux install steps above in that environment. -Install automation clients such as Puppeteer or Playwright on the Windows host so they can drive Lightpanda through CDP over the mapped ports. +Lightpanda has no native Windows binary. Install it inside WSL following the Linux steps above. + +WSL not installed? Run `wsl --install` from an administrator shell, restart, then open `wsl`. +See [Microsoft's WSL install guide](https://learn.microsoft.com/en-us/windows/wsl/install) for details. + +Your automation client (Puppeteer, Playwright, etc.) can run either inside WSL or on the Windows host. WSL forwards `localhost:9222` automatically. **Install from Docker** From 0b72826cabf0dec2fe6f24716ef716bc0cd34b4b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 16:02:24 +0800 Subject: [PATCH 20/27] Fix Page.createIsolatedWorld Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/170 Gets the correct executeContextId from the v8 inspector. --- build.zig.zon | 4 ++-- src/browser/js/Inspector.zig | 4 ++++ src/cdp/domains/page.zig | 18 +++++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 89590ac8..79f1e66a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.9.tar.gz", - .hash = "v8-0.0.0-xddH64iHBACfPm7oAqZerjmLLO6ftP4Yg5V7dtEGcD0i", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/0c56b7dc38c869d586272068755dadb4f2474264.tar.gz", + .hash = "v8-0.0.0-xddH61yIBAD04dV4CHW0qIFiqbOGvkN_-amGdmgbQ3dU", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index d956cc52..0bc81872 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -128,6 +128,10 @@ pub fn contextCreated( } } +pub fn getContextId(_: *const Inspector, local: *const js.Local) i32 { + return v8.v8__inspector__executionContextId(local.handle); +} + pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void { v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 17455391..91af5287 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -257,7 +257,23 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { const page = bc.session.currentPage() orelse return error.PageNotLoaded; const js_context = try world.createContext(page); - return cmd.sendResult(.{ .executionContextId = js_context.id }, .{}); + + const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":true,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId}); + + var ls: js.Local.Scope = undefined; + js_context.localScope(&ls); + defer ls.deinit(); + + bc.inspector_session.inspector.contextCreated( + &ls.local, + "", + page.origin orelse "", + aux_data, + true, + ); + + const context_id = bc.inspector_session.inspector.getContextId(&ls.local); + return cmd.sendResult(.{ .executionContextId = context_id }, .{}); } fn navigate(cmd: *CDP.Command) !void { From dd7fbf17ed73c2bee1a38a5c12ff6c6da1c0e0b0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 16:11:35 +0800 Subject: [PATCH 21/27] fix default-ness --- src/cdp/domains/page.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 91af5287..80a93cc5 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -258,7 +258,7 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { const js_context = try world.createContext(page); - const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":true,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId}); + const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId}); var ls: js.Local.Scope = undefined; js_context.localScope(&ls); @@ -269,7 +269,7 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { "", page.origin orelse "", aux_data, - true, + false, ); const context_id = bc.inspector_session.inspector.getContextId(&ls.local); From 5dc059cbb3db3771c408852d239e67021cc4406a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 17:16:42 +0800 Subject: [PATCH 22/27] maintain isolated world name --- src/cdp/domains/page.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 80a93cc5..641b2d80 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -257,7 +257,6 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { const page = bc.session.currentPage() orelse return error.PageNotLoaded; const js_context = try world.createContext(page); - const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId}); var ls: js.Local.Scope = undefined; @@ -266,7 +265,7 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { bc.inspector_session.inspector.contextCreated( &ls.local, - "", + params.worldName, page.origin orelse "", aux_data, false, From 513af82751cb8aad3b95af25c97e00276395dbbe Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 17:40:33 +0800 Subject: [PATCH 23/27] On page reset, reset IsolatedWorld identity This is an attempt to fix reference counting issues. When Session.replacePage is called, isolated worlds survive. This appears to be the correct behavior, but it means that their identity outlives the page reset, which can result in a use-after-free. The idea is that, on reset, IsolatedWorld persist, but their identity is cleared. --- src/cdp/domains/page.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 17455391..83c01b28 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -283,6 +283,12 @@ fn navigate(cmd: *CDP.Command) !void { var page = session.currentPage() orelse return error.PageNotLoaded; if (page._load_state != .waiting) { + // Reset isolated world identities to disable V8 weak callbacks before + // resetPageResources releases refs. Prevents double-release crashes. + for (bc.isolated_worlds.items) |isolated_world| { + isolated_world.identity.deinit(); + isolated_world.identity = .{}; + } page = try session.replacePage(); } @@ -313,6 +319,12 @@ fn doReload(cmd: *CDP.Command) !void { const reload_url = try cmd.arena.dupeZ(u8, page.url); if (page._load_state != .waiting) { + // Reset isolated world identities to disable V8 weak callbacks before + // resetPageResources releases refs. Prevents double-release crashes. + for (bc.isolated_worlds.items) |isolated_world| { + isolated_world.identity.deinit(); + isolated_world.identity = .{}; + } page = try session.replacePage(); } From 8ab5f1b21fe47317cdf714ed785207c9eb0b4cf4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 19:06:10 +0800 Subject: [PATCH 24/27] Improve finalizer code 2 small changes: 1 - Ensure that isolated world identity is always reset. Not clear how we can have identity without a context, but very little harm in doing it this way. 2 - Clear finalizer_callback map _before_ finalizing an instance. Finalizing an instance could (a) release an arena (they almost all do) and (b) create an object with the newly released arena. I don't think anything does that now, but they could. That object could even be passed into v8 during finalization. In which case, we'd temporarily have 2 "live" instances (one being finalized, one jsut created) at the same address. Don't think we have any code that does this, but switching the order (remove from map, then finalize) protects against this address re-use. 1 big change: Not really big, but more likely to actually fix things. A finalizer can still be called _after_ we've cleared the finalizer callback. This can happen if v8 has queued the finalizer prior to us clearing it. We do see some evidence that this might be an issue, as many extra releaseRefs are happening in the message loop or microtasks. This makes that scenario safer. First, it moves the finalizer identity to a dedicated MemoryPool that can outlive the page. Second, it uses the finalizer_callback map itself to tell whether or not anything has to happen. --- src/browser/Session.zig | 12 +++++++++++- src/browser/js/Local.zig | 29 +++++++++++++++++----------- src/browser/webapi/net/WebSocket.zig | 1 + src/cdp/CDP.zig | 16 ++++++++------- src/cdp/domains/page.zig | 4 ++-- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index baea1590..ae016b41 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -75,6 +75,10 @@ identity: js.Identity = .{}, // This ensures objects are only freed when ALL v8 wrappers are gone. finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, +// Pool for FinalizerCallback.Identity structs. These must survive page resets +// so V8 weak callbacks can validate the FC before dereferencing it. +fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity), + // Tracked global v8 objects that need to be released on cleanup. // Lives at Session level so objects can outlive individual Identities. globals: std.ArrayList(v8.Global) = .empty, @@ -133,6 +137,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .queued_queued_navigation = .{}, .notification = notification, .cookie_jar = storage.Cookie.Jar.init(allocator), + .fc_identity_pool = .init(allocator), }; self.queued_navigation = &self.queued_navigation_1; } @@ -142,6 +147,7 @@ pub fn deinit(self: *Session) void { self.removePage(); } self.cookie_jar.deinit(); + self.fc_identity_pool.deinit(); self.storage_shed.deinit(self.browser.app.allocator); self.arena_pool.release(self.page_arena); @@ -506,9 +512,13 @@ pub const FinalizerCallback = struct { // For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one // for every identity that gets the instance. In most cases, that'l be 1. + // Allocated from Session.fc_identity_pool so it survives page resets and + // allows the weak callback to validate the FC before dereferencing it. pub const Identity = struct { + session: *Session, identity: *js.Identity, - fc: *Session.FinalizerCallback, + finalizer_ptr_id: usize, + resolved_ptr_id: usize, }; // Called during page reset to force cleanup regardless of identity_count. diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 63a0fe4e..09953859 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -281,10 +281,12 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.release_ref_from_zig); } const fc = finalizer_gop.value_ptr.*; - const identity_finalizer = try fc.arena.create(Session.FinalizerCallback.Identity); + const identity_finalizer = try session.fc_identity_pool.create(); identity_finalizer.* = .{ - .fc = fc, + .session = session, .identity = ctx.identity, + .finalizer_ptr_id = finalizer_ptr_id, + .resolved_ptr_id = resolved_ptr_id, }; fc.identity_count += 1; @@ -1218,26 +1220,31 @@ fn resolveT(comptime T: type, value: *T) Resolved { const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr)); - const fc = identity_finalizer.fc; - const session = fc.session; - const finalizer_ptr_id = fc.finalizer_ptr_id; + // Identity is allocated from pool, so it's valid even after page reset. + const session = identity_finalizer.session; + const finalizer_ptr_id = identity_finalizer.finalizer_ptr_id; + const resolved_ptr_id = identity_finalizer.resolved_ptr_id; + defer session.fc_identity_pool.destroy(identity_finalizer); - // Remove from this identity's map - if (identity_finalizer.identity.identity_map.fetchRemove(fc.resolved_ptr_id)) |kv| { + // Always clean up the identity map entry + if (identity_finalizer.identity.identity_map.fetchRemove(resolved_ptr_id)) |kv| { var global = kv.value; v8.v8__Global__Reset(&global); } + // Validate FC before dereferencing - it may have been cleaned up during page reset + const fc = session.finalizer_callbacks.get(finalizer_ptr_id) orelse return; + const identity_count = fc.identity_count; if (identity_count == 1) { // All IsolatedWorlds that reference this object have // released it. Release the instance ref, remove the // FinalizerCallback and free it. + // + // Remove from finalizer_callbacks before releaseRef. releaseRef + // could cause a new object at the same address. + _ = session.finalizer_callbacks.remove(finalizer_ptr_id); FT.releaseRef(@ptrFromInt(finalizer_ptr_id), session); - const removed = session.finalizer_callbacks.remove(finalizer_ptr_id); - if (comptime IS_DEBUG) { - std.debug.assert(removed); - } session.releaseArena(fc.arena); } else { fc.identity_count = identity_count - 1; diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 6869cac3..e0907bca 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -38,6 +38,7 @@ const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; const WebSocket = @This(); + _rc: lp.RC(u8) = .{}, _page: *Page, _proto: *EventTarget, diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 4ee83f66..cc4919c6 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -587,7 +587,7 @@ pub const BrowserContext = struct { pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); - try @import("domains/page.zig").pageRemove(self); + @import("domains/page.zig").pageRemove(self); } pub fn onPageCreated(ctx: *anyopaque, page: *Page) !void { @@ -808,16 +808,18 @@ const IsolatedWorld = struct { identity: js.Identity = .{}, pub fn deinit(self: *IsolatedWorld) void { - self.removeContext() catch {}; - self.identity.deinit(); + self.removeContext(); self.browser.arena_pool.release(self.call_arena); self.browser.arena_pool.release(self.arena); } - pub fn removeContext(self: *IsolatedWorld) !void { - const ctx = self.context orelse return error.NoIsolatedContextToRemove; - self.browser.env.destroyContext(ctx); - self.context = null; + pub fn removeContext(self: *IsolatedWorld) void { + if (self.context) |ctx| { + self.browser.env.destroyContext(ctx); + self.context = null; + } + // I don't think it's possible to have any identity without a context, + // but there's no harm in being safe. self.identity.deinit(); self.identity = .{}; } diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 17455391..0931ad13 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -376,14 +376,14 @@ pub fn pageNavigate(bc: *CDP.BrowserContext, event: *const Notification.PageNavi }, .{ .session_id = session_id }); } -pub fn pageRemove(bc: *CDP.BrowserContext) !void { +pub fn pageRemove(bc: *CDP.BrowserContext) 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(); + isolated_world.removeContext(); } } From 55791932f8ed65d0b086dc49e14c4e1ca567c0b6 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Apr 2026 19:30:46 +0800 Subject: [PATCH 25/27] quiet a couple test warnings --- src/browser/Page.zig | 2 ++ src/browser/js/Env.zig | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 4fd62c05..f944760d 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -3689,6 +3689,8 @@ fn asUint(comptime string: anytype) std.meta.Int( const testing = @import("../testing.zig"); test "WebApi: Page" { + const filter: testing.LogFilter = .init(&.{.http}); + defer filter.deinit(); try testing.htmlRunner("page", .{}); } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 2158e51d..075cd1f1 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -561,7 +561,7 @@ test "Env: Worker context " { const page = try session.createPage(); defer session.removePage(); - const worker = try @import("../webapi/Worker.zig").init("about:blank", &page.js.execution); + const worker = try @import("../webapi/Worker.zig").init("http://localhost:9582/src/browser/tests/testing.js", &page.js.execution); var ls: js.Local.Scope = undefined; worker._worker_scope.js.localScope(&ls); From 51a3835a4ada7b7b910261564f0c237722c251d7 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 16 Apr 2026 07:00:26 +0800 Subject: [PATCH 26/27] Use flag to protect against resolve_ptr_reuse The point of this change is that v8 might call these finalizers after we've cleared things (e.g. because it's already queued it). We need to make the FC.Identity self-contained and not rely on any external state (like the finalizer_callback lookup) which might have new entries. --- src/browser/Session.zig | 20 +++++++++++++++----- src/browser/js/Local.zig | 18 ++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index ae016b41..22194238 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -506,23 +506,33 @@ pub const FinalizerCallback = struct { finalizer_ptr_id: usize, release_ref: *const fn (ptr_id: usize, session: *Session) void, - // Track how many identities (JS worlds) reference this FC. - // Only cleanup when all identities have finalized. + // Linked list of Identities referencing this FC. + identities: ?*Identity = null, + // Count of active identities (for knowing when to clean up FC). identity_count: u8 = 0, // For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one - // for every identity that gets the instance. In most cases, that'l be 1. + // for every identity that gets the instance. In most cases, that'll be 1. // Allocated from Session.fc_identity_pool so it survives page resets and - // allows the weak callback to validate the FC before dereferencing it. + // allows the weak callback to safely check the done flag. pub const Identity = struct { session: *Session, identity: *js.Identity, finalizer_ptr_id: usize, resolved_ptr_id: usize, + next: ?*Identity = null, + done: bool = false, }; - // Called during page reset to force cleanup regardless of identity_count. + // Called during page reset to force cleanup regardless of identities. fn deinit(self: *FinalizerCallback, session: *Session) void { + // Mark all identities as done so stale V8 weak callbacks + // won't find the wrong FC if resolved_ptr_id is reused. + var id = self.identities; + while (id) |identity| { + identity.done = true; + id = identity.next; + } self.release_ref(self.finalizer_ptr_id, session); session.releaseArena(self.arena); } diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 09953859..f0474639 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -287,7 +287,9 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, .identity = ctx.identity, .finalizer_ptr_id = finalizer_ptr_id, .resolved_ptr_id = resolved_ptr_id, + .next = fc.identities, }; + fc.identities = identity_finalizer; fc.identity_count += 1; v8.v8__Global__SetWeakFinalizer(gop.value_ptr, identity_finalizer, finalizer.release_ref, v8.kParameter); @@ -1222,7 +1224,6 @@ fn resolveT(comptime T: type, value: *T) Resolved { // Identity is allocated from pool, so it's valid even after page reset. const session = identity_finalizer.session; - const finalizer_ptr_id = identity_finalizer.finalizer_ptr_id; const resolved_ptr_id = identity_finalizer.resolved_ptr_id; defer session.fc_identity_pool.destroy(identity_finalizer); @@ -1232,17 +1233,18 @@ fn resolveT(comptime T: type, value: *T) Resolved { v8.v8__Global__Reset(&global); } - // Validate FC before dereferencing - it may have been cleaned up during page reset + // If done, FC was already cleaned up during page reset. The + // finalizer_ptr_id may have been reused for a new object, so + // we must not look it up in the map. + if (identity_finalizer.done) return; + + const finalizer_ptr_id = identity_finalizer.finalizer_ptr_id; const fc = session.finalizer_callbacks.get(finalizer_ptr_id) orelse return; const identity_count = fc.identity_count; if (identity_count == 1) { - // All IsolatedWorlds that reference this object have - // released it. Release the instance ref, remove the - // FinalizerCallback and free it. - // - // Remove from finalizer_callbacks before releaseRef. releaseRef - // could cause a new object at the same address. + // Last identity - clean up the FC. + // Remove from map before releaseRef to prevent address reuse issues. _ = session.finalizer_callbacks.remove(finalizer_ptr_id); FT.releaseRef(@ptrFromInt(finalizer_ptr_id), session); session.releaseArena(fc.arena); From 0adb482bae07a7bf8c6718df9f2ff22bcea4b9cb Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 16 Apr 2026 15:02:58 +0800 Subject: [PATCH 27/27] update v8 dep --- .github/actions/install/action.yml | 2 +- Dockerfile | 2 +- build.zig.zon | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index f78b307c..cd3999e8 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.9' + default: 'v0.4.0' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index 715441a0..fd872c45 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.9 +ARG ZIG_V8=v0.4.0 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig.zon b/build.zig.zon index 79f1e66a..ea0517b4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,7 +5,7 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/0c56b7dc38c869d586272068755dadb4f2474264.tar.gz", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.0.tar.gz", .hash = "v8-0.0.0-xddH61yIBAD04dV4CHW0qIFiqbOGvkN_-amGdmgbQ3dU", }, // .v8 = .{ .path = "../zig-v8-fork" },