Working navigation events (clicks, form submission)

This commit is contained in:
Karl Seguin
2025-08-02 12:29:22 +08:00
parent f65a39a3e3
commit 3555680335
6 changed files with 82 additions and 61 deletions

View File

@@ -76,6 +76,22 @@ pub fn deinit(self: *ScriptManager) void {
self.script_pool.deinit(); self.script_pool.deinit();
} }
pub fn reset(self: *ScriptManager) void {
self.client.abort();
self.clearList(&self.scripts);
self.clearList(&self.deferred);
self.static_scripts_done = false;
}
fn clearList(_: *const ScriptManager, list: *OrderList) void {
while (list.first) |node| {
const pending_script = node.data;
// this removes it from the list
pending_script.deinit();
}
std.debug.assert(list.first == null);
}
pub fn addFromElement(self: *ScriptManager, element: *parser.Element) !void { pub fn addFromElement(self: *ScriptManager, element: *parser.Element) !void {
if (try parser.elementGetAttribute(element, "nomodule") != null) { if (try parser.elementGetAttribute(element, "nomodule") != null) {
// these scripts should only be loaded if we don't support modules // these scripts should only be loaded if we don't support modules

View File

@@ -48,6 +48,7 @@ const polyfill = @import("polyfill/polyfill.zig");
// end() to stop the previous navigation before starting a new one. // end() to stop the previous navigation before starting a new one.
// The page handle all its memory in an arena allocator. The arena is reseted // The page handle all its memory in an arena allocator. The arena is reseted
// when end() is called. // when end() is called.
pub const Page = struct { pub const Page = struct {
cookie_jar: *storage.CookieJar, cookie_jar: *storage.CookieJar,
@@ -150,7 +151,8 @@ pub const Page = struct {
fn reset(self: *Page) void { fn reset(self: *Page) void {
_ = self.session.browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 }); _ = self.session.browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.http_client.abort(); // this will reset the http_client
self.script_manager.reset();
self.scheduler.reset(); self.scheduler.reset();
self.document_state = .parsing; self.document_state = .parsing;
self.mode = .{ .pre = {} }; self.mode = .{ .pre = {} };
@@ -729,26 +731,36 @@ pub const Page = struct {
// The page.arena is safe to use here, but the transfer_arena exists // The page.arena is safe to use here, but the transfer_arena exists
// specifically for this type of lifetime. // specifically for this type of lifetime.
pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void { pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void {
const session = self.session;
if (session.queued_navigation != null) {
// It might seem like this should never happen. And it might not,
// BUT..consider the case where we have script like:
// top.location = X;
// top.location = Y;
// Will the 2nd top.location execute? You'd think not, since,
// when we're in this function for the 1st, we'll call:
// session.executor.terminateExecution();
// But, this doesn't seem guaranteed to stop on the current line.
// My best guess is that v8 groups executes in chunks (how they are
// chunked, I can't guess) and always executes them together.
return;
}
log.debug(.browser, "delayed navigation", .{ log.debug(.browser, "delayed navigation", .{
.url = url, .url = url,
.reason = opts.reason, .reason = opts.reason,
}); });
self.delayed_navigation = true; self.delayed_navigation = true;
const session = self.session; session.queued_navigation = .{
const arena = session.transfer_arena;
const navi = try arena.create(DelayedNavigation);
navi.* = .{
.opts = opts, .opts = opts,
.session = session, .url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always }),
.url = try URL.stitch(arena, url, self.url.raw, .{ .alloc = .always }),
}; };
self.http_client.abort(); self.http_client.abort();
// In v8, this throws an exception which JS code cannot catch. // In v8, this throws an exception which JS code cannot catch.
session.executor.terminateExecution(); session.executor.terminateExecution();
_ = try self.scheduler.add(navi, DelayedNavigation.run, 0, .{ .name = "delayed navigation" });
} }
pub fn getOrCreateNodeState(self: *Page, node: *parser.Node) !*State { pub fn getOrCreateNodeState(self: *Page, node: *parser.Node) !*State {
@@ -833,54 +845,6 @@ pub const Page = struct {
} }
}; };
const DelayedNavigation = struct {
url: []const u8,
session: *Session,
opts: NavigateOpts,
// Navigation is blocking, which is problem because it can seize up
// the loop and deadlock. We can only safely try to navigate to a
// new page when we're sure there's at least 1 free slot in the
// http client. We handle this in two phases:
//
// In the first phase, when self.initial == true, we'll shutdown the page
// and create a new one. The shutdown is important, because it resets the
// loop ctx_id and removes the JsContext. Removing the context calls our XHR
// destructors which aborts requests. This is necessary to make sure our
// [blocking] navigate won't block.
//
// In the 2nd phase, we wait until there's a free http slot so that our
// navigate definetly won't block (which could deadlock the system if there
// are still pending async requests, which we've seen happen, even after
// an abort).
fn run(ctx: *anyopaque) ?u32 {
const self: *DelayedNavigation = @alignCast(@ptrCast(ctx));
const session = self.session;
// abort any pending requests or active tranfers;
session.browser.http_client.abort();
// Prior to schedule this task, we terminated excution to stop
// the running script. If we don't resume it before doing a shutdown
// we'll get an error.
session.executor.resumeExecution();
session.removePage();
const page = session.createPage() catch |err| {
log.err(.browser, "delayed navigation page error", .{
.err = err,
.url = self.url,
});
return null;
};
page.navigate(self.url, self.opts) catch |err| {
log.err(.browser, "delayed navigation error", .{ .err = err, .url = self.url });
};
return null;
}
};
pub const NavigateReason = enum { pub const NavigateReason = enum {
anchor, anchor,
address_bar, address_bar,

View File

@@ -56,6 +56,12 @@ pub const Session = struct {
page: ?Page = null, page: ?Page = null,
// If the current page want to navigate to a new page
// (form submit, link click, top.location = xxx)
// the details are stored here so that, on the next call to session.wait
// we can destroy the current page and start a new one.
queued_navigation: ?QueuedNavigation,
pub fn init(self: *Session, browser: *Browser) !void { pub fn init(self: *Session, browser: *Browser) !void {
var executor = try browser.env.newExecutionWorld(); var executor = try browser.env.newExecutionWorld();
errdefer executor.deinit(); errdefer executor.deinit();
@@ -64,6 +70,7 @@ pub const Session = struct {
self.* = .{ self.* = .{
.browser = browser, .browser = browser,
.executor = executor, .executor = executor,
.queued_navigation = null,
.arena = browser.session_arena.allocator(), .arena = browser.session_arena.allocator(),
.storage_shed = storage.Shed.init(allocator), .storage_shed = storage.Shed.init(allocator),
.cookie_jar = storage.CookieJar.init(allocator), .cookie_jar = storage.CookieJar.init(allocator),
@@ -132,4 +139,40 @@ pub const Session = struct {
pub fn currentPage(self: *Session) ?*Page { pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null); return &(self.page orelse return null);
} }
pub fn wait(self: *Session, wait_sec: usize) void {
if (self.queued_navigation) |qn| {
// This was already aborted on the page, but it would be pretty
// bad if old requests went to the new page, so let's make double sure
self.browser.http_client.abort();
// Page.navigateFromWebAPI terminatedExecution. If we don't resume
// it before doing a shutdown we'll get an error.
self.executor.resumeExecution();
self.removePage();
self.queued_navigation = null;
const page = self.createPage() catch |err| {
log.err(.browser, "queued navigation page error", .{
.err = err,
.url = qn.url,
});
return;
};
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url });
return;
};
}
if (self.page) |*page| {
page.wait(wait_sec);
}
}
};
const QueuedNavigation = struct {
url: []const u8,
opts: NavigateOpts,
}; };

View File

@@ -123,11 +123,9 @@ pub fn CDPT(comptime TypeProvider: type) type {
// This is hopefully temporary. // This is hopefully temporary.
pub fn pageWait(self: *Self) void { pub fn pageWait(self: *Self) void {
const session = &(self.browser.session orelse return); const session = &(self.browser.session orelse return);
var page = session.currentPage() orelse return;
// exits early if there's nothing to do, so a large value like // exits early if there's nothing to do, so a large value like
// 5 seconds should be ok // 5 seconds should be ok
page.wait(5); session.wait(5);
} }
// Called from above, in processMessage which handles client messages // Called from above, in processMessage which handles client messages

View File

@@ -130,7 +130,7 @@ fn run(alloc: Allocator) !void {
}, },
}; };
page.wait(5); // 5 seconds session.wait(5); // 5 seconds
// dump // dump
if (opts.dump) { if (opts.dump) {

View File

@@ -441,7 +441,7 @@ pub const JsRunner = struct {
} }
return err; return err;
}; };
self.page.wait(1); self.page.session.wait(1);
@import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start); @import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start);
if (case.@"1") |expected| { if (case.@"1") |expected| {