Skip to content

keep the existing page active until the pending one is loaded#2297

Merged
krichprollsch merged 17 commits into
mainfrom
pending-page
May 4, 2026
Merged

keep the existing page active until the pending one is loaded#2297
krichprollsch merged 17 commits into
mainfrom
pending-page

Conversation

@krichprollsch

Copy link
Copy Markdown
Member

During a root navigation, we keep the existing page active until we get
the headers callback from the pending page. Then
Session.commitPendingPage makes the switch.

It delays the deinit of CPD execution context to handle JS execution in
the meantime.

Now session has an array of two pages, _active_idx points to the main
page.

Both active and pending pages share the same frame_id, it must remains
stable. So this PR adds a Request.protect_from_abort to avoid removing
the request form the pending page when deinit the previous active page.

fix #2187

@krichprollsch

krichprollsch commented Apr 28, 2026

Copy link
Copy Markdown
Member Author

I have to add a new e2e test
EDIT: lightpanda-io/demo#169

@krichprollsch

krichprollsch commented Apr 28, 2026

Copy link
Copy Markdown
Member Author

The branch works on my own test case, but fails on #2187 (comment) script.

EDIT: it works now ✔️

@krichprollsch krichprollsch self-assigned this Apr 28, 2026
@krichprollsch krichprollsch marked this pull request as draft April 28, 2026 10:35
@krichprollsch krichprollsch marked this pull request as ready for review April 28, 2026 16:32
@krichprollsch krichprollsch force-pushed the pending-page branch 2 times, most recently from 7a59be4 to b730739 Compare April 29, 2026 09:22
Comment thread src/browser/Runner.zig Outdated
// Refresh self.frame from session. In case of pending page, we want to
// take its state while loading. If we use only the current frame, we will
// return a .done result immediately.
self.frame = self.session.currentOrPendingFrame() orelse return .done;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about that... But I don't really have a better solution :/

@krichprollsch krichprollsch force-pushed the pending-page branch 2 times, most recently from 55efd0e to d4765fc Compare April 29, 2026 15:40
Comment thread src/browser/Session.zig Outdated

self.* = .{
.page = null,
._pages = .{ null, null },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guilty of doing this too, but you both set default value sin the fields, then set those same defaults on init...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed from init, but I'm not sure it it's better this way or to remove default from fields?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think either is better than both :)

field defaults have the advantage of letting you create an empty struct without an init. But, when that's not an option (like here), the practical different is small. I do think field defaults might better improve documentation / discoverability. If I want to understand a field, I'll go scroll up to where it's defined, hope for a comment, and the default might provide more insight too.

Comment thread src/browser/Session.zig Outdated
// available for the next pending allocation.
//
// Convention: a slot is "occupied" iff its `?Page` is non-null.
_pages: [2]?Page = .{ null, null },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you consider going with two fields and allocating page?

active: ?*Page,
pending: ?*Page,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did, and I disliked it 🤔 But I didn't remember exactly why. I'm trying again.
You think it's better to allocate to avoid these empty structs in the stack?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's minor. A few places that are currently 2 lines ((1) get the index (2) get the page) become 1. That's pretty much it.

pub fn currentPage(self: *Session) ?*Page {
    const idx = self._active_idx orelse return null;
    return &self._pages[idx].?;
}

becomes

pub fn currentPage(self: *Session) ?*Page {
    return self.active;
}

Or, if you look at the start ofinitRootNavigation, it becomes:

    self.discardPendingPage(); // can do const page = self.pending orelse return;

    const page = try self.pageInit(frame_id);
    errdefer page.deinit();

    page._state = .pending;
    self.pending = page;
    errdefer self.pending = null
    ..

(again, quite minor)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, it's simpler after all. But where should I allocate the pages? Claude suggests to use app.allocator directly, but not sure about that. WDYT?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya, or a dedicated std.heap.MemoryPool(Page) on the session if you think we'll create a lot. Or on the Browser if you think we'll create a lot of Sessions...

Comment thread src/browser/Session.zig Outdated
Comment thread src/browser/Session.zig Outdated
current_frame._queued_navigation = null;

// Synthetic navigations (about:blank, blob:) commit instantly — no HTTP,
// so there is no in-flight window to worry about. Use the legacy

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that it's "legacy" isn't relevant. We normally say something is legacy to explain why a behavior is the way it is. But here, replaceRootImmediate doesn't exist for some "legacy" reason..it's just a more optimized / simpler (maybe more correct?) path to take for these cases. Or am I misunderstanding and replaceRootImmediate exists only to keep an pre-existing behavior?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are correct.

Comment thread src/browser/Session.zig Outdated
// Synthetic navigations (about:blank, blob:) commit instantly — no HTTP,
// so there is no in-flight window to worry about. Use the legacy
// immediate-swap path for them.
const is_synthetic = std.mem.eql(u8, qn.url, "about:blank") or

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qn.is_about_blank

We need to check this when QueuedNavigation is created (in Frame.scheduleNavigationWithArena) and I know it was checked again during actual navigation, so I figured might as well save the boolean.

Comment thread src/browser/Session.zig Outdated
pub fn commitPendingPage(self: *Session) !void {
const pending_idx = self._pending_idx orelse {
lp.assert(false, "Session.commitPendingPage - no pending page", .{});
return error.NoPendingPage;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unreachable.

The intent here is unclear. Do you want the assert only in debug mode? (this pattern happens 3 or so times in this file, I think they should all be cleaned up).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good, point, Claude did see that too and I didn't fix it 🤦

Comment thread src/browser/Session.zig Outdated
lp.assert(false, "Session.commitPendingPage - no pending page", .{});
return error.NoPendingPage;
};
const old_idx = self._active_idx orelse {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

old_active_idx? just a big more clear later on when you do:

    self._pages[old_active_idx].?.deinit();
    self.freeSlot(old_active_idx);

(pretty minor, I'd probably have named it old_idx too!)

@krichprollsch krichprollsch Apr 30, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, I did move that after a rebase... It was closer to the deinit at first.

// with this flag so the callback chain we are sitting inside isn't killed
// mid-flight. Session.discardPendingPage uses .full scope to override
// the flag in failure paths.
protect_from_abort: bool = false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is clever, and it makes sense. BUT, I think if we do give HttpClient a major cleanup (e.g. merging Request and Transfer), I'd try to come up with a better way to track ownership of frame <-> request.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this trick is only because I must share the same frame_id between actually 2 different frames...
And I dislike it...

@krichprollsch krichprollsch force-pushed the pending-page branch 5 times, most recently from b8ed5b3 to 0b374b0 Compare May 1, 2026 15:03
During a root navigation, we keep the existing page active until we get
the headers callback from the pending page. Then
Session.commitPendingPage makes the switch.

It delays the deinit of CPD execution context to handle JS execution in
the meantime.

Now session has an array of two pages, _active_idx points to the main
page.

Both active and pending pages share the same frame_id, it must remains
stable. So this PR adds a Request.protect_from_abort to avoid removing
the request form the pending page when deinit the previous active page.
So the CDP can remove/reset context and re-create isolated world
accordingly
We want to take in account the pending frame in Runner._tick to continue
to process. If we use only the current frame, we will immediately return
in case of navigation.
Use distinct key for laoder id and request id based captured response.
@krichprollsch krichprollsch merged commit 65308f0 into main May 4, 2026
90 of 95 checks passed
@krichprollsch krichprollsch deleted the pending-page branch May 4, 2026 07:42
@github-actions github-actions Bot locked and limited conversation to collaborators May 4, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Runtime.evaluate after a click-driven navigation fails with "Cannot find default execution context"

2 participants