diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index e6b25894d..87f5d4931 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1124,6 +1124,32 @@ pub fn abortTransfers(self: *Frame) void { http_client.abortOwner(&self._http_owner); } +pub fn stopLoading(self: *Frame) void { + var i: usize = 0; + while (i < self.child_frames.items.len) : (i += 1) { + // Each frame will cancel its requests, which can fire JS callbacks + // which can destroy/change frames. Hence `while` instead of `for`. + self.child_frames.items[i].stopLoading(); + } + + if (self._queued_navigation) |qn| { + const queued = self._page.queued_navigation; + if (std.mem.indexOfScalar(*Frame, queued.items, self)) |idx| { + _ = queued.swapRemove(idx); + } + qn.arena.release(); + self._queued_navigation = null; + } + + const http_client = &self._session.browser.http_client; + if (http_client.findTransfer(self._req_id)) |transfer| { + // the main navigation is still transfering, force it to finish now, + // with whatever it has + _ = transfer.finishEarly(); + } + http_client.cancelRequests(&self._http_owner); +} + pub fn documentIsLoaded(self: *Frame) void { if (self._load_state != .parsing) { // Ideally, documentIsLoaded would only be called once, but if a @@ -1809,7 +1835,8 @@ fn frameErrorCallback(ctx: *anyopaque, err: anyerror) void { var self: *Frame = @ptrCast(@alignCast(ctx)); self._last_navigate_error = err; - log.err(.frame, "navigate failed", .{ .err = err, .type = self._type, .url = self.url }); + const level: log.Level = if (err == error.TransferCanceled) .info else .err; + log.log(.frame, level, "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 diff --git a/src/browser/Session.zig b/src/browser/Session.zig index f6f0653ca..05a9439d3 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -399,7 +399,14 @@ pub fn getPinnedArena(self: *Session, size_or_bucket: anytype, debug: []const u8 return self.arena_pool.acquirePinned(&self.browser.arena_account, size_or_bucket, debug); } -// The live page for a top-level browsing context, by its root frame id. +pub fn stopLoading(self: *Session, frame_id: u32) void { + const live = self.livePage(frame_id) orelse return; + if (self.replacementOf(live)) |pending| { + pending.frame.stopLoading(); + } + live.frame.stopLoading(); +} + pub fn livePage(self: *Session, frame_id: u32) ?*Page { for (self.pages.items) |page| { if (page.frame._frame_id == frame_id) { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 55f99fe50..9475804e6 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -941,7 +941,7 @@ fn dynamicModuleSourceCallback(ctx: *anyopaque, module_source_: anyerror!ScriptM var ms = module_source_ catch |err| { const resolver = local.toLocal(state.resolver); switch (err) { - error.UrlMalformat, error.Abort => resolver.rejectError("dynamic module source", .{ .type_error = @errorName(err) }), + error.UrlMalformat, error.Abort, error.TransferCanceled => resolver.rejectError("dynamic module source", .{ .type_error = @errorName(err) }), else => _ = resolver.reject("dynamic module source", local.newString(@errorName(err))), } return; diff --git a/src/browser/tests/window/stop.html b/src/browser/tests/window/stop.html new file mode 100644 index 000000000..1b72bc813 --- /dev/null +++ b/src/browser/tests/window/stop.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + diff --git a/src/browser/tests/window/support/stop_defer.js b/src/browser/tests/window/support/stop_defer.js new file mode 100644 index 000000000..e46826f80 --- /dev/null +++ b/src/browser/tests/window/support/stop_defer.js @@ -0,0 +1 @@ +window.deferRan = true; diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index 7bf26cdc6..0e83c5b22 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -135,7 +135,7 @@ pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Wor // Called from Page.deinit of the owning (creating) page. pub fn deinit(self: *SharedWorkerGlobalScope) void { if (self._http_transfer) |transfer| { - transfer.abort(error.Abort); + transfer.cancel(); self._http_transfer = null; } self.releaseScriptArena(); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index dadb7baf7..09e35e132 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -813,6 +813,10 @@ pub fn close(self: *Window) void { pub fn focus(_: *Window) void {} pub fn blur(_: *Window) void {} +pub fn stop(self: *Window) void { + self._frame.stopLoading(); +} + pub fn postMessage(self: *Window, message: js.Value, target_origin: ?[]const u8, transfer: ?[]const *MessagePort, frame: *Frame) !void { // For now, we ignore targetOrigin checking and just dispatch the message // In a full implementation, we would validate the origin @@ -1260,6 +1264,7 @@ pub const JsApi = struct { pub const close = bridge.function(Window.close, .{}); pub const focus = bridge.function(Window.focus, .{}); pub const blur = bridge.function(Window.blur, .{}); + pub const stop = bridge.function(Window.stop, .{}); pub const alert = bridge.function(struct { fn alert(_: *const Window, message: ?[]const u8, frame: *Frame) void { @@ -1377,6 +1382,7 @@ const CrossOriginWindow = struct { const testing = @import("../../testing.zig"); test "WebApi: Window" { + testing.expectLog(&.{.http}); // stop aborts try testing.htmlRunner("window", .{}); } diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index d6013a4b4..c19bd9fc9 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -139,7 +139,7 @@ pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker { pub fn deinit(self: *Worker) void { // No pending frame for workers, so we can abort all frames. if (self._http_transfer) |res| { - res.abort(error.Abort); + res.cancel(); self._http_transfer = null; } self.releaseScriptArena(); @@ -324,7 +324,7 @@ fn _fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Gl pub fn terminate(self: *Worker) void { // Abort any pending script fetch if (self._http_transfer) |resp| { - resp.abort(error.Abort); + resp.cancel(); self._http_transfer = null; } } diff --git a/src/browser/webapi/net/EventSource.zig b/src/browser/webapi/net/EventSource.zig index 0fdc14c08..f301c9572 100644 --- a/src/browser/webapi/net/EventSource.zig +++ b/src/browser/webapi/net/EventSource.zig @@ -132,7 +132,7 @@ pub fn deinit(self: *EventSource, _: *Page) void { self._ready_state = .closed; if (self._transfer) |transfer| { self._transfer = null; - transfer.abort(error.Abort); + transfer.cancel(); } if (self._on_open) |func| { @@ -241,7 +241,7 @@ fn deactivate(self: *EventSource) void { self._active = false; if (self._transfer) |transfer| { self._transfer = null; - transfer.abort(error.Abort); + transfer.cancel(); } self.releaseRef(self._exec.page); } diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 944a1d2db..578b72d60 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -146,7 +146,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { if (self._signal) |signal| { if (signal._aborted) { - return .abort; + return error.TransferCanceled; } } @@ -214,7 +214,7 @@ fn httpDataCallback(transfer: *Transfer, data: []const u8) !void { // Check if aborted if (self._signal) |signal| { if (signal._aborted) { - return error.Abort; + return error.TransferCanceled; } } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 79ff5f936..c5f9f2644 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -226,7 +226,7 @@ pub fn createJson(data: js.Value, opts_: ?InitOpts, exec: *const Execution) !*Re pub fn deinit(self: *Response, _: *Page) void { if (self._http_transfer) |resp| { - resp.abort(error.Abort); + resp.cancel(); self._http_transfer = null; } self._arena.release(); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index d503ac2bb..43d99c5be 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -138,7 +138,7 @@ fn clearResponse(self: *XMLHttpRequest, page: *Page) void { pub fn deinit(self: *XMLHttpRequest, page: *Page) void { if (self._http_transfer) |resp| { - resp.abort(error.Abort); + resp.cancel(); self._http_transfer = null; } @@ -219,7 +219,7 @@ pub fn setTimeout(self: *XMLHttpRequest, value: u32, exec: *const Execution) !vo pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8, async_: ?bool) !void { // Abort any in-progress request if (self._http_transfer) |transfer| { - transfer.abort(error.Abort); + transfer.cancel(); self._http_transfer = null; } self._send_flag = false; @@ -714,10 +714,10 @@ fn httpShutdownCallback(ctx: *anyopaque) void { } pub fn abort(self: *XMLHttpRequest) void { - self.handleError(error.Abort); + self.handleError(error.TransferCanceled); if (self._http_transfer) |resp| { self._http_transfer = null; - resp.abort(error.Abort); + resp.cancel(); } self.releaseSelfRef(); } @@ -731,7 +731,7 @@ fn handleError(self: *XMLHttpRequest, err: anyerror) void { }; } fn _handleError(self: *XMLHttpRequest, err: anyerror) !void { - const is_abort = err == error.Abort; + const is_abort = err == error.TransferCanceled; const is_timeout = err == error.OperationTimedout; const new_state: ReadyState = if (is_abort) .unsent else .done; @@ -750,7 +750,7 @@ fn _handleError(self: *XMLHttpRequest, err: anyerror) !void { try self._proto.dispatch(.load_end, null, exec); } - const level: log.Level = if (err == error.Abort) .debug else .err; + const level: log.Level = if (err == error.TransferCanceled) .debug else .err; log.log(.http, level, "error", .{ .url = self._url, .err = err, diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 344865a21..e823acce4 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -524,6 +524,25 @@ pub fn abortRequests(_: *Client, owner: *Owner) void { // the owner itself is freed, no orphan transfer points at it. } +// Unlike abortOwner, the owner intends to stay alive (e.g. window.stop()). Our +// shutdown has to be a little nicer, i.e. firing error callbacks. +pub fn cancelRequests(self: *Client, owner: *Owner) void { + const last_id = self.next_request_id; + while (true) { + // Don't walk the linked list "normally", instead keep poping the head... + // cancel() can run JS which can mutate / invalidate nodes + var n = owner.transfers.first; + + const target = while (n) |node| : (n = node.next) { + const t: *Transfer = @fieldParentPtr("owner_node", node); + if (t.id <= last_id and !t._outcome_delivered and !t.isCompleteAwaitingDispatch()) { + break t; + } + } else return; + target.cancel(); + } +} + // Point-in-time snapshot of the client's outstanding work pub const Activity = struct { // in-flight + buffered-awaiting-dispatch + parked-for-CDP-interception @@ -2386,13 +2405,24 @@ pub const Transfer = struct { self.client.graveyard.append(&self._node); } - // Cancel this transfer with `err`. Fires error_callback once (latched + // The consumer doesn't want this transfer. Reserved for things like + // xhr.abort(), an AbortController, a worker.terminate(), i.e. mostly JS + // driven (though, potentially indirectly). + // HeaderResult.abort on the other hand is more internal, e.g. ScriptManager + // getting a non-2xx response code. + // cancel results in a `error.TransferCanceled` and abort results in an + // error.Abort. Various places need to make the distinction between the two. + pub fn cancel(self: *Transfer) void { + self.abort(error.TransferCanceled); + } + + // Fail this transfer with `err`. Fires error_callback once (latched // via _notified_fail), then either deinits synchronously or, if // deliver() is running our callbacks, detaches and lets deliver() // deinit when its loop exits. // - // This is the ONE entry point external callers should use to cancel - // a transfer. Don't reach for kill() or requestFailed() directly — + // abort() or cancel() are the only entry points external callers should use + // to end a transfer. Don't reach for kill() or requestFailed() directly — // they're internal helpers. pub fn abort(self: *Transfer, err: anyerror) void { // error_callback can run JS that tears this transfer down again @@ -2406,6 +2436,37 @@ pub const Transfer = struct { self.detachOrDeinit(); } + // take whatever we have, and deliver it. + pub fn finishEarly(self: *Transfer) bool { + if (self.state != .inflight or self.req.streaming or self._cache_intent == .revalidate) { + return false; + } + const conn = self._conn orelse return false; + const status = conn.getResponseCode() catch 0; + if (status == 0 or (status >= 300 and status < 400)) { + // we don't have a status code yet, or it's a 3xx. Nothing to deliver. + return false; + } + + self.materializeResponse(conn, .{}) catch return false; + self.client.removeConn(conn); + self._conn = null; + self._content_length = self.res.buffer.items.len; + self.bufferEvents(self.res.buffer.items) catch |err| { + self.failAsync(err); + }; + return true; + } + + // We have our events, and the last event is .done + fn isCompleteAwaitingDispatch(self: *const Transfer) bool { + if (self.state != .buffered) { + return false; + } + const events = self._events.items; + return events.len > 0 and events[events.len - 1] == .done; + } + // A pipeline entry point failed. The pipeline still owns the transfer in // two states: .created and .inflight. In all other states, the owner // delivers the failure. @@ -2526,7 +2587,9 @@ pub const Transfer = struct { // A consumer callback failed mid-delivery: latch the failure, notify, // free. Only called from deliver(). fn failDelivery(self: *Transfer, err: anyerror) void { - log.err(.http, "delivery callback", .{ .err = err, .req = self }); + if (err != error.TransferCanceled) { + log.err(.http, "delivery callback", .{ .err = err, .req = self }); + } self.requestFailed(err); self.finishDelivery(); } diff --git a/src/network/http.zig b/src/network/http.zig index 12db17269..19295ee59 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -860,6 +860,7 @@ pub fn errorReason(err: anyerror) ErrorReason { => .tls, error.ResponseTooLarge => .too_large, error.Abort, + error.TransferCanceled, error.AbortedByCallback, error.AbortAuthChallenge, error.SyncWaitInterrupted, diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index 4f26ec077..30152a742 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -397,14 +397,18 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request // notification is tied to), without a frame. lp.assert(bc.session.hasPage(), "CDP.network.httpRequestFail null frame", .{}); + // Consumer-side cancel (stopLoading, xhr.abort, AbortController, ...): + const canceled = msg.err == error.TransferCanceled; + const error_text: []const u8 = if (canceled) "net::ERR_ABORTED" else @errorName(msg.err); + // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.loadingFailed", .{ .requestId = &id.toRequestId(msg.transfer), .timestamp = lp.datetime.timestamp(.boot), // Seems to be what chrome answers with. I assume it depends on the type of error? .type = "Ping", - .errorText = msg.err, - .canceled = false, + .errorText = error_text, + .canceled = canceled, .blockedReason = msg.blocked_reason, }, .{ .session_id = session_id }); } diff --git a/src/server/cdp/domains/page.zig b/src/server/cdp/domains/page.zig index 4f44c731b..7ea711e4a 100644 --- a/src/server/cdp/domains/page.zig +++ b/src/server/cdp/domains/page.zig @@ -65,7 +65,7 @@ pub fn processMessage(cmd: *CDP.Command) !void { .navigate => return navigate(cmd), .navigateToHistoryEntry => return navigateToHistoryEntry(cmd), .reload => return doReload(cmd), - .stopLoading => return cmd.sendResult(null, .{}), + .stopLoading => return stopLoading(cmd), .close => return close(cmd), .captureScreenshot => return captureScreenshot(cmd), .printToPDF => return printToPDF(cmd), @@ -315,6 +315,13 @@ fn navigate(cmd: *CDP.Command) !void { try session.initiateRootNavigation(frame._frame_id, encoded_url, opts); } +fn stopLoading(cmd: *CDP.Command) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const frame = bc.mainFrame() orelse return error.FrameNotLoaded; + bc.session.stopLoading(frame._frame_id); + return cmd.sendResult(null, .{}); +} + // Fast path that allows using the initial about:blank Frame as-is. Only safe // when the frame is still waiting for a navigate AND no JS has been run in the // instance (for about:blank, that's only possible via an Runtime.* call) @@ -569,7 +576,10 @@ pub fn frameNavigateFailed(bc: *CDP.BrowserContext, event: *const Notification.F .result = .{ .frameId = &id.toFrameId(event.frame_id), .loaderId = &id.toLoaderId(event.loader_id), - .errorText = @errorName(event.err), + .errorText = switch (event.err) { + error.TransferCanceled => "net::ERR_ABORTED", + else => |err| @errorName(err), + }, }, .sessionId = session_id, }); @@ -1600,6 +1610,74 @@ test "cdp.frame: reload" { } } +test "cdp.page: stopLoading finishes a streaming document with what has arrived" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-SL1", .session_id = "SID-SL1", .target_id = "TID-SL1-000000".* }); + _ = try bc.session.createPage(); + try ctx.processMessage(.{ .id = 40, .method = "Page.navigate", .params = .{ .url = "http://127.0.0.1:9582/stop_loading/streaming.html" } }); + + // Tick until the first chunk has arrived; the server holds the rest back. + var runner = bc.session.runner(.{}); + const frame_id = bc.page_handle.?.frame_id; + var attempts: usize = 0; + while (true) : (attempts += 1) { + _ = try runner.tickForFrame(frame_id, 20, .{}); + const frame = bc.mainFrame() orelse unreachable; + if (bc.session.browser.http_client.findTransfer(frame._req_id)) |transfer| { + if (std.mem.indexOf(u8, transfer.res.buffer.items, "first") != null) { + break; + } + } + if (attempts == 200) { + return error.FirstChunkNeverArrived; + } + } + + try ctx.processMessage(.{ .id = 41, .method = "Page.stopLoading" }); + try ctx.expectSentResult(null, .{ .id = 41 }); + try testing.waitForPage(bc); + + // Blink semantics: the parser is cancelled, the document finishes with + // what arrived, and load fires. No "Navigation failed" placeholder. + const frame = bc.mainFrame() orelse unreachable; + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + const v = try ls.local.exec("document.readyState === 'complete' && document.getElementById('first') !== null && document.getElementById('second') === null && document.querySelector('h1') === null", null); + try testing.expect(v.toBool()); +} + +test "cdp.page: stopLoading cancels an uncommitted root navigation" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-SL2", .session_id = "SID-SL2", .target_id = "TID-SL2-000000".*, .url = "hi.html" }); + const live = bc.mainPage() orelse unreachable; + + // Slow origin: no headers arrive before we stop, so the navigation never commits. + try ctx.processMessage(.{ .id = 50, .method = "Page.navigate", .params = .{ .url = "http://127.0.0.1:9582/src/browser/tests/hi.html?delay_ms=1000" } }); + try testing.expect(bc.session.replacementOf(live) != null); + + try ctx.processMessage(.{ .id = 51, .method = "Page.stopLoading" }); + // The pending Page.navigate is answered the way Chrome answers a stopped one... + try ctx.expectSentResult(.{ .errorText = "net::ERR_ABORTED" }, .{ .id = 50, .session_id = "SID-SL2" }); + try ctx.expectSentResult(null, .{ .id = 51 }); + + // ...and the live page is untouched and still scriptable. + try testing.expectEqual(null, bc.session.replacementOf(live)); + try testing.expect(live == bc.mainPage().?); + const frame = bc.mainFrame() orelse unreachable; + try testing.expect(std.mem.endsWith(u8, frame.url, "/hi.html")); + + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + const v = try ls.local.exec("document.readyState === 'complete'", null); + try testing.expect(v.toBool()); +} + test "cdp.frame: reload replays POST navigation" { var ctx = try testing.context(); defer ctx.deinit(); diff --git a/src/testing.zig b/src/testing.zig index c1706cb36..00272a125 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -865,6 +865,24 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/stop_loading/streaming.html")) { + var send_buffer: [1024]u8 = undefined; + var res = try req.respondStreaming(&send_buffer, .{ + .respond_options = .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, + }, + }, + }); + try res.writer.writeAll("

first

"); + try res.writer.flush(); + try res.flush(); + lp.io.sleep(.fromMilliseconds(1500), .awake) catch {}; + try res.writer.writeAll("

second

"); + try res.writer.flush(); + return res.end(); + } + if (std.mem.eql(u8, path, "/sse/streaming")) { sse_flag.store(false, .release); var send_buffer: [1024]u8 = undefined;