diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 136c06ab..11e31ff6 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.4.3' + default: 'v0.4.4' v8: description: 'v8 version to install' required: false 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 < 0) { + std.Thread.sleep(10 * std.time.ns_per_ms); + } + + self.conns.deinit(self.app.allocator); + self.pending.deinit(self.app.allocator); + self.conns_pool.deinit(); + self.app.allocator.free(self.json_version_response); + self.app.allocator.destroy(self); } fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { @@ -90,102 +103,6 @@ fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { }; } -// Liveness is enforced at the TCP layer via keepalive probes sent by the -// kernel. This is transparent to CDP clients — unlike a WebSocket ping, which -// go-rod panics on and chromedp logs as "malformed". Tunables in Config.zig. -fn setTcpKeepalive(socket: posix.socket_t) void { - posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.KEEPALIVE, &std.mem.toBytes(@as(c_int, 1))) catch |err| { - log.warn(.app, "SO_KEEPALIVE", .{ .err = err }); - return; - }; - - const option = switch (@import("builtin").os.tag) { - .macos, .ios => posix.TCP.KEEPALIVE, - else => posix.TCP.KEEPIDLE, - }; - - posix.setsockopt(socket, posix.IPPROTO.TCP, option, &std.mem.toBytes(Config.CDP_KEEPALIVE_IDLE_S)) catch |err| { - log.warn(.app, "TCP_KEEPIDLE", .{ .err = err }); - }; - - if (@hasDecl(posix.TCP, "KEEPINTVL")) { - posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(Config.CDP_KEEPALIVE_INTVL_S)) catch |err| { - log.warn(.app, "TCP_KEEPINTVL", .{ .err = err }); - }; - } - if (@hasDecl(posix.TCP, "KEEPCNT")) { - posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, &std.mem.toBytes(Config.CDP_KEEPALIVE_CNT)) catch |err| { - log.warn(.app, "TCP_KEEPCNT", .{ .err = err }); - }; - } -} - -fn handleConnection(self: *Server, socket: posix.socket_t) void { - defer posix.close(socket); - - setTcpKeepalive(socket); - - // Client is HUGE (> 512KB) because it has a large read buffer. - // V8 crashes if this is on the stack (likely related to its size). - const client = self.getClient() catch |err| { - log.err(.app, "CDP client create", .{ .err = err }); - return; - }; - defer self.releaseClient(client); - - client.* = Client.init( - socket, - self.allocator, - self.app, - self.json_version_response, - ) catch |err| { - log.err(.app, "CDP client init", .{ .err = err }); - return; - }; - defer client.deinit(); - - self.registerClient(client); - defer self.unregisterClient(client); - - // Check shutdown after registering to avoid missing the stop signal. - // If deinit() already iterated over clients, this client won't receive stop() - // and would block joinThreads() indefinitely. - if (self.app.shutdown()) { - return; - } - - client.start(); -} - -fn getClient(self: *Server) !*Client { - self.client_mutex.lock(); - defer self.client_mutex.unlock(); - return self.clients_pool.create(); -} - -fn releaseClient(self: *Server, client: *Client) void { - self.client_mutex.lock(); - defer self.client_mutex.unlock(); - self.clients_pool.destroy(client); -} - -fn registerClient(self: *Server, client: *Client) void { - self.client_mutex.lock(); - defer self.client_mutex.unlock(); - self.clients.append(self.allocator, client) catch {}; -} - -fn unregisterClient(self: *Server, client: *Client) void { - self.client_mutex.lock(); - defer self.client_mutex.unlock(); - for (self.clients.items, 0..) |c, i| { - if (c == client) { - _ = self.clients.swapRemove(i); - break; - } - } -} - fn spawnWorker(self: *Server, socket: posix.socket_t) !void { if (self.app.shutdown()) { return error.ShuttingDown; @@ -213,300 +130,109 @@ fn spawnWorker(self: *Server, socket: posix.socket_t) !void { } errdefer _ = self.active_threads.fetchSub(1, .monotonic); - const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket }); + const thread = try std.Thread.spawn(.{}, handleConnection, .{ self, socket }); thread.detach(); } -fn runWorker(self: *Server, socket: posix.socket_t) void { +fn handleConnection(self: *Server, socket: posix.socket_t) void { defer _ = self.active_threads.fetchSub(1, .monotonic); - handleConnection(self, socket); -} + defer posix.close(socket); -fn joinThreads(self: *Server) void { - while (self.active_threads.load(.monotonic) > 0) { - std.Thread.sleep(10 * std.time.ns_per_ms); + // CDP is HUGE (> 512KB) because WsConnection has a large read buffer. + // V8 crashes if this is on the stack (likely related to its size). + const cdp = self.allocConn() catch |err| { + log.err(.app, "CDP alloc", .{ .err = err }); + return; + }; + defer self.releaseConn(cdp); + + cdp.init(self.app, socket, self.json_version_response) catch |err| { + log.err(.app, "CDP init", .{ .err = err }); + return; + }; + defer cdp.deinit(); + + if (log.enabled(.app, .info)) { + const client_address = cdp.ws.getAddress() catch null; + log.info(.app, "client connected", .{ .ip = client_address }); + } + + self.registerHandshake(cdp); + const handshake_result = cdp.ws.handshake(); + self.unregisterHandshake(cdp); + + const upgraded = handshake_result catch |err| { + log.err(.app, "CDP handshake", .{ .err = err }); + return; + }; + if (!upgraded) return; + + self.registerConn(cdp); + defer self.unregisterConn(cdp); + + // Check shutdown after registering to avoid missing the stop signal. + // If shutdown() already iterated over conns, this conn won't be terminated + // and would block deinit() indefinitely. + if (self.app.shutdown()) { + return; + } + + while (true) { + const next = cdp.tick() catch |err| { + log.err(.app, "cdp tick", .{ .err = err }); + return; + }; + if (!next) break; } } -// Handle exactly one TCP connection. -pub const Client = struct { - // The client is initially serving HTTP requests but, under normal circumstances - // should eventually be upgraded to a websocket connections - mode: union(enum) { - http: void, - cdp: CDP, - }, +fn registerHandshake(self: *Server, conn: *CDP) void { + self.conns_mutex.lock(); + defer self.conns_mutex.unlock(); - allocator: Allocator, - app: *App, - http: *HttpClient, - ws: Net.WsConnection, + self.pending.append(self.app.allocator, conn) catch {}; +} - pub fn init( - socket: posix.socket_t, - allocator: Allocator, - app: *App, - json_version_response: []const u8, - ) !Client { - var ws = try Net.WsConnection.init(socket, allocator, json_version_response); - errdefer ws.deinit(); +fn unregisterHandshake(self: *Server, conn: *CDP) void { + self.conns_mutex.lock(); + defer self.conns_mutex.unlock(); - if (log.enabled(.app, .info)) { - const client_address = ws.getAddress() catch null; - log.info(.app, "client connected", .{ .ip = client_address }); - } - - const http = try HttpClient.init(allocator, &app.network); - errdefer http.deinit(); - - return .{ - .allocator = allocator, - .app = app, - .http = http, - .ws = ws, - .mode = .{ .http = {} }, - }; - } - - fn stop(self: *Client) void { - switch (self.mode) { - .http => {}, - .cdp => |*cdp| { - cdp.browser.env.terminate(); - self.ws.sendClose(); - }, - } - self.ws.shutdown(); - } - - pub fn deinit(self: *Client) void { - switch (self.mode) { - .cdp => |*cdp| cdp.deinit(), - .http => {}, - } - self.ws.deinit(); - self.http.deinit(); - } - - fn start(self: *Client) void { - const http = self.http; - http.cdp_client = .{ - .socket = self.ws.socket, - .ctx = self, - .blocking_read_start = Client.blockingReadStart, - .blocking_read = Client.blockingRead, - .blocking_read_end = Client.blockingReadStop, - }; - defer http.cdp_client = null; - - self.httpLoop(http) catch |err| { - log.err(.app, "CDP client loop", .{ .err = err }); - }; - } - - fn httpLoop(self: *Client, http: *HttpClient) !void { - lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{}); - - // Liveness is enforced by TCP keepalive configured in - // Server.setTcpKeepalive; the kernel closes dead sockets, which - // surfaces as EOF/error from readSocket. The loop blocks for ~24 days - // on each poll rather than tracking app-level timeouts. Capped at - // i32-max because HttpClient.tick narrows to c_int. - const wait_ms: u32 = std.math.maxInt(i32); - - while (true) { - const status = http.tick(wait_ms) catch |err| { - log.err(.app, "http tick", .{ .err = err }); - return; - }; - if (status != .cdp_socket) continue; - - if (self.readSocket() == false) { - return; - } - - if (self.mode == .cdp) { - break; - } - } - - var cdp = &self.mode.cdp; - - while (true) { - const result = cdp.pageWait(wait_ms) catch |wait_err| switch (wait_err) { - error.NoPage => { - const status = http.tick(wait_ms) catch |err| { - log.err(.app, "http tick", .{ .err = err }); - return; - }; - if (status != .cdp_socket) continue; - if (self.readSocket() == false) { - return; - } - continue; - }, - else => return wait_err, - }; - - switch (result) { - .cdp_socket => { - if (self.readSocket() == false) { - return; - } - }, - .done => {}, - } + for (self.pending.items, 0..) |w, i| { + if (w == conn) { + _ = self.pending.swapRemove(i); + break; } } +} - fn blockingReadStart(ctx: *anyopaque) bool { - const self: *Client = @ptrCast(@alignCast(ctx)); - self.ws.setBlocking(true) catch |err| { - log.warn(.app, "CDP blockingReadStart", .{ .err = err }); - return false; - }; - return true; - } +fn allocConn(self: *Server) !*CDP { + self.conns_mutex.lock(); + defer self.conns_mutex.unlock(); + return self.conns_pool.create(); +} - fn blockingRead(ctx: *anyopaque) bool { - const self: *Client = @ptrCast(@alignCast(ctx)); - return self.readSocket(); - } +fn releaseConn(self: *Server, conn: *CDP) void { + self.conns_mutex.lock(); + defer self.conns_mutex.unlock(); + self.conns_pool.destroy(conn); +} - fn blockingReadStop(ctx: *anyopaque) bool { - const self: *Client = @ptrCast(@alignCast(ctx)); - self.ws.setBlocking(false) catch |err| { - log.warn(.app, "CDP blockingReadStop", .{ .err = err }); - return false; - }; - return true; - } +fn registerConn(self: *Server, conn: *CDP) void { + self.conns_mutex.lock(); + defer self.conns_mutex.unlock(); + self.conns.append(self.app.allocator, conn) catch {}; +} - fn readSocket(self: *Client) bool { - const n = self.ws.read() catch |err| { - log.warn(.app, "CDP read", .{ .err = err }); - return false; - }; - - if (n == 0) { - log.info(.app, "CDP disconnect", .{}); - return false; - } - - return self.processData() catch false; - } - - fn processData(self: *Client) !bool { - switch (self.mode) { - .cdp => |*cdp| return self.processWebsocketMessage(cdp), - .http => return self.processHTTPRequest(), +fn unregisterConn(self: *Server, conn: *CDP) void { + self.conns_mutex.lock(); + defer self.conns_mutex.unlock(); + for (self.conns.items, 0..) |c, i| { + if (c == conn) { + _ = self.conns.swapRemove(i); + break; } } - - fn processHTTPRequest(self: *Client) !bool { - lp.assert(self.ws.reader.pos == 0, "Client.HTTP pos", .{ .pos = self.ws.reader.pos }); - const request = self.ws.reader.buf[0..self.ws.reader.len]; - - if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) { - self.writeHTTPErrorResponse(413, "Request too large"); - return error.RequestTooLarge; - } - - // we're only expecting [body-less] GET requests. - if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) { - // we need more data, put any more data here - return true; - } - - // the next incoming data can go to the front of our buffer - defer self.ws.reader.len = 0; - return self.handleHTTPRequest(request) catch |err| { - switch (err) { - error.NotFound => self.writeHTTPErrorResponse(404, "Not found"), - error.InvalidRequest => self.writeHTTPErrorResponse(400, "Invalid request"), - error.InvalidProtocol => self.writeHTTPErrorResponse(400, "Invalid HTTP protocol"), - error.MissingHeaders => self.writeHTTPErrorResponse(400, "Missing required header"), - error.InvalidUpgradeHeader => self.writeHTTPErrorResponse(400, "Unsupported upgrade type"), - error.InvalidVersionHeader => self.writeHTTPErrorResponse(400, "Invalid websocket version"), - error.InvalidConnectionHeader => self.writeHTTPErrorResponse(400, "Invalid connection header"), - else => { - log.err(.app, "server 500", .{ .err = err, .req = request[0..@min(100, request.len)] }); - self.writeHTTPErrorResponse(500, "Internal Server Error"); - }, - } - return err; - }; - } - - fn handleHTTPRequest(self: *Client, request: []u8) !bool { - if (request.len < 18) { - // 18 is [generously] the smallest acceptable HTTP request - return error.InvalidRequest; - } - - if (std.mem.eql(u8, request[0..4], "GET ") == false) { - return error.NotFound; - } - - const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse { - return error.InvalidRequest; - }; - - const url = request[4..url_end]; - - if (std.mem.eql(u8, url, "/")) { - try self.upgradeConnection(request); - return true; - } - - if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) { - try self.ws.send(self.ws.json_version_response); - // Chromedp (a Go driver) does an http request to /json/version - // then to / (websocket upgrade) using a different connection. - // Since we only allow 1 connection at a time, the 2nd one (the - // websocket upgrade) blocks until the first one times out. - // We can avoid that by closing the connection. json_version_response - // has a Connection: Close header too. - self.ws.shutdown(); - return false; - } - - if (std.mem.eql(u8, url, "/json/list") or std.mem.eql(u8, url, "/json/list/") or - std.mem.eql(u8, url, "/json") or std.mem.eql(u8, url, "/json/")) - { - try self.ws.send(empty_json_list_response); - self.ws.shutdown(); - return false; - } - - return error.NotFound; - } - - fn upgradeConnection(self: *Client, request: []u8) !void { - try self.ws.upgrade(request); - self.mode = .{ .cdp = try CDP.init(self) }; - } - - fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void { - self.ws.sendHttpError(status, body); - } - - fn processWebsocketMessage(self: *Client, cdp: *CDP) !bool { - return self.ws.processMessages(cdp); - } - - pub fn sendAllocator(self: *Client) Allocator { - return self.ws.send_arena.allocator(); - } - - pub fn sendJSON(self: *Client, message: anytype, opts: std.json.Stringify.Options) !void { - return self.ws.sendJSON(message, opts); - } - - pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void { - return self.ws.sendJSONRaw(buf); - } -}; +} // Utils // -------- @@ -545,13 +271,6 @@ fn buildJSONVersionResponse( return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port }); } -const empty_json_list_response = - "HTTP/1.1 200 OK\r\n" ++ - "Content-Length: 2\r\n" ++ - "Connection: Close\r\n" ++ - "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ - "[]"; - pub const timestamp = @import("datetime.zig").timestamp; pub const milliTimestamp = @import("datetime.zig").milliTimestamp; @@ -901,7 +620,7 @@ fn createTestClient() !TestClient { const TestClient = struct { stream: std.net.Stream, buf: [1024]u8 = undefined, - reader: Net.Reader(false), + reader: WsConnection.Reader(false), fn deinit(self: *TestClient) void { self.stream.close(); @@ -968,7 +687,7 @@ const TestClient = struct { "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); } - fn readWebsocketMessage(self: *TestClient) !?Net.Message { + fn readWebsocketMessage(self: *TestClient) !?WsConnection.Message { while (true) { const n = try self.stream.read(self.reader.readBuf()); if (n == 0) { 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); diff --git a/src/agent/ToolExecutor.zig b/src/agent/ToolExecutor.zig index f806e58b..f520e605 100644 --- a/src/agent/ToolExecutor.zig +++ b/src/agent/ToolExecutor.zig @@ -3,7 +3,6 @@ const lp = @import("lightpanda"); const zenai = @import("zenai"); const App = @import("../App.zig"); -const HttpClient = @import("../browser/HttpClient.zig"); const CDPNode = @import("../cdp/Node.zig"); const browser_tools = lp.tools; @@ -11,7 +10,6 @@ const Self = @This(); allocator: std.mem.Allocator, app: *App, -http_client: *HttpClient, notification: *lp.Notification, browser: lp.Browser, session: *lp.Session, @@ -19,29 +17,25 @@ node_registry: CDPNode.Registry, tool_schema_arena: std.heap.ArenaAllocator, pub fn init(allocator: std.mem.Allocator, app: *App) !*Self { - const http_client: *HttpClient = try .init(allocator, &app.network); - errdefer http_client.deinit(); - const notification: *lp.Notification = try .init(allocator); errdefer notification.deinit(); const self = try allocator.create(Self); errdefer allocator.destroy(self); - var browser: lp.Browser = try .init(app, .{ .http_client = http_client }); - errdefer browser.deinit(); - self.* = .{ .allocator = allocator, .app = app, - .http_client = http_client, .notification = notification, - .browser = browser, + .browser = undefined, .session = undefined, .node_registry = CDPNode.Registry.init(allocator), .tool_schema_arena = std.heap.ArenaAllocator.init(allocator), }; + try self.browser.init(app, .{}, null); + errdefer self.browser.deinit(); + self.session = try self.browser.newSession(self.notification); return self; } @@ -51,7 +45,6 @@ pub fn deinit(self: *Self) void { self.node_registry.deinit(); self.browser.deinit(); self.notification.deinit(); - self.http_client.deinit(); self.allocator.destroy(self); } @@ -60,7 +53,7 @@ pub fn deinit(self: *Self) void { /// state that depended on the old session. pub fn resetSession(self: *Self) !void { self.browser.deinit(); - self.browser = try lp.Browser.init(self.app, .{ .http_client = self.http_client }); + try self.browser.init(self.app, .{}, null); self.session = try self.browser.newSession(self.notification); } diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index cecda783..c29c796e 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. @@ -39,32 +40,38 @@ app: *App, session: ?Session, allocator: Allocator, arena_pool: *ArenaPool, -http_client: *HttpClient, +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, }; -pub fn init(app: *App, opts: InitOpts) !Browser { +pub fn init(self: *Browser, app: *App, opts: InitOpts, cdp_client: ?HttpClient.CDPClient) !void { const allocator = app.allocator; var env = try js.Env.init(app, opts.env); errdefer env.deinit(); - return .{ + self.* = .{ .app = app, .env = env, .session = null, .allocator = allocator, .arena_pool = &app.arena_pool, - .http_client = opts.http_client, + .http_client = undefined, + .page_pool = std.heap.MemoryPool(Page).init(allocator), }; + try self.http_client.init(allocator, &app.network, cdp_client); } pub fn deinit(self: *Browser) void { self.closeSession(); self.env.deinit(); + self.page_pool.deinit(); + self.http_client.deinit(); } pub fn newSession(self: *Browser, notification: *Notification) !*Session { diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index d86428a9..52213bac 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -327,7 +327,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void { errdefer self._style_manager.deinit(); const browser = session.browser; - self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); + self._script_manager = ScriptManager.init(browser.allocator, &browser.http_client, self); errdefer self._script_manager.deinit(); self.js = try browser.env.createContext(self, .{ @@ -353,13 +353,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); - } - - for (self.workers.items) |worker| { - worker.deinit(); + frame.deinit(); } if (comptime IS_DEBUG) { @@ -413,16 +409,16 @@ pub fn deinit(self: *Frame, abort_http: bool) void { const browser = page.session.browser; browser.env.destroyContext(self.js); + // Must be after context is destroyed. A finalizer can reach into the *Worker + // (e.g. Worker.ReceiveMessageCallback) so the worker must still be valid. + for (self.workers.items) |worker| { + worker.deinit(); + } + 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); - } + // don't abort pending frames. + browser.http_client.abortFrame(self._frame_id, .{}); self._script_manager.deinit(); self._style_manager.deinit(); @@ -613,7 +609,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo return; } - var http_client = session.browser.http_client; + const http_client = &session.browser.http_client; self.url = try self.arena.dupeZ(u8, request_url); self.origin = try URL.getOrigin(self.arena, self.url); @@ -635,6 +631,15 @@ 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.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. // It ensures the event frame_navigated is not dispatched before this one. session.notification.dispatch(.frame_navigate, &.{ @@ -644,6 +649,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 @@ -667,6 +673,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, @@ -757,17 +764,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() @@ -972,6 +969,28 @@ 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.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, 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) { + .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 @@ -1201,6 +1220,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 }; @@ -1276,7 +1305,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; @@ -1385,7 +1414,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 @@ -2854,6 +2883,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/HttpClient.zig b/src/browser/HttpClient.zig index a810c535..fe619020 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 @@ -166,23 +166,21 @@ pub const CDPClient = struct { blocking_read_end: *const fn (*anyopaque) bool, }; -pub fn init(allocator: Allocator, network: *Network) !*Client { +pub fn init(self: *Client, allocator: Allocator, network: *Network, cdp_client: ?CDPClient) !void { var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator); errdefer transfer_pool.deinit(); - const client = try allocator.create(Client); - errdefer allocator.destroy(client); - var handles = try http.Handles.init(network.config); errdefer handles.deinit(); const http_proxy = network.config.httpProxy(); - client.* = .{ + self.* = Client{ .handles = handles, .network = network, .allocator = allocator, .transfer_pool = transfer_pool, + .cdp_client = cdp_client, .use_proxy = http_proxy != null, .http_proxy = http_proxy, @@ -197,25 +195,23 @@ pub fn init(allocator: Allocator, network: *Network) !*Client { .entry_layer = undefined, }; - var next = client.layer(); + var next = self.layer(); if (network.config.obeyRobots()) { - next = layerWith(&client.robots_layer, next); + next = layerWith(&self.robots_layer, next); } if (network.config.httpCacheDir() != null) { - next = layerWith(&client.cache_layer, next); + next = layerWith(&self.cache_layer, next); } - next = layerWith(&client.interception_layer, next); + next = layerWith(&self.interception_layer, next); if (network.config.webBotAuth() != null) { - next = layerWith(&client.web_bot_auth_layer, next); + next = layerWith(&self.web_bot_auth_layer, next); } - client.entry_layer = next; - - return client; + self.entry_layer = next; } pub fn deinit(self: *Client) void { @@ -226,8 +222,6 @@ pub fn deinit(self: *Client) void { self.clearUserAgentOverride(); self.robots_layer.deinit(self.allocator); - - self.allocator.destroy(self); } pub fn layer(self: *Client) Layer { @@ -304,19 +298,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; @@ -324,11 +324,14 @@ 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) { - 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(); + } } } } @@ -339,8 +342,6 @@ 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. var it = self.in_use.first; var leftover: usize = 0; while (it) |node| : (it = node.next) { @@ -356,15 +357,20 @@ 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; 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 params = transfer.req.params; + 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| { @@ -878,6 +884,15 @@ 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 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 { document, xhr, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 1848692d..4a35ff17 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"); @@ -120,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); @@ -178,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/Runner.zig b/src/browser/Runner.zig index c1f0ce89..009235bb 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -45,7 +45,7 @@ pub fn init(session: *Session, _: Opts) !Runner { return .{ .frame = frame, .session = session, - .http_client = session.browser.http_client, + .http_client = &session.browser.http_client, }; } @@ -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. 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.pendingOrCurrentFrame() orelse return .done; const frame = self.frame; const http_client = self.http_client; 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/Session.zig b/src/browser/Session.zig index daa388ef..6c25a1ac 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -65,9 +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), -// The currently-active Page. Null when no Page exists (between removePage -// and createPage, or at startup). -page: ?Page, +// The currently-active Page +// flips this pointer. +_active: ?*Page = 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, @@ -81,7 +84,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi errdefer arena_pool.release(arena); self.* = .{ - .page = null, .arena = arena, .arena_pool = arena_pool, .history = .{}, @@ -96,7 +98,10 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi } pub fn deinit(self: *Session) void { - if (self.page != null) { + if (self._pending != null) { + self.discardPendingPage(); + } + if (self._active != null) { self.removePage(); } self.cookie_jar.deinit(); @@ -106,52 +111,103 @@ pub fn deinit(self: *Session) void { self.arena_pool.release(self.arena); } -// 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", .{}); +// True iff there is an active Page. CDP / external callers should use this +// (or `currentPage()`) rather than poking at the underlying field. +pub fn hasPage(self: *const Session) bool { + return self._active != null; +} - self.page = @as(Page, undefined); - const page = &self.page.?; +// 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); - errdefer self.page = null; + try Page.init(page, self, frame_id); + return page; +} + +// 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 +// 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 page = self._active orelse { + if (comptime IS_DEBUG) { + lp.assert(false, "Session.tearDownActivePage - no active page", .{}); + } + return; + }; + self.destroyPage(page); + self._active = 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 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 page = try self.allocatePage(frame_id); + errdefer self.destroyPage(page); + self._active = page; + errdefer self._active = null; - try Page.init(page, self, self.nextFrameId()); const frame = &page.frame; - - // Creates a new NavigationEventTarget for this frame. try self.navigation.onNewFrame(frame); - - 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 + // 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 == null, "Session.createPage - page not null", .{}); + if (comptime IS_DEBUG) { + log.debug(.browser, "create page", .{}); + } + return self.installNewActivePage(self.nextFrameId()); +} + 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 page = self._active orelse { + lp.assert(false, "Session.removePage - page is null", .{}); + }; + + 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; } - // 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.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; - + // 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 != null) { + self.discardPendingPage(); + } + self.tearDownActivePage(); if (comptime IS_DEBUG) { log.debug(.browser, "remove page", .{}); } @@ -166,42 +222,24 @@ 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); -} - -pub fn replacePage(self: *Session) !*Frame { - if (comptime IS_DEBUG) { - 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 frame_id = current.frame._frame_id; - current.deinit(true); - self.page = 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); - return &page.frame; + self.currentPage().?.releaseOrigin(origin); } pub fn currentPage(self: *Session) ?*Page { - return &(self.page orelse return null); + return self._active; +} + +pub fn pendingPage(self: *Session) ?*Page { + return self._pending; +} + +pub fn pendingOrCurrentFrame(self: *Session) ?*Frame { + const page = self.pendingPage() orelse self.currentPage() orelse return null; + return &page.frame; } pub fn currentFrame(self: *Session) ?*Frame { @@ -219,7 +257,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 { @@ -318,7 +356,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 { @@ -338,7 +376,7 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) if (parent_notified) { parent._pending_loads -= 1; } - frame.deinit(true); + frame.deinit(); } frame.iframe = iframe; @@ -364,7 +402,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 { @@ -378,7 +416,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; @@ -390,42 +428,43 @@ 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 = self._active orelse { + lp.assert(false, "Session.processRootQueuedNavigation - no active page", .{}); + }; + const current_frame = &active.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 optimized + // immediate-swap path for them. + 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); + } + + // 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); - // Dispatch frame_remove (same as removePage) then replace the Page - // in-place, keeping the frame_id stable. - self.notification.dispatch(.frame_remove, .{}); - self.page.?.deinit(true); - self.page = null; + return self.initiateRootNavigation(current_frame._frame_id, qn.url, qn.opts); +} - self.navigation.onRemoveFrame(); +// 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); - // 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_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 }); @@ -433,6 +472,121 @@ 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. +pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void { + self.discardPendingPage(); + + const page = try self.allocatePage(frame_id); + errdefer self.destroyPage(page); + + page._state = .pending; + self._pending = page; + errdefer self._pending = null; + + if (comptime IS_DEBUG) { + log.debug(.browser, "initiate root navigation", .{ .url = 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(url, opts) catch |err| { + log.err(.browser, "pending navigation start", .{ .err = err, .url = 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.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 = self._pending orelse { + lp.assert(false, "Session.commitPendingPage - no pending page", .{}); + }; + const old_active = self._active orelse { + lp.assert(false, "Session.commitPendingPage - no active page", .{}); + }; + + if (comptime IS_DEBUG) { + log.debug(.browser, "commit pending page", .{}); + } + + // Step 1: clear the OLD page's CDP / V8 inspector state. + self.notification.dispatch(.frame_remove, .{}); + self.navigation.onRemoveFrame(); + + // 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 = pending; + pending._state = .active; + + // 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. + self.navigation.onNewFrame(&pending.frame) catch |err| { + log.err(.browser, "commitPendingPage onNewFrame", .{ .err = err }); + }; + self.notification.dispatch(.frame_created, &pending.frame); + + // 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 + // context group, isolated worlds) has already done so. The OLD page's + // 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.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 page = self._pending orelse return; + + if (comptime IS_DEBUG) { + log.debug(.browser, "discard pending page", .{}); + } + + // Force abort all inflight queries. + self.browser.http_client.abortFrame(page.frame._frame_id, .{ .scope = .full }); + + self._pending = null; + self.destroyPage(page); +} + pub fn nextFrameId(self: *Session) u32 { const id = self.frame_id_gen +% 1; self.frame_id_gen = id; 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/RegExp.zig b/src/browser/js/RegExp.zig new file mode 100644 index 00000000..b341a944 --- /dev/null +++ b/src/browser/js/RegExp.zig @@ -0,0 +1,71 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const js = @import("js.zig"); + +const v8 = js.v8; + +const RegExp = @This(); + +local: *const js.Local, +handle: *const v8.RegExp, + +// Mirrors v8::RegExp::Flags. Combine with bitwise OR. +pub const Flag = struct { + pub const none: c_int = v8.kRegExpNone; + pub const global: c_int = v8.kRegExpGlobal; + pub const ignore_case: c_int = v8.kRegExpIgnoreCase; + pub const multiline: c_int = v8.kRegExpMultiline; + pub const sticky: c_int = v8.kRegExpSticky; + pub const unicode: c_int = v8.kRegExpUnicode; + pub const dot_all: c_int = v8.kRegExpDotAll; + pub const linear: c_int = v8.kRegExpLinear; + pub const has_inSelfdices: c_int = v8.kRegExpHasIndices; + pub const unicode_sets: c_int = v8.kRegExpUnicodeSets; +}; + +pub fn init(local: *const js.Local, pattern: []const u8, flags: c_int) !RegExp { + const pattern_handle = local.isolate.initStringHandle(pattern); + const handle = v8.v8__RegExp__New(local.handle, pattern_handle, flags) orelse return error.JsException; + return .{ .local = local, .handle = handle }; +} + +// Runs the pattern against `subject`. Returns the result Array (as a generic +// Object) on match, or null on no match. Returns error.JsException if V8 +// throws — typically when the pattern is malformed for the current flags. +pub fn exec(self: RegExp, subject: []const u8) !?js.Object { + const local = self.local; + const subject_handle = local.isolate.initStringHandle(subject); + const handle = v8.v8__RegExp__Exec(self.handle, local.handle, subject_handle) orelse return error.JsException; + if (v8.v8__Value__IsNullOrUndefined(@ptrCast(handle))) return null; + return .{ .local = local, .handle = handle }; +} + +// Equivalent to `RegExp.prototype.test()` — true iff the pattern matches. +pub fn match(self: RegExp, subject: []const u8) !bool { + return (try self.exec(subject)) != null; +} + +pub fn toValue(self: RegExp) js.Value { + return .{ + .local = self.local, + .handle = @ptrCast(self.handle), + }; +} 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/js/js.zig b/src/browser/js/js.zig index 28a1fb51..adc36255 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -42,6 +42,7 @@ pub const Object = @import("Object.zig"); pub const TryCatch = @import("TryCatch.zig"); pub const Function = @import("Function.zig"); pub const Promise = @import("Promise.zig"); +pub const RegExp = @import("RegExp.zig"); pub const Module = @import("Module.zig"); pub const BigInt = @import("BigInt.zig"); pub const Number = @import("Number.zig"); 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/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..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); }); } @@ -333,3 +329,60 @@ }); } + + + + + + 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/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 b6d671b8..e660b492 100644 --- a/src/browser/tests/worker/worker.html +++ b/src/browser/tests/worker/worker.html @@ -275,3 +275,57 @@ }); } + + + + diff --git a/src/browser/tools.zig b/src/browser/tools.zig index f82bdd88..7de77e82 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -941,7 +941,7 @@ fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const } fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!void { - if (session.page != null) { + if (session.hasPage()) { registry.reset(); session.removePage(); } 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/Timers.zig b/src/browser/webapi/Timers.zig new file mode 100644 index 00000000..bbfca664 --- /dev/null +++ b/src/browser/webapi/Timers.zig @@ -0,0 +1,205 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Shared bookkeeping for setTimeout / setInterval (and Window-only +// setImmediate / requestAnimationFrame / requestIdleCallback). Both Window +// and WorkerGlobalScope embed a Timers and forward their JS-bridged +// methods through `schedule` / `clear`. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +const Timers = @This(); + +_timer_id: u30 = 0, +_callbacks: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{}, + +pub const Mode = enum { + idle, + normal, + animation_frame, +}; + +pub const ScheduleOpts = struct { + repeat: bool, + params: []js.Value.Temp, + name: []const u8, + low_priority: bool = false, + mode: Mode = .normal, +}; + +pub fn schedule( + self: *Timers, + exec: *js.Execution, + cb: js.Function.Temp, + delay_ms: u32, + opts: ScheduleOpts, +) !u32 { + if (self._callbacks.count() > 512) { + // these are active + return error.TooManyTimeout; + } + + const arena = try exec.getArena(.tiny, "Timers.schedule"); + errdefer exec.releaseArena(arena); + + const timer_id = self._timer_id +% 1; + self._timer_id = timer_id; + + var persisted_params: []js.Value.Temp = &.{}; + if (opts.params.len > 0) { + persisted_params = try arena.dupe(js.Value.Temp, opts.params); + } + + const gop = try self._callbacks.getOrPut(exec.arena, timer_id); + if (gop.found_existing) { + // 2^31 would have to wrap for this to happen. + return error.TooManyTimeout; + } + errdefer _ = self._callbacks.remove(timer_id); + + const callback = try arena.create(ScheduleCallback); + callback.* = .{ + .cb = cb, + .exec = exec, + .timers = self, + .arena = arena, + .mode = opts.mode, + .name = opts.name, + .timer_id = timer_id, + .params = persisted_params, + .repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null, + }; + gop.value_ptr.* = callback; + + try exec.context.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{ + .name = opts.name, + .low_priority = opts.low_priority, + .finalizer = ScheduleCallback.cancelled, + }); + + return timer_id; +} + +pub fn clear(self: *Timers, id: u32) void { + var sc = self._callbacks.fetchRemove(id) orelse return; + sc.value.removed = true; +} + +// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout +// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timerhandler +// TimerHandler = Function or DOMString. When a string is passed, it is +// compiled into an anonymous function body, matching how legacy browsers +// (and all current UAs) interpret `setTimeout("foo()", 100)`. +pub const LegacyHandler = union(enum) { + function: js.Function.Temp, + string: js.String, + + pub fn resolve(handler: LegacyHandler, exec: *js.Execution) !js.Function.Temp { + switch (handler) { + .function => |fun| return fun, + .string => |str| { + const fun = try exec.context.local.?.compileFunction(str, &.{}, &.{}); + return fun.temp(); + }, + } + } +}; + +const ScheduleCallback = struct { + // for debugging + name: []const u8, + + // Timers._callbacks key + timer_id: u31, + + // delay, in ms, to repeat. When null, removed after first invocation. + repeat_ms: ?u32, + + cb: js.Function.Temp, + + mode: Mode, + exec: *js.Execution, + timers: *Timers, + arena: Allocator, + removed: bool = false, + params: []const js.Value.Temp, + + fn cancelled(ptr: *anyopaque) void { + var self: *ScheduleCallback = @ptrCast(@alignCast(ptr)); + self.deinit(); + } + + fn deinit(self: *ScheduleCallback) void { + self.cb.release(); + for (self.params) |param| { + param.release(); + } + self.exec.releaseArena(self.arena); + } + + fn run(ptr: *anyopaque) !?u32 { + const self: *ScheduleCallback = @ptrCast(@alignCast(ptr)); + if (self.removed) { + self.deinit(); + return null; + } + + var ls: js.Local.Scope = undefined; + self.exec.context.localScope(&ls); + defer ls.deinit(); + + switch (self.mode) { + .idle => { + const IdleDeadline = @import("IdleDeadline.zig"); + ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| { + log.warn(.js, "idleCallback", .{ .name = self.name, .err = err }); + }; + }, + .animation_frame => { + // requestAnimationFrame is window-only; if a worker ever + // schedules with this mode it's a programming error. + const window = switch (self.exec.context.global) { + .frame => |frame| frame.window, + .worker => unreachable, + }; + ls.toLocal(self.cb).call(void, .{window._performance.now()}) catch |err| { + log.warn(.js, "RAF", .{ .name = self.name, .err = err }); + }; + }, + .normal => { + ls.toLocal(self.cb).call(void, self.params) catch |err| { + log.warn(.js, "timer", .{ .name = self.name, .err = err }); + }; + }, + } + ls.local.runMicrotasks(); + + if (self.repeat_ms) |ms| { + return ms; + } + defer self.deinit(); + _ = self.timers._callbacks.remove(self.timer_id); + return null; + } +}; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 29243e7c..5b4135fc 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -44,6 +44,7 @@ const Element = @import("Element.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); const CustomElementRegistry = @import("CustomElementRegistry.zig"); const Selection = @import("Selection.zig"); +const Timers = @import("Timers.zig"); const Notification = @import("../../Notification.zig"); const log = lp.log; @@ -77,8 +78,7 @@ _on_rejection_handled: ?js.Function.Global = null, _on_unhandled_rejection: ?js.Function.Global = null, _current_event: ?*Event = null, _location: *Location, -_timer_id: u30 = 0, -_timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{}, +_timers: Timers = .{}, _custom_elements: CustomElementRegistry = .{}, _scroll_pos: struct { x: u32, @@ -282,67 +282,43 @@ 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) { - function: js.Function.Temp, - string: js.String, -}; - -pub fn setTimeout(self: *Window, handler: LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, frame: *Frame) !u32 { - const cb = try resolveTimerHandler(handler, frame); - return self.scheduleCallback(cb, delay_ms orelse 0, .{ +pub fn setTimeout(self: *Window, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, exec: *js.Execution) !u32 { + const cb = try handler.resolve(exec); + return self._timers.schedule(exec, cb, delay_ms orelse 0, .{ .repeat = false, .params = params, - .low_priority = false, .name = "window.setTimeout", - }, frame); + }); } -pub fn setInterval(self: *Window, handler: LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, frame: *Frame) !u32 { - const cb = try resolveTimerHandler(handler, frame); - return self.scheduleCallback(cb, delay_ms orelse 0, .{ +pub fn setInterval(self: *Window, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, exec: *js.Execution) !u32 { + const cb = try handler.resolve(exec); + return self._timers.schedule(exec, cb, delay_ms orelse 0, .{ .repeat = true, .params = params, - .low_priority = false, .name = "window.setInterval", - }, frame); + }); } -// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout -// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timerhandler -// TimerHandler = Function or DOMString. When a string is passed, it is -// compiled into an anonymous function body, matching how legacy browsers -// (and all current UAs) interpret `setTimeout("foo()", 100)`. -fn resolveTimerHandler(handler: LegacyHandler, frame: *Frame) !js.Function.Temp { - switch (handler) { - .function => |fun| return fun, - .string => |str| { - const fun = try frame.js.local.?.compileFunction(str, &.{}, &.{}); - return fun.temp(); - }, - } -} - -pub fn setImmediate(self: *Window, cb: js.Function.Temp, params: []js.Value.Temp, frame: *Frame) !u32 { - return self.scheduleCallback(cb, 0, .{ +pub fn setImmediate(self: *Window, cb: js.Function.Temp, params: []js.Value.Temp, exec: *js.Execution) !u32 { + return self._timers.schedule(exec, cb, 0, .{ .repeat = false, .params = params, - .low_priority = false, .name = "window.setImmediate", - }, frame); + }); } -pub fn requestAnimationFrame(self: *Window, cb: js.Function.Temp, frame: *Frame) !u32 { - return self.scheduleCallback(cb, 5, .{ +pub fn requestAnimationFrame(self: *Window, cb: js.Function.Temp, exec: *js.Execution) !u32 { + return self._timers.schedule(exec, cb, 5, .{ .repeat = false, .params = &.{}, - .low_priority = false, .mode = .animation_frame, .name = "window.requestAnimationFrame", - }, frame); + }); } pub fn queueMicrotask(_: *Window, cb: js.Function, frame: *Frame) void { @@ -350,42 +326,37 @@ pub fn queueMicrotask(_: *Window, cb: js.Function, frame: *Frame) void { } pub fn clearTimeout(self: *Window, id: u32) void { - var sc = self._timers.fetchRemove(id) orelse return; - sc.value.removed = true; + self._timers.clear(id); } pub fn clearInterval(self: *Window, id: u32) void { - var sc = self._timers.fetchRemove(id) orelse return; - sc.value.removed = true; + self._timers.clear(id); } pub fn clearImmediate(self: *Window, id: u32) void { - var sc = self._timers.fetchRemove(id) orelse return; - sc.value.removed = true; + self._timers.clear(id); } pub fn cancelAnimationFrame(self: *Window, id: u32) void { - var sc = self._timers.fetchRemove(id) orelse return; - sc.value.removed = true; + self._timers.clear(id); } const RequestIdleCallbackOpts = struct { timeout: ?u32 = null, }; -pub fn requestIdleCallback(self: *Window, cb: js.Function.Temp, opts_: ?RequestIdleCallbackOpts, frame: *Frame) !u32 { +pub fn requestIdleCallback(self: *Window, cb: js.Function.Temp, opts_: ?RequestIdleCallbackOpts, exec: *js.Execution) !u32 { const opts = opts_ orelse RequestIdleCallbackOpts{}; - return self.scheduleCallback(cb, opts.timeout orelse 50, .{ + return self._timers.schedule(exec, cb, opts.timeout orelse 50, .{ .mode = .idle, .repeat = false, .params = &.{}, .low_priority = true, .name = "window.requestIdleCallback", - }, frame); + }); } pub fn cancelIdleCallback(self: *Window, id: u32) void { - var sc = self._timers.fetchRemove(id) orelse return; - sc.value.removed = true; + self._timers.clear(id); } pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void { @@ -568,6 +539,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 @@ -799,140 +772,6 @@ pub const Access = union(enum) { } }; -const ScheduleOpts = struct { - repeat: bool, - params: []js.Value.Temp, - name: []const u8, - low_priority: bool = false, - animation_frame: bool = false, - mode: ScheduleCallback.Mode = .normal, -}; -fn scheduleCallback(self: *Window, cb: js.Function.Temp, delay_ms: u32, opts: ScheduleOpts, frame: *Frame) !u32 { - if (self._timers.count() > 512) { - // these are active - return error.TooManyTimeout; - } - - const arena = try frame.getArena(.tiny, "Window.schedule"); - errdefer frame.releaseArena(arena); - - const timer_id = self._timer_id +% 1; - self._timer_id = timer_id; - - const params = opts.params; - var persisted_params: []js.Value.Temp = &.{}; - if (params.len > 0) { - persisted_params = try arena.dupe(js.Value.Temp, params); - } - - const gop = try self._timers.getOrPut(frame.arena, timer_id); - if (gop.found_existing) { - // 2^31 would have to wrap for this to happen. - return error.TooManyTimeout; - } - errdefer _ = self._timers.remove(timer_id); - - const callback = try arena.create(ScheduleCallback); - callback.* = .{ - .cb = cb, - .frame = frame, - .arena = arena, - .mode = opts.mode, - .name = opts.name, - .timer_id = timer_id, - .params = persisted_params, - .repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null, - }; - gop.value_ptr.* = callback; - - try frame.js.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{ - .name = opts.name, - .low_priority = opts.low_priority, - .finalizer = ScheduleCallback.cancelled, - }); - - return timer_id; -} - -const ScheduleCallback = struct { - // for debugging - name: []const u8, - - // window._timers key - timer_id: u31, - - // delay, in ms, to repeat. When null, will be removed after the first time - repeat_ms: ?u32, - - cb: js.Function.Temp, - - mode: Mode, - frame: *Frame, - arena: Allocator, - removed: bool = false, - params: []const js.Value.Temp, - - const Mode = enum { - idle, - normal, - animation_frame, - }; - - fn cancelled(ctx: *anyopaque) void { - var self: *ScheduleCallback = @ptrCast(@alignCast(ctx)); - self.deinit(); - } - - fn deinit(self: *ScheduleCallback) void { - self.cb.release(); - for (self.params) |param| { - param.release(); - } - self.frame.releaseArena(self.arena); - } - - fn run(ctx: *anyopaque) !?u32 { - const self: *ScheduleCallback = @ptrCast(@alignCast(ctx)); - const frame = self.frame; - const window = frame.window; - - if (self.removed) { - self.deinit(); - return null; - } - - var ls: js.Local.Scope = undefined; - frame.js.localScope(&ls); - defer ls.deinit(); - - switch (self.mode) { - .idle => { - const IdleDeadline = @import("IdleDeadline.zig"); - ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| { - log.warn(.js, "window.idleCallback", .{ .name = self.name, .err = err }); - }; - }, - .animation_frame => { - ls.toLocal(self.cb).call(void, .{window._performance.now()}) catch |err| { - log.warn(.js, "window.RAF", .{ .name = self.name, .err = err }); - }; - }, - .normal => { - ls.toLocal(self.cb).call(void, self.params) catch |err| { - log.warn(.js, "window.timer", .{ .name = self.name, .err = err }); - }; - }, - } - ls.local.runMicrotasks(); - if (self.repeat_ms) |ms| { - return ms; - } - defer self.deinit(); - _ = window._timers.remove(self.timer_id); - return null; - } -}; - const PostMessageCallback = struct { frame: *Frame, source: *Window, diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 81d74116..5a271793 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, @@ -92,7 +92,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { return self; } - const http_client = session.browser.http_client; + const http_client = &session.browser.http_client; http_client.request(.{ .ctx = self, .params = .{ @@ -121,6 +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 { + // 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; diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index c633c31c..1034f0e6 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -24,19 +24,24 @@ 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"); 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"); +const Timers = @import("Timers.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 +54,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 +74,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, @@ -87,6 +98,8 @@ _on_unhandled_rejection: ?JS.Function.Global = null, _on_message: ?JS.Function.Global = null, _on_messageerror: ?JS.Function.Global = null, +_timers: Timers = .{}, + pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { const arena = worker._arena; const parent = worker._frame; @@ -108,6 +121,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, }); @@ -115,7 +130,7 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { self._script_manager = ScriptManagerBase.init( arena, - session.browser.http_client, + &session.browser.http_client, .{ .worker = self }, ); @@ -149,8 +164,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, @@ -170,6 +183,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; } @@ -309,6 +345,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(), @@ -359,10 +453,39 @@ pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { } } -// TODO: importScripts - needs script loading infrastructure -// TODO: location - needs WorkerLocation -// TODO: navigator - needs WorkerNavigator -// TODO: Timer functions - need scheduler integration +pub fn fetch(_: *const WorkerGlobalScope, input: Fetch.Input, options: ?Fetch.InitOpts, exec: *const JS.Execution) !JS.Promise { + return Fetch.init(input, options, exec); +} + +pub fn queueMicrotask(self: *WorkerGlobalScope, cb: JS.Function) void { + self.js.queueMicrotaskFunc(cb); +} + +pub fn setTimeout(self: *WorkerGlobalScope, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []JS.Value.Temp, exec: *JS.Execution) !u32 { + const cb = try handler.resolve(exec); + return self._timers.schedule(exec, cb, delay_ms orelse 0, .{ + .repeat = false, + .params = params, + .name = "worker.setTimeout", + }); +} + +pub fn clearTimeout(self: *WorkerGlobalScope, id: u32) void { + self._timers.clear(id); +} + +pub fn setInterval(self: *WorkerGlobalScope, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []JS.Value.Temp, exec: *JS.Execution) !u32 { + const cb = try handler.resolve(exec); + return self._timers.schedule(exec, cb, delay_ms orelse 0, .{ + .repeat = true, + .params = params, + .name = "worker.setInterval", + }); +} + +pub fn clearInterval(self: *WorkerGlobalScope, id: u32) void { + self._timers.clear(id); +} const FunctionSetter = union(enum) { func: JS.Function.Global, @@ -454,6 +577,13 @@ 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 importScripts = bridge.function(WorkerGlobalScope.importScripts, .{ .dom_exception = true }); + pub const queueMicrotask = bridge.function(WorkerGlobalScope.queueMicrotask, .{}); + pub const setTimeout = bridge.function(WorkerGlobalScope.setTimeout, .{}); + pub const clearTimeout = bridge.function(WorkerGlobalScope.clearTimeout, .{}); + pub const setInterval = bridge.function(WorkerGlobalScope.setInterval, .{}); + pub const clearInterval = bridge.function(WorkerGlobalScope.clearInterval, .{}); pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{}); pub const onmessageerror = bridge.accessor(WorkerGlobalScope.getOnMessageError, WorkerGlobalScope.setOnMessageError, .{}); diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index a2fda9dc..ebcd4073 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,35 @@ 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; + + // Per HTML spec, anchor the pattern with ^(?:...)$ and compile under the + // "v" (Unicode sets) flag. An invalid pattern is ignored — V8 throws and + // we treat that as "no mismatch". TryCatch absorbs the exception so it + // doesn't linger in the isolate. + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const wrapped = std.fmt.allocPrint(frame.call_arena, "^(?:{s})$", .{pattern}) catch return false; + const re = js.RegExp.init(&ls.local, wrapped, js.RegExp.Flag.unicode_sets) catch return false; + const matched = re.match(value) catch return false; + + return !matched; } pub fn suffersTooLong(self: *const Input) bool { @@ -597,6 +620,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 +1268,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 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..6fe47ad7 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/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 0b49b693..4677aed8 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -115,7 +115,7 @@ pub fn init(url: []const u8, protocols: [][]const u8, frame: *Frame) !*WebSocket const resolved_url = try URL.resolve(arena, frame.base(), url, .{ .always_dupe = true, .encoding = frame.charset }); - const http_client = frame._session.browser.http_client; + const http_client = &frame._session.browser.http_client; const conn = http_client.network.newConnection() orelse { return error.NoFreeConnection; }; diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 19a7676b..3c5031f9 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), diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 82b7db24..a56c5f30 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -19,8 +19,8 @@ const std = @import("std"); const lp = @import("lightpanda"); +const App = @import("../App.zig"); const Notification = @import("../Notification.zig"); -const Client = @import("../Server.zig").Client; const js = @import("../browser/js/js.zig"); const Browser = @import("../browser/Browser.zig"); const Session = @import("../browser/Session.zig"); @@ -28,12 +28,16 @@ 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 CDPClient = @import("../browser/HttpClient.zig").CDPClient; +const WsConnection = @import("../network/WsConnection.zig"); const Incrementing = @import("id.zig").Incrementing; const InterceptState = @import("domains/fetch.zig").InterceptState; const log = lp.log; const json = std.json; +const posix = std.posix; const Allocator = std.mem.Allocator; pub const URL_BASE = "chrome://newtab/"; @@ -47,10 +51,10 @@ const BrowserContextIdGen = Incrementing(u32, "BID"); // Generic so that we can inject mocks into it. const CDP = @This(); -// Used for sending message to the client and closing on error -client: *Client, - allocator: Allocator, +app: *App, + +ws: WsConnection, // The active browser browser: Browser, @@ -78,18 +82,18 @@ frame_arena: std.heap.ArenaAllocator, // (or altogether eliminate) our use of this. browser_context_arena: std.heap.ArenaAllocator, -pub fn init(client: *Client) !CDP { - const app = client.app; +pub fn init( + self: *CDP, + app: *App, + socket: posix.socket_t, + json_version_response: []const u8, +) !void { const allocator = app.allocator; - const browser = try Browser.init(app, .{ - .env = .{ .with_inspector = true }, - .http_client = client.http, - }); - errdefer browser.deinit(); - return .{ - .client = client, - .browser = browser, + self.* = .{ + .app = app, + .ws = undefined, + .browser = undefined, .allocator = allocator, .browser_context = null, .frame_arena = std.heap.ArenaAllocator.init(allocator), @@ -97,6 +101,17 @@ pub fn init(client: *Client) !CDP { .notification_arena = std.heap.ArenaAllocator.init(allocator), .browser_context_arena = std.heap.ArenaAllocator.init(allocator), }; + + try self.ws.init(socket, self.app.allocator, json_version_response); + errdefer self.ws.deinit(); + + try self.browser.init(app, .{ .env = .{ .with_inspector = true } }, .{ + .ctx = self, + .socket = socket, + .blocking_read_start = CDP.blockingReadStart, + .blocking_read = CDP.blockingRead, + .blocking_read_end = CDP.blockingReadStop, + }); } pub fn deinit(self: *CDP) void { @@ -108,6 +123,48 @@ pub fn deinit(self: *CDP) void { self.message_arena.deinit(); self.notification_arena.deinit(); self.browser_context_arena.deinit(); + self.ws.deinit(); +} + +pub fn blockingReadStart(ctx: *anyopaque) bool { + const self: *CDP = @ptrCast(@alignCast(ctx)); + self.ws.setBlocking(true) catch |err| { + log.warn(.app, "CDP blockingReadStart", .{ .err = err }); + return false; + }; + return true; +} + +pub fn blockingRead(ctx: *anyopaque) bool { + const self: *CDP = @ptrCast(@alignCast(ctx)); + return self.readSocket(); +} + +pub fn blockingReadStop(ctx: *anyopaque) bool { + const self: *CDP = @ptrCast(@alignCast(ctx)); + self.ws.setBlocking(false) catch |err| { + log.warn(.app, "CDP blockingReadStop", .{ .err = err }); + return false; + }; + return true; +} + +pub fn readSocket(self: *CDP) bool { + const n = self.ws.read() catch |err| { + log.warn(.app, "CDP read", .{ .err = err }); + return false; + }; + + if (n == 0) { + log.info(.app, "CDP disconnect", .{}); + return false; + } + + return self.ws.processMessages(self) catch false; +} + +pub fn sendJSON(self: *CDP, message: anytype) !void { + try self.ws.sendJSON(message, .{ .emit_null_optional_fields = false }); } pub fn handleMessage(self: *CDP, msg: []const u8) bool { @@ -132,6 +189,29 @@ pub fn pageWait(self: *CDP, ms: u32) !Session.Runner.CDPWaitResult { return runner.waitCDP(.{ .ms = ms }); } +pub fn tick(self: *CDP) !bool { + // Liveness is enforced by TCP keepalive configured in + // Network.acceptConnections; the wakeup lets V8 run or terminate. + const wait_ms: u32 = 1000; // 1s + + const result = self.pageWait(wait_ms) catch |wait_err| switch (wait_err) { + error.NoPage => { + const status = self.browser.http_client.tick(wait_ms) catch |err| { + log.err(.app, "http tick", .{ .err = err }); + return false; + }; + return status != .cdp_socket or self.readSocket(); + }, + else => return wait_err, + }; + + if (result == .cdp_socket) { + return self.readSocket(); + } + + return true; +} + // Called from above, in processMessage which handles client messages // but can also be called internally. For example, Target.sendMessageToTarget // calls back into dispatch to capture the response. @@ -223,11 +303,11 @@ fn dispatchCommand(command: *Command, method: []const u8) !void { 5 => switch (@as(u40, @bitCast(domain[0..5].*))) { asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command), asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command), - asUint(u40, "Audit") => return @import("domains/audit.zig").processMessage(command), else => {}, }, 6 => switch (@as(u48, @bitCast(domain[0..6].*))) { asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command), + asUint(u48, "Audits") => return @import("domains/audits.zig").processMessage(command), else => {}, }, 7 => switch (@as(u56, @bitCast(domain[0..7].*))) { @@ -302,12 +382,6 @@ pub fn sendEvent(self: *CDP, method: []const u8, p: anytype, opts: SendEventOpts }); } -pub fn sendJSON(self: *CDP, message: anytype) !void { - return self.client.sendJSON(message, .{ - .emit_null_optional_fields = false, - }); -} - pub const BrowserContext = struct { const Node = @import("Node.zig"); const AXNode = @import("AXNode.zig"); @@ -317,6 +391,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 +468,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, @@ -401,7 +487,7 @@ pub const BrowserContext = struct { errdefer notification.deinit(); const session = try cdp.browser.newSession(notification); - if (cdp.client.app.config.cookieFile()) |cookie_path| { + if (cdp.app.config.cookieFile()) |cookie_path| { lp.cookies.loadFromFile(session, cookie_path); } @@ -457,7 +543,7 @@ pub const BrowserContext = struct { // abort all intercepted requests before closing the session/page // since some of these might callback into the page/scriptmanager - const http_client = browser.http_client; + const http_client = &browser.http_client; for (self.intercept_state.pendingIntercepts()) |intercept| { defer { lp.assert( @@ -685,6 +771,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,8 +785,8 @@ pub const BrowserContext = struct { const arena = self.frame_arena; // Prepare the captured response value. - const id = msg.request.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, @@ -729,8 +822,8 @@ pub const BrowserContext = struct { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); const arena = self.frame_arena; - const id = msg.request.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); } @@ -784,7 +877,7 @@ pub const BrowserContext = struct { }; const cdp = self.cdp; - const allocator = cdp.client.sendAllocator(); + const allocator = cdp.ws.send_arena.allocator(); const field = ",\"sessionId\":\""; @@ -810,7 +903,7 @@ pub const BrowserContext = struct { std.debug.assert(buf.items.len == message_len); } - try cdp.client.sendJSONRaw(buf); + try cdp.ws.sendJSONRaw(buf); } }; diff --git a/src/cdp/domains/audit.zig b/src/cdp/domains/audits.zig similarity index 100% rename from src/cdp/domains/audit.zig rename to src/cdp/domains/audits.zig diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index e9d0a96b..4738e9ac 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -98,7 +98,7 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void { }; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const http_client = cmd.cdp.browser.http_client; + const http_client = &cmd.cdp.browser.http_client; try http_client.setUserAgentOverride(ua); bc.user_agent_changed = true; diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index edff1761..8eb3ab25 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -286,7 +286,7 @@ fn continueRequest(cmd: *CDP.Command) !void { } // todo: replace. - const client = bc.cdp.browser.http_client; + const client = &bc.cdp.browser.http_client; try client.interception_layer.continueRequest(client, request); return cmd.sendResult(null, .{}); } @@ -321,7 +321,7 @@ fn continueWithAuth(cmd: *CDP.Command) !void { .response = params.authChallengeResponse.response, }); - const client = bc.cdp.browser.http_client; + const client = &bc.cdp.browser.http_client; if (params.authChallengeResponse.response != .ProvideCredentials) { transfer.abortAuthChallenge(); @@ -385,7 +385,7 @@ fn fulfillRequest(cmd: *CDP.Command) !void { body = buf; } - const client = bc.cdp.browser.http_client; + const client = &bc.cdp.browser.http_client; try client.interception_layer.fulfillRequest(client, request, params.responseCode, params.responseHeaders orelse &.{}, body); return cmd.sendResult(null, .{}); } @@ -403,7 +403,7 @@ fn failRequest(cmd: *CDP.Command) !void { const pending = intercept_state.remove(request_id) orelse return error.RequestNotFound; const request = pending.request; - const client = bc.cdp.browser.http_client; + const client = &bc.cdp.browser.http_client; defer client.interception_layer.abortRequest(client, request); log.info(.cdp, "request intercept", .{ diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 0554681a..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(.{ @@ -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", .{ @@ -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"); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 28a2fc8f..65068028 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(.{}, .{}); @@ -298,20 +298,10 @@ fn navigate(cmd: *CDP.Command) !void { } const session = bc.session; - 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(); - } + const frame = session.currentFrame() orelse return error.FrameNotLoaded; 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 }, @@ -331,10 +321,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); @@ -347,17 +337,7 @@ 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(); - } - - try frame.navigate(reload_url, .{ + try session.initiateRootNavigation(frame._frame_id, reload_url, .{ .reason = .address_bar, .cdp_id = cmd.input.id, .kind = .reload, @@ -372,7 +352,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 +416,40 @@ pub fn frameRemove(bc: *CDP.BrowserContext) void { for (bc.isolated_worlds.items) |isolated_world| { isolated_world.removeContext(); } + + // 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(); } 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 { @@ -1016,17 +1025,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 }); } } 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..db22e736 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -22,6 +22,8 @@ const posix = std.posix; const CDP = @import("CDP.zig"); const Server = @import("../Server.zig"); +const Net = @import("../network/WsConnection.zig"); +const HttpClient = @import("../browser/HttpClient.zig"); const base = @import("../testing.zig"); pub const allocator = base.allocator; @@ -37,26 +39,25 @@ pub const LogFilter = base.LogFilter; const TestContext = struct { read_at: usize = 0, read_buf: [1024 * 32]u8 = undefined, - cdp_: ?CDP = null, - client: Server.Client, + cdp_: CDP = undefined, + cdp_initialized: bool = false, + cdp_socket: posix.socket_t, socket: posix.socket_t, received: std.ArrayList(json.Value) = .empty, received_raw: std.ArrayList([]const u8) = .empty, pub fn deinit(self: *TestContext) void { - if (self.cdp_) |*c| { - c.deinit(); - } - self.client.deinit(); + if (self.cdp_initialized) self.cdp_.deinit(); posix.close(self.socket); base.reset(); } pub fn cdp(self: *TestContext) *CDP { - if (self.cdp_ == null) { - self.cdp_ = CDP.init(&self.client) catch |err| @panic(@errorName(err)); + if (!self.cdp_initialized) { + self.cdp_.init(base.test_app, self.cdp_socket, "json-version") catch |err| @panic(@errorName(err)); + self.cdp_initialized = true; } - return &self.cdp_.?; + return &self.cdp_; } const BrowserContextOpts = struct { @@ -202,12 +203,10 @@ const TestContext = struct { return; } - if (self.cdp_) |*cdp__| { - if (cdp__.browser_context) |*bc| { - if (bc.session.page != null) { - var runner = try bc.session.runner(.{}); - _ = try runner.tick(.{ .ms = 1000 }); - } + if (self.cdp_.browser_context) |*bc| { + if (bc.session.hasPage()) { + var runner = try bc.session.runner(.{}); + _ = try runner.tick(.{ .ms = 1000 }); } } std.Thread.sleep(5 * std.time.ns_per_ms); @@ -315,10 +314,8 @@ pub fn context() !TestContext { try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768))); try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768))); - const client = try Server.Client.init(pair[1], base.arena_allocator, base.test_app, "json-version"); - return .{ - .client = client, + .cdp_socket = pair[1], .socket = pair[0], }; } diff --git a/src/lightpanda.zig b/src/lightpanda.zig index bdeac93f..d1345b1f 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -250,29 +250,26 @@ noinline fn assertionFailure(comptime ctx: []const u8, args: anytype) noreturn { // Reference counting helper pub fn RC(comptime T: type) type { return struct { - _refs: T = 0, + _refs: std.atomic.Value(T) = .init(0), pub fn init(refs: T) @This() { - return .{ ._refs = refs }; + return .{ ._refs = .init(refs) }; } pub fn acquire(self: *@This()) void { - self._refs += 1; + _ = self._refs.fetchAdd(1, .monotonic); } pub fn release(self: *@This(), value: anytype, page: *Page) void { - assert(self._refs > 0, "release overflow", .{ .type = @typeName(@TypeOf(value)) }); - - const refs = self._refs - 1; - self._refs = refs; - if (refs > 0) { - return; + const prev = self._refs.fetchSub(1, .acq_rel); + assert(prev > 0, "release overflow", .{ .type = @typeName(@TypeOf(value)) }); + if (prev == 1) { + value.deinit(page); } - value.deinit(page); } pub fn format(self: @This(), writer: *std.Io.Writer) !void { - return writer.print("{d}", .{self._refs}); + return writer.print("{d}", .{self._refs.load(.monotonic)}); } }; } diff --git a/src/main.zig b/src/main.zig index b111ef89..b29ff4ad 100644 --- a/src/main.zig +++ b/src/main.zig @@ -256,13 +256,8 @@ const FetchTerminator = struct { fn fetchThread(app: *App, ft: *FetchTerminator, url: [:0]const u8, fetch_opts: lp.FetchOpts) void { defer app.network.stop(); - const http_client = lp.HttpClient.init(app.allocator, &app.network) catch |err| { - log.fatal(.app, "http client init error", .{ .err = err }); - return; - }; - defer http_client.deinit(); - - var browser = lp.Browser.init(app, .{ .http_client = http_client }) catch |err| { + var browser: lp.Browser = undefined; + browser.init(app, .{}, null) catch |err| { log.fatal(.app, "browser init error", .{ .err = err }); return; }; diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 8e02f4ff..6274a31c 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -44,10 +44,8 @@ pub fn main() !void { var test_arena = std.heap.ArenaAllocator.init(allocator); defer test_arena.deinit(); - const http_client = try lp.HttpClient.init(allocator, &app.network); - defer http_client.deinit(); - - var browser = try lp.Browser.init(app, .{ .http_client = http_client }); + var browser: lp.Browser = undefined; + try browser.init(app, .{}, null); defer browser.deinit(); const notification = try lp.Notification.init(allocator); diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 44e9b967..89d4da39 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -3,7 +3,6 @@ const std = @import("std"); const lp = @import("lightpanda"); const App = @import("../App.zig"); -const HttpClient = @import("../browser/HttpClient.zig"); const testing = @import("../testing.zig"); const protocol = @import("protocol.zig"); const resources = @import("resources.zig"); @@ -17,7 +16,6 @@ const Self = @This(); allocator: std.mem.Allocator, app: *App, -http_client: *HttpClient, notification: *lp.Notification, browser: lp.Browser, session: *lp.Session, @@ -26,29 +24,25 @@ node_registry: CDPNode.Registry, transport: Transport, pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self { - const http_client = try HttpClient.init(allocator, &app.network); - errdefer http_client.deinit(); - const notification = try lp.Notification.init(allocator); errdefer notification.deinit(); const self = try allocator.create(Self); errdefer allocator.destroy(self); - var browser = try lp.Browser.init(app, .{ .http_client = http_client }); - errdefer browser.deinit(); - self.* = .{ .allocator = allocator, .app = app, - .browser = browser, + .browser = undefined, .transport = .init(allocator, writer), - .http_client = http_client, .notification = notification, .session = undefined, .node_registry = CDPNode.Registry.init(allocator), }; + try self.browser.init(app, .{}, null); + errdefer self.browser.deinit(); + self.session = try self.browser.newSession(self.notification); if (app.config.cookieFile()) |cookie_path| { @@ -67,7 +61,6 @@ pub fn deinit(self: *Self) void { self.transport.deinit(); self.browser.deinit(); self.notification.deinit(); - self.http_client.deinit(); self.allocator.destroy(self); } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 21dfabe5..d0ae859b 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -250,7 +250,7 @@ test "MCP - Actions by selector: hover, selectOption, setChecked" { const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); defer server.deinit(); - const page = &server.session.page.?; + const page = server.session.currentPage().?; { // Hover by selector diff --git a/src/network/Network.zig b/src/network/Network.zig index 4057641c..991eada5 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -70,6 +70,7 @@ ws_mutex: std.Thread.Mutex = .{}, pollfds: []posix.pollfd, listener: ?Listener = null, +accept: std.atomic.Value(bool) = .init(true), // Wakeup pipe: workers write to [1], main thread polls [0] wakeup_pipe: [2]posix.fd_t = .{ -1, -1 }, @@ -355,6 +356,10 @@ pub fn bind( ctx: *anyopaque, on_accept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, ) !void { + if (self.listener != null) return error.TooManyListeners; + + self.accept.store(true, .release); + const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); errdefer posix.close(listener); @@ -374,8 +379,6 @@ pub fn bind( try posix.getsockname(listener, @ptrCast(&bound), &bound_len); address.* = net.Address.initPosix(@ptrCast(@alignCast(&bound))); - if (self.listener != null) return error.TooManyListeners; - self.listener = .{ .socket = listener, .ctx = ctx, @@ -388,6 +391,11 @@ pub fn bind( }; } +pub fn unbind(self: *Network) void { + self.accept.store(false, .release); + self.wakeupPoll(); +} + pub fn onTick(self: *Network, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void { self.callbacks_mutex.lock(); defer self.callbacks_mutex.unlock(); @@ -424,6 +432,12 @@ pub fn run(self: *Network) void { // telemetry, but we stop accepting new connections. It is the responsibility // of external code to terminate its requests upon shutdown. while (true) { + if (self.listener != null and !self.accept.load(.acquire)) { + posix.close(self.listener.?.socket); + self.listener = null; + self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 }; + } + self.drainQueue(); if (self.multi) |multi| { @@ -574,6 +588,28 @@ fn acceptConnections(self: *Network) void { } }; + // Liveness is enforced at the TCP layer via keepalive probes sent by the + // kernel. This is transparent to CDP clients — unlike a WebSocket ping, which + // go-rod panics on and chromedp logs as "malformed". Tunables in Config.zig. + posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.KEEPALIVE, &std.mem.toBytes(@as(c_int, 1))) catch |err| { + log.warn(.app, "SO_KEEPALIVE", .{ .err = err }); + return; + }; + + const option = switch (@import("builtin").os.tag) { + .macos, .ios => posix.TCP.KEEPALIVE, + else => posix.TCP.KEEPIDLE, + }; + posix.setsockopt(socket, posix.IPPROTO.TCP, option, &std.mem.toBytes(Config.CDP_KEEPALIVE_IDLE_S)) catch |err| { + log.warn(.app, "TCP_KEEPIDLE", .{ .err = err }); + }; + posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(Config.CDP_KEEPALIVE_INTVL_S)) catch |err| { + log.warn(.app, "TCP_KEEPINTVL", .{ .err = err }); + }; + posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, &std.mem.toBytes(Config.CDP_KEEPALIVE_CNT)) catch |err| { + log.warn(.app, "TCP_KEEPCNT", .{ .err = err }); + }; + listener.onAccept(listener.ctx, socket); } } diff --git a/src/network/websocket.zig b/src/network/WsConnection.zig similarity index 51% rename from src/network/websocket.zig rename to src/network/WsConnection.zig index 4ecb5daa..d4598904 100644 --- a/src/network/websocket.zig +++ b/src/network/WsConnection.zig @@ -24,7 +24,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const log = @import("lightpanda").log; const assert = @import("lightpanda").assert; -const CDP_MAX_MESSAGE_SIZE = @import("../Config.zig").CDP_MAX_MESSAGE_SIZE; +const Config = @import("../Config.zig"); +const CDP_MAX_MESSAGE_SIZE = Config.CDP_MAX_MESSAGE_SIZE; const Fragments = struct { type: Message.Type, @@ -305,301 +306,429 @@ pub fn Reader(comptime EXPECT_MASK: bool) type { }; } -pub const WsConnection = struct { - // CLOSE, 2 length, code - const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 - const CLOSE_GOING_AWAY = [_]u8{ 136, 2, 3, 233 }; // code: 1001 - const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009 - const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002 - // "private-use" close codes must be from 4000-49999 - const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000 +pub const WsConnection = @This(); +// CLOSE, 2 length, code +const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 +const CLOSE_GOING_AWAY = [_]u8{ 136, 2, 3, 233 }; // code: 1001 +const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009 +const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002 +// "private-use" close codes must be from 4000-49999 +const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000 + +socket: posix.socket_t, +socket_flags: usize, +reader: Reader(true), +send_arena: ArenaAllocator, +json_version_response: []const u8, + +pub fn init( + self: *WsConnection, socket: posix.socket_t, - socket_flags: usize, - reader: Reader(true), - send_arena: ArenaAllocator, + allocator: Allocator, json_version_response: []const u8, +) !void { + const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0); + const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true })); + if (builtin.is_test == false) { + assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{}); + } - pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8) !WsConnection { - const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0); - const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true })); - if (builtin.is_test == false) { - assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{}); - } + var reader = try Reader(true).init(allocator); + errdefer reader.deinit(); - var reader = try Reader(true).init(allocator); - errdefer reader.deinit(); + self.* = .{ + .socket = socket, + .socket_flags = socket_flags, + .reader = reader, + .send_arena = ArenaAllocator.init(allocator), + .json_version_response = json_version_response, + }; +} - return .{ - .socket = socket, - .socket_flags = socket_flags, - .reader = reader, - .send_arena = ArenaAllocator.init(allocator), - .json_version_response = json_version_response, +pub fn deinit(self: *WsConnection) void { + self.reader.deinit(); + self.send_arena.deinit(); +} + +pub fn send(self: *WsConnection, data: []const u8) !void { + var pos: usize = 0; + var changed_to_blocking: bool = false; + defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 }); + + defer if (changed_to_blocking) { + // We had to change our socket to blocking me to get our write out + // We need to change it back to non-blocking. + _ = posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags) catch |err| { + log.err(.app, "ws restore nonblocking", .{ .err = err }); }; - } + }; - pub fn deinit(self: *WsConnection) void { - self.reader.deinit(); - self.send_arena.deinit(); - } - - pub fn send(self: *WsConnection, data: []const u8) !void { - var pos: usize = 0; - var changed_to_blocking: bool = false; - defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 }); - - defer if (changed_to_blocking) { - // We had to change our socket to blocking me to get our write out - // We need to change it back to non-blocking. - _ = posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags) catch |err| { - log.err(.app, "ws restore nonblocking", .{ .err = err }); - }; + LOOP: while (pos < data.len) { + const written = posix.write(self.socket, data[pos..]) catch |err| switch (err) { + error.WouldBlock => { + // self.socket is nonblocking, because we don't want to block + // reads. But our life is a lot easier if we block writes, + // largely, because we don't have to maintain a queue of pending + // writes (which would each need their own allocations). So + // if we get a WouldBlock error, we'll switch the socket to + // blocking and switch it back to non-blocking after the write + // is complete. Doesn't seem particularly efficiently, but + // this should virtually never happen. + assert(changed_to_blocking == false, "WsConnection.double block", .{}); + changed_to_blocking = true; + _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))); + continue :LOOP; + }, + else => return err, }; - LOOP: while (pos < data.len) { - const written = posix.write(self.socket, data[pos..]) catch |err| switch (err) { - error.WouldBlock => { - // self.socket is nonblocking, because we don't want to block - // reads. But our life is a lot easier if we block writes, - // largely, because we don't have to maintain a queue of pending - // writes (which would each need their own allocations). So - // if we get a WouldBlock error, we'll switch the socket to - // blocking and switch it back to non-blocking after the write - // is complete. Doesn't seem particularly efficiently, but - // this should virtually never happen. - assert(changed_to_blocking == false, "WsConnection.double block", .{}); - changed_to_blocking = true; - _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))); - continue :LOOP; - }, - else => return err, - }; - - if (written == 0) { - return error.Closed; - } - pos += written; + if (written == 0) { + return error.Closed; } + pos += written; } +} - const EMPTY_PONG = [_]u8{ 138, 0 }; +const EMPTY_PONG = [_]u8{ 138, 0 }; - fn sendPong(self: *WsConnection, data: []const u8) !void { - if (data.len == 0) { - return self.send(&EMPTY_PONG); +fn sendPong(self: *WsConnection, data: []const u8) !void { + if (data.len == 0) { + return self.send(&EMPTY_PONG); + } + var header_buf: [10]u8 = undefined; + const header = websocketHeader(&header_buf, .pong, data.len); + + const allocator = self.send_arena.allocator(); + const framed = try allocator.alloc(u8, header.len + data.len); + @memcpy(framed[0..header.len], header); + @memcpy(framed[header.len..], data); + return self.send(framed); +} + +// called by CDP +// Websocket frames have a variable length header. For server-client, +// it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have +// writev, so we need to get creative. We'll JSON serialize to a +// buffer, where the first 10 bytes are reserved. We can then backfill +// the header and send the slice. +pub fn sendJSON(self: *WsConnection, message: anytype, opts: std.json.Stringify.Options) !void { + const allocator = self.send_arena.allocator(); + + var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512); + + // reserve space for the maximum possible header + try aw.writer.writeAll(&[_]u8{0} ** 10); + try std.json.Stringify.value(message, opts, &aw.writer); + const framed = fillWebsocketHeader(aw.toArrayList()); + return self.send(framed); +} + +pub fn sendJSONRaw( + self: *WsConnection, + buf: std.ArrayList(u8), +) !void { + // Dangerous API!. We assume the caller has reserved the first 10 + // bytes in `buf`. + const framed = fillWebsocketHeader(buf); + return self.send(framed); +} + +pub const HttpResult = enum { more, upgraded, close }; + +pub fn handshake(self: *WsConnection) !bool { + // Liveness is enforced by TCP keepalive configured in + // Server.setTcpKeepalive; a dead peer surfaces as a poll error or + // EOF from read(). The poll blocks for ~24 days rather than tracking + // an app-level timeout. Capped at i32-max because posix.poll narrows + // to c_int. + const wait_ms: i32 = std.math.maxInt(i32); + while (true) { + var pfds = [_]posix.pollfd{.{ + .fd = self.socket, + .events = posix.POLL.IN, + .revents = 0, + }}; + const n = try posix.poll(&pfds, wait_ms); + if (n == 0) { + log.info(.app, "CDP timeout", .{}); + return false; } - var header_buf: [10]u8 = undefined; - const header = websocketHeader(&header_buf, .pong, data.len); - - const allocator = self.send_arena.allocator(); - const framed = try allocator.alloc(u8, header.len + data.len); - @memcpy(framed[0..header.len], header); - @memcpy(framed[header.len..], data); - return self.send(framed); - } - - // called by CDP - // Websocket frames have a variable length header. For server-client, - // it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have - // writev, so we need to get creative. We'll JSON serialize to a - // buffer, where the first 10 bytes are reserved. We can then backfill - // the header and send the slice. - pub fn sendJSON(self: *WsConnection, message: anytype, opts: std.json.Stringify.Options) !void { - const allocator = self.send_arena.allocator(); - - var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512); - - // reserve space for the maximum possible header - try aw.writer.writeAll(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }); - try std.json.Stringify.value(message, opts, &aw.writer); - const framed = fillWebsocketHeader(aw.toArrayList()); - return self.send(framed); - } - - pub fn sendJSONRaw( - self: *WsConnection, - buf: std.ArrayList(u8), - ) !void { - // Dangerous API!. We assume the caller has reserved the first 10 - // bytes in `buf`. - const framed = fillWebsocketHeader(buf); - return self.send(framed); - } - - pub fn read(self: *WsConnection) !usize { - const n = try posix.read(self.socket, self.reader.readBuf()); - self.reader.len += n; - return n; - } - - pub fn processMessages(self: *WsConnection, handler: anytype) !bool { - var reader = &self.reader; - while (true) { - const msg = reader.next() catch |err| { - switch (err) { - error.TooLarge => self.send(&CLOSE_TOO_BIG) catch {}, - error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, - error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, - error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, - error.ControlTooLarge => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, - error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, - error.NestedFragmentation => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, - error.OutOfMemory => {}, // don't borther trying to send an error in this case - } - return err; - } orelse break; - - switch (msg.type) { - .pong => {}, - .ping => try self.sendPong(msg.data), - .close => { - self.send(&CLOSE_NORMAL) catch {}; - return false; - }, - .text, .binary => if (handler.handleMessage(msg.data) == false) { - return false; - }, - } - if (msg.cleanup_fragment) { - reader.cleanup(); - } - } - - // We might have read part of the next message. Our reader potentially - // has to move data around in its buffer to make space. - reader.compact(); - return true; - } - - pub fn upgrade(self: *WsConnection, request: []u8) !void { - // our caller already confirmed that we have a trailing \r\n\r\n - const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable; - const request_line = request[0..request_line_end]; - - if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) { - return error.InvalidProtocol; - } - - // we need to extract the sec-websocket-key value - var key: []const u8 = ""; - - // we need to make sure that we got all the necessary headers + values - var required_headers: u8 = 0; - - // can't std.mem.split because it forces the iterated value to be const - // (we could @constCast...) - - var buf = request[request_line_end + 2 ..]; - - while (buf.len > 4) { - const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable; - const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest; - - const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace); - const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace); - - if (std.mem.eql(u8, name, "upgrade")) { - if (!std.ascii.eqlIgnoreCase("websocket", value)) { - return error.InvalidUpgradeHeader; - } - required_headers |= 1; - } else if (std.mem.eql(u8, name, "sec-websocket-version")) { - if (value.len != 2 or value[0] != '1' or value[1] != '3') { - return error.InvalidVersionHeader; - } - required_headers |= 2; - } else if (std.mem.eql(u8, name, "connection")) { - // find if connection header has upgrade in it, example header: - // Connection: keep-alive, Upgrade - if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) { - return error.InvalidConnectionHeader; - } - required_headers |= 4; - } else if (std.mem.eql(u8, name, "sec-websocket-key")) { - key = value; - required_headers |= 8; - } - - const next = index + 2; - buf = buf[next..]; - } - - if (required_headers != 15) { - return error.MissingHeaders; - } - - // our caller has already made sure this request ended in \r\n\r\n - // so it isn't something we need to check again - - const alloc = self.send_arena.allocator(); - - const response = blk: { - // Response to an upgrade request is always this, with - // the Sec-Websocket-Accept value a spacial sha1 hash of the - // request "sec-websocket-version" and a magic value. - - const template = - "HTTP/1.1 101 Switching Protocols\r\n" ++ - "Upgrade: websocket\r\n" ++ - "Connection: upgrade\r\n" ++ - "Sec-Websocket-Accept: 0000000000000000000000000000\r\n\r\n"; - - // The response will be sent via the IO Loop and thus has to have its - // own lifetime. - const res = try alloc.dupe(u8, template); - - // magic response - const key_pos = res.len - 32; - var h: [20]u8 = undefined; - var hasher = std.crypto.hash.Sha1.init(.{}); - hasher.update(key); - // websocket spec always used this value - hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); - hasher.final(&h); - - _ = std.base64.standard.Encoder.encode(res[key_pos .. key_pos + 28], h[0..]); - - break :blk res; + const read_bytes = self.read() catch |err| { + log.warn(.app, "CDP read", .{ .err = err }); + return false; }; - - return self.send(response); - } - - pub fn sendHttpError(self: *WsConnection, comptime status: u16, comptime body: []const u8) void { - const response = std.fmt.comptimePrint( - "HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}", - .{ status, body.len, body }, - ); - - // we're going to close this connection anyways, swallowing any - // error seems safe - self.send(response) catch {}; - } - - pub fn getAddress(self: *WsConnection) !std.net.Address { - var address: std.net.Address = undefined; - var socklen: posix.socklen_t = @sizeOf(std.net.Address); - try posix.getpeername(self.socket, &address.any, &socklen); - return address; - } - - pub fn sendClose(self: *WsConnection) void { - self.send(&CLOSE_GOING_AWAY) catch {}; - } - - pub fn shutdown(self: *WsConnection) void { - posix.shutdown(self.socket, .recv) catch {}; - } - - pub fn setBlocking(self: *WsConnection, blocking: bool) !void { - if (blocking) { - _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))); - } else { - _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags); + if (read_bytes == 0) { + log.info(.app, "CDP disconnect", .{}); + return false; + } + const result = self.processHttpRequest() catch return false; + switch (result) { + .more => continue, + .upgraded => return true, + .close => return false, } } -}; +} + +pub fn read(self: *WsConnection) !usize { + const n = try posix.read(self.socket, self.reader.readBuf()); + self.reader.len += n; + return n; +} + +fn processHttpRequest(self: *WsConnection) !HttpResult { + assert(self.reader.pos == 0, "WsConnection.HTTP pos", .{ .pos = self.reader.pos }); + const request = self.reader.buf[0..self.reader.len]; + + if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) { + self.sendHttpError(413, "Request too large"); + return error.RequestTooLarge; + } + + // we're only expecting [body-less] GET requests. + if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) { + // we need more data, put any more data here + return .more; + } + + // the next incoming data can go to the front of our buffer + defer self.reader.len = 0; + return self.handleHttpRequest(request) catch |err| { + switch (err) { + error.NotFound => self.sendHttpError(404, "Not found"), + error.InvalidRequest => self.sendHttpError(400, "Invalid request"), + error.InvalidProtocol => self.sendHttpError(400, "Invalid HTTP protocol"), + error.MissingHeaders => self.sendHttpError(400, "Missing required header"), + error.InvalidUpgradeHeader => self.sendHttpError(400, "Unsupported upgrade type"), + error.InvalidVersionHeader => self.sendHttpError(400, "Invalid websocket version"), + error.InvalidConnectionHeader => self.sendHttpError(400, "Invalid connection header"), + else => { + log.err(.app, "server 500", .{ .err = err, .req = request[0..@min(100, request.len)] }); + self.sendHttpError(500, "Internal Server Error"); + }, + } + return err; + }; +} + +fn handleHttpRequest(self: *WsConnection, request: []u8) !HttpResult { + if (request.len < 18) { + // 18 is [generously] the smallest acceptable HTTP request + return error.InvalidRequest; + } + + if (std.mem.eql(u8, request[0..4], "GET ") == false) { + return error.NotFound; + } + + const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse { + return error.InvalidRequest; + }; + + const url = request[4..url_end]; + + if (std.mem.eql(u8, url, "/")) { + try self.upgrade(request); + return .upgraded; + } + + if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) { + try self.send(self.json_version_response); + // Chromedp (a Go driver) does an http request to /json/version + // then to / (websocket upgrade) using a different connection. + // Since we only allow 1 connection at a time, the 2nd one (the + // websocket upgrade) blocks until the first one times out. + // We can avoid that by closing the connection. json_version_response + // has a Connection: Close header too. + self.shutdown(); + return .close; + } + + if (std.mem.eql(u8, url, "/json/list") or std.mem.eql(u8, url, "/json/list/") or + std.mem.eql(u8, url, "/json") or std.mem.eql(u8, url, "/json/")) + { + try self.send(empty_json_list_response); + self.shutdown(); + return .close; + } + + return error.NotFound; +} + +const empty_json_list_response = + "HTTP/1.1 200 OK\r\n" ++ + "Content-Length: 2\r\n" ++ + "Connection: Close\r\n" ++ + "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ + "[]"; + +pub fn processMessages(self: *WsConnection, handler: anytype) !bool { + var reader = &self.reader; + while (true) { + const msg = reader.next() catch |err| { + switch (err) { + error.TooLarge => self.send(&CLOSE_TOO_BIG) catch {}, + error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.ControlTooLarge => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.NestedFragmentation => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.OutOfMemory => {}, // don't borther trying to send an error in this case + } + return err; + } orelse break; + + switch (msg.type) { + .pong => {}, + .ping => try self.sendPong(msg.data), + .close => { + self.send(&CLOSE_NORMAL) catch {}; + return false; + }, + .text, .binary => if (handler.handleMessage(msg.data) == false) { + return false; + }, + } + if (msg.cleanup_fragment) { + reader.cleanup(); + } + } + + // We might have read part of the next message. Our reader potentially + // has to move data around in its buffer to make space. + reader.compact(); + return true; +} + +pub fn upgrade(self: *WsConnection, request: []u8) !void { + // our caller already confirmed that we have a trailing \r\n\r\n + const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable; + const request_line = request[0..request_line_end]; + + if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) { + return error.InvalidProtocol; + } + + // we need to extract the sec-websocket-key value + var key: []const u8 = ""; + + // we need to make sure that we got all the necessary headers + values + var required_headers: u8 = 0; + + // can't std.mem.split because it forces the iterated value to be const + // (we could @constCast...) + + var buf = request[request_line_end + 2 ..]; + + while (buf.len > 4) { + const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable; + const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest; + + const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace); + const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace); + + if (std.mem.eql(u8, name, "upgrade")) { + if (!std.ascii.eqlIgnoreCase("websocket", value)) { + return error.InvalidUpgradeHeader; + } + required_headers |= 1; + } else if (std.mem.eql(u8, name, "sec-websocket-version")) { + if (value.len != 2 or value[0] != '1' or value[1] != '3') { + return error.InvalidVersionHeader; + } + required_headers |= 2; + } else if (std.mem.eql(u8, name, "connection")) { + // find if connection header has upgrade in it, example header: + // Connection: keep-alive, Upgrade + if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) { + return error.InvalidConnectionHeader; + } + required_headers |= 4; + } else if (std.mem.eql(u8, name, "sec-websocket-key")) { + key = value; + required_headers |= 8; + } + + const next = index + 2; + buf = buf[next..]; + } + + if (required_headers != 15) { + return error.MissingHeaders; + } + + // our caller has already made sure this request ended in \r\n\r\n + // so it isn't something we need to check again + + const alloc = self.send_arena.allocator(); + + const response = blk: { + // Response to an upgrade request is always this, with + // the Sec-Websocket-Accept value a spacial sha1 hash of the + // request "sec-websocket-version" and a magic value. + + const template = + "HTTP/1.1 101 Switching Protocols\r\n" ++ + "Upgrade: websocket\r\n" ++ + "Connection: upgrade\r\n" ++ + "Sec-Websocket-Accept: 0000000000000000000000000000\r\n\r\n"; + + // The response will be sent via the IO Loop and thus has to have its + // own lifetime. + const res = try alloc.dupe(u8, template); + + // magic response + const key_pos = res.len - 32; + var h: [20]u8 = undefined; + var hasher = std.crypto.hash.Sha1.init(.{}); + hasher.update(key); + // websocket spec always used this value + hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + hasher.final(&h); + + _ = std.base64.standard.Encoder.encode(res[key_pos .. key_pos + 28], h[0..]); + + break :blk res; + }; + + return self.send(response); +} + +pub fn sendHttpError(self: *WsConnection, comptime status: u16, comptime body: []const u8) void { + const response = std.fmt.comptimePrint( + "HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}", + .{ status, body.len, body }, + ); + + // we're going to close this connection anyways, swallowing any + // error seems safe + self.send(response) catch {}; +} + +pub fn getAddress(self: *WsConnection) !std.net.Address { + var address: std.net.Address = undefined; + var socklen: posix.socklen_t = @sizeOf(std.net.Address); + try posix.getpeername(self.socket, &address.any, &socklen); + return address; +} + +pub fn sendClose(self: *WsConnection) void { + self.send(&CLOSE_GOING_AWAY) catch {}; +} + +pub fn shutdown(self: *WsConnection) void { + posix.shutdown(self.socket, .recv) catch {}; +} + +pub fn setBlocking(self: *WsConnection, blocking: bool) !void { + if (blocking) { + _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))); + } else { + _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags); + } +} fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 { // can't use buf[0..10] here, because the header length 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 { diff --git a/src/testing.zig b/src/testing.zig index 1ea704b0..549d1349 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -333,7 +333,6 @@ fn isJsonValue(a: std.json.Value, b: std.json.Value) bool { } pub var test_app: *App = undefined; -pub var test_http: *HttpClient = undefined; pub var test_browser: Browser = undefined; pub var test_notification: *Notification = undefined; pub var test_session: *Session = undefined; @@ -499,10 +498,7 @@ test "tests:beforeAll" { test_app = try App.init(test_allocator, &test_config); errdefer test_app.deinit(); - test_http = try HttpClient.init(test_allocator, &test_app.network); - errdefer test_http.deinit(); - - test_browser = try Browser.init(test_app, .{ .http_client = test_http }); + try test_browser.init(test_app, .{}, null); errdefer test_browser.deinit(); // Create notification for testing @@ -557,7 +553,6 @@ test "tests:afterAll" { test_notification.deinit(); test_browser.deinit(); - test_http.deinit(); test_app.deinit(); test_config.deinit(@import("root").tracking_allocator); }