From c3afcc51dce69c456ca2b7c66a58102ab7aaf616 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Fri, 12 Jun 2026 19:13:10 +0200 Subject: [PATCH] cdp: answer Page.navigate with errorText when navigation fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root navigation that fails before any response headers arrive (connection refused, DNS failure, TLS error) never answered the Page.navigate command: the success reply is only sent from the response-header path (frame_navigated), and frameErrorCallback dropped the stored cdp_id along with the pending Page. The CDP client awaiting the response was left hanging forever. Dispatch a frame_navigate_failed notification from frameErrorCallback when no response headers were processed (_http_status == null guards against double-answering mid-body failures), and answer the pending command with {frameId, loaderId, errorText} — matching Chrome, where errorText is "present if and only if navigation has failed". Closes #2728 --- src/Notification.zig | 15 ++++++++++++ src/browser/Frame.zig | 17 ++++++++++++++ src/cdp/CDP.zig | 6 +++++ src/cdp/domains/page.zig | 51 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/src/Notification.zig b/src/Notification.zig index 1dff474fe..17f30a764 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -76,6 +76,7 @@ const EventListeners = struct { frame_created: List = .{}, frame_navigate: List = .{}, frame_navigated: List = .{}, + frame_navigate_failed: List = .{}, frame_network_idle: List = .{}, frame_network_almost_idle: List = .{}, frame_child_frame_created: List = .{}, @@ -102,6 +103,7 @@ const Events = union(enum) { frame_created: *Frame, frame_navigate: *const FrameNavigate, frame_navigated: *const FrameNavigated, + frame_navigate_failed: *const FrameNavigateFailed, frame_network_idle: *const FrameNetworkIdle, frame_network_almost_idle: *const FrameNetworkAlmostIdle, frame_child_frame_created: *const FrameChildFrameCreated, @@ -149,6 +151,19 @@ pub const FrameNavigated = struct { opts: Frame.NavigatedOpts, }; +// A root navigation that failed before commit (DNS failure, connection +// refused, malformed response, ...). Dispatched so CDP can answer the +// pending Page.navigate command with an errorText instead of leaving the +// command id unanswered forever. +pub const FrameNavigateFailed = struct { + frame_id: u32, + loader_id: u32, + timestamp: u64, + url: [:0]const u8, + err: anyerror, + opts: Frame.NavigatedOpts, +}; + pub const FrameNetworkIdle = struct { req_id: u32, frame_id: u32, diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 72e1b9ade..abe0fb1fe 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1325,6 +1325,23 @@ fn frameErrorCallback(ctx: *anyopaque, err: anyerror) void { log.err(.frame, "navigate failed", .{ .err = err, .type = self._type, .url = self.url }); + // A navigation that fails before any response headers arrive never + // reaches the frame_navigated dispatch in frameHeaderCallback, so the + // Page.navigate command that initiated it would stay unanswered forever. + // Tell CDP so it can answer with an errorText (Chrome semantics). + // _http_status is set as soon as headers are processed; non-null means + // frameHeaderCallback already answered the command — don't answer twice. + if (self._http_status == null) { + self._session.notification.dispatch(.frame_navigate_failed, &.{ + .frame_id = self._frame_id, + .loader_id = self._loader_id, + .timestamp = timestamp(.monotonic), + .url = self.url, + .err = err, + .opts = self._navigated_options orelse .{}, + }); + } + // 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 diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 05e66933a..f8d529ced 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -600,6 +600,7 @@ pub const BrowserContext = struct { try notification.register(.frame_created, self, onFrameCreated); try notification.register(.frame_navigate, self, onFrameNavigate); try notification.register(.frame_navigated, self, onFrameNavigated); + try notification.register(.frame_navigate_failed, self, onFrameNavigateFailed); try notification.register(.frame_child_frame_created, self, onFrameChildFrameCreated); try notification.register(.frame_dom_content_loaded, self, onFrameDOMContentLoaded); try notification.register(.frame_loaded, self, onFrameLoaded); @@ -837,6 +838,11 @@ pub const BrowserContext = struct { return @import("domains/page.zig").frameNavigated(self.notification_arena, self, msg); } + pub fn onFrameNavigateFailed(ctx: *anyopaque, msg: *const Notification.FrameNavigateFailed) !void { + const self: *BrowserContext = @ptrCast(@alignCast(ctx)); + return @import("domains/page.zig").frameNavigateFailed(self, msg); + } + pub fn onFrameChildFrameCreated(ctx: *anyopaque, msg: *const Notification.FrameChildFrameCreated) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); return @import("domains/page.zig").frameChildFrameCreated(self, msg); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 30fc699b2..2a5808e1d 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -551,6 +551,26 @@ pub fn frameCreated(bc: *CDP.BrowserContext, frame: *Frame) !void { } } +// A root navigation failed before commit — the pending Page is being +// discarded and no frameNavigated will ever fire, but the Page.navigate +// command that initiated it is still awaiting its response. Answer it with +// an errorText (Chrome semantics: "present if and only if navigation has +// failed") so the client isn't left waiting on the command id forever. +pub fn frameNavigateFailed(bc: *CDP.BrowserContext, event: *const Notification.FrameNavigateFailed) !void { + const session_id = bc.session_id orelse return; + + const input_id = event.opts.cdp_id orelse return; + try bc.cdp.sendJSON(.{ + .id = input_id, + .result = .{ + .frameId = &id.toFrameId(event.frame_id), + .loaderId = &id.toLoaderId(event.loader_id), + .errorText = @errorName(event.err), + }, + .sessionId = session_id, + }); +} + pub fn frameChildFrameCreated(bc: *CDP.BrowserContext, event: *const Notification.FrameChildFrameCreated) !void { const session_id = bc.session_id orelse return; @@ -1368,6 +1388,37 @@ test "cdp.frame: navigate does not follow Location on a non-redirect 3xx" { try testing.expect(v.toBool()); } +test "cdp.frame: navigate answers with errorText when the navigation fails" { + // A root navigation that fails before commit (here: connection refused — + // nothing listens on port 1) must still answer the Page.navigate command. + // Chrome resolves it with an errorText field ("present if and only if + // navigation has failed"); leaving the command id unanswered forever + // deadlocks clients that await the response. + var ctx = try testing.context(); + defer ctx.deinit(); + + var bc = try ctx.loadBrowserContext(.{ .id = "BID-NAVF", .url = "hi.html", .target_id = "FID-000000NAVF".* }); + + try ctx.processMessage(.{ + .id = 52, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:1/unreachable" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + // The pending page was discarded; the active document is untouched. + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/src/browser/tests/hi.html", frame.url); + + try ctx.expectSentResult(.{ + .frameId = "FID-0000000001", + .loaderId = "LID-0000000002", + .errorText = "CouldntConnect", + }, .{ .id = 52 }); +} + test "cdp.frame: navigate to about:blank replaces a non-blank document" { // Regression test for #2363. Page.navigate("about:blank") issued against a // tab that already holds a real document must replace the active document