cdp: answer Page.navigate with errorText when navigation fails

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
This commit is contained in:
Navid EMAD committed 2026-06-12 19:13:10 +02:00
1 parent 1fae856330
commit c3afcc51dc
4 files changed
+89

No files matched your search

+15
View File
@@ -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,
+17
View File
@@ -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
+6
View File
@@ -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);
+51
View File
@@ -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