mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-18 18:05:28 -04:00
webapi: window.stop()
window.stop() is less destructive than other mechanisms we have. For one, it seems largely isolated to pending or inflight HTTP requests. For anther, it keeps the page intact. To achieve this, HttpClient gains an `cancelRequests` which is a gentler version of `abortOwner`. It cancels inflight/pending HTTP requests, which results in error callbacks (not shutdown callbacks) firing. Just like https://github.com/lightpanda-io/browser/pull/3189 I ran into the problem that I couldn't distinguish between an HTTP request that was canceled because of user-action (e.g. calling window.stop(), or xhr.abort()) and an HTTP request that was internally aborted. These now have distinct errors/flows so that we can present the correct state. Most places that aborted now all transfer.cancel() which results in a distinct `error.TransferCanceled` (some places still abort -> `error.Abort`). It should be possible to revisit 3189 now. The CDP "Page.stopLoading" now hooks into this new behavior. Fixes https://github.com/lightpanda-io/browser/issues/3351
This commit is contained in:
17 files changed
+311
-25
No files matched your search
+28
-1
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<!-- Started while parsing; the inline script below stops it before it lands.
|
||||
Its error must still fire so the script manager doesn't wedge the load. -->
|
||||
<script defer src="support/stop_defer.js?delay_ms=300" onerror="window.deferErrored = true"></script>
|
||||
<script id=stop_during_parse>
|
||||
window.stop();
|
||||
// Not a teardown: the document keeps parsing after the call.
|
||||
testing.expectEqual('loading', document.readyState);
|
||||
</script>
|
||||
|
||||
<script id=stop_rejects_pending_fetch type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const pending = fetch('http://127.0.0.1:9582/xhr/slow');
|
||||
window.stop();
|
||||
let err = null;
|
||||
try {
|
||||
await pending;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
state.resolve(err);
|
||||
await state.done((err) => {
|
||||
testing.expectTrue(err instanceof TypeError);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=stop_aborts_xhr>
|
||||
{
|
||||
const events = [];
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.onabort = () => events.push('abort');
|
||||
xhr.onloadend = () => events.push('loadend');
|
||||
xhr.open('GET', 'http://127.0.0.1:9582/xhr/slow');
|
||||
xhr.send();
|
||||
window.stop();
|
||||
testing.expectEqual(0, xhr.readyState);
|
||||
testing.expectEqual(['abort', 'loadend'], events);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=stop_spares_requests_started_by_a_cancel_handler type=module>
|
||||
{
|
||||
// A cancelled request's handler may stop() again and start new loads;
|
||||
// only loads that existed when stop() was called are cancelled.
|
||||
const state = await testing.async();
|
||||
let started = null;
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.onabort = () => {
|
||||
window.stop();
|
||||
started = fetch('http://127.0.0.1:9582/xhr');
|
||||
};
|
||||
xhr.open('GET', 'http://127.0.0.1:9582/xhr/slow');
|
||||
xhr.send();
|
||||
window.stop();
|
||||
const response = await started;
|
||||
state.resolve(response.status);
|
||||
await state.done((status) => {
|
||||
testing.expectEqual(200, status);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=stop_keeps_page_alive>
|
||||
{
|
||||
// stop() is not a teardown: timers keep firing and the load event
|
||||
// still arrives (deferred script errored rather than hanging).
|
||||
window.timerFired = false;
|
||||
setTimeout(() => { window.timerFired = true; }, 0);
|
||||
window.stop();
|
||||
testing.onload(() => {
|
||||
testing.expectTrue(window.timerFired);
|
||||
testing.expectTrue(window.deferErrored);
|
||||
testing.expectEqual(undefined, window.deferRan);
|
||||
testing.expectEqual('complete', document.readyState);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
window.deferRan = true;
|
||||
@@ -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();
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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("<html><body><p id=first>first</p>");
|
||||
try res.writer.flush();
|
||||
try res.flush();
|
||||
lp.io.sleep(.fromMilliseconds(1500), .awake) catch {};
|
||||
try res.writer.writeAll("<p id=second>second</p></body></html>");
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user