From 0c7a4f15408995dc217e11d28c4b4158e10cc588 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 2 May 2026 11:58:46 +0800 Subject: [PATCH 01/33] Make sighandler thread-safe Currently, variables (`attempt`, `listeners`) can be read/written to concurrently from 2 threads (main + the sighandler thread). This opens up some undefined behavior. More practically, what I'm seeing is that if I ctrl-c quickly after a `zig build run` launch, the program appears to terminate but remains running in the background. --- src/Sighandler.zig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Sighandler.zig b/src/Sighandler.zig index 85a8d8e5..f497e064 100644 --- a/src/Sighandler.zig +++ b/src/Sighandler.zig @@ -36,6 +36,7 @@ sigset: std.posix.sigset_t = undefined, handle_thread: ?std.Thread = null, attempt: u32 = 0, +mutex: std.Thread.Mutex = .{}, listeners: std.ArrayList(Listener) = .empty, pub const Listener = struct { @@ -96,10 +97,22 @@ pub fn on(self: *SigHandler, func: anytype, args: std.meta.ArgsTuple(@TypeOf(fun const bytes: []const u8 = @ptrCast((&args)[0..1]); @memcpy(buffer, bytes); + self.mutex.lock(); + defer self.mutex.unlock(); + try self.listeners.append(self.arena, .{ .args = buffer, .start = TypeErased.start, }); + + // If a termination signal arrived before this listener was registered, + // the sighandler thread had nothing to call. Fire the new listener now + // so the shutdown isn't lost — otherwise main proceeds into the network + // run loop and the process becomes an orphan that ignores the signal. + if (self.attempt > 0) { + const item = &self.listeners.items[self.listeners.items.len - 1]; + item.start(item.args.ptr); + } } fn sighandle(self: *SigHandler) noreturn { @@ -114,7 +127,9 @@ fn sighandle(self: *SigHandler) noreturn { switch (sig) { std.posix.SIG.INT, std.posix.SIG.TERM => { + self.mutex.lock(); if (self.attempt > 1) { + self.mutex.unlock(); std.process.exit(1); } self.attempt += 1; @@ -123,12 +138,15 @@ fn sighandle(self: *SigHandler) noreturn { for (self.listeners.items) |*item| { item.start(item.args.ptr); } + self.mutex.unlock(); continue; }, std.posix.SIG.ALRM => { // Deadline tripped (e.g. --terminate-ms). Run the same listeners, // but don't bump `attempt` — a subsequent ctrl-c should still get // the normal first-attempt graceful path before hard-exiting. + self.mutex.lock(); + defer self.mutex.unlock(); log.info(.app, "Deadline reached ", .{}); for (self.listeners.items) |*item| { item.start(item.args.ptr); From 2ee398b6d5aee667635e97f9f49ec1f59e4e80fe Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 4 May 2026 11:31:02 +0800 Subject: [PATCH 02/33] On window.close, reset the frame's scheduler Saw that worker.close() does this and it makes sense to do for window.close() too. --- src/browser/webapi/Window.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 29243e7c..03698f9c 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -568,6 +568,8 @@ pub fn close(self: *Window) void { } } + frame.js.scheduler.reset(); + // We can't tear the Frame down here — close() is invoked from JS still // running on top of this Frame's V8 context, often deep inside a script // eval whose parser is still holding the Frame. Destroying the context From d8b16eb4f68e3c1644e4287e589d8f3dbeb2f1f6 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 29 Apr 2026 12:12:10 +0800 Subject: [PATCH 03/33] Make Fetch and XHR usable from Worker Follows previous changes to make a WebAPI worker-compatible by replacing any dependency on *Frame with *js.Execute, *Page and/or *Session. The changes here are relatively minor since most of the existing supporting WebApis (e.g. Blob, Response, Request) have already been migrated. --- src/browser/Frame.zig | 4 + src/browser/js/Execution.zig | 62 ++++++++ src/browser/js/bridge.zig | 3 + src/browser/tests/net/fetch-worker.js | 50 +++++++ src/browser/tests/net/fetch.html | 50 +++++++ src/browser/tests/net/xhr-worker.js | 74 ++++++++++ src/browser/tests/net/xhr.html | 57 ++++++++ src/browser/webapi/FileReader.zig | 37 +++-- src/browser/webapi/Window.zig | 4 +- src/browser/webapi/WorkerGlobalScope.zig | 41 +++++- src/browser/webapi/event/ProgressEvent.zig | 22 +-- src/browser/webapi/net/Fetch.zig | 63 ++++---- src/browser/webapi/net/XMLHttpRequest.zig | 136 +++++++++--------- .../webapi/net/XMLHttpRequestEventTarget.zig | 9 +- 14 files changed, 480 insertions(+), 132 deletions(-) create mode 100644 src/browser/tests/net/fetch-worker.js create mode 100644 src/browser/tests/net/xhr-worker.js diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 138b0044..86069e13 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2823,6 +2823,10 @@ pub fn dispatch( return self._event_manager.dispatchDirect(target, event, handler, opts); } +pub fn hasDirectListeners(self: *Frame, target: *EventTarget, typ: []const u8, handler: anytype) bool { + return self._event_manager.hasDirectListeners(target, typ, handler); +} + pub fn dupeSSO(self: *Frame, value: []const u8) !String { return String.init(self.arena, value, .{ .dupe = true }); } diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index e752e904..15de46ce 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -31,6 +31,12 @@ const lp = @import("lightpanda"); const Context = @import("Context.zig"); const Scheduler = @import("Scheduler.zig"); const Factory = @import("../Factory.zig"); +const HttpClient = @import("../HttpClient.zig"); +const EventManagerBase = @import("../EventManagerBase.zig"); + +const Blob = @import("../webapi/Blob.zig"); +const Event = @import("../webapi/Event.zig"); +const EventTarget = @import("../webapi/EventTarget.zig"); const String = lp.String; const Allocator = std.mem.Allocator; @@ -63,3 +69,59 @@ pub fn dupeString(self: *const Execution, value: []const u8) ![]const u8 { } return self.arena.dupe(u8, value); } + +pub fn getArena(self: *const Execution, size_or_bucket: anytype, debug: []const u8) !Allocator { + return self.context.page.getArena(size_or_bucket, debug); +} + +pub fn releaseArena(self: *const Execution, allocator: Allocator) void { + self.context.page.releaseArena(allocator); +} + +pub fn headersForRequest(self: *const Execution, headers: *HttpClient.Headers) !void { + return switch (self.context.global) { + inline else => |g| g.headersForRequest(headers), + }; +} + +pub fn isSameOrigin(self: *const Execution, url: [:0]const u8) bool { + return switch (self.context.global) { + inline else => |g| g.isSameOrigin(url), + }; +} + +pub fn lookupBlobUrl(self: *const Execution, url: []const u8) ?*Blob { + return switch (self.context.global) { + inline else => |g| g.lookupBlobUrl(url), + }; +} + +pub fn dispatch( + self: *const Execution, + target: *EventTarget, + event: *Event, + handler: anytype, + comptime opts: EventManagerBase.DispatchDirectOptions, +) !void { + return switch (self.context.global) { + inline else => |g| g.dispatch(target, event, handler, opts), + }; +} + +pub fn hasDirectListeners(self: *const Execution, target: *EventTarget, typ: []const u8, handler: anytype) bool { + return switch (self.context.global) { + inline else => |g| g.hasDirectListeners(target, typ, handler), + }; +} + +pub fn frameId(self: *const Execution) u32 { + return switch (self.context.global) { + inline else => |g| g._frame_id, + }; +} + +pub fn loaderId(self: *const Execution) u32 { + return switch (self.context.global) { + inline else => |g| g._loader_id, + }; +} diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 95c2dd6b..f2ec997e 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -966,6 +966,9 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/AbortController.zig"), @import("../webapi/URL.zig"), @import("../webapi/canvas/OffscreenCanvas.zig"), + @import("../webapi/net/XMLHttpRequest.zig"), + @import("../webapi/net/XMLHttpRequestEventTarget.zig"), + @import("../webapi/FileReader.zig"), // @import("../webapi/Performance.zig"), }); diff --git a/src/browser/tests/net/fetch-worker.js b/src/browser/tests/net/fetch-worker.js new file mode 100644 index 00000000..d1fbdbbc --- /dev/null +++ b/src/browser/tests/net/fetch-worker.js @@ -0,0 +1,50 @@ +// Exercises fetch() inside a worker. Receives a command from the page, +// performs the fetch, and posts the results back. +self.onmessage = async function(e) { + const cmd = e.data; + try { + if (cmd.kind === 'basic') { + const response = await fetch('http://127.0.0.1:9582/xhr'); + const text = await response.text(); + postMessage({ + ok: true, + status: response.status, + url: response.url, + type: response.type, + content_type: response.headers.get('Content-Type'), + length: text.length, + }); + return; + } + + if (cmd.kind === 'post') { + const response = await fetch('http://127.0.0.1:9582/xhr', { + method: 'POST', + body: 'hello-from-worker', + }); + const text = await response.text(); + postMessage({ ok: true, status: response.status, length: text.length }); + return; + } + + if (cmd.kind === 'blob') { + const blob = new Blob(['Hello from worker blob!'], { type: 'text/plain' }); + const blobUrl = URL.createObjectURL(blob); + const response = await fetch(blobUrl); + const text = await response.text(); + URL.revokeObjectURL(blobUrl); + postMessage({ + ok: true, + status: response.status, + url_matches: response.url === blobUrl, + content_type: response.headers.get('Content-Type'), + text, + }); + return; + } + + postMessage({ ok: false, err: 'unknown command' }); + } catch (err) { + postMessage({ ok: false, err: String(err), stack: err.stack }); + } +}; diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html index 0cd8a8d2..525a0c88 100644 --- a/src/browser/tests/net/fetch.html +++ b/src/browser/tests/net/fetch.html @@ -280,3 +280,53 @@ } } + + + + + + diff --git a/src/browser/tests/net/xhr-worker.js b/src/browser/tests/net/xhr-worker.js new file mode 100644 index 00000000..15095fb7 --- /dev/null +++ b/src/browser/tests/net/xhr-worker.js @@ -0,0 +1,74 @@ +// Exercises XMLHttpRequest inside a worker. Receives a command from the page, +// performs the XHR, and posts the results back. +self.onmessage = function(e) { + const cmd = e.data; + try { + if (cmd.kind === 'basic') { + const req = new XMLHttpRequest(); + const states = []; + req.onreadystatechange = () => states.push(req.readyState); + req.onload = () => { + postMessage({ + ok: true, + status: req.status, + status_text: req.statusText, + response_url: req.responseURL, + response_text_length: req.responseText.length, + content_type: req.getResponseHeader('Content-Type'), + states, + }); + }; + req.onerror = () => postMessage({ ok: false, err: 'xhr error', status: req.status }); + req.open('GET', 'http://127.0.0.1:9582/xhr'); + req.send(); + return; + } + + if (cmd.kind === 'arraybuffer') { + const req = new XMLHttpRequest(); + req.responseType = 'arraybuffer'; + req.onload = () => { + const view = new Uint8Array(req.response); + postMessage({ + ok: true, + status: req.status, + byte_length: req.response.byteLength, + first: view[0], + third: view[2], + last: view[6], + response_type: req.responseType, + }); + }; + req.onerror = () => postMessage({ ok: false, err: 'xhr error', status: req.status }); + req.open('GET', 'http://127.0.0.1:9582/xhr/binary'); + req.send(); + return; + } + + if (cmd.kind === 'document_unsupported') { + const req = new XMLHttpRequest(); + req.responseType = 'document'; + req.onload = () => { + let threw = false; + let err = null; + try { + // Reading .response in worker context with responseType=document + // must error: workers have no DOM document. + void req.response; + } catch (e) { + threw = true; + err = String(e); + } + postMessage({ ok: true, status: req.status, threw, err }); + }; + req.onerror = () => postMessage({ ok: false, err: 'xhr error', status: req.status }); + req.open('GET', 'http://127.0.0.1:9582/xhr'); + req.send(); + return; + } + + postMessage({ ok: false, err: 'unknown command' }); + } catch (err) { + postMessage({ ok: false, err: String(err), stack: err.stack }); + } +}; diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index b83f6bcd..e03b885b 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -333,3 +333,60 @@ }); } + + + + + + diff --git a/src/browser/webapi/FileReader.zig b/src/browser/webapi/FileReader.zig index e53e10b6..3c352443 100644 --- a/src/browser/webapi/FileReader.zig +++ b/src/browser/webapi/FileReader.zig @@ -27,6 +27,7 @@ const EventTarget = @import("EventTarget.zig"); const ProgressEvent = @import("event/ProgressEvent.zig"); const Blob = @import("Blob.zig"); +const Execution = js.Execution; const Allocator = std.mem.Allocator; /// https://w3c.github.io/FileAPI/#dfn-filereader @@ -34,7 +35,7 @@ const Allocator = std.mem.Allocator; const FileReader = @This(); _rc: lp.RC(u8) = .{}, -_frame: *Frame, +_exec: *Execution, _proto: *EventTarget, _arena: Allocator, @@ -63,12 +64,12 @@ const Result = union(enum) { arraybuffer: js.ArrayBuffer, }; -pub fn init(frame: *Frame) !*FileReader { - const arena = try frame.getArena(.tiny, "FileReader"); - errdefer frame.releaseArena(arena); +pub fn init(exec: *Execution) !*FileReader { + const arena = try exec.getArena(.tiny, "FileReader"); + errdefer exec.releaseArena(arena); - return frame._factory.eventTargetWithAllocator(arena, FileReader{ - ._frame = frame, + return exec._factory.eventTargetWithAllocator(arena, FileReader{ + ._exec = exec, ._arena = arena, ._proto = undefined, }); @@ -192,9 +193,9 @@ fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void { self._error = null; self._aborted = false; - const frame = self._frame; + const exec = self._exec; - try self.dispatch(.load_start, .{ .loaded = 0, .total = blob.getSize() }, frame); + try self.dispatch(.load_start, .{ .loaded = 0, .total = blob.getSize() }, exec); if (self._aborted) { return; } @@ -202,7 +203,7 @@ fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void { // Perform the read (synchronous since data is in memory) const data = blob._slice; const size = data.len; - try self.dispatch(.progress, .{ .loaded = size, .total = size }, frame); + try self.dispatch(.progress, .{ .loaded = size, .total = size }, exec); if (self._aborted) { return; } @@ -222,8 +223,8 @@ fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void { self._ready_state = .done; - try self.dispatch(.load, .{ .loaded = size, .total = size }, frame); - try self.dispatch(.load_end, .{ .loaded = size, .total = size }, frame); + try self.dispatch(.load, .{ .loaded = size, .total = size }, exec); + try self.dispatch(.load_end, .{ .loaded = size, .total = size }, exec); } pub fn abort(self: *FileReader) !void { @@ -235,14 +236,12 @@ pub fn abort(self: *FileReader) !void { self._ready_state = .done; self._result = null; - const frame = self._frame; - - try self.dispatch(.abort, null, frame); - - try self.dispatch(.load_end, null, frame); + const exec = self._exec; + try self.dispatch(.abort, null, exec); + try self.dispatch(.load_end, null, exec); } -fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Progress, frame: *Frame) !void { +fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Progress, exec: *Execution) !void { const field, const typ = comptime blk: { break :blk switch (event_type) { .abort => .{ "_on_abort", "abort" }, @@ -258,10 +257,10 @@ fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Pr const event = (try ProgressEvent.initTrusted( comptime .wrap(typ), .{ .total = progress.total, .loaded = progress.loaded }, - frame, + exec.context.page, )).asEvent(); - return frame._event_manager.dispatchDirect( + return exec.dispatch( self.asEventTarget(), event, @field(self, field), diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 29243e7c..ef9e303e 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -282,8 +282,8 @@ pub fn setOnUnhandledRejection(self: *Window, setter: ?FunctionSetter) void { self._on_unhandled_rejection = getFunctionFromSetter(setter); } -pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.InitOpts, frame: *Frame) !js.Promise { - return Fetch.init(input, options, frame); +pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.InitOpts, exec: *const js.Execution) !js.Promise { + return Fetch.init(input, options, exec); } const LegacyHandler = union(enum) { diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index c633c31c..67d30d8e 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -24,9 +24,11 @@ const std = @import("std"); const lp = @import("lightpanda"); const JS = @import("../js/js.zig"); +const URL = @import("../URL.zig"); const Page = @import("../Page.zig"); const Factory = @import("../Factory.zig"); const Session = @import("../Session.zig"); +const HttpClient = @import("../HttpClient.zig"); const EventManagerBase = @import("../EventManagerBase.zig"); const ScriptManagerBase = @import("../ScriptManagerBase.zig"); @@ -37,6 +39,7 @@ const Console = @import("Console.zig"); const EventTarget = @import("EventTarget.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); +const Fetch = @import("net/Fetch.zig"); const builtin = @import("builtin"); const IS_DEBUG = builtin.mode == .Debug; @@ -49,8 +52,8 @@ const WorkerGlobalScope = @This(); // Meant to follow the same field naming as Page so that an anytype of generic // can access these the same for a Page of a WGS. // These fields represent the "Page"-like component of the WGS -_session: *Session, _page: *Page, +_session: *Session, _factory: *Factory, _identity: JS.Identity = .{}, arena: Allocator, @@ -69,6 +72,12 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{}, // Reference back to the Worker object (for postMessage to frame) _worker: *Worker, +// HTTP attribution. Mirrors Frame's fields so that generic code over +// (Frame|WorkerGlobalScope) can read them uniformly. Populated from the +// owning Worker at init. +_frame_id: u32, +_loader_id: u32, + // Event management for non-DOM targets in worker context _event_manager: EventManagerBase, @@ -108,6 +117,8 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { ._proto = undefined, ._factory = factory, ._worker = worker, + ._frame_id = worker._frame_id, + ._loader_id = worker._loader_id, ._event_manager = .init(arena), ._script_manager = undefined, }); @@ -170,6 +181,29 @@ pub fn dispatch( ); } +pub fn hasDirectListeners(self: *WorkerGlobalScope, target: *EventTarget, typ: []const u8, handler: anytype) bool { + return self._event_manager.hasDirectListeners(target, typ, handler); +} + +// Workers don't have their own Referer; per spec, dedicated worker requests +// use the parent document's URL. Delegate to the owning frame. +pub fn headersForRequest(self: *WorkerGlobalScope, headers: *HttpClient.Headers) !void { + return self._worker._frame.headersForRequest(headers); +} + +pub fn isSameOrigin(self: *const WorkerGlobalScope, url: [:0]const u8) bool { + const current_origin = self.origin orelse return false; + + if (!std.mem.startsWith(u8, url, current_origin)) { + return false; + } + return std.mem.eql(u8, URL.getHost(url), URL.getHost(current_origin)); +} + +pub fn lookupBlobUrl(self: *WorkerGlobalScope, url: []const u8) ?*Blob { + return self._blob_urls.get(url); +} + pub fn getSelf(self: *WorkerGlobalScope) *WorkerGlobalScope { return self; } @@ -359,6 +393,10 @@ pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { } } +pub fn fetch(_: *const WorkerGlobalScope, input: Fetch.Input, options: ?Fetch.InitOpts, exec: *const JS.Execution) !JS.Promise { + return Fetch.init(input, options, exec); +} + // TODO: importScripts - needs script loading infrastructure // TODO: location - needs WorkerLocation // TODO: navigator - needs WorkerNavigator @@ -454,6 +492,7 @@ pub const JsApi = struct { pub const postMessage = bridge.function(WorkerGlobalScope.postMessage, .{}); pub const reportError = bridge.function(WorkerGlobalScope.reportError, .{}); pub const close = bridge.function(WorkerGlobalScope.close, .{}); + pub const fetch = bridge.function(WorkerGlobalScope.fetch, .{}); pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{}); pub const onmessageerror = bridge.accessor(WorkerGlobalScope.getOnMessageError, WorkerGlobalScope.setOnMessageError, .{}); diff --git a/src/browser/webapi/event/ProgressEvent.zig b/src/browser/webapi/event/ProgressEvent.zig index 3f6a0a9c..6e357300 100644 --- a/src/browser/webapi/event/ProgressEvent.zig +++ b/src/browser/webapi/event/ProgressEvent.zig @@ -19,7 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); -const Frame = @import("../../Frame.zig"); +const Page = @import("../../Page.zig"); const Event = @import("../Event.zig"); const String = lp.String; @@ -39,23 +39,23 @@ const ProgressEventOptions = struct { const Options = Event.inheritOptions(ProgressEvent, ProgressEventOptions); -pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*ProgressEvent { - const arena = try frame.getArena(.tiny, "ProgressEvent"); - errdefer frame.releaseArena(arena); +pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*ProgressEvent { + const arena = try page.getArena(.tiny, "ProgressEvent"); + errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); - return initWithTrusted(arena, type_string, _opts, false, frame); + return initWithTrusted(arena, type_string, _opts, false, page); } -pub fn initTrusted(typ: String, _opts: ?Options, frame: *Frame) !*ProgressEvent { - const arena = try frame.getArena(.tiny, "ProgressEvent.trusted"); - errdefer frame.releaseArena(arena); - return initWithTrusted(arena, typ, _opts, true, frame); +pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*ProgressEvent { + const arena = try page.getArena(.tiny, "ProgressEvent.trusted"); + errdefer page.releaseArena(arena); + return initWithTrusted(arena, typ, _opts, true, page); } -fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, frame: *Frame) !*ProgressEvent { +fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*ProgressEvent { const opts = _opts orelse Options{}; - const event = try frame._factory.event( + const event = try page.factory.event( arena, typ, ProgressEvent{ diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 777f8311..e6c71a9f 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -21,7 +21,7 @@ const lp = @import("lightpanda"); const HttpClient = @import("../../HttpClient.zig"); const js = @import("../../js/js.zig"); -const Frame = @import("../../Frame.zig"); +const Page = @import("../../Page.zig"); const URL = @import("../../URL.zig"); const Blob = @import("../Blob.zig"); @@ -31,11 +31,12 @@ const AbortSignal = @import("../AbortSignal.zig"); const DOMException = @import("../DOMException.zig"); const log = lp.log; +const Execution = js.Execution; const IS_DEBUG = @import("builtin").mode == .Debug; const Fetch = @This(); -_frame: *Frame, +_exec: *const Execution, _url: []const u8, _buf: std.ArrayList(u8), _response: *Response, @@ -46,9 +47,9 @@ _signal: ?*AbortSignal, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; -pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise { - const request = try Request.init(input, options, &frame.js.execution); - const resolver = frame.js.local.?.createPromiseResolver(); +pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promise { + const request = try Request.init(input, options, exec); + const resolver = exec.context.local.?.createPromiseResolver(); if (request._signal) |signal| { if (signal._aborted) { @@ -58,15 +59,15 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise { } if (std.mem.startsWith(u8, request._url, "blob:")) { - return handleBlobUrl(request._url, resolver, frame); + return handleBlobUrl(request._url, resolver, exec); } - const response = try Response.init(null, .{ .status = 0 }, &frame.js.execution); - errdefer response.deinit(frame._page); + const response = try Response.init(null, .{ .status = 0 }, exec); + errdefer response.deinit(exec.context.page); const fetch = try response._arena.create(Fetch); fetch.* = .{ - ._frame = frame, + ._exec = exec, ._buf = .empty, ._url = try response._arena.dupe(u8, request._url), ._resolver = try resolver.persist(), @@ -75,12 +76,13 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise { ._signal = request._signal, }; - const http_client = frame._session.browser.http_client; + const session = exec.context.page.session; + const http_client = session.browser.http_client; var headers = try http_client.newHeaders(); if (request._headers) |h| { - try h.populateHttpHeader(frame.call_arena, &headers); + try h.populateHttpHeader(exec.call_arena, &headers); } - try frame.headersForRequest(&headers); + try exec.headersForRequest(&headers); if (comptime IS_DEBUG) { log.debug(.http, "fetch", .{ .url = request._url }); @@ -88,8 +90,8 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise { const cookie_jar = switch (request._credentials) { .omit => null, - .include => &frame._session.cookie_jar, - .@"same-origin" => if (frame.isSameOrigin(request._url)) &frame._session.cookie_jar else null, + .include => &session.cookie_jar, + .@"same-origin" => if (exec.isSameOrigin(request._url)) &session.cookie_jar else null, }; try http_client.request(.{ @@ -97,14 +99,14 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise { .params = .{ .url = request._url, .method = request._method, - .frame_id = frame._frame_id, - .loader_id = frame._loader_id, + .frame_id = exec.frameId(), + .loader_id = exec.loaderId(), .body = request._body, .headers = headers, .resource_type = .fetch, .cookie_jar = cookie_jar, - .cookie_origin = frame.url, - .notification = frame._session.notification, + .cookie_origin = exec.url.*, + .notification = session.notification, }, .start_callback = httpStartCallback, .header_callback = httpHeaderDoneCallback, @@ -116,22 +118,22 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise { return resolver.promise(); } -fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, frame: *Frame) !js.Promise { - const blob: *Blob = frame.lookupBlobUrl(url) orelse { +fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, exec: *const Execution) !js.Promise { + const blob: *Blob = exec.lookupBlobUrl(url) orelse { resolver.rejectError("fetch blob error", .{ .type_error = "BlobNotFound" }); return resolver.promise(); }; - const response = try Response.init(null, .{ .status = 200 }, &frame.js.execution); + const response = try Response.init(null, .{ .status = 200 }, exec); response._body = .{ .bytes = try response._arena.dupe(u8, blob._slice) }; response._url = try response._arena.dupeZ(u8, url); response._type = .basic; if (blob._mime.len > 0) { - try response._headers.append("Content-Type", blob._mime, &frame.js.execution); + try response._headers.append("Content-Type", blob._mime, exec); } - const js_val = try frame.js.local.?.zigValueToJs(response, .{}); + const js_val = try exec.context.local.?.zigValueToJs(response, .{}); resolver.resolve("fetch blob done", js_val); return resolver.promise(); } @@ -174,10 +176,11 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { res._is_redirected = response.redirectCount().? > 0; // Determine response type based on origin comparison - const frame_origin = URL.getOrigin(arena, self._frame.url) catch null; + const exec = self._exec; + const requesting_origin = URL.getOrigin(arena, exec.url.*) catch null; const response_origin = URL.getOrigin(arena, res._url) catch null; - if (frame_origin) |fo| { + if (requesting_origin) |fo| { if (response_origin) |ro| { if (std.mem.eql(u8, fo, ro)) { res._type = .basic; // Same-origin @@ -193,7 +196,7 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { var it = response.headerIterator(); while (it.next()) |hdr| { - try res._headers.append(hdr.name, hdr.value, &self._frame.js.execution); + try res._headers.append(hdr.name, hdr.value, exec); } return true; @@ -226,7 +229,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void { }); var ls: js.Local.Scope = undefined; - self._frame.js.localScope(&ls); + self._exec.context.localScope(&ls); defer ls.deinit(); const js_val = try ls.local.zigValueToJs(self._response, .{}); @@ -250,11 +253,11 @@ 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(self._frame._page); + response.deinit(self._exec.context.page); }; var ls: js.Local.Scope = undefined; - self._frame.js.localScope(&ls); + self._exec.context.localScope(&ls); defer ls.deinit(); // fetch() must reject with a TypeError on network errors per spec @@ -271,7 +274,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void { if (self._owns_response) { var response = self._response; response._http_response = null; - response.deinit(self._frame._page); + response.deinit(self._exec.context.page); // 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/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 19a7676b..31a72cd0 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -26,7 +26,6 @@ const http = @import("../../../network/http.zig"); const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); const Page = @import("../../Page.zig"); -const Frame = @import("../../Frame.zig"); const Node = @import("../Node.zig"); const Event = @import("../Event.zig"); @@ -35,12 +34,13 @@ const EventTarget = @import("../EventTarget.zig"); const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig"); const log = lp.log; +const Execution = js.Execution; const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; const XMLHttpRequest = @This(); _rc: lp.RC(u8) = .{}, -_frame: *Frame, +_exec: *const Execution, _proto: *XMLHttpRequestEventTarget, _arena: Allocator, _http_response: ?HttpClient.Response = null, @@ -88,14 +88,14 @@ const ResponseType = enum { // TODO: other types to support }; -pub fn init(frame: *Frame) !*XMLHttpRequest { - const arena = try frame.getArena(.large, "XMLHttpRequest"); - errdefer frame.releaseArena(arena); - const self = try frame._factory.xhrEventTarget(arena, XMLHttpRequest{ - ._frame = frame, +pub fn init(exec: *const Execution) !*XMLHttpRequest { + const arena = try exec.getArena(.large, "XMLHttpRequest"); + errdefer exec.releaseArena(arena); + const self = try exec._factory.xhrEventTarget(arena, XMLHttpRequest{ + ._exec = exec, ._arena = arena, ._proto = undefined, - ._request_headers = try Headers.init(null, &frame.js.execution), + ._request_headers = try Headers.init(null, exec), }); return self; } @@ -142,7 +142,7 @@ fn releaseSelfRef(self: *XMLHttpRequest) void { if (self._active_request == false) { return; } - self.releaseRef(self._frame._page); + self.releaseRef(self._exec.context.page); self._active_request = false; } @@ -208,17 +208,17 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void self._response_headers.clearRetainingCapacity(); self._request_body = null; - const frame = self._frame; + const exec = self._exec; self._method = try parseMethod(method_); - self._url = try URL.resolve(self._arena, frame.base(), url, .{ .always_dupe = true, .encoding = frame.charset }); - try self.stateChanged(.opened, frame); + self._url = try URL.resolve(self._arena, exec.base(), url, .{ .always_dupe = true, .encoding = exec.charset.* }); + try self.stateChanged(.opened, exec); } -pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const u8, frame: *Frame) !void { +pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const u8, exec: *const Execution) !void { if (self._ready_state != .opened) { return error.InvalidStateError; } - return self._request_headers.append(name, value, &frame.js.execution); + return self._request_headers.append(name, value, exec); } pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { @@ -235,21 +235,22 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { } } - const frame = self._frame; + const exec = self._exec; if (std.mem.startsWith(u8, self._url, "blob:")) { - return self.handleBlobUrl(frame); + return self.handleBlobUrl(exec); } - const http_client = frame._session.browser.http_client; + const session = exec.context.page.session; + const http_client = session.browser.http_client; var headers = try http_client.newHeaders(); // Only add cookies for same-origin or when withCredentials is true - const cookie_support = self._with_credentials or frame.isSameOrigin(self._url); + const cookie_support = self._with_credentials or exec.isSameOrigin(self._url); - try self._request_headers.populateHttpHeader(frame.call_arena, &headers); + try self._request_headers.populateHttpHeader(exec.call_arena, &headers); if (cookie_support) { - try frame.headersForRequest(&headers); + try exec.headersForRequest(&headers); } self.acquireRef(); @@ -261,14 +262,14 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { .url = self._url, .method = self._method, .headers = headers, - .frame_id = frame._frame_id, - .loader_id = frame._loader_id, + .frame_id = exec.frameId(), + .loader_id = exec.loaderId(), .body = self._request_body, - .cookie_jar = if (cookie_support) &frame._session.cookie_jar else null, - .cookie_origin = frame.url, + .cookie_jar = if (cookie_support) &session.cookie_jar else null, + .cookie_origin = exec.url.*, .resource_type = .xhr, .timeout_ms = self._timeout, - .notification = frame._session.notification, + .notification = session.notification, }, .start_callback = httpStartCallback, .header_callback = httpHeaderDoneCallback, @@ -282,8 +283,8 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { }; } -fn handleBlobUrl(self: *XMLHttpRequest, frame: *Frame) !void { - const blob = frame.lookupBlobUrl(self._url) orelse { +fn handleBlobUrl(self: *XMLHttpRequest, exec: *const Execution) !void { + const blob = exec.lookupBlobUrl(self._url) orelse { self.handleError(error.BlobNotFound); return; }; @@ -294,24 +295,24 @@ fn handleBlobUrl(self: *XMLHttpRequest, frame: *Frame) !void { try self._response_data.appendSlice(self._arena, blob._slice); self._response_len = blob._slice.len; - try self.stateChanged(.headers_received, frame); - try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, frame); - try self.stateChanged(.loading, frame); + try self.stateChanged(.headers_received, exec); + try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, exec); + try self.stateChanged(.loading, exec); try self._proto.dispatch(.progress, .{ .total = self._response_len orelse 0, .loaded = self._response_data.items.len, - }, frame); - try self.stateChanged(.done, frame); + }, exec); + try self.stateChanged(.done, exec); const loaded = self._response_data.items.len; try self._proto.dispatch(.load, .{ .total = loaded, .loaded = loaded, - }, frame); + }, exec); try self._proto.dispatch(.load_end, .{ .total = loaded, .loaded = loaded, - }, frame); + }, exec); } pub fn getReadyState(self: *const XMLHttpRequest) u32 { @@ -334,14 +335,14 @@ pub fn getResponseHeader(self: *const XMLHttpRequest, name: []const u8) ?[]const return null; } -pub fn getAllResponseHeaders(self: *const XMLHttpRequest, frame: *Frame) ![]const u8 { +pub fn getAllResponseHeaders(self: *const XMLHttpRequest, exec: *const Execution) ![]const u8 { if (self._ready_state != .done) { // MDN says this should return null, but it seems to return an empty string // in every browser. Specs are too hard for a dumbo like me to understand. return ""; } - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(exec.call_arena); for (self._response_headers.items) |entry| { try buf.writer.writeAll(entry); try buf.writer.writeAll("\r\n"); @@ -378,7 +379,7 @@ pub fn getResponseURL(self: *XMLHttpRequest) []const u8 { return self._response_url; } -pub fn getResponse(self: *XMLHttpRequest, frame: *Frame) !?Response { +pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response { if (self._ready_state != .done) { return null; } @@ -392,13 +393,20 @@ pub fn getResponse(self: *XMLHttpRequest, frame: *Frame) !?Response { const res: Response = switch (self._response_type) { .text => .{ .text = data }, .json => blk: { - const value = try frame.js.local.?.parseJSON(data); + const value = try exec.context.local.?.parseJSON(data); break :blk .{ .json = try value.persist() }; }, .document => blk: { - const document = try frame._factory.node(Node.Document{ ._proto = undefined, ._type = .generic }); - try frame.parseHtmlAsChildren(document.asNode(), data); - break :blk .{ .document = document }; + // responseType=document is only meaningful in a Frame; workers + // have no DOM. Drastically different impls -> switch on global. + switch (exec.context.global) { + .frame => |frame| { + const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic }); + try frame.parseHtmlAsChildren(document.asNode(), data); + break :blk .{ .document = document }; + }, + .worker => return error.NotSupportedInWorker, + } }, .arraybuffer => .{ .arraybuffer = .{ .values = data } }, }; @@ -407,8 +415,8 @@ pub fn getResponse(self: *XMLHttpRequest, frame: *Frame) !?Response { return res; } -pub fn getResponseXML(self: *XMLHttpRequest, frame: *Frame) !?*Node.Document { - const res = (try self.getResponse(frame)) orelse return null; +pub fn getResponseXML(self: *XMLHttpRequest, exec: *const Execution) !?*Node.Document { + const res = (try self.getResponse(exec)) orelse return null; return switch (res) { .document => |doc| doc, else => null, @@ -464,15 +472,15 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { } self._response_url = try self._arena.dupeZ(u8, response.url()); - const frame = self._frame; + const exec = self._exec; var ls: js.Local.Scope = undefined; - frame.js.localScope(&ls); + exec.context.localScope(&ls); defer ls.deinit(); - try self.stateChanged(.headers_received, frame); - try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, frame); - try self.stateChanged(.loading, frame); + try self.stateChanged(.headers_received, exec); + try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, exec); + try self.stateChanged(.loading, exec); return true; } @@ -481,12 +489,10 @@ fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx)); try self._response_data.appendSlice(self._arena, data); - const frame = self._frame; - try self._proto.dispatch(.progress, .{ .total = self._response_len orelse 0, .loaded = self._response_data.items.len, - }, frame); + }, self._exec); } fn httpDoneCallback(ctx: *anyopaque) !void { @@ -503,19 +509,19 @@ fn httpDoneCallback(ctx: *anyopaque) !void { // object. It isn't safe to keep it around. self._http_response = null; - const frame = self._frame; + const exec = self._exec; - try self.stateChanged(.done, frame); + try self.stateChanged(.done, exec); const loaded = self._response_data.items.len; try self._proto.dispatch(.load, .{ .total = loaded, .loaded = loaded, - }, frame); + }, exec); try self._proto.dispatch(.load_end, .{ .total = loaded, .loaded = loaded, - }, frame); + }, exec); self.releaseSelfRef(); } @@ -559,18 +565,18 @@ fn _handleError(self: *XMLHttpRequest, err: anyerror) !void { const new_state: ReadyState = if (is_abort) .unsent else .done; if (new_state != self._ready_state) { - const frame = self._frame; + const exec = self._exec; - try self.stateChanged(new_state, frame); + try self.stateChanged(new_state, exec); if (is_abort) { - try self._proto.dispatch(.abort, null, frame); + try self._proto.dispatch(.abort, null, exec); } else if (is_timeout) { - try self._proto.dispatch(.timeout, null, frame); + try self._proto.dispatch(.timeout, null, exec); } if (!is_timeout) { - try self._proto.dispatch(.err, null, frame); + try self._proto.dispatch(.err, null, exec); } - try self._proto.dispatch(.load_end, null, frame); + try self._proto.dispatch(.load_end, null, exec); } const level: log.Level = if (err == error.Abort) .debug else .err; @@ -581,7 +587,7 @@ fn _handleError(self: *XMLHttpRequest, err: anyerror) !void { }); } -fn stateChanged(self: *XMLHttpRequest, state: ReadyState, frame: *Frame) !void { +fn stateChanged(self: *XMLHttpRequest, state: ReadyState, exec: *const Execution) !void { if (state == self._ready_state) { return; } @@ -589,9 +595,9 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, frame: *Frame) !void { self._ready_state = state; const target = self.asEventTarget(); - if (frame._event_manager.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) { - const event = try Event.initTrusted(.wrap("readystatechange"), .{}, frame._page); - try frame._event_manager.dispatchDirect(target, event, self._on_ready_state_change, .{ .context = "XHR state change" }); + if (exec.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) { + const event = try Event.initTrusted(.wrap("readystatechange"), .{}, exec.context.page); + try exec.dispatch(target, event, self._on_ready_state_change, .{ .context = "XHR state change" }); } } diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig index ac2c4a76..7cbedc70 100644 --- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig +++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig @@ -18,10 +18,11 @@ const js = @import("../../js/js.zig"); -const Frame = @import("../../Frame.zig"); const EventTarget = @import("../EventTarget.zig"); const ProgressEvent = @import("../event/ProgressEvent.zig"); +const Execution = js.Execution; + const XMLHttpRequestEventTarget = @This(); _type: Type, @@ -43,7 +44,7 @@ pub fn asEventTarget(self: *XMLHttpRequestEventTarget) *EventTarget { return self._proto; } -pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchType, progress_: ?Progress, frame: *Frame) !void { +pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchType, progress_: ?Progress, exec: *const Execution) !void { const field, const typ = comptime blk: { break :blk switch (event_type) { .abort => .{ "_on_abort", "abort" }, @@ -60,10 +61,10 @@ pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchT const event = (try ProgressEvent.initTrusted( comptime .wrap(typ), .{ .total = progress.total, .loaded = progress.loaded }, - frame, + exec.context.page, )).asEvent(); - return frame._event_manager.dispatchDirect( + return exec.dispatch( self.asEventTarget(), event, @field(self, field), From 0c55875b635b02a516b9720dab0f655cda2e22cd Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 4 May 2026 13:52:38 +0800 Subject: [PATCH 04/33] encode worker URL --- src/browser/webapi/Worker.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 81d74116..e0f94b2e 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -68,7 +68,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { const arena = try session.getArena(.large, "Worker"); errdefer session.releaseArena(arena); - const resolved_url = try URL.resolve(arena, exec.url.*, url, .{}); + const resolved_url = try URL.resolve(arena, exec.url.*, url, .{ .encoding = frame.charset }); const self = try frame._page.factory.eventTargetWithAllocator(arena, Worker{ ._arena = arena, ._proto = undefined, From eab9ae02434791e5c530207b195d24fb131f4d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 4 May 2026 08:01:22 +0200 Subject: [PATCH 05/33] RobotsLayer: use managed ArrayList --- src/network/layer/RobotsLayer.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/network/layer/RobotsLayer.zig b/src/network/layer/RobotsLayer.zig index 1bfae1b6..7d15bade 100644 --- a/src/network/layer/RobotsLayer.zig +++ b/src/network/layer/RobotsLayer.zig @@ -32,7 +32,7 @@ const RobotsLayer = @This(); next: Layer = undefined, allocator: std.mem.Allocator, -pending: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(Request)) = .empty, +pending: std.StringHashMapUnmanaged(std.ArrayList(Request)) = .empty, pub fn layer(self: *RobotsLayer) Layer { return .{ @@ -166,7 +166,7 @@ const RobotsContext = struct { arena: std.mem.Allocator, client: *Client, robots_url: [:0]const u8, - buffer: std.ArrayListUnmanaged(u8), + buffer: std.ArrayList(u8), status: u16 = 0, fn deinit(self: *RobotsContext) void { From 77a1fdc2a09f44ccedde6a0f630ed66a71dfb575 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 4 May 2026 13:50:09 +0800 Subject: [PATCH 06/33] Worker.importScripts I also noticed that failures in async assertions could be ignored, so I fixed that and fixed a couple failing XHR tests. --- src/browser/ScriptManagerBase.zig | 19 ++++-- src/browser/tests/net/xhr.html | 8 +-- src/browser/tests/testing.js | 7 +- src/browser/tests/worker/import-script1.js | 1 + src/browser/tests/worker/import-script2.js | 1 + .../tests/worker/importScripts-worker.js | 1 + src/browser/tests/worker/worker.html | 20 ++++++ src/browser/webapi/WorkerGlobalScope.zig | 67 +++++++++++++++++-- 8 files changed, 104 insertions(+), 20 deletions(-) create mode 100644 src/browser/tests/worker/import-script1.js create mode 100644 src/browser/tests/worker/import-script2.js create mode 100644 src/browser/tests/worker/importScripts-worker.js diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 3dd2cdd4..b73121a6 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -648,12 +648,19 @@ pub const Script = struct { pub fn errorCallback(ctx: *anyopaque, err: anyerror) void { const self: *Script = @ptrCast(@alignCast(ctx)); - log.warn(.http, "script fetch error", .{ - .err = err, - .req = self.url, - .extra = std.meta.activeTag(self.extra), - .status = self.status, - }); + if (self.status == 404) { + log.info(.http, "script 404", .{ + .req = self.url, + .extra = std.meta.activeTag(self.extra), + }); + } else { + log.warn(.http, "script fetch error", .{ + .err = err, + .req = self.url, + .extra = std.meta.activeTag(self.extra), + .status = self.status, + }); + } if (self.extra == .frame and self.extra.frame.mode == .normal) { // This is blocked in a loop at the end of addFromElement, setting diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index e03b885b..62780f97 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -76,10 +76,7 @@ await state.done(() => { testing.expectEqual(200, req3.status); testing.expectEqual('OK', req3.statusText); - testing.expectEqual('9000!!!', req3.response.over); - testing.expectEqual("number", typeof json.updated_at); - testing.expectEqual(1765867200000, json.updated_at); - testing.expectEqual({over: '9000!!!',updated_at:1765867200000}, json); + testing.expectEqual({over: '9000!!!', updated_at:1765867200000}, req3.response); }); } @@ -142,8 +139,7 @@ testing.expectEqual(200, req6.status); testing.expectEqual('OK', req6.statusText); testing.expectEqual(7, req6.response.byteLength); - testing.expectEqual([0, 0, 1, 2, 0, 0, 9], new Int32Array(req6.response)); - testing.expectEqual('', typeof req6.response); + testing.expectEqual([0, 0, 1, 2, 0, 0, 9], new Int8Array(req6.response)); testing.expectEqual('arraybuffer', req6.responseType); }); } diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 79a61070..ca44d8ec 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -87,7 +87,12 @@ const res = await this.promise; async_pending.delete(script_id); async_capture = this.capture; - cb(res); + try { + cb(res); + } catch (err) { + console.warn(script_id, err); + failed = true; + } async_capture = false; } }; diff --git a/src/browser/tests/worker/import-script1.js b/src/browser/tests/worker/import-script1.js new file mode 100644 index 00000000..768b69f0 --- /dev/null +++ b/src/browser/tests/worker/import-script1.js @@ -0,0 +1 @@ +postMessage('importScripts-1'); diff --git a/src/browser/tests/worker/import-script2.js b/src/browser/tests/worker/import-script2.js new file mode 100644 index 00000000..a6af6ac6 --- /dev/null +++ b/src/browser/tests/worker/import-script2.js @@ -0,0 +1 @@ +postMessage('importScripts-2'); diff --git a/src/browser/tests/worker/importScripts-worker.js b/src/browser/tests/worker/importScripts-worker.js new file mode 100644 index 00000000..a64177fd --- /dev/null +++ b/src/browser/tests/worker/importScripts-worker.js @@ -0,0 +1 @@ +importScripts('import-script1.js', 'import-script2.js'); diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html index b6d671b8..dda6d1a8 100644 --- a/src/browser/tests/worker/worker.html +++ b/src/browser/tests/worker/worker.html @@ -275,3 +275,23 @@ }); } + + diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 67d30d8e..95f1f303 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -33,6 +33,7 @@ const EventManagerBase = @import("../EventManagerBase.zig"); const ScriptManagerBase = @import("../ScriptManagerBase.zig"); const Blob = @import("Blob.zig"); +const Event = @import("Event.zig"); const Worker = @import("Worker.zig"); const Crypto = @import("Crypto.zig"); const Console = @import("Console.zig"); @@ -160,8 +161,6 @@ pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget { return self._proto; } -const Event = @import("Event.zig"); - // Dispatch an event to listeners on the given target within this worker context. pub fn dispatch( self: *WorkerGlobalScope, @@ -343,6 +342,64 @@ pub fn close(self: *WorkerGlobalScope) void { self._closed = true; } +pub fn importScripts(self: *WorkerGlobalScope, urls: []const [:0]const u8) !void { + const session = self._session; + const arena = try session.getArena(.large, "importScript"); + defer session.releaseArena(arena); + + for (urls) |url| { + defer session.arena_pool.resetRetain(arena); + try self.importScript(arena, url); + } +} + +fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) !void { + const session = self._session; + + const resolved_url = try URL.resolve(arena, self.url, url, .{}); + + const http_client = session.browser.http_client; + + var headers = try http_client.newHeaders(); + try self.headersForRequest(&headers); + + const response = http_client.syncRequest(arena, .{ + .url = resolved_url, + .method = .GET, + .frame_id = self._frame_id, + .loader_id = self._loader_id, + .headers = headers, + .cookie_jar = &session.cookie_jar, + .cookie_origin = self.url, + .resource_type = .script, + .notification = session.notification, + }) catch |err| { + log.warn(.http, "importScript", .{ .url = resolved_url, .err = err }); + return error.NetworkError; + }; + + if (response.status != 200) { + log.warn(.http, "importScript", .{ .url = resolved_url, .status = response.status }); + return error.NetworkError; + } + + var ls: JS.Local.Scope = undefined; + self.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: JS.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + _ = ls.local.eval(response.body.items, url) catch |err| { + const caught = try_catch.caughtOrError(arena, err); + log.err(.browser, "importScript", .{ .url = resolved_url, .caught = caught }); + return; + }; + + ls.local.runMacrotasks(); +} + pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{ .@"error" = try err.temp(), @@ -397,11 +454,6 @@ pub fn fetch(_: *const WorkerGlobalScope, input: Fetch.Input, options: ?Fetch.In return Fetch.init(input, options, exec); } -// TODO: importScripts - needs script loading infrastructure -// TODO: location - needs WorkerLocation -// TODO: navigator - needs WorkerNavigator -// TODO: Timer functions - need scheduler integration - const FunctionSetter = union(enum) { func: JS.Function.Global, anything: JS.Value, @@ -493,6 +545,7 @@ pub const JsApi = struct { pub const reportError = bridge.function(WorkerGlobalScope.reportError, .{}); pub const close = bridge.function(WorkerGlobalScope.close, .{}); pub const fetch = bridge.function(WorkerGlobalScope.fetch, .{}); + pub const importScripts = bridge.function(WorkerGlobalScope.importScripts, .{ .dom_exception = true }); pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{}); pub const onmessageerror = bridge.accessor(WorkerGlobalScope.getOnMessageError, WorkerGlobalScope.setOnMessageError, .{}); From acdddb7ec82189cc313620f09f8a35119533faef Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 27 Apr 2026 15:27:52 +0200 Subject: [PATCH 07/33] keep the existing page active until the pending one is loaded During a root navigation, we keep the existing page active until we get the headers callback from the pending page. Then Session.commitPendingPage makes the switch. It delays the deinit of CPD execution context to handle JS execution in the meantime. Now session has an array of two pages, _active_idx points to the main page. Both active and pending pages share the same frame_id, it must remains stable. So this PR adds a Request.protect_from_abort to avoid removing the request form the pending page when deinit the previous active page. --- src/Notification.zig | 5 + src/browser/Frame.zig | 40 +++++ src/browser/HttpClient.zig | 15 +- src/browser/Page.zig | 10 ++ src/browser/Runner.zig | 4 + src/browser/Session.zig | 339 +++++++++++++++++++++++++++++++----- src/cdp/domains/network.zig | 2 +- src/cdp/domains/page.zig | 42 ++++- src/cdp/domains/target.zig | 6 +- src/cdp/testing.zig | 2 +- src/mcp/tools.zig | 2 +- 11 files changed, 407 insertions(+), 60 deletions(-) diff --git a/src/Notification.zig b/src/Notification.zig index 3eaee419..7079c658 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -118,6 +118,11 @@ pub const FrameNavigate = struct { timestamp: u64, url: [:0]const u8, opts: Frame.NavigateOpts, + // True when this navigation is being issued against a Page that is in + // .pending state (i.e. an in-flight root navigation whose old Page is + // still alive). CDP uses this to skip BrowserContext.reset() — the old + // page's nodes must remain live and addressable until commit. + is_pending_root: bool = false, }; pub const FrameNavigated = struct { diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 138b0044..01021a75 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -633,6 +633,14 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo const ref_header = try std.mem.concatWithSentinel(self.arena, u8, &.{ "Referer: ", ref }, 0); try headers.add(ref_header); } + + // A root navigation issued against a pending Page (i.e. one allocated by + // Session.initiateRootNavigation) flags both the notification and the + // HTTP request itself: CDP skips its node-registry reset until commit, + // and the in-flight transfer survives the OLD page's frame.deinit which + // calls http_client.abort() during commitPendingPage. + const is_pending_root = self._page._state == .pending; + // We dispatch frame_navigate event before sending the request. // It ensures the event frame_navigated is not dispatched before this one. session.notification.dispatch(.frame_navigate, &.{ @@ -642,6 +650,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo .frame_id = self._frame_id, .loader_id = self._loader_id, .timestamp = timestamp(.monotonic), + .is_pending_root = is_pending_root, }); // Record telemetry for navigation @@ -665,6 +674,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo .cookie_origin = self.url, .resource_type = .document, .notification = self._session.notification, + .protect_from_abort = is_pending_root, }, .header_callback = frameHeaderDoneCallback, .data_callback = frameDataCallback, @@ -970,6 +980,26 @@ fn notifyParentLoadComplete(self: *Frame) void { fn frameHeaderDoneCallback(response: HttpClient.Response) !bool { var self: *Frame = @ptrCast(@alignCast(response.ctx)); + // Commit point for a pending root navigation. The session has been + // holding the OLD page alive during the round-trip; now that response + // headers have arrived, swap pending → active. This dispatches + // frame_remove (clears OLD V8 context group + CDP node_registry), + // tears down the OLD page, flips the pointer, and dispatches + // frame_created against the new (now active) frame. + // + // The OLD page's frame.deinit calls http_client.abort() — our transfer + // survives because Session.initiateRootNavigation flagged the request + // protect_from_abort. Once we are past commit, that protection is no + // longer needed and may interfere with subsequent aborts (e.g. another + // navigation while we are still streaming the body), so clear it. + if (self._page._state == .pending) { + try self._session.commitPendingPage(); + switch (response.inner) { + .transfer => |t| t.req.params.protect_from_abort = false, + .fulfilled, .cached => {}, + } + } + const response_url = response.url(); if (std.mem.eql(u8, response_url, self.url) == false) { // would be different than self.url in the case of a redirect @@ -1199,6 +1229,16 @@ fn frameErrorCallback(ctx: *anyopaque, err: anyerror) void { var self: *Frame = @ptrCast(@alignCast(ctx)); log.err(.frame, "navigate failed", .{ .err = err, .type = self._type, .url = self.url }); + + // A pending root navigation that failed before commit: discard the + // pending Page; the OLD active Page (and its V8 context) is untouched. + // We do NOT run frameDoneCallback against the pending frame — the frame + // is about to be freed. + if (self._page._state == .pending) { + self._session.discardPendingPage(); + return; + } + self._parse_state.deinit(self); self._parse_state = .{ .err = err }; diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index a810c535..a6b9960f 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -340,13 +340,14 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void { if (comptime IS_DEBUG and abort_all) { // Even after an abort_all, we could still have transfers, but, at the - // very least, they should all be flagged as aborted. + // very least, they should all be flagged as aborted (or be a transfer + // we explicitly protected with protect_from_abort). var it = self.in_use.first; var leftover: usize = 0; while (it) |node| : (it = node.next) { const conn: *http.Connection = @fieldParentPtr("node", node); switch (conn.transport) { - .http => |transfer| std.debug.assert(transfer.aborted), + .http => |transfer| std.debug.assert(transfer.aborted or transfer.req.params.protect_from_abort), .websocket => {}, .none => {}, } @@ -363,7 +364,8 @@ fn abortConnections(list: std.DoublyLinkedList, comptime abort_all: bool, frame_ const conn: *http.Connection = @fieldParentPtr("node", node); switch (conn.transport) { .http => |transfer| { - if ((comptime abort_all) or transfer.req.params.frame_id == frame_id) { + const matches = (comptime abort_all) or transfer.req.params.frame_id == frame_id; + if (matches and !transfer.req.params.protect_from_abort) { transfer.kill(); } }, @@ -878,6 +880,13 @@ pub const RequestParams = struct { notification: *Notification, timeout_ms: u32 = 0, + // Set on an in-flight root-navigation transfer that was issued against a + // pending Page. The old Page's frame.deinit (called from Session.commit + // PendingPage when response headers arrive) calls http_client.abort() — + // that abort_all path skips transfers with this flag so the callback + // chain we are sitting inside isn't killed mid-flight. + protect_from_abort: bool = false, + const ResourceType = enum { document, xhr, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 1848692d..28e64e43 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -102,6 +102,16 @@ popups: std.ArrayList(*Frame) = .empty, // from a script eval whose parser still holds the Frame). queued_close: std.ArrayList(*Frame) = .empty, +// Lifecycle state. A Page is `.pending` while we hold it as the in-flight +// destination of a root navigation — its V8 context exists but is not yet the +// session's active context. Flipped to `.active` by Session.commitPendingPage +// when response headers arrive. Frame.navigate / frameHeaderDoneCallback +// branch on this to: (a) stamp `is_pending_root` on the frame_navigate +// notification (so CDP doesn't reset its node registry yet) and +// (b) flag the HTTP request `protect_from_abort` (so the old page's deinit +// can't kill the transfer we're sitting inside). +_state: enum { active, pending } = .active, + // Initialize a Page and its root Frame. pub fn init(self: *Page, session: *Session, frame_id: u32) !void { const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena"); diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index c1f0ce89..dfca0700 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -142,6 +142,10 @@ pub fn tickCDP(self: *Runner, opts: TickOpts) !CDPTickResult { } fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult { + // Refresh self.frame from session — the previous _tick's http_client.tick() + // may have fired frameHeaderDoneCallback → Session.commitPendingPage, + // freeing the OLD page and replacing it with the pending one. + self.frame = self.session.currentFrame() orelse return .done; const frame = self.frame; const http_client = self.http_client; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index a8b896e8..c02fc89a 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -65,9 +65,31 @@ arena_pool: *ArenaPool, // teardowns so V8 weak callbacks can validate the FC before dereferencing it. fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity), -// The currently-active Page. Null when no Page exists (between removePage -// and createPage, or at startup). -page: ?Page, +// Two physical slots for Pages, both stored inline in the Session struct. +// Each slot has a stable address, so Frame self-pointers (window._frame, +// document._frame, EventManager.frame, etc.) — which point inside the +// slot's Page — remain valid across the pending → active promotion done +// by commitPendingPage. We never move a Page; we only flip the index that +// names the active vs pending slot. +// +// Why two slots: at any given moment we may need to hold one active Page +// (the user-visible page) AND one pending Page (the in-flight destination +// of a root navigation, kept alive across the HTTP round-trip per Chrome's +// behavior). After commit, the OLD active slot is freed and becomes +// available for the next pending allocation. +// +// Convention: a slot is "occupied" iff its `?Page` is non-null. +_pages: [2]?Page = .{ null, null }, + +// Index into `_pages` for the currently-active page, or null when no Page +// exists (between removePage and createPage, or at startup). +_active_idx: ?u1 = null, + +// Index into `_pages` for an in-flight root navigation, or null when no +// pending navigation is in flight. CDP commands and the rest of the +// codebase MUST NOT see this as the current page; it is invisible to +// Target.* / DOM.* / Runtime.* until commit promotes it to `_active_idx`. +_pending_idx: ?u1 = null, // IDs. Kept at Session level so IDs can remain unique across Page replacements. frame_id_gen: u32 = 0, @@ -81,7 +103,9 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi errdefer arena_pool.release(arena); self.* = .{ - .page = null, + ._pages = .{ null, null }, + ._active_idx = null, + ._pending_idx = null, .arena = arena, .arena_pool = arena_pool, .history = .{}, @@ -96,7 +120,13 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi } pub fn deinit(self: *Session) void { - if (self.page != null) { + // Tear down a pending navigation first so its in-flight transfer is + // aborted before we shutter the active Page (which calls + // http_client.abort() unconditionally from frame.deinit). + if (self._pending_idx != null) { + self.discardPendingPage(); + } + if (self._active_idx != null) { self.removePage(); } self.cookie_jar.deinit(); @@ -106,17 +136,56 @@ pub fn deinit(self: *Session) void { self.arena_pool.release(self.arena); } +// True iff there is an active Page. CDP / external callers should use this +// (or `currentPage()`) rather than poking at the underlying slots. +pub fn hasPage(self: *const Session) bool { + return self._active_idx != null; +} + +// Pick a free slot index — i.e. one whose `_pages` entry is null. +// In normal operation we always have at most one of (active, pending), so +// at least one slot is free. Returns null only if both slots are occupied, +// which is an invariant violation. +fn findFreeSlot(self: *const Session) !u1 { + if (self._pages[0] == null) return 0; + if (self._pages[1] == null) return 1; + + return error.NoFreePageSlot; +} + +// Initialize a Page in the given inline slot and return a stable pointer +// to it. The slot must be currently empty. The returned Page is in the +// .active state by default; callers that want a pending page +// (initiateRootNavigation) must flip _state themselves. +fn pageInit(self: *Session, slot: u1, frame_id: u32) !*Page { + lp.assert(self._pages[slot] == null, "Session.pageInit - slot occupied", .{}); + self._pages[slot] = @as(Page, undefined); + const page = &self._pages[slot].?; + errdefer self._pages[slot] = null; + try Page.init(page, self, frame_id); + return page; +} + +// Free the inline slot whose Page has already been Page.deinit'd. After +// this, the slot is available for a future allocation. +fn freeSlot(self: *Session, slot: u1) void { + self._pages[slot] = null; +} + // NOTE: the caller is not the owner of the returned value, // the pointer on Frame is just returned as a convenience pub fn createPage(self: *Session) !*Frame { - lp.assert(self.page == null, "Session.createPage - page not null", .{}); + lp.assert(self._active_idx == null, "Session.createPage - page not null", .{}); - self.page = @as(Page, undefined); - const page = &self.page.?; + const slot = try self.findFreeSlot(); + const page = try self.pageInit(slot, self.nextFrameId()); + errdefer { + page.deinit(false); + self.freeSlot(slot); + } + self._active_idx = slot; + errdefer self._active_idx = null; - errdefer self.page = null; - - try Page.init(page, self, self.nextFrameId()); const frame = &page.frame; // Creates a new NavigationEventTarget for this frame. @@ -133,18 +202,31 @@ pub fn createPage(self: *Session) !*Frame { } pub fn removePage(self: *Session) void { - lp.assert(self.page != null, "Session.removePage - page is null", .{}); - if (self.page.?.frame._script_manager.base.is_evaluating) { + const idx = self._active_idx orelse { + lp.assert(false, "Session.removePage - page is null", .{}); + return; + }; + + if (self._pages[idx].?.frame._script_manager.base.is_evaluating) { // Reentrant teardown from a CDP message drained inside syncRequest; // Session.deinit reclaims the page when the connection closes. return; } + // If a navigation is in flight, drop the pending Page first. Its + // transfer was protected from abort to survive commitPendingPage's + // teardown of the old page, but we are now permanently removing the + // session's page state — the pending transfer should die with it. + if (self._pending_idx != null) { + self.discardPendingPage(); + } + // Inform CDP the frame is going to be removed, allowing other worlds to remove themselves before the main one self.notification.dispatch(.frame_remove, .{}); - self.page.?.deinit(false); - self.page = null; + self._pages[idx].?.deinit(false); + self.freeSlot(idx); + self._active_idx = null; self.navigation.onRemoveFrame(); @@ -166,11 +248,11 @@ pub fn releaseArena(self: *Session, allocator: Allocator) void { } pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin { - return self.page.?.getOrCreateOrigin(key_); + return self.currentPage().?.getOrCreateOrigin(key_); } pub fn releaseOrigin(self: *Session, origin: *js.Origin) void { - return self.page.?.releaseOrigin(origin); + self.currentPage().?.releaseOrigin(origin); } pub fn replacePage(self: *Session) !*Frame { @@ -178,30 +260,44 @@ pub fn replacePage(self: *Session) !*Frame { log.debug(.browser, "replace page", .{}); } - lp.assert(self.page != null, "Session.replacePage null page", .{}); - const current = &self.page.?; - lp.assert(current.frame.parent == null, "Session.replacePage with parent", .{}); + const old_idx = self._active_idx orelse { + lp.assert(false, "Session.replacePage null page", .{}); + return error.NoActivePage; + }; + const old_page = &self._pages[old_idx].?; + lp.assert(old_page.frame.parent == null, "Session.replacePage with parent", .{}); - const frame_id = current.frame._frame_id; - current.deinit(true); - self.page = null; + const frame_id = old_page.frame._frame_id; + old_page.deinit(true); + self.freeSlot(old_idx); + self._active_idx = null; // Preserve prior behavior: frame_id_gen reset on root replacement so a // subsequent createPage starts from id 1. The captured frame_id is // passed into Page.init explicitly, so it isn't affected. self.frame_id_gen = 0; - self.page = @as(Page, undefined); - const page = &self.page.?; - - errdefer self.page = null; - - try Page.init(page, self, frame_id); + const new_slot = try self.findFreeSlot(); + const page = try self.pageInit(new_slot, frame_id); + errdefer { + page.deinit(false); + self.freeSlot(new_slot); + } + self._active_idx = new_slot; return &page.frame; } pub fn currentPage(self: *Session) ?*Page { - return &(self.page orelse return null); + const idx = self._active_idx orelse return null; + return &self._pages[idx].?; +} + +// Returns the pending Page if a root navigation is in flight. CDP / DOM / +// Runtime callers MUST NOT use this; it is only for the navigation +// machinery (Frame.navigate / commitPendingPage). +pub fn pendingPage(self: *Session) ?*Page { + const idx = self._pending_idx orelse return null; + return &self._pages[idx].?; } pub fn currentFrame(self: *Session) ?*Frame { @@ -219,7 +315,7 @@ pub fn runner(self: *Session, opts: Runner.Opts) !Runner { } pub fn scheduleNavigation(self: *Session, frame: *Frame) !void { - return self.page.?.scheduleNavigation(frame); + return self.currentPage().?.scheduleNavigation(frame); } pub fn processQueuedNavigation(self: *Session) !void { @@ -384,32 +480,62 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) } fn processRootQueuedNavigation(self: *Session) !void { - const current_frame = &self.page.?.frame; - const frame_id = current_frame._frame_id; + const active_idx = self._active_idx orelse { + lp.assert(false, "Session.processRootQueuedNavigation - no active page", .{}); + return; + }; + const current_frame = &self._pages[active_idx].?.frame; - // create a copy before the frame is cleared + // Detach the QueuedNavigation. Whether we keep it on the active frame + // (synthetic path) or transfer it to the pending frame (HTTP path), the + // current frame must no longer claim it. const qn = current_frame._queued_navigation.?; current_frame._queued_navigation = null; + // Synthetic navigations (about:blank, blob:) commit instantly — no HTTP, + // so there is no in-flight window to worry about. Use the legacy + // immediate-swap path for them. + const is_synthetic = std.mem.eql(u8, qn.url, "about:blank") or + std.mem.startsWith(u8, qn.url, "blob:"); + + if (is_synthetic) { + return self.replaceRootImmediate(current_frame._frame_id, qn); + } + + return self.initiateRootNavigation(current_frame._frame_id, qn); +} + +// Legacy immediate-swap path: tear down the active page and create a new one +// in its place before issuing the navigation. Used for synthetic navigations +// (about:blank, blob:) where there is no in-flight HTTP and therefore no +// "pending" window to span. +fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !void { defer self.arena_pool.release(qn.arena); - // Dispatch frame_remove (same as removePage) then replace the Page - // in-place, keeping the frame_id stable. + const old_idx = self._active_idx orelse { + lp.assert(false, "Session.replaceRootImmediate - no active page", .{}); + return; + }; + + // Dispatch frame_remove (same as removePage) then tear down the OLD + // page's slot. self.notification.dispatch(.frame_remove, .{}); - self.page.?.deinit(true); - self.page = null; + self._pages[old_idx].?.deinit(true); + self.freeSlot(old_idx); + self._active_idx = null; self.navigation.onRemoveFrame(); // Preserve prior behavior: the old resetFrameResources reset frame_id_gen. self.frame_id_gen = 0; - self.page = @as(Page, undefined); - const page = &self.page.?; - - errdefer self.page = null; - - try Page.init(page, self, frame_id); + const new_slot = try self.findFreeSlot(); + const page = try self.pageInit(new_slot, frame_id); + errdefer { + page.deinit(false); + self.freeSlot(new_slot); + } + self._active_idx = new_slot; const new_frame = &page.frame; // Creates a new NavigationEventTarget for this frame. @@ -427,6 +553,131 @@ fn processRootQueuedNavigation(self: *Session) !void { }; } +// Real HTTP root navigation: allocate a pending Page, leave the active Page +// alive, and dispatch the navigation HTTP request against the pending frame. +// The active Page (and its V8 context) stays addressable across the round- +// trip — Runtime.evaluate, DOM.*, etc. continue to operate on the OLD page +// until commitPendingPage swaps the pointer when response headers arrive. +fn initiateRootNavigation(self: *Session, frame_id: u32, qn: *QueuedNavigation) !void { + lp.assert(self._pending_idx == null, "Session.initiateRootNavigation - pending already set", .{}); + + // The qn arena is consumed here regardless of success — frame.navigate + // dupes the URL into the page's own arena, so we can release the qn + // arena as soon as navigate returns. + defer self.arena_pool.release(qn.arena); + + // Pick the slot NOT occupied by the active page. + const slot = try self.findFreeSlot(); + const page = try self.pageInit(slot, frame_id); + errdefer { + page.deinit(false); + self.freeSlot(slot); + } + + page._state = .pending; + self._pending_idx = slot; + errdefer self._pending_idx = null; + + if (comptime IS_DEBUG) { + log.debug(.browser, "initiate root navigation", .{ .url = qn.url }); + } + + // No frame_created notification yet — CDP must not see the pending page + // (no isolated worlds, no Target.* visibility). Both the pending main + // world and the isolated worlds get registered with the V8 inspector at + // commit, after frame_remove tears down the OLD page's context group. + + page.frame.navigate(qn.url, qn.opts) catch |err| { + log.err(.browser, "pending navigation start", .{ .err = err, .url = qn.url }); + return err; + }; +} + +// Promote the pending Page to be the active Page. Called from +// frameHeaderDoneCallback when the in-flight pending root navigation's +// response headers arrive. +// +// Order matters here: +// 1. frame_remove dispatch — CDP's frameRemove resets the V8 inspector +// context group (emits Runtime.executionContextsCleared) and clears +// isolated world contexts plus the node_registry. The OLD page's +// memory is still alive at this point (intentional: CDP teardown can +// walk old-page state without UAF). +// 2. Pointer flip and _state = .active. session.page now points at the +// pending page. +// 3. frame_created dispatch — CDP creates fresh isolated world contexts +// against the new (now active) frame. While pending_page is still +// non-null at this point, CDP's frameCreated handler skips its +// frame_arena reset and captured_responses zeroing (the captured_ +// response for the request we are committing was just inserted by +// onHttpResponseHeadersDone moments earlier and must survive). +// 4. pending_page = null. Order matters: step 3 reads it. +// 5. OLD Page.deinit + free LAST. Its frame.deinit calls +// http_client.abort() unconditionally — the in-flight navigation +// transfer (whose callback we are inside) is shielded by +// protect_from_abort, which the caller clears AFTER we return. +pub fn commitPendingPage(self: *Session) !void { + const pending_idx = self._pending_idx orelse { + lp.assert(false, "Session.commitPendingPage - no pending page", .{}); + return error.NoPendingPage; + }; + const old_idx = self._active_idx orelse { + lp.assert(false, "Session.commitPendingPage - no active page", .{}); + return error.NoActivePage; + }; + + if (comptime IS_DEBUG) { + log.debug(.browser, "commit pending page", .{}); + } + + const pending = &self._pages[pending_idx].?; + + // Step 1: clear the OLD page's CDP / V8 inspector state. + self.notification.dispatch(.frame_remove, .{}); + self.navigation.onRemoveFrame(); + + // Step 2: index flip. Page slot addresses are stable (inline in + // Session), so every self-pointer inside `pending` (window._frame, + // document._frame, EventManager.frame, etc.) remains valid. + self._active_idx = pending_idx; + pending._state = .active; + + // Step 3: register the new page with CDP. _pending_idx is still set at + // this point — CDP's frameCreated handler reads `pendingPage() != null` + // to skip the captured_responses / frame_arena resets that would wipe + // the in-flight response we just received. + self.navigation.onNewFrame(&pending.frame) catch |err| { + log.err(.browser, "commitPendingPage onNewFrame", .{ .err = err }); + }; + self.notification.dispatch(.frame_created, &pending.frame); + + // Step 4: _pending_idx = null AFTER frame_created so step 3 saw it. + self._pending_idx = null; + + // Step 5: tear down the OLD page LAST. Anything in steps 1-4 that + // needed to walk the OLD page's state (CDP node_registry, inspector + // context group, isolated worlds) has already done so. The OLD page's + // frame.deinit calls http_client.abort() unconditionally; the in-flight + // transfer survives via protect_from_abort. + self._pages[old_idx].?.deinit(false); + self.freeSlot(old_idx); +} + +// Discard a pending Page without committing. Used for failure paths +// (HTTP error before commit, session deinit during pending, etc.). The +// active page is untouched. +pub fn discardPendingPage(self: *Session) void { + const idx = self._pending_idx orelse return; + + if (comptime IS_DEBUG) { + log.debug(.browser, "discard pending page", .{}); + } + + self._pending_idx = null; + self._pages[idx].?.deinit(false); + self.freeSlot(idx); +} + pub fn nextFrameId(self: *Session) u32 { const id = self.frame_id_gen +% 1; self.frame_id_gen = id; diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 0554681a..eaa7c93b 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -258,7 +258,7 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request // Isn't possible to do a network request within a Browser (which our // notification is tied to), without a frame. - lp.assert(bc.session.page != null, "CDP.network.httpRequestFail null frame", .{}); + lp.assert(bc.session.hasPage(), "CDP.network.httpRequestFail null frame", .{}); // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.loadingFailed", .{ diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 28a2fc8f..83106645 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -212,7 +212,7 @@ fn close(cmd: *CDP.Command) !void { const target_id = bc.target_id orelse return error.TargetNotLoaded; // can't be null if we have a target_id - lp.assert(bc.session.page != null, "CDP.frame.close null frame", .{}); + lp.assert(bc.session.hasPage(), "CDP.frame.close null frame", .{}); try cmd.sendResult(.{}, .{}); @@ -372,7 +372,14 @@ pub fn frameNavigate(bc: *CDP.BrowserContext, event: *const Notification.FrameNa // detachTarget could be called, in which case, we still have a frame doing // things, but no session. const session_id = bc.session_id orelse return; - bc.reset(); + + // is_pending_root means this navigation is in flight against a pending + // Page while the OLD page is still alive and addressable. Don't blow + // away the node_registry — the OLD page's nodes are still referenced + // by client-held objectIds. The reset moves to frameRemove (commit). + if (!event.is_pending_root) { + bc.reset(); + } const frame_id = &id.toFrameId(event.frame_id); const loader_id = &id.toLoaderId(event.loader_id); @@ -429,18 +436,39 @@ pub fn frameRemove(bc: *CDP.BrowserContext) void { for (bc.isolated_worlds.items) |isolated_world| { isolated_world.removeContext(); } + + // node_registry / node_search_list reference Nodes that live in the page + // about to be torn down. Clear them now — for legacy navigations this is + // a no-op-equivalent of the bc.reset() that frameNavigate used to do up + // front; for pending root commits this is the moment that registry was + // deferred to (frameNavigate skipped it because the OLD page was still + // live during the in-flight HTTP). + bc.reset(); } pub fn frameCreated(bc: *CDP.BrowserContext, frame: *Frame) !void { - _ = bc.cdp.frame_arena.reset(.{ .retain_with_limit = 1024 * 512 }); + // Detect "in commit" mode: Session.commitPendingPage dispatches frame_ + // created BEFORE clearing pending_page (deliberate ordering — see + // Session.commitPendingPage). The captured_response for the request we + // just committed was inserted by onHttpResponseHeadersDone moments ago + // and lives in cdp.frame_arena; resetting either would lose it. + const in_commit = bc.session.pendingPage() != null; + + if (!in_commit) { + _ = bc.cdp.frame_arena.reset(.{ .retain_with_limit = 1024 * 512 }); + } for (bc.isolated_worlds.items) |isolated_world| { _ = try isolated_world.createContext(frame); } - // Only retain captured responses until a navigation event. In CDP term, - // this is called a "renderer" and the cache-duration can be controlled via - // the Network.configureDurableMessages message (which we don't support) - bc.captured_responses = .empty; + + if (!in_commit) { + // Only retain captured responses until a navigation event. In CDP + // terms, this is called a "renderer" and the cache-duration can be + // controlled via Network.configureDurableMessages (which we don't + // support). + bc.captured_responses = .empty; + } } pub fn frameChildFrameCreated(bc: *CDP.BrowserContext, event: *const Notification.FrameChildFrameCreated) !void { diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index e6cfe1ff..8b70ea15 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -171,7 +171,7 @@ fn createTarget(cmd: *CDP.Command) !void { } // if target_id is null, we should never have a blank frame - lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{}); + lp.assert(!bc.session.hasPage(), "CDP.target.createTarget not null page", .{}); // if target_id is null, we should never have a session_id lp.assert(bc.session_id == null, "CDP.target.createTarget not null session_id", .{}); @@ -284,7 +284,7 @@ fn closeTarget(cmd: *CDP.Command) !void { } // can't be null if we have a target_id - lp.assert(bc.session.page != null, "CDP.target.closeTarget null frame", .{}); + lp.assert(bc.session.hasPage(), "CDP.target.closeTarget null frame", .{}); try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false }); @@ -636,7 +636,7 @@ test "cdp.target: closeTarget" { { try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } }); try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 }); - try testing.expectEqual(null, bc.session.page); + try testing.expectEqual(false, bc.session.hasPage()); try testing.expectEqual(null, bc.target_id); } } diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 1838ef39..04489d98 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -204,7 +204,7 @@ const TestContext = struct { if (self.cdp_) |*cdp__| { if (cdp__.browser_context) |*bc| { - if (bc.session.page != null) { + if (bc.session.hasPage()) { var runner = try bc.session.runner(.{}); _ = try runner.tick(.{ .ms = 1000 }); } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index b1fb7c38..053957d8 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -920,7 +920,7 @@ fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Va fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !void { const session = server.session; - if (session.page != null) { + if (session.hasPage()) { session.removePage(); } const frame = session.createPage() catch { From 6e1b8f6a41b74590953136193f5362318d9de990 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 27 Apr 2026 18:43:42 +0200 Subject: [PATCH 08/33] always deinit http of frame/page/worker by frame id --- src/browser/Frame.zig | 29 ++++++----------------------- src/browser/HttpClient.zig | 13 ++++++------- src/browser/Page.zig | 8 ++++---- src/browser/Session.zig | 29 +++++++++++++---------------- src/browser/webapi/Worker.zig | 1 + 5 files changed, 30 insertions(+), 50 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 01021a75..ac065dfd 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -351,9 +351,9 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void { } } -pub fn deinit(self: *Frame, abort_http: bool) void { +pub fn deinit(self: *Frame) void { for (self.child_frames.items) |frame| { - frame.deinit(abort_http); + frame.deinit(); } for (self.workers.items) |worker| { @@ -413,14 +413,7 @@ pub fn deinit(self: *Frame, abort_http: bool) void { self._script_manager.base.shutdown = true; - if (self.parent == null) { - browser.http_client.abort(); - } else if (abort_http) { - // a small optimization, it's faster to abort _everything_ on the root - // frame, so we prefer that. But if it's just the frame that's going - // away (a frame navigation) then we'll abort the frame-related requests - browser.http_client.abortFrame(self._frame_id); - } + browser.http_client.abortFrame(self._frame_id); self._script_manager.deinit(); self._style_manager.deinit(); @@ -765,17 +758,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url .type = target._type, }); - // This is a micro-optimization. Terminate any inflight request as early - // as we can. This will be more properly shutdown when we process the - // scheduled navigation. - if (target.parent == null) { - session.browser.http_client.abort(); - } else { - // This doesn't terminate any inflight requests for nested frames, but - // again, this is just an optimization. We'll correctly shut down all - // nested inflight requests when we process the navigation. - session.browser.http_client.abortFrame(target._frame_id); - } + session.browser.http_client.abortFrame(target._frame_id); // Capture the originating frame's URL as the Referer for this // navigation. The originator's frame may be torn down before navigate() @@ -1314,7 +1297,7 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { const frame_id = session.nextFrameId(); try Frame.init(new_frame, frame_id, self._page, self); - errdefer new_frame.deinit(true); + errdefer new_frame.deinit(); self._pending_loads += 1; new_frame.iframe = iframe; @@ -1423,7 +1406,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame { const frame_id = session.nextFrameId(); try Frame.init(popup, frame_id, page, null); - errdefer popup.deinit(true); + errdefer popup.deinit(); popup.window._opener = opts.opener; if (opts.name.len > 0 and diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index a6b9960f..28acecd2 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -324,9 +324,10 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void { while (n) |node| { n = node.next; const transfer: *Transfer = @fieldParentPtr("_node", node); + const params = transfer.req.params; if (comptime abort_all) { transfer.kill(); - } else if (transfer.req.params.frame_id == frame_id) { + } else if (params.frame_id == frame_id and !params.protect_from_abort) { q.remove(node); transfer.kill(); } @@ -339,15 +340,12 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void { } if (comptime IS_DEBUG and abort_all) { - // Even after an abort_all, we could still have transfers, but, at the - // very least, they should all be flagged as aborted (or be a transfer - // we explicitly protected with protect_from_abort). var it = self.in_use.first; var leftover: usize = 0; while (it) |node| : (it = node.next) { const conn: *http.Connection = @fieldParentPtr("node", node); switch (conn.transport) { - .http => |transfer| std.debug.assert(transfer.aborted or transfer.req.params.protect_from_abort), + .http => |transfer| std.debug.assert(transfer.aborted), .websocket => {}, .none => {}, } @@ -364,8 +362,9 @@ fn abortConnections(list: std.DoublyLinkedList, comptime abort_all: bool, frame_ const conn: *http.Connection = @fieldParentPtr("node", node); switch (conn.transport) { .http => |transfer| { - const matches = (comptime abort_all) or transfer.req.params.frame_id == frame_id; - if (matches and !transfer.req.params.protect_from_abort) { + const params = transfer.req.params; + const matches = (comptime abort_all) or (params.frame_id == frame_id and !params.protect_from_abort); + if (matches) { transfer.kill(); } }, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 28e64e43..4a35ff17 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -130,15 +130,15 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void { // Tear down the Page and its root Frame. Equivalent to the old // Session.removePage + Session.resetFrameResources. -pub fn deinit(self: *Page, abort_http: bool) void { +pub fn deinit(self: *Page) void { self.cleanupClosedPopups(); for (self.popups.items) |popup| { - popup.deinit(abort_http); + popup.deinit(); } self.popups = .empty; - self.frame.deinit(abort_http); + self.frame.deinit(); const session = self.session; defer session.browser.env.memoryPressureNotification(.moderate); @@ -188,7 +188,7 @@ pub fn deinit(self: *Page, abort_http: bool) void { pub fn cleanupClosedPopups(self: *Page) void { for (self.queued_close.items) |popup| { - popup.deinit(true); + popup.deinit(); } self.queued_close = .empty; } diff --git a/src/browser/Session.zig b/src/browser/Session.zig index c02fc89a..9081ad08 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -120,9 +120,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi } pub fn deinit(self: *Session) void { - // Tear down a pending navigation first so its in-flight transfer is - // aborted before we shutter the active Page (which calls - // http_client.abort() unconditionally from frame.deinit). if (self._pending_idx != null) { self.discardPendingPage(); } @@ -180,7 +177,7 @@ pub fn createPage(self: *Session) !*Frame { const slot = try self.findFreeSlot(); const page = try self.pageInit(slot, self.nextFrameId()); errdefer { - page.deinit(false); + page.deinit(); self.freeSlot(slot); } self._active_idx = slot; @@ -224,7 +221,7 @@ pub fn removePage(self: *Session) void { // Inform CDP the frame is going to be removed, allowing other worlds to remove themselves before the main one self.notification.dispatch(.frame_remove, .{}); - self._pages[idx].?.deinit(false); + self._pages[idx].?.deinit(); self.freeSlot(idx); self._active_idx = null; @@ -268,7 +265,7 @@ pub fn replacePage(self: *Session) !*Frame { lp.assert(old_page.frame.parent == null, "Session.replacePage with parent", .{}); const frame_id = old_page.frame._frame_id; - old_page.deinit(true); + old_page.deinit(); self.freeSlot(old_idx); self._active_idx = null; @@ -280,7 +277,7 @@ pub fn replacePage(self: *Session) !*Frame { const new_slot = try self.findFreeSlot(); const page = try self.pageInit(new_slot, frame_id); errdefer { - page.deinit(false); + page.deinit(); self.freeSlot(new_slot); } self._active_idx = new_slot; @@ -408,7 +405,7 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) const frame_id = frame._frame_id; const page = self.currentPage().?; - frame.deinit(true); + frame.deinit(); frame.* = undefined; errdefer { @@ -428,7 +425,7 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) if (parent_notified) { parent._pending_loads -= 1; } - frame.deinit(true); + frame.deinit(); } frame.iframe = iframe; @@ -454,7 +451,7 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) const frame_id = frame._frame_id; const page = self.currentPage().?; - frame.deinit(true); + frame.deinit(); frame.* = undefined; errdefer { @@ -468,7 +465,7 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) } try Frame.init(frame, frame_id, page, null); - errdefer frame.deinit(true); + errdefer frame.deinit(); frame.window._name = saved_name; frame.window._opener = saved_opener; @@ -520,7 +517,7 @@ fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !v // Dispatch frame_remove (same as removePage) then tear down the OLD // page's slot. self.notification.dispatch(.frame_remove, .{}); - self._pages[old_idx].?.deinit(true); + self._pages[old_idx].?.deinit(); self.freeSlot(old_idx); self._active_idx = null; @@ -532,7 +529,7 @@ fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !v const new_slot = try self.findFreeSlot(); const page = try self.pageInit(new_slot, frame_id); errdefer { - page.deinit(false); + page.deinit(); self.freeSlot(new_slot); } self._active_idx = new_slot; @@ -570,7 +567,7 @@ fn initiateRootNavigation(self: *Session, frame_id: u32, qn: *QueuedNavigation) const slot = try self.findFreeSlot(); const page = try self.pageInit(slot, frame_id); errdefer { - page.deinit(false); + page.deinit(); self.freeSlot(slot); } @@ -659,7 +656,7 @@ pub fn commitPendingPage(self: *Session) !void { // context group, isolated worlds) has already done so. The OLD page's // frame.deinit calls http_client.abort() unconditionally; the in-flight // transfer survives via protect_from_abort. - self._pages[old_idx].?.deinit(false); + self._pages[old_idx].?.deinit(); self.freeSlot(old_idx); } @@ -674,7 +671,7 @@ pub fn discardPendingPage(self: *Session) void { } self._pending_idx = null; - self._pages[idx].?.deinit(false); + self._pages[idx].?.deinit(); self.freeSlot(idx); } diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 81d74116..aed9ad16 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -121,6 +121,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { // Called from Frame.deinit when the frame is destroyed, so we don't need to // remove from the frame's worker list. pub fn deinit(self: *Worker) void { + self._frame._session.browser.http_client.abortFrame(self._frame_id); if (self._http_response) |res| { res.abort(error.Abort); self._http_response = null; From f7ac258b8c1011e5326a646fcc9d69f18ea8e96a Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 28 Apr 2026 09:10:50 +0200 Subject: [PATCH 09/33] dispatch frame_remove and new_frame events from sesion.replacePage So the CDP can remove/reset context and re-create isolated world accordingly --- src/browser/Session.zig | 22 +++++++++++++++++++++- src/cdp/domains/page.zig | 12 ------------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 9081ad08..6c84524d 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -265,9 +265,17 @@ pub fn replacePage(self: *Session) !*Frame { lp.assert(old_page.frame.parent == null, "Session.replacePage with parent", .{}); const frame_id = old_page.frame._frame_id; + + // Dispatch frame_remove so CDP can tear down the OLD page's isolated + // world V8 contexts and inspector state. Without this, old V8 contexts + // leak (and a later frameNavigated would localScope() into a freed + // handle — UAF). + self.notification.dispatch(.frame_remove, .{}); + old_page.deinit(); self.freeSlot(old_idx); self._active_idx = null; + self.navigation.onRemoveFrame(); // Preserve prior behavior: frame_id_gen reset on root replacement so a // subsequent createPage starts from id 1. The captured frame_id is @@ -281,7 +289,19 @@ pub fn replacePage(self: *Session) !*Frame { self.freeSlot(new_slot); } self._active_idx = new_slot; - return &page.frame; + const new_frame = &page.frame; + + // Creates a new NavigationEventTarget for this frame. + self.navigation.onNewFrame(new_frame) catch |err| { + log.err(.browser, "replacePage onNewFrame", .{ .err = err }); + }; + + // Dispatch frame_created so CDP creates fresh isolated world V8 contexts + // for the new frame. The subsequent frame.navigate call will dispatch + // frame_navigated which registers them with the inspector. + self.notification.dispatch(.frame_created, new_frame); + + return new_frame; } pub fn currentPage(self: *Session) ?*Page { diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 83106645..e7210a5b 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -301,12 +301,6 @@ fn navigate(cmd: *CDP.Command) !void { var frame = session.currentFrame() orelse return error.FrameNotLoaded; if (frame._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 = .{}; - } frame = try session.replacePage(); } @@ -348,12 +342,6 @@ fn doReload(cmd: *CDP.Command) !void { }; if (frame._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 = .{}; - } frame = try session.replacePage(); } From c251f0c03b5918829804171c89867a8f5ee752e0 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 28 Apr 2026 16:53:04 +0200 Subject: [PATCH 10/33] cdp: remove replacePage and use Session.initiateRootNavigation --- src/browser/Session.zig | 72 ++++++---------------------------------- src/cdp/domains/page.zig | 17 +++------- 2 files changed, 15 insertions(+), 74 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 6c84524d..cb78ea3a 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -252,58 +252,6 @@ pub fn releaseOrigin(self: *Session, origin: *js.Origin) void { self.currentPage().?.releaseOrigin(origin); } -pub fn replacePage(self: *Session) !*Frame { - if (comptime IS_DEBUG) { - log.debug(.browser, "replace page", .{}); - } - - const old_idx = self._active_idx orelse { - lp.assert(false, "Session.replacePage null page", .{}); - return error.NoActivePage; - }; - const old_page = &self._pages[old_idx].?; - lp.assert(old_page.frame.parent == null, "Session.replacePage with parent", .{}); - - const frame_id = old_page.frame._frame_id; - - // Dispatch frame_remove so CDP can tear down the OLD page's isolated - // world V8 contexts and inspector state. Without this, old V8 contexts - // leak (and a later frameNavigated would localScope() into a freed - // handle — UAF). - self.notification.dispatch(.frame_remove, .{}); - - old_page.deinit(); - self.freeSlot(old_idx); - self._active_idx = null; - self.navigation.onRemoveFrame(); - - // Preserve prior behavior: frame_id_gen reset on root replacement so a - // subsequent createPage starts from id 1. The captured frame_id is - // passed into Page.init explicitly, so it isn't affected. - self.frame_id_gen = 0; - - const new_slot = try self.findFreeSlot(); - const page = try self.pageInit(new_slot, frame_id); - errdefer { - page.deinit(); - self.freeSlot(new_slot); - } - self._active_idx = new_slot; - const new_frame = &page.frame; - - // Creates a new NavigationEventTarget for this frame. - self.navigation.onNewFrame(new_frame) catch |err| { - log.err(.browser, "replacePage onNewFrame", .{ .err = err }); - }; - - // Dispatch frame_created so CDP creates fresh isolated world V8 contexts - // for the new frame. The subsequent frame.navigate call will dispatch - // frame_navigated which registers them with the inspector. - self.notification.dispatch(.frame_created, new_frame); - - return new_frame; -} - pub fn currentPage(self: *Session) ?*Page { const idx = self._active_idx orelse return null; return &self._pages[idx].?; @@ -519,7 +467,12 @@ fn processRootQueuedNavigation(self: *Session) !void { return self.replaceRootImmediate(current_frame._frame_id, qn); } - return self.initiateRootNavigation(current_frame._frame_id, qn); + // The qn arena is consumed here regardless of success — frame.navigate + // dupes the URL into the page's own arena, so we can release the qn + // arena as soon as navigate returns. + defer self.arena_pool.release(qn.arena); + + return self.initiateRootNavigation(current_frame._frame_id, qn.url, qn.opts); } // Legacy immediate-swap path: tear down the active page and create a new one @@ -575,14 +528,9 @@ fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !v // The active Page (and its V8 context) stays addressable across the round- // trip — Runtime.evaluate, DOM.*, etc. continue to operate on the OLD page // until commitPendingPage swaps the pointer when response headers arrive. -fn initiateRootNavigation(self: *Session, frame_id: u32, qn: *QueuedNavigation) !void { +pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void { lp.assert(self._pending_idx == null, "Session.initiateRootNavigation - pending already set", .{}); - // The qn arena is consumed here regardless of success — frame.navigate - // dupes the URL into the page's own arena, so we can release the qn - // arena as soon as navigate returns. - defer self.arena_pool.release(qn.arena); - // Pick the slot NOT occupied by the active page. const slot = try self.findFreeSlot(); const page = try self.pageInit(slot, frame_id); @@ -596,7 +544,7 @@ fn initiateRootNavigation(self: *Session, frame_id: u32, qn: *QueuedNavigation) errdefer self._pending_idx = null; if (comptime IS_DEBUG) { - log.debug(.browser, "initiate root navigation", .{ .url = qn.url }); + log.debug(.browser, "initiate root navigation", .{ .url = url }); } // No frame_created notification yet — CDP must not see the pending page @@ -604,8 +552,8 @@ fn initiateRootNavigation(self: *Session, frame_id: u32, qn: *QueuedNavigation) // world and the isolated worlds get registered with the V8 inspector at // commit, after frame_remove tears down the OLD page's context group. - page.frame.navigate(qn.url, qn.opts) catch |err| { - log.err(.browser, "pending navigation start", .{ .err = err, .url = qn.url }); + page.frame.navigate(url, opts) catch |err| { + log.err(.browser, "pending navigation start", .{ .err = err, .url = url }); return err; }; } diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index e7210a5b..950376ad 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -298,14 +298,11 @@ fn navigate(cmd: *CDP.Command) !void { } const session = bc.session; - var frame = session.currentFrame() orelse return error.FrameNotLoaded; + const frame = session.currentFrame() orelse return error.FrameNotLoaded; - if (frame._load_state != .waiting) { - frame = try session.replacePage(); - } const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8"); - try frame.navigate(encoded_url, .{ + try session.initiateRootNavigation(frame._frame_id, encoded_url, .{ .reason = .address_bar, .cdp_id = cmd.input.id, .kind = .{ .push = null }, @@ -325,10 +322,10 @@ fn doReload(cmd: *CDP.Command) !void { } const session = bc.session; - var frame = session.currentFrame() orelse return error.FrameNotLoaded; + const frame = session.currentFrame() orelse return error.FrameNotLoaded; // Capture URL plus the prior navigation's method/body/header before - // replacePage() frees the old frame's arena. Replaying the same HTTP + // we free the old frame's arena. Replaying the same HTTP // method on reload matches Chrome's F5 behavior — POST navigations // re-submit, GET navigations re-fetch. const reload_url = try cmd.arena.dupeZ(u8, frame.url); @@ -341,11 +338,7 @@ fn doReload(cmd: *CDP.Command) !void { }; }; - if (frame._load_state != .waiting) { - frame = try session.replacePage(); - } - - try frame.navigate(reload_url, .{ + try session.initiateRootNavigation(frame._frame_id, reload_url, .{ .reason = .address_bar, .cdp_id = cmd.input.id, .kind = .reload, From 84246c3b57f06e81744f01230d85d8c9ea22ea9c Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 28 Apr 2026 18:28:06 +0200 Subject: [PATCH 11/33] Get the pending frame from the Runner We want to take in account the pending frame in Runner._tick to continue to process. If we use only the current frame, we will immediately return in case of navigation. --- src/browser/Runner.zig | 8 ++++---- src/browser/Session.zig | 5 +++++ src/cdp/domains/page.zig | 7 +++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index dfca0700..18712c6c 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -142,10 +142,10 @@ pub fn tickCDP(self: *Runner, opts: TickOpts) !CDPTickResult { } fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult { - // Refresh self.frame from session — the previous _tick's http_client.tick() - // may have fired frameHeaderDoneCallback → Session.commitPendingPage, - // freeing the OLD page and replacing it with the pending one. - self.frame = self.session.currentFrame() orelse return .done; + // Refresh self.frame from session. In case of pending page, we want to + // take its state while loading. If we use only the current frame, we will + // return a .done result immediately. + self.frame = self.session.currentOrPendingFrame() orelse return .done; const frame = self.frame; const http_client = self.http_client; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index cb78ea3a..052648c0 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -265,6 +265,11 @@ pub fn pendingPage(self: *Session) ?*Page { return &self._pages[idx].?; } +pub fn currentOrPendingFrame(self: *Session) ?*Frame { + const page = self.pendingPage() orelse self.currentPage() orelse return null; + return &page.frame; +} + pub fn currentFrame(self: *Session) ?*Frame { const page = self.currentPage() orelse return null; return &page.frame; diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 950376ad..6ce6e5b3 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -300,7 +300,6 @@ fn navigate(cmd: *CDP.Command) !void { const session = bc.session; const frame = session.currentFrame() orelse return error.FrameNotLoaded; - const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8"); try session.initiateRootNavigation(frame._frame_id, encoded_url, .{ .reason = .address_bar, @@ -1025,17 +1024,21 @@ test "cdp.frame: reload" { try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 30 }); } - _ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); + const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); { // reload with no params — should not error (navigation is async, // so no result is sent synchronously; we just verify no error) try ctx.processMessage(.{ .id = 31, .method = "Page.reload" }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); } { // reload with ignoreCache param try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); } } From 11172a341a242c0a81f46ad66d550de32c053d30 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 28 Apr 2026 20:56:01 +0200 Subject: [PATCH 12/33] cdp: use loader_id as captured response key for documents --- src/cdp/CDP.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 82b7db24..68df5dbb 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -692,7 +692,9 @@ pub const BrowserContext = struct { const arena = self.frame_arena; // Prepare the captured response value. - const id = msg.request.params.request_id; + const params = msg.request.params; + // for documents, CDP uses the loarder id as request id. + const id = if (params.resource_type == .document) params.loader_id else params.request_id; const gop = try self.captured_responses.getOrPut(arena, id); if (!gop.found_existing) { gop.value_ptr.* = .{ @@ -729,7 +731,9 @@ pub const BrowserContext = struct { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); const arena = self.frame_arena; - const id = msg.request.params.request_id; + const params = msg.request.params; + // for documents, CDP uses the loarder id as request id. + const id = if (params.resource_type == .document) params.loader_id else params.request_id; const resp = self.captured_responses.getPtr(id) orelse lp.assert(false, "onHttpResponseData missinf captured response", .{}); return resp.data.appendSlice(arena, msg.data); From e33018f40ef2300f4094eb38935ad85efe06a01d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 28 Apr 2026 21:44:22 +0200 Subject: [PATCH 13/33] discardPendingPage on Session.initiateRootNavigation --- src/browser/Frame.zig | 5 +++-- src/browser/HttpClient.zig | 35 +++++++++++++++++++++++------------ src/browser/Session.zig | 11 +++++++++-- src/browser/webapi/Worker.zig | 3 ++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index ac065dfd..3998117d 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -413,7 +413,8 @@ pub fn deinit(self: *Frame) void { self._script_manager.base.shutdown = true; - browser.http_client.abortFrame(self._frame_id); + // don't abort pending frames. + browser.http_client.abortFrame(self._frame_id, .{}); self._script_manager.deinit(); self._style_manager.deinit(); @@ -758,7 +759,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url .type = target._type, }); - session.browser.http_client.abortFrame(target._frame_id); + session.browser.http_client.abortFrame(target._frame_id, .{}); // Capture the originating frame's URL as the Referer for this // navigation. The originator's frame may be torn down before navigate() diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 28acecd2..a68134ed 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -304,19 +304,25 @@ pub fn getUserAgent(self: *const Client) [:0]const u8 { return self.user_agent_override orelse self.network.config.http_headers.user_agent; } +const AbortOpts = struct { + scope: enum { normal, full } = .normal, +}; + pub fn abort(self: *Client) void { - self._abort(true, 0); + self._abort(true, 0, .{ .scope = .full }); } -pub fn abortFrame(self: *Client, frame_id: u32) void { - self._abort(false, frame_id); +// abortFrame with .normal doesn't abort protect_from_abort requests. +// .full abort all relqtive requests. +pub fn abortFrame(self: *Client, frame_id: u32, opts: AbortOpts) void { + self._abort(false, frame_id, opts); } // Written this way so that both abort and abortFrame can share the same code // but abort can avoid the frame_id check at comptime. -fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void { - abortConnections(self.in_use, abort_all, frame_id); - abortConnections(self.ready_queue, abort_all, frame_id); +fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32, opts: AbortOpts) void { + abortConnections(self.in_use, abort_all, frame_id, opts); + abortConnections(self.ready_queue, abort_all, frame_id, opts); { var q = &self.queue; @@ -327,9 +333,11 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void { const params = transfer.req.params; if (comptime abort_all) { transfer.kill(); - } else if (params.frame_id == frame_id and !params.protect_from_abort) { - q.remove(node); - transfer.kill(); + } else if (params.frame_id == frame_id) { + if (opts.scope == .full or !params.protect_from_abort) { + q.remove(node); + transfer.kill(); + } } } } @@ -355,7 +363,7 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void { } } -fn abortConnections(list: std.DoublyLinkedList, comptime abort_all: bool, frame_id: u32) void { +fn abortConnections(list: std.DoublyLinkedList, comptime abort_all: bool, frame_id: u32, opts: AbortOpts) void { var n = list.first; while (n) |node| { n = node.next; @@ -363,9 +371,12 @@ fn abortConnections(list: std.DoublyLinkedList, comptime abort_all: bool, frame_ switch (conn.transport) { .http => |transfer| { const params = transfer.req.params; - const matches = (comptime abort_all) or (params.frame_id == frame_id and !params.protect_from_abort); - if (matches) { + if (comptime abort_all) { transfer.kill(); + } else if (params.frame_id == frame_id) { + if (opts.scope == .full or !params.protect_from_abort) { + transfer.kill(); + } } }, .websocket => |ws| { diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 052648c0..1c97cc9d 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -534,7 +534,9 @@ fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !v // trip — Runtime.evaluate, DOM.*, etc. continue to operate on the OLD page // until commitPendingPage swaps the pointer when response headers arrive. pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void { - lp.assert(self._pending_idx == null, "Session.initiateRootNavigation - pending already set", .{}); + if (self._pending_idx != null) { + self.discardPendingPage(); + } // Pick the slot NOT occupied by the active page. const slot = try self.findFreeSlot(); @@ -643,8 +645,13 @@ pub fn discardPendingPage(self: *Session) void { log.debug(.browser, "discard pending page", .{}); } + const pending_page = &self._pages[idx].?; + + // Force abort all inflight queries. + self.browser.http_client.abortFrame(pending_page.frame._frame_id, .{ .scope = .full }); + self._pending_idx = null; - self._pages[idx].?.deinit(); + pending_page.deinit(); self.freeSlot(idx); } diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index aed9ad16..56f40413 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -121,7 +121,8 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { // Called from Frame.deinit when the frame is destroyed, so we don't need to // remove from the frame's worker list. pub fn deinit(self: *Worker) void { - self._frame._session.browser.http_client.abortFrame(self._frame_id); + // No pending frame for workers, so we can abort all frames. + self._frame._session.browser.http_client.abortFrame(self._frame_id, .{ .scope = .full }); if (self._http_response) |res| { res.abort(error.Abort); self._http_response = null; From b2c53f4a1dc10d00ccd4dfbdc8b7e1244565c8b4 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 29 Apr 2026 09:58:45 +0200 Subject: [PATCH 14/33] update comments according to abortFrame change --- src/browser/Frame.zig | 13 ++++++++----- src/browser/HttpClient.zig | 14 ++++++++------ src/browser/Session.zig | 13 ++++++++----- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 3998117d..079d6b81 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -632,7 +632,8 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo // Session.initiateRootNavigation) flags both the notification and the // HTTP request itself: CDP skips its node-registry reset until commit, // and the in-flight transfer survives the OLD page's frame.deinit which - // calls http_client.abort() during commitPendingPage. + // calls http_client.abortFrame(frame_id) on the shared frame_id during + // commitPendingPage. const is_pending_root = self._page._state == .pending; // We dispatch frame_navigate event before sending the request. @@ -971,11 +972,13 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !bool { // tears down the OLD page, flips the pointer, and dispatches // frame_created against the new (now active) frame. // - // The OLD page's frame.deinit calls http_client.abort() — our transfer + // The OLD page's frame.deinit calls http_client.abortFrame(frame_id) on + // the frame_id it shares with the (now-active) pending page; our transfer // survives because Session.initiateRootNavigation flagged the request - // protect_from_abort. Once we are past commit, that protection is no - // longer needed and may interfere with subsequent aborts (e.g. another - // navigation while we are still streaming the body), so clear it. + // protect_from_abort, which abortFrame's default .normal scope honors. + // Once we are past commit, that protection is no longer needed and may + // interfere with subsequent aborts (e.g. another navigation while we are + // still streaming the body), so clear it. if (self._page._state == .pending) { try self._session.commitPendingPage(); switch (response.inner) { diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index a68134ed..3423bdcd 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -54,9 +54,9 @@ pub const InterceptionLayer = @import("../network/layer/InterceptionLayer.zig"); // // The app has other secondary http needs, like telemetry. While we want to // share some things (namely the ca blob, and maybe some configuration -// (TODO: ??? should proxy settings be global ???)), we're able to do call -// client.abort() to abort the transfers being made by a frame, without impacting -// those other http requests. +// (TODO: ??? should proxy settings be global ???)), we're able to call +// client.abortFrame() to abort the transfers being made by a frame, without +// impacting those other http requests. pub const Client = @This(); // Count of active ws requests @@ -892,9 +892,11 @@ pub const RequestParams = struct { // Set on an in-flight root-navigation transfer that was issued against a // pending Page. The old Page's frame.deinit (called from Session.commit - // PendingPage when response headers arrive) calls http_client.abort() — - // that abort_all path skips transfers with this flag so the callback - // chain we are sitting inside isn't killed mid-flight. + // PendingPage when response headers arrive) calls abortFrame() on the + // shared frame_id; abortFrame's default .normal scope skips transfers + // with this flag so the callback chain we are sitting inside isn't killed + // mid-flight. Session.discardPendingPage uses .full scope to override + // the flag in failure paths. protect_from_abort: bool = false, const ResourceType = enum { diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 1c97cc9d..99689d85 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -585,9 +585,11 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, // onHttpResponseHeadersDone moments earlier and must survive). // 4. pending_page = null. Order matters: step 3 reads it. // 5. OLD Page.deinit + free LAST. Its frame.deinit calls -// http_client.abort() unconditionally — the in-flight navigation -// transfer (whose callback we are inside) is shielded by -// protect_from_abort, which the caller clears AFTER we return. +// http_client.abortFrame(frame_id) on the frame_id that the OLD +// page shares with the now-active pending page; the in-flight +// navigation transfer (whose callback we are inside) is shielded +// by protect_from_abort, which abortFrame's default .normal scope +// honors. The caller clears the flag AFTER we return. pub fn commitPendingPage(self: *Session) !void { const pending_idx = self._pending_idx orelse { lp.assert(false, "Session.commitPendingPage - no pending page", .{}); @@ -629,8 +631,9 @@ pub fn commitPendingPage(self: *Session) !void { // Step 5: tear down the OLD page LAST. Anything in steps 1-4 that // needed to walk the OLD page's state (CDP node_registry, inspector // context group, isolated worlds) has already done so. The OLD page's - // frame.deinit calls http_client.abort() unconditionally; the in-flight - // transfer survives via protect_from_abort. + // frame.deinit calls http_client.abortFrame(frame_id) on the frame_id + // shared with the pending page; the in-flight transfer survives via + // protect_from_abort. self._pages[old_idx].?.deinit(); self.freeSlot(old_idx); } From cddabe60f541083660b7a7ef6cc731aa50d60677 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 29 Apr 2026 15:38:38 +0200 Subject: [PATCH 15/33] cdp: avoid request id conflict between LID- and REQ- Use distinct key for laoder id and request id based captured response. --- src/cdp/CDP.zig | 34 +++++++++++++++++++++++++--------- src/cdp/domains/network.zig | 17 +++++++++-------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 68df5dbb..c4fa1d84 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -28,6 +28,7 @@ const Frame = @import("../browser/Frame.zig"); const Mime = @import("../browser/Mime.zig"); const Element = @import("../browser/webapi/Element.zig"); const Label = @import("../browser/webapi/element/html/Label.zig"); +const Request = @import("../browser/HttpClient.zig").Request; const Incrementing = @import("id.zig").Incrementing; const InterceptState = @import("domains/fetch.zig").InterceptState; @@ -317,6 +318,18 @@ pub const BrowserContext = struct { data: std.ArrayList(u8), }; + // Key for `captured_responses`. Documents are keyed by `loader_id`, + // everything else by `request_id` — the two id-spaces are independent + // counters and overlap numerically (loader 1 / request 1, loader 2 / + // request 2, ...), so the map key has to carry the namespace or + // entries collide. The wire-format prefix (`LID-` / `REQ-`) provides + // the same disambiguation on lookup; see `idFromRequestId` in + // domains/network.zig. + pub const CapturedResponseKey = struct { + kind: enum { request, loader }, + id: u32, + }; + id: []const u8, cdp: *CDP, @@ -382,7 +395,7 @@ pub const BrowserContext = struct { // ever streamed. So if CDP is the only thing that needs bodies in // memory for an arbitrary amount of time, then that's where we're going // to store the, - captured_responses: std.AutoHashMapUnmanaged(usize, CapturedResponse), + captured_responses: std.AutoHashMapUnmanaged(CapturedResponseKey, CapturedResponse), notification: *Notification, @@ -685,6 +698,13 @@ pub const BrowserContext = struct { return @import("domains/page.zig").javascriptDialogOpening(self, msg); } + fn keyFromRequestReq(req: *const Request) CDP.BrowserContext.CapturedResponseKey { + return if (req.params.resource_type == .document) + .{ .kind = .loader, .id = req.params.loader_id } + else + .{ .kind = .request, .id = req.params.request_id }; + } + pub fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); defer self.resetNotificationArena(); @@ -692,10 +712,8 @@ pub const BrowserContext = struct { const arena = self.frame_arena; // Prepare the captured response value. - const params = msg.request.params; - // for documents, CDP uses the loarder id as request id. - const id = if (params.resource_type == .document) params.loader_id else params.request_id; - const gop = try self.captured_responses.getOrPut(arena, id); + const key = keyFromRequestReq(msg.request); + const gop = try self.captured_responses.getOrPut(arena, key); if (!gop.found_existing) { gop.value_ptr.* = .{ .data = .empty, @@ -731,10 +749,8 @@ pub const BrowserContext = struct { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); const arena = self.frame_arena; - const params = msg.request.params; - // for documents, CDP uses the loarder id as request id. - const id = if (params.resource_type == .document) params.loader_id else params.request_id; - const resp = self.captured_responses.getPtr(id) orelse lp.assert(false, "onHttpResponseData missinf captured response", .{}); + const key = keyFromRequestReq(msg.request); + const resp = self.captured_responses.getPtr(key) orelse lp.assert(false, "onHttpResponseData missing captured response", .{}); return resp.data.appendSlice(arena, msg.data); } diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index eaa7c93b..76fb5ae3 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -229,9 +229,9 @@ fn getResponseBody(cmd: *CDP.Command) !void { requestId: []const u8, // "REQ-{d}" or "LID-{d}" })) orelse return error.InvalidParams; - const request_id = try idFromRequestId(params.requestId); + const key = try keyFromRequestId(params.requestId); const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const resp = bc.captured_responses.getPtr(request_id) orelse return error.RequestNotFound; + const resp = bc.captured_responses.getPtr(key) orelse return error.RequestNotFound; if (!resp.must_encode) { return cmd.sendResult(.{ @@ -476,12 +476,13 @@ const ResponseWriter = struct { } }; -fn idFromRequestId(request_id: []const u8) !u64 { - // The requesIid for the original document is its loaderId. - if (!std.mem.startsWith(u8, request_id, "REQ-") and !std.mem.startsWith(u8, request_id, "LID-")) { - return error.InvalidParams; - } - return std.fmt.parseInt(u64, request_id[4..], 10) catch return error.InvalidParams; +fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedResponseKey { + const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams; + + return if (std.mem.startsWith(u8, request_id, "LID-")) + .{ .id = key, .kind = .loader } + else + .{ .id = key, .kind = .request }; } const testing = @import("../testing.zig"); From ddbdaafa28a9e2b7bba937edf017c05886620f4d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 29 Apr 2026 16:15:48 +0200 Subject: [PATCH 16/33] refacto Session install and teardown active page --- src/browser/Session.zig | 106 +++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 60 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 99689d85..d17d8d89 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -169,13 +169,41 @@ fn freeSlot(self: *Session, slot: u1) void { self._pages[slot] = null; } -// NOTE: the caller is not the owner of the returned value, -// the pointer on Frame is just returned as a convenience -pub fn createPage(self: *Session) !*Frame { - lp.assert(self._active_idx == null, "Session.createPage - page not null", .{}); +// Tear down the currently-active Page. Dispatches `frame_remove` first +// so CDP can clear inspector state while the OLD page is still walkable, +// then frees the slot and notifies Navigation. Resets `frame_id_gen` to +// match pre-pending-page behavior. Used by removePage and by the +// synthetic-nav path (replaceRootImmediate). Does NOT touch any pending +// page — callers handle that themselves. +// +// NOT a substitute for the careful 5-step sequence in commitPendingPage, +// which interleaves the OLD-page teardown with the pending-page promotion +// in a specific order. +fn tearDownActivePage(self: *Session) void { + self.notification.dispatch(.frame_remove, .{}); + const idx = self._active_idx orelse { + lp.assert(false, "Session.tearDownActivePage - no active page", .{}); + return; + }; + self._pages[idx].?.deinit(); + self.freeSlot(idx); + self._active_idx = null; + self.navigation.onRemoveFrame(); + self.frame_id_gen = 0; +} +// Allocate a Page in a free slot, publish it as the active page, and +// dispatch `frame_created` so CDP creates fresh isolated-world V8 +// contexts. Used by createPage and by the synthetic-nav path. Does NOT +// dispatch `frame_navigate` — the caller does that (or doesn't, for a +// blank initial page). +// +// On any failure after slot allocation, the errdefers roll back the slot +// and `_active_idx`, leaving the session pageless (the caller is +// responsible for any prior teardown of an old page). +fn installNewActivePage(self: *Session, frame_id: u32) !*Frame { const slot = try self.findFreeSlot(); - const page = try self.pageInit(slot, self.nextFrameId()); + const page = try self.pageInit(slot, frame_id); errdefer { page.deinit(); self.freeSlot(slot); @@ -184,18 +212,21 @@ pub fn createPage(self: *Session) !*Frame { errdefer self._active_idx = null; const frame = &page.frame; - - // Creates a new NavigationEventTarget for this frame. try self.navigation.onNewFrame(frame); + // Inform CDP the main frame has been created such that additional + // context for other Worlds can be created as well. + self.notification.dispatch(.frame_created, frame); + return frame; +} +// NOTE: the caller is not the owner of the returned value, +// the pointer on Frame is just returned as a convenience +pub fn createPage(self: *Session) !*Frame { + lp.assert(self._active_idx == null, "Session.createPage - page not null", .{}); if (comptime IS_DEBUG) { log.debug(.browser, "create page", .{}); } - // start JS env - // Inform CDP the main frame has been created such that additional context for other Worlds can be created as well - self.notification.dispatch(.frame_created, frame); - - return frame; + return self.installNewActivePage(self.nextFrameId()); } pub fn removePage(self: *Session) void { @@ -217,20 +248,7 @@ pub fn removePage(self: *Session) void { if (self._pending_idx != null) { self.discardPendingPage(); } - - // Inform CDP the frame is going to be removed, allowing other worlds to remove themselves before the main one - self.notification.dispatch(.frame_remove, .{}); - - self._pages[idx].?.deinit(); - self.freeSlot(idx); - self._active_idx = null; - - self.navigation.onRemoveFrame(); - - // resetting frame_id_gen preserves previous behavior where removing the - // root page returned us to a clean-slate state. - self.frame_id_gen = 0; - + self.tearDownActivePage(); if (comptime IS_DEBUG) { log.debug(.browser, "remove page", .{}); } @@ -487,40 +505,8 @@ fn processRootQueuedNavigation(self: *Session) !void { fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !void { defer self.arena_pool.release(qn.arena); - const old_idx = self._active_idx orelse { - lp.assert(false, "Session.replaceRootImmediate - no active page", .{}); - return; - }; - - // Dispatch frame_remove (same as removePage) then tear down the OLD - // page's slot. - self.notification.dispatch(.frame_remove, .{}); - self._pages[old_idx].?.deinit(); - self.freeSlot(old_idx); - self._active_idx = null; - - self.navigation.onRemoveFrame(); - - // Preserve prior behavior: the old resetFrameResources reset frame_id_gen. - self.frame_id_gen = 0; - - const new_slot = try self.findFreeSlot(); - const page = try self.pageInit(new_slot, frame_id); - errdefer { - page.deinit(); - self.freeSlot(new_slot); - } - self._active_idx = new_slot; - const new_frame = &page.frame; - - // Creates a new NavigationEventTarget for this frame. - self.navigation.onNewFrame(new_frame) catch |err| { - log.err(.browser, "createPage onNewNewFrame", .{ .err = err }); - }; - - // start JS env - // Inform CDP the main frame has been created such that additional context for other Worlds can be created as well - self.notification.dispatch(.frame_created, new_frame); + self.tearDownActivePage(); + const new_frame = try self.installNewActivePage(frame_id); new_frame.navigate(qn.url, qn.opts) catch |err| { log.err(.browser, "queued navigation error", .{ .err = err }); From e3eb8eba46f789238fa9f996c637a925a9f501b4 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 29 Apr 2026 16:28:56 +0200 Subject: [PATCH 17/33] typo fix --- src/cdp/domains/page.zig | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 6ce6e5b3..65068028 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -417,12 +417,13 @@ pub fn frameRemove(bc: *CDP.BrowserContext) void { isolated_world.removeContext(); } - // node_registry / node_search_list reference Nodes that live in the page - // about to be torn down. Clear them now — for legacy navigations this is - // a no-op-equivalent of the bc.reset() that frameNavigate used to do up - // front; for pending root commits this is the moment that registry was - // deferred to (frameNavigate skipped it because the OLD page was still - // live during the in-flight HTTP). + // node_registry / node_search_list reference Nodes from the page being + // torn down — clear them before the page's memory is freed. For pending + // root commits this is the only reset, because frameNavigate set + // is_pending_root=true and deliberately skipped its own reset so the + // OLD page's nodes stayed addressable during the in-flight HTTP. For + // synthetic / non-pending navs frameNavigate also calls bc.reset() + // (via the !is_pending_root branch); the two are redundant but harmless. bc.reset(); } From 38169fdb52e74526170f0e18e72d2835a806bb68 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 30 Apr 2026 09:00:37 +0200 Subject: [PATCH 18/33] rename pendingOrCurrentFrame Co-authored-by: Karl Seguin --- src/browser/Runner.zig | 2 +- src/browser/Session.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 18712c6c..d7a66e64 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -145,7 +145,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult { // Refresh self.frame from session. In case of pending page, we want to // take its state while loading. If we use only the current frame, we will // return a .done result immediately. - self.frame = self.session.currentOrPendingFrame() orelse return .done; + self.frame = self.session.pendingOrCurrentFrame() orelse return .done; const frame = self.frame; const http_client = self.http_client; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index d17d8d89..ccad0579 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -283,7 +283,7 @@ pub fn pendingPage(self: *Session) ?*Page { return &self._pages[idx].?; } -pub fn currentOrPendingFrame(self: *Session) ?*Frame { +pub fn pendingOrCurrentFrame(self: *Session) ?*Frame { const page = self.pendingPage() orelse self.currentPage() orelse return null; return &page.frame; } From 3fc5e6d8a5930e43064ae9ad39210b3b77fb2ab1 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 30 Apr 2026 08:49:11 +0200 Subject: [PATCH 19/33] rename var old_idx into old_active_idx for clarity --- src/browser/Session.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index ccad0579..514e9c86 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -581,7 +581,7 @@ pub fn commitPendingPage(self: *Session) !void { lp.assert(false, "Session.commitPendingPage - no pending page", .{}); return error.NoPendingPage; }; - const old_idx = self._active_idx orelse { + const old_active_idx = self._active_idx orelse { lp.assert(false, "Session.commitPendingPage - no active page", .{}); return error.NoActivePage; }; @@ -620,8 +620,8 @@ pub fn commitPendingPage(self: *Session) !void { // frame.deinit calls http_client.abortFrame(frame_id) on the frame_id // shared with the pending page; the in-flight transfer survives via // protect_from_abort. - self._pages[old_idx].?.deinit(); - self.freeSlot(old_idx); + self._pages[old_active_idx].?.deinit(); + self.freeSlot(old_active_idx); } // Discard a pending Page without committing. Used for failure paths From 35cecf6fcc6f0e1c4fb9b26606730ac12ad60e7d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 30 Apr 2026 08:58:46 +0200 Subject: [PATCH 20/33] fix lp.assert usage Remove unreachable code and use `comptime IS_DEBUG` check when need. --- src/browser/Session.zig | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 514e9c86..1096a11f 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -182,7 +182,9 @@ fn freeSlot(self: *Session, slot: u1) void { fn tearDownActivePage(self: *Session) void { self.notification.dispatch(.frame_remove, .{}); const idx = self._active_idx orelse { - lp.assert(false, "Session.tearDownActivePage - no active page", .{}); + if (comptime IS_DEBUG) { + lp.assert(false, "Session.tearDownActivePage - no active page", .{}); + } return; }; self._pages[idx].?.deinit(); @@ -232,7 +234,6 @@ pub fn createPage(self: *Session) !*Frame { pub fn removePage(self: *Session) void { const idx = self._active_idx orelse { lp.assert(false, "Session.removePage - page is null", .{}); - return; }; if (self._pages[idx].?.frame._script_manager.base.is_evaluating) { @@ -470,7 +471,6 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) fn processRootQueuedNavigation(self: *Session) !void { const active_idx = self._active_idx orelse { lp.assert(false, "Session.processRootQueuedNavigation - no active page", .{}); - return; }; const current_frame = &self._pages[active_idx].?.frame; @@ -579,11 +579,9 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, pub fn commitPendingPage(self: *Session) !void { const pending_idx = self._pending_idx orelse { lp.assert(false, "Session.commitPendingPage - no pending page", .{}); - return error.NoPendingPage; }; const old_active_idx = self._active_idx orelse { lp.assert(false, "Session.commitPendingPage - no active page", .{}); - return error.NoActivePage; }; if (comptime IS_DEBUG) { From 6e7398d586df7101c1747bb58bee36585d03fe06 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 30 Apr 2026 09:03:31 +0200 Subject: [PATCH 21/33] Don't re-init fields w/ default values --- src/browser/Session.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 1096a11f..05b976cf 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -103,9 +103,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi errdefer arena_pool.release(arena); self.* = .{ - ._pages = .{ null, null }, - ._active_idx = null, - ._pending_idx = null, .arena = arena, .arena_pool = arena_pool, .history = .{}, From f9cdc12bf6df841a1f552e6f4f096ef831461d24 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 30 Apr 2026 09:08:04 +0200 Subject: [PATCH 22/33] use qn.is_about_blank for check instead of string comparison And fix comment --- src/browser/Session.zig | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 05b976cf..1861ddb8 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -478,10 +478,9 @@ fn processRootQueuedNavigation(self: *Session) !void { current_frame._queued_navigation = null; // Synthetic navigations (about:blank, blob:) commit instantly — no HTTP, - // so there is no in-flight window to worry about. Use the legacy + // so there is no in-flight window to worry about. Use the optimized // immediate-swap path for them. - const is_synthetic = std.mem.eql(u8, qn.url, "about:blank") or - std.mem.startsWith(u8, qn.url, "blob:"); + const is_synthetic = qn.is_about_blank or std.mem.startsWith(u8, qn.url, "blob:"); if (is_synthetic) { return self.replaceRootImmediate(current_frame._frame_id, qn); From 14743e4369d88c95a609926c56c26a7d8a1e8bda Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 30 Apr 2026 09:43:08 +0200 Subject: [PATCH 23/33] replace pages array from Session to allocated active and pending pages --- src/browser/Browser.zig | 6 ++ src/browser/Session.zig | 163 +++++++++++++--------------------------- 2 files changed, 59 insertions(+), 110 deletions(-) diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index cecda783..b1490fcb 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -27,6 +27,7 @@ const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; const Session = @import("Session.zig"); +const Page = @import("Page.zig"); const Notification = @import("../Notification.zig"); // Browser is an instance of the browser. @@ -41,6 +42,9 @@ allocator: Allocator, arena_pool: *ArenaPool, http_client: *HttpClient, +// used by sessions to allocate pages. +page_pool: std.heap.MemoryPool(Page), + const InitOpts = struct { env: js.Env.InitOpts = .{}, http_client: *HttpClient, @@ -59,12 +63,14 @@ pub fn init(app: *App, opts: InitOpts) !Browser { .allocator = allocator, .arena_pool = &app.arena_pool, .http_client = opts.http_client, + .page_pool = std.heap.MemoryPool(Page).init(allocator), }; } pub fn deinit(self: *Browser) void { self.closeSession(); self.env.deinit(); + self.page_pool.deinit(); } pub fn newSession(self: *Browser, notification: *Notification) !*Session { diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 1861ddb8..5e8141ca 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -65,31 +65,12 @@ arena_pool: *ArenaPool, // teardowns so V8 weak callbacks can validate the FC before dereferencing it. fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity), -// Two physical slots for Pages, both stored inline in the Session struct. -// Each slot has a stable address, so Frame self-pointers (window._frame, -// document._frame, EventManager.frame, etc.) — which point inside the -// slot's Page — remain valid across the pending → active promotion done -// by commitPendingPage. We never move a Page; we only flip the index that -// names the active vs pending slot. -// -// Why two slots: at any given moment we may need to hold one active Page -// (the user-visible page) AND one pending Page (the in-flight destination -// of a root navigation, kept alive across the HTTP round-trip per Chrome's -// behavior). After commit, the OLD active slot is freed and becomes -// available for the next pending allocation. -// -// Convention: a slot is "occupied" iff its `?Page` is non-null. -_pages: [2]?Page = .{ null, null }, +// The currently-active Page +// flips this pointer. +_active: ?*Page = null, -// Index into `_pages` for the currently-active page, or null when no Page -// exists (between removePage and createPage, or at startup). -_active_idx: ?u1 = null, - -// Index into `_pages` for an in-flight root navigation, or null when no -// pending navigation is in flight. CDP commands and the rest of the -// codebase MUST NOT see this as the current page; it is invisible to -// Target.* / DOM.* / Runtime.* until commit promotes it to `_active_idx`. -_pending_idx: ?u1 = null, +// In-flight root navigation +_pending: ?*Page = null, // IDs. Kept at Session level so IDs can remain unique across Page replacements. frame_id_gen: u32 = 0, @@ -117,10 +98,10 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi } pub fn deinit(self: *Session) void { - if (self._pending_idx != null) { + if (self._pending != null) { self.discardPendingPage(); } - if (self._active_idx != null) { + if (self._active != null) { self.removePage(); } self.cookie_jar.deinit(); @@ -131,39 +112,24 @@ pub fn deinit(self: *Session) void { } // True iff there is an active Page. CDP / external callers should use this -// (or `currentPage()`) rather than poking at the underlying slots. +// (or `currentPage()`) rather than poking at the underlying field. pub fn hasPage(self: *const Session) bool { - return self._active_idx != null; + return self._active != null; } -// Pick a free slot index — i.e. one whose `_pages` entry is null. -// In normal operation we always have at most one of (active, pending), so -// at least one slot is free. Returns null only if both slots are occupied, -// which is an invariant violation. -fn findFreeSlot(self: *const Session) !u1 { - if (self._pages[0] == null) return 0; - if (self._pages[1] == null) return 1; +// Allocate and initialize a Page. +fn allocatePage(self: *Session, frame_id: u32) !*Page { + const page = try self.browser.page_pool.create(); + errdefer self.browser.page_pool.destroy(page); - return error.NoFreePageSlot; -} - -// Initialize a Page in the given inline slot and return a stable pointer -// to it. The slot must be currently empty. The returned Page is in the -// .active state by default; callers that want a pending page -// (initiateRootNavigation) must flip _state themselves. -fn pageInit(self: *Session, slot: u1, frame_id: u32) !*Page { - lp.assert(self._pages[slot] == null, "Session.pageInit - slot occupied", .{}); - self._pages[slot] = @as(Page, undefined); - const page = &self._pages[slot].?; - errdefer self._pages[slot] = null; try Page.init(page, self, frame_id); return page; } -// Free the inline slot whose Page has already been Page.deinit'd. After -// this, the slot is available for a future allocation. -fn freeSlot(self: *Session, slot: u1) void { - self._pages[slot] = null; +// Tear down and free a Page allocated via allocatePage. +fn destroyPage(self: *Session, page: *Page) void { + page.deinit(); + self.browser.page_pool.destroy(page); } // Tear down the currently-active Page. Dispatches `frame_remove` first @@ -178,15 +144,14 @@ fn freeSlot(self: *Session, slot: u1) void { // in a specific order. fn tearDownActivePage(self: *Session) void { self.notification.dispatch(.frame_remove, .{}); - const idx = self._active_idx orelse { + const page = self._active orelse { if (comptime IS_DEBUG) { lp.assert(false, "Session.tearDownActivePage - no active page", .{}); } return; }; - self._pages[idx].?.deinit(); - self.freeSlot(idx); - self._active_idx = null; + self.destroyPage(page); + self._active = null; self.navigation.onRemoveFrame(); self.frame_id_gen = 0; } @@ -197,18 +162,14 @@ fn tearDownActivePage(self: *Session) void { // dispatch `frame_navigate` — the caller does that (or doesn't, for a // blank initial page). // -// On any failure after slot allocation, the errdefers roll back the slot -// and `_active_idx`, leaving the session pageless (the caller is -// responsible for any prior teardown of an old page). +// On any failure after allocation, the errdefers roll back the Page +// and `active`, leaving the session pageless (the caller is responsible +// for any prior teardown of an old page). fn installNewActivePage(self: *Session, frame_id: u32) !*Frame { - const slot = try self.findFreeSlot(); - const page = try self.pageInit(slot, frame_id); - errdefer { - page.deinit(); - self.freeSlot(slot); - } - self._active_idx = slot; - errdefer self._active_idx = null; + const page = try self.allocatePage(frame_id); + errdefer self.destroyPage(page); + self._active = page; + errdefer self._active = null; const frame = &page.frame; try self.navigation.onNewFrame(frame); @@ -221,7 +182,7 @@ fn installNewActivePage(self: *Session, frame_id: u32) !*Frame { // NOTE: the caller is not the owner of the returned value, // the pointer on Frame is just returned as a convenience pub fn createPage(self: *Session) !*Frame { - lp.assert(self._active_idx == null, "Session.createPage - page not null", .{}); + lp.assert(self._active == null, "Session.createPage - page not null", .{}); if (comptime IS_DEBUG) { log.debug(.browser, "create page", .{}); } @@ -229,11 +190,11 @@ pub fn createPage(self: *Session) !*Frame { } pub fn removePage(self: *Session) void { - const idx = self._active_idx orelse { + const page = self._active orelse { lp.assert(false, "Session.removePage - page is null", .{}); }; - if (self._pages[idx].?.frame._script_manager.base.is_evaluating) { + if (page.frame._script_manager.base.is_evaluating) { // Reentrant teardown from a CDP message drained inside syncRequest; // Session.deinit reclaims the page when the connection closes. return; @@ -243,7 +204,7 @@ pub fn removePage(self: *Session) void { // transfer was protected from abort to survive commitPendingPage's // teardown of the old page, but we are now permanently removing the // session's page state — the pending transfer should die with it. - if (self._pending_idx != null) { + if (self._pending != null) { self.discardPendingPage(); } self.tearDownActivePage(); @@ -269,16 +230,11 @@ pub fn releaseOrigin(self: *Session, origin: *js.Origin) void { } pub fn currentPage(self: *Session) ?*Page { - const idx = self._active_idx orelse return null; - return &self._pages[idx].?; + return self._active; } -// Returns the pending Page if a root navigation is in flight. CDP / DOM / -// Runtime callers MUST NOT use this; it is only for the navigation -// machinery (Frame.navigate / commitPendingPage). pub fn pendingPage(self: *Session) ?*Page { - const idx = self._pending_idx orelse return null; - return &self._pages[idx].?; + return self._pending; } pub fn pendingOrCurrentFrame(self: *Session) ?*Frame { @@ -466,10 +422,10 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) } fn processRootQueuedNavigation(self: *Session) !void { - const active_idx = self._active_idx orelse { + const active = self._active orelse { lp.assert(false, "Session.processRootQueuedNavigation - no active page", .{}); }; - const current_frame = &self._pages[active_idx].?.frame; + const current_frame = &active.frame; // Detach the QueuedNavigation. Whether we keep it on the active frame // (synthetic path) or transfer it to the pending frame (HTTP path), the @@ -516,21 +472,14 @@ fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !v // trip — Runtime.evaluate, DOM.*, etc. continue to operate on the OLD page // until commitPendingPage swaps the pointer when response headers arrive. pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void { - if (self._pending_idx != null) { - self.discardPendingPage(); - } + self.discardPendingPage(); - // Pick the slot NOT occupied by the active page. - const slot = try self.findFreeSlot(); - const page = try self.pageInit(slot, frame_id); - errdefer { - page.deinit(); - self.freeSlot(slot); - } + const page = try self.allocatePage(frame_id); + errdefer self.destroyPage(page); page._state = .pending; - self._pending_idx = slot; - errdefer self._pending_idx = null; + self._pending = page; + errdefer self._pending = null; if (comptime IS_DEBUG) { log.debug(.browser, "initiate root navigation", .{ .url = url }); @@ -573,10 +522,10 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, // by protect_from_abort, which abortFrame's default .normal scope // honors. The caller clears the flag AFTER we return. pub fn commitPendingPage(self: *Session) !void { - const pending_idx = self._pending_idx orelse { + const pending = self._pending orelse { lp.assert(false, "Session.commitPendingPage - no pending page", .{}); }; - const old_active_idx = self._active_idx orelse { + const old_active = self._active orelse { lp.assert(false, "Session.commitPendingPage - no active page", .{}); }; @@ -584,19 +533,17 @@ pub fn commitPendingPage(self: *Session) !void { log.debug(.browser, "commit pending page", .{}); } - const pending = &self._pages[pending_idx].?; - // Step 1: clear the OLD page's CDP / V8 inspector state. self.notification.dispatch(.frame_remove, .{}); self.navigation.onRemoveFrame(); - // Step 2: index flip. Page slot addresses are stable (inline in - // Session), so every self-pointer inside `pending` (window._frame, + // Step 2: pointer flip. Page addresses are stable (heap-allocated), + // so every self-pointer inside `pending` (window._frame, // document._frame, EventManager.frame, etc.) remains valid. - self._active_idx = pending_idx; + self._active = pending; pending._state = .active; - // Step 3: register the new page with CDP. _pending_idx is still set at + // Step 3: register the new page with CDP. `pending` is still set at // this point — CDP's frameCreated handler reads `pendingPage() != null` // to skip the captured_responses / frame_arena resets that would wipe // the in-flight response we just received. @@ -605,8 +552,8 @@ pub fn commitPendingPage(self: *Session) !void { }; self.notification.dispatch(.frame_created, &pending.frame); - // Step 4: _pending_idx = null AFTER frame_created so step 3 saw it. - self._pending_idx = null; + // Step 4: `pending` = null AFTER frame_created so step 3 saw it. + self._pending = null; // Step 5: tear down the OLD page LAST. Anything in steps 1-4 that // needed to walk the OLD page's state (CDP node_registry, inspector @@ -614,28 +561,24 @@ pub fn commitPendingPage(self: *Session) !void { // frame.deinit calls http_client.abortFrame(frame_id) on the frame_id // shared with the pending page; the in-flight transfer survives via // protect_from_abort. - self._pages[old_active_idx].?.deinit(); - self.freeSlot(old_active_idx); + self.destroyPage(old_active); } // Discard a pending Page without committing. Used for failure paths // (HTTP error before commit, session deinit during pending, etc.). The // active page is untouched. pub fn discardPendingPage(self: *Session) void { - const idx = self._pending_idx orelse return; + const page = self._pending orelse return; if (comptime IS_DEBUG) { log.debug(.browser, "discard pending page", .{}); } - const pending_page = &self._pages[idx].?; - // Force abort all inflight queries. - self.browser.http_client.abortFrame(pending_page.frame._frame_id, .{ .scope = .full }); + self.browser.http_client.abortFrame(page.frame._frame_id, .{ .scope = .full }); - self._pending_idx = null; - pending_page.deinit(); - self.freeSlot(idx); + self._pending = null; + self.destroyPage(page); } pub fn nextFrameId(self: *Session) u32 { From f8f14efe405712d312749d3192a46c4a70de95ed Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 4 May 2026 09:04:15 +0200 Subject: [PATCH 24/33] forms: implement HTMLInputElement.pattern + patternMismatch validity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `pattern` IDL attribute had no accessor registered on `HTMLInputElement.prototype` (returned undefined despite getAttribute working), and `Input.suffersPatternMismatch` was a TODO stub returning false. As a result, `validity.patternMismatch` never fired for `` and `validationMessage` was always empty for pattern violations. This change registers `pattern = bridge.accessor(Input.getPattern, Input.setPattern, .{})` and rewrites `suffersPatternMismatch` to evaluate `new RegExp("^(?:" + pattern + ")$", "v").test(value)` via `ls.local.exec` on the owner frame. Strings are JSON-encoded for safe interpolation. Per HTML §4.10.5.3.5, the constraint applies only to text-like input types and skips empty values; an unparseable pattern is ignored. `ValidityState.getPatternMismatch` now takes a `*Frame` so the regex evaluation can reach the owner frame's V8 scope. Closes #2351 --- .../tests/element/html/input-validity.html | 90 +++++++++++++++++++ src/browser/webapi/element/html/Input.zig | 53 +++++++++-- .../webapi/element/html/ValidityState.zig | 6 +- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/src/browser/tests/element/html/input-validity.html b/src/browser/tests/element/html/input-validity.html index 0747d3ab..d84f3874 100644 --- a/src/browser/tests/element/html/input-validity.html +++ b/src/browser/tests/element/html/input-validity.html @@ -17,6 +17,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index a2fda9dc..46795637 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -233,7 +233,7 @@ pub fn getValidationMessage(self: *const Input, frame: *Frame) []const u8 { .url => "Please enter a URL.", else => "Please enter a valid value.", }; - if (self.suffersPatternMismatch()) return "Please match the requested format."; + if (self.suffersPatternMismatch(frame)) return "Please match the requested format."; if (self.suffersTooLong()) return "Please shorten this text."; if (self.suffersTooShort()) return "Please lengthen this text."; if (self.suffersRangeUnderflow()) return "Value is too small."; @@ -295,12 +295,42 @@ pub fn suffersTypeMismatch(self: *const Input) bool { }; } -pub fn suffersPatternMismatch(self: *const Input) bool { - _ = self; - // Pattern matching requires evaluating a JS RegExp anchored with ^(?: ... )$. - // Not yet implemented from Zig; returning false leaves well-formed inputs valid. - // TODO: route through the V8 RegExp constructor on the owner Frame. - return false; +pub fn suffersPatternMismatch(self: *const Input, frame: *Frame) bool { + if (!self.getWillValidate()) return false; + // Per HTML §4.10.5.3.5, pattern only applies to text-like input types. + switch (self._input_type) { + .text, .search, .url, .tel, .email, .password => {}, + else => return false, + } + const value = self._value orelse return false; + if (value.len == 0) return false; + const pattern = self.asConstElement().getAttributeSafe(comptime .wrap("pattern")) orelse return false; + if (pattern.len == 0) return false; + + // Evaluate `new RegExp("^(?:" + pattern + ")$", "v").test(value)` via V8. + // Per spec, an invalid pattern is ignored — the catch arm returns null and + // we treat that as "no mismatch". + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + const arena = frame.call_arena; + const pattern_json = std.json.Stringify.valueAlloc(arena, pattern, .{}) catch return false; + const value_json = std.json.Stringify.valueAlloc(arena, value, .{}) catch return false; + + const expr = std.fmt.allocPrint(arena, + \\(function() {{ + \\ try {{ + \\ return new RegExp("^(?:" + {s} + ")$", "v").test({s}); + \\ }} catch (_) {{ + \\ return null; + \\ }} + \\}})() + , .{ pattern_json, value_json }) catch return false; + + const result = ls.local.exec(expr, "Input.suffersPatternMismatch") catch return false; + if (result.isNullOrUndefined()) return false; + return !result.toBool(); } pub fn suffersTooLong(self: *const Input) bool { @@ -597,6 +627,14 @@ pub fn setPlaceholder(self: *Input, placeholder: []const u8, frame: *Frame) !voi try self.asElement().setAttributeSafe(comptime .wrap("placeholder"), .wrap(placeholder), frame); } +pub fn getPattern(self: *const Input) []const u8 { + return self.asConstElement().getAttributeSafe(comptime .wrap("pattern")) orelse ""; +} + +pub fn setPattern(self: *Input, pattern: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("pattern"), .wrap(pattern), frame); +} + pub fn getMin(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("min")) orelse ""; } @@ -1237,6 +1275,7 @@ pub const JsApi = struct { pub const labels = bridge.accessor(Input.getLabels, null, .{}); pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{}); pub const placeholder = bridge.accessor(Input.getPlaceholder, Input.setPlaceholder, .{}); + pub const pattern = bridge.accessor(Input.getPattern, Input.setPattern, .{}); pub const min = bridge.accessor(Input.getMin, Input.setMin, .{}); pub const max = bridge.accessor(Input.getMax, Input.setMax, .{}); pub const step = bridge.accessor(Input.getStep, Input.setStep, .{}); diff --git a/src/browser/webapi/element/html/ValidityState.zig b/src/browser/webapi/element/html/ValidityState.zig index 769f3fc1..34477eba 100644 --- a/src/browser/webapi/element/html/ValidityState.zig +++ b/src/browser/webapi/element/html/ValidityState.zig @@ -46,8 +46,8 @@ pub fn getTypeMismatch(self: *const ValidityState) bool { return false; } -pub fn getPatternMismatch(self: *const ValidityState) bool { - if (self._owner.is(Input)) |input| return input.suffersPatternMismatch(); +pub fn getPatternMismatch(self: *const ValidityState, frame: *Frame) bool { + if (self._owner.is(Input)) |input| return input.suffersPatternMismatch(frame); return false; } @@ -100,7 +100,7 @@ pub fn getCustomError(self: *const ValidityState) bool { pub fn getValid(self: *const ValidityState, frame: *Frame) bool { return !self.getValueMissing(frame) and !self.getTypeMismatch() and - !self.getPatternMismatch() and + !self.getPatternMismatch(frame) and !self.getTooLong() and !self.getTooShort() and !self.getRangeUnderflow() and From 2fde24c9c1fb9d7946d09d873bb861765af30b20 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 4 May 2026 11:29:24 +0200 Subject: [PATCH 25/33] ci: remove archlinux packaging Better use ``` yay -S lightpanda-bin ``` --- .github/workflows/package-archlinux.yml | 79 ------------------------- .github/workflows/release.yml | 5 -- 2 files changed, 84 deletions(-) delete mode 100644 .github/workflows/package-archlinux.yml diff --git a/.github/workflows/package-archlinux.yml b/.github/workflows/package-archlinux.yml deleted file mode 100644 index dba6d2d0..00000000 --- a/.github/workflows/package-archlinux.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: package archlinux - -on: - workflow_call: - -permissions: - contents: write - -env: - RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} - -jobs: - package: - strategy: - fail-fast: false - matrix: - arch: [x86_64, aarch64] - - env: - ARCH: ${{ matrix.arch }} - OS: linux - - runs-on: ubuntu-22.04 - container: archlinux:latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v6 - - - name: Install packaging deps - run: pacman -Syu --noconfirm --needed base-devel sudo - - - name: Download linux binary - uses: actions/download-artifact@v4 - with: - name: lightpanda-${{ env.ARCH }}-${{ env.OS }} - path: . - - - name: Build Arch package - run: | - useradd -m builder - echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers - - RAW_VERSION="${{ env.RELEASE }}" - PKGVER="${RAW_VERSION#v}" - PKGREL="1" - echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV" - echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV" - - mkdir -p pkg - cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/ - cp LICENSE pkg/ - - cat > pkg/PKGBUILD < Date: Mon, 4 May 2026 11:36:52 +0200 Subject: [PATCH 26/33] README: add hombrew and AUR install --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 84acd807..6aeefa56 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,24 @@ See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMAR ## Quick start ### Install -**Install from the nightly builds** + +**Package Managers** + +Latest nightly from Homebrew: +```console +brew install lightpanda-io/browser/lightpanda +``` + +Latest nightly from Arch Linux User Repository: +```console +yay -S lightpanda-nightly-bi +``` + +**Download from the nightly builds** You can download the last binary from the [nightly builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for -Linux x86_64 and MacOS aarch64. +Linux and MacOS for both x86_64 and aarch64. *For Linux* ```console From d1bf44b6869edcd24c8b6d77d65ce60c99cb4c7c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 4 May 2026 17:55:54 +0800 Subject: [PATCH 27/33] Extract Window Scheduling and re-use it in Worker Add worker.setInterval, clearInterval, setTimeout, clearTimeout by extracting the scheduling logic from Window and making it use Execution rather than Frame. --- src/browser/tests/worker/timers-worker.js | 85 +++++++++ src/browser/tests/worker/worker.html | 34 ++++ src/browser/webapi/Timers.zig | 205 +++++++++++++++++++++ src/browser/webapi/Window.zig | 211 +++------------------- src/browser/webapi/WorkerGlobalScope.zig | 38 ++++ 5 files changed, 386 insertions(+), 187 deletions(-) create mode 100644 src/browser/tests/worker/timers-worker.js create mode 100644 src/browser/webapi/Timers.zig diff --git a/src/browser/tests/worker/timers-worker.js b/src/browser/tests/worker/timers-worker.js new file mode 100644 index 00000000..aa37bb8f --- /dev/null +++ b/src/browser/tests/worker/timers-worker.js @@ -0,0 +1,85 @@ +// Exercises setTimeout / setInterval inside a WorkerGlobalScope. +// Mirrors src/browser/tests/window/timers.html. +(async function() { + try { + const results = {}; + + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + // setTimeout: returns a number; passes extra args through; `this` is self. + { + let timeout_this = null; + const sum = await new Promise((resolve) => { + const id = setTimeout(function (a, b) { + timeout_this = this; + resolve(a + b); + }, 1, 2, 3); + results.setTimeout_id_is_number = (typeof id === 'number'); + }); + results.setTimeout_args = sum; + results.setTimeout_this_is_self = (timeout_this === self); + results.setTimeout_length = setTimeout.length; + } + + // setInterval fires repeatedly; clearInterval stops it. + // A second timer cleared before its first tick must never fire. + { + let count1 = 0; + const id1 = setInterval(() => { count1 += 1; }, 1); + + let fired2 = false; + const id2 = setInterval(() => { fired2 = true; }, 1); + clearInterval(id2); + + results.setInterval_ids_distinct = (id1 !== id2); + + await sleep(10); + clearInterval(id1); + const after_clear = count1; + await sleep(5); + + results.setInterval_fired_multiple = (after_clear >= 1); + results.setInterval_clear_stops = (count1 === after_clear); + results.setInterval_pre_clear_silent = !fired2; + } + + // clearTimeout / clearInterval with bogus ids must be silent. + { + let threw = false; + try { + clearTimeout(-1); + clearInterval(-2); + } catch (_) { threw = true; } + results.clear_invalid_silent = !threw; + } + + // Legacy: setTimeout("...", n) compiles the string into a function body. + { + self.__st_string_ran = 0; + const id = setTimeout("self.__st_string_ran = 42;", 1); + results.setTimeout_string_id_is_number = (typeof id === 'number'); + await sleep(5); + results.setTimeout_string_ran = self.__st_string_ran; + } + + // Legacy: setInterval("...", n) compiles the string into a function body. + { + self.__si_string_ran = 0; + const id = setInterval("self.__si_string_ran += 1;", 1); + await sleep(5); + clearInterval(id); + results.setInterval_string_ran = (self.__si_string_ran >= 1); + } + + // Non-function, non-string handlers must throw. + { + let threw = false; + try { setTimeout(123, 1); } catch (_) { threw = true; } + results.setTimeout_invalid_throws = threw; + } + + postMessage({ ok: true, results }); + } catch (e) { + postMessage({ ok: false, err: String(e), stack: e.stack }); + } +})(); diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html index dda6d1a8..e660b492 100644 --- a/src/browser/tests/worker/worker.html +++ b/src/browser/tests/worker/worker.html @@ -276,6 +276,40 @@ } + +