From 2189c8cd8257479708488d6c79c7e05229b68df5 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 16 Feb 2026 17:46:37 +0100 Subject: [PATCH 1/5] dispatch .page_navigated event on page error callback When a CDP client navigates to a page and the page generates an error, it blocks waiting for the .page_navigated event. It currently happens w/ robots.txt denied page. Example: https://httpbin.io/deny --- src/browser/Page.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 3fa7f860..50e81a7b 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -799,6 +799,14 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { var self: *Page = @ptrCast(@alignCast(ctx)); self.clearTransferArena(); self._parse_state = .{ .err = err }; + + // Dispatch a navigated event to indicate the end of the navigation. + self._session.notification.dispatch(.page_navigated, &.{ + .req_id = self._req_id.?, + .opts = self._navigated_options.?, + .url = self.url, + .timestamp = timestamp(.monotonic), + }); } // The transfer arena is useful and interesting, but has a weird lifetime. From 311bcadacb2724715ecc68b3fa51867b57c42f04 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 16 Feb 2026 18:17:07 +0100 Subject: [PATCH 2/5] create HTML page error on page error callback. --- src/browser/Page.zig | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 50e81a7b..04c35332 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -799,14 +799,23 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { var self: *Page = @ptrCast(@alignCast(ctx)); self.clearTransferArena(); self._parse_state = .{ .err = err }; + defer self.documentIsComplete(); - // Dispatch a navigated event to indicate the end of the navigation. - self._session.notification.dispatch(.page_navigated, &.{ - .req_id = self._req_id.?, - .opts = self._navigated_options.?, - .url = self.url, - .timestamp = timestamp(.monotonic), - }); + // Generate a pseudo HTML page indicating the navigation falilure. + const parse_arena = self.getArena(.{ .debug = "Page.parse" }) catch |e| { + log.err(.browser, "get arena on pageErrorCallback", .{ .err = e }); + return; + }; + defer self.releaseArena(parse_arena); + + const html = std.mem.concat(parse_arena, u8, &.{ + "

Navigation failed

Reason: ", + @errorName(err), + "

", + }) catch "

Navigation failed

"; + + var parser = Parser.init(parse_arena, self.document.asNode(), self); + parser.parse(html); } // The transfer arena is useful and interesting, but has a weird lifetime. From 14b0095822da23086e5dc985f3797372092c084c Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 17 Feb 2026 09:47:45 +0100 Subject: [PATCH 3/5] move page error HTML creation into pageDoneCallback Now pageErrCllaback call pageDoneCallback to finalize the page. --- src/browser/Page.zig | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 04c35332..257f6750 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -789,6 +789,21 @@ fn pageDoneCallback(ctx: *anyopaque) !void { self._parse_state = .{ .complete = {} }; self.documentIsComplete(); }, + .err => |err| { + // Generate a pseudo HTML page indicating the failure. + const parse_arena = try self.getArena(.{ .debug = "Page.parse" }); + defer self.releaseArena(parse_arena); + + const html = std.mem.concat(parse_arena, u8, &.{ + "

Navigation failed

Reason: ", + @errorName(err), + "

", + }) catch "

Navigation failed

"; + + var parser = Parser.init(parse_arena, self.document.asNode(), self); + parser.parse(html); + self.documentIsComplete(); + }, else => unreachable, } } @@ -797,25 +812,14 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { log.err(.page, "navigate failed", .{ .err = err }); var self: *Page = @ptrCast(@alignCast(ctx)); - self.clearTransferArena(); self._parse_state = .{ .err = err }; - defer self.documentIsComplete(); - // Generate a pseudo HTML page indicating the navigation falilure. - const parse_arena = self.getArena(.{ .debug = "Page.parse" }) catch |e| { - log.err(.browser, "get arena on pageErrorCallback", .{ .err = e }); + // In case of error, we want to complete the page with a custom HTML + // containing the error. + pageDoneCallback(ctx) catch |e| { + log.err(.browser, "pageErrorCallback", .{ .err = e }); return; }; - defer self.releaseArena(parse_arena); - - const html = std.mem.concat(parse_arena, u8, &.{ - "

Navigation failed

Reason: ", - @errorName(err), - "

", - }) catch "

Navigation failed

"; - - var parser = Parser.init(parse_arena, self.document.asNode(), self); - parser.parse(html); } // The transfer arena is useful and interesting, but has a weird lifetime. From c7b414492d3f4bcd60fc25d9e3a89a52c8e4a648 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 17 Feb 2026 10:30:47 +0100 Subject: [PATCH 4/5] add image content type detection into Mime --- src/browser/Mime.zig | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index e452ce53..02ebb9f9 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -38,6 +38,10 @@ pub const ContentTypeEnum = enum { text_javascript, text_plain, text_css, + image_jpeg, + image_gif, + image_png, + image_webp, application_json, unknown, other, @@ -49,6 +53,10 @@ pub const ContentType = union(ContentTypeEnum) { text_javascript: void, text_plain: void, text_css: void, + image_jpeg: void, + image_gif: void, + image_png: void, + image_webp: void, application_json: void, unknown: void, other: struct { type: []const u8, sub_type: []const u8 }, @@ -61,6 +69,10 @@ pub fn contentTypeString(mime: *const Mime) []const u8 { .text_javascript => "application/javascript", .text_plain => "text/plain", .text_css => "text/css", + .image_jpeg => "image/jpeg", + .image_png => "image/png", + .image_gif => "image/gif", + .image_webp => "image/webp", .application_json => "application/json", else => "", }; @@ -243,6 +255,11 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } { @"application/javascript", @"application/x-javascript", + @"image/jpeg", + @"image/png", + @"image/gif", + @"image/webp", + @"application/json", }, type_name)) |known_type| { const ct: ContentType = switch (known_type) { @@ -251,6 +268,10 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } { .@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} }, .@"text/plain" => .{ .text_plain = {} }, .@"text/css" => .{ .text_css = {} }, + .@"image/jpeg" => .{ .image_jpeg = {} }, + .@"image/png" => .{ .image_png = {} }, + .@"image/gif" => .{ .image_gif = {} }, + .@"image/webp" => .{ .image_webp = {} }, .@"application/json" => .{ .application_json = {} }, }; return .{ ct, attribute_start }; @@ -358,6 +379,11 @@ test "Mime: parse common" { try expect(.{ .content_type = .{ .application_json = {} } }, "application/json"); try expect(.{ .content_type = .{ .text_css = {} } }, "text/css"); + + try expect(.{ .content_type = .{ .image_jpeg = {} } }, "image/jpeg"); + try expect(.{ .content_type = .{ .image_png = {} } }, "image/png"); + try expect(.{ .content_type = .{ .image_gif = {} } }, "image/gif"); + try expect(.{ .content_type = .{ .image_webp = {} } }, "image/webp"); } test "Mime: parse uncommon" { From 7505aec706ca72bc0236bec449bb8d53205898d3 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 17 Feb 2026 10:31:47 +0100 Subject: [PATCH 5/5] generate always an HTML on pageDoneCallback Add also image support --- src/browser/Page.zig | 50 ++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 257f6750..5940d2c5 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -708,7 +708,10 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { try arr.appendSlice(self.arena, "
");
                 self._parse_state = .{ .text = arr };
             },
-            else => self._parse_state = .{ .raw = .{} },
+            .image_jpeg, .image_gif, .image_png, .image_webp => {
+                self._parse_state = .{ .image = .empty };
+            },
+            else => self._parse_state = .{ .raw = .empty },
         }
     }
 
@@ -730,7 +733,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
                 v = v[index + 1 ..];
             }
         },
-        .raw => |*buf| try buf.appendSlice(self.arena, data),
+        .raw, .image => |*buf| try buf.appendSlice(self.arena, data),
         .pre => unreachable,
         .complete => unreachable,
         .err => unreachable,
@@ -753,12 +756,13 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
         log.debug(.page, "page.load.complete", .{ .url = self.url });
     };
 
+    const parse_arena = try self.getArena(.{ .debug = "Page.parse" });
+    defer self.releaseArena(parse_arena);
+
+    var parser = Parser.init(parse_arena, self.document.asNode(), self);
+
     switch (self._parse_state) {
         .html => |buf| {
-            const parse_arena = try self.getArena(.{ .debug = "Page.parse" });
-            defer self.releaseArena(parse_arena);
-
-            var parser = Parser.init(parse_arena, self.document.asNode(), self);
             parser.parse(buf.items);
             self._script_manager.staticScriptsDone();
             if (self._script_manager.isDone()) {
@@ -770,16 +774,26 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
         },
         .text => |*buf| {
             try buf.appendSlice(self.arena, "
"); - - const parse_arena = try self.getArena(.{ .debug = "Page.parse" }); - defer self.releaseArena(parse_arena); - - var parser = Parser.init(parse_arena, self.document.asNode(), self); parser.parse(buf.items); self.documentIsComplete(); }, + .image => |buf| { + self._parse_state = .{ .raw_done = buf.items }; + + // Use empty an HTML containing the image. + const html = try std.mem.concat(parse_arena, u8, &.{ + "", + }); + parser.parse(html); + self.documentIsComplete(); + }, .raw => |buf| { self._parse_state = .{ .raw_done = buf.items }; + + // Use empty an empty HTML document. + parser.parse(""); self.documentIsComplete(); }, .pre => { @@ -787,20 +801,19 @@ fn pageDoneCallback(ctx: *anyopaque) !void { // We assume we have received an OK status (checked in Client.headerCallback) // so we load a blank document to navigate away from any prior page. self._parse_state = .{ .complete = {} }; + + // Use empty an empty HTML document. + parser.parse(""); self.documentIsComplete(); }, .err => |err| { // Generate a pseudo HTML page indicating the failure. - const parse_arena = try self.getArena(.{ .debug = "Page.parse" }); - defer self.releaseArena(parse_arena); - - const html = std.mem.concat(parse_arena, u8, &.{ + const html = try std.mem.concat(parse_arena, u8, &.{ "

Navigation failed

Reason: ", @errorName(err), "

", - }) catch "

Navigation failed

"; + }); - var parser = Parser.init(parse_arena, self.document.asNode(), self); parser.parse(html); self.documentIsComplete(); }, @@ -880,7 +893,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { while (true) { switch (self._parse_state) { - .pre, .raw, .text => { + .pre, .raw, .text, .image => { // The main page hasn't started/finished navigating. // There's no JS to run, and no reason to run the scheduler. if (http_client.active == 0 and exit_when_done) { @@ -2848,6 +2861,7 @@ const ParseState = union(enum) { err: anyerror, html: std.ArrayList(u8), text: std.ArrayList(u8), + image: std.ArrayList(u8), raw: std.ArrayList(u8), raw_done: []const u8, };