Commit Graph

454 Commits

Author SHA1 Message Date
Karl Seguin
af7498d283 Run the MessageLoop [a lot] more.
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/152

We previously ran the message loop every 250ms. This commit changes it to run on
every tick (much more frequently). It also runs microtasks after draining the
message loop (since it can generate microtasks).

Also, we use to run microtasks after each script execution. Now we drain the
message Loop + microtasks.

We still only drain the microtasks when executing v8 callbacks.

As part of this change, we also adjust our wait time based on whether or not
there are pending background tasks in v8 in order to try to execute them (in
general) and in a timely manner.

The goal is to ensure that tasks v8 enqueued on the foreground thread are
executed promptly.

This change is particularly useful for calls to webassembly as compilation
happens in the background and eventually requires the message loop to be drained
to continue.

Previously, if a script did `await WebAssembly.instantiate(....)`, there was
a good chance we'd never finish the code - we'd wait too long to run the
message loop AND, after running it, we wouldn't necessarily resolve the promise.
2026-02-25 13:55:35 +08:00
Karl Seguin
603e7d922e Improve Context shutdown
Under some conditions, a microtask would be executed for a context that was
already deinit'd, resulting in various use-after-free.

The culprit appears to be WASM compilation being placed in the microtask queue
(by a user-script) and then resolved at some point in the future. We guard the
microtask queue by a context.shutting_down boolean, but v8 doesn't know anything
about this flag. The fact is that, microtasks are tied to an isolate, not a
context.

This commit introduces a number of changes:

1 - It follows 309f254c2c and stores the zig Context inside of an embedder field. This
    ensures v8 doesn't consider this when GC'ing, which _could_ extend the
    lifetime of the v8::Context beyond what we expect

2 - Most significantly, it introduces per-context microtasks queues. Each
    context gets its own queue. This makes cleanup much simpler and reduces the
    chance of microtasks outliving the context

3 - pumpMessageLoop is called on context.deinit, this helps to ensure that any
    tasks v8 has for our context are processed (e.g. wasm compilation) before
    shtudown

4 - The order of context shutdown is important, we notify the isolate of the
    context destruction first, then pump the message loop and finally destroy
    the context's message loop.

Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/151
2026-02-21 13:02:43 +08:00
Karl Seguin
71d34592d9 add frame created cdp messages 2026-02-19 23:47:33 +08:00
Karl Seguin
db2927eea7 cleanup a not-so-great rebase 2026-02-19 23:47:33 +08:00
Karl Seguin
bb01a5cb31 Make CDP frame-aware 2026-02-19 23:47:33 +08:00
Karl Seguin
081979be3b Initial support for frames
Missing:

- [ ] Navigation support within frames (in fact, as-is, any navigation done
      inside a frame, will almost certainly break things
- [ ] Correct CDP support. I don't know how frames are supposed to be exposed
      to CDP. Normal navigate events? Distinct CDP frame_ids?
- [ ] Cross-origin restrictions. The interaction between frames is supposed to
      change depending on whether or not they're on the same origin
- [ ] Potentially handling src-less frames incorrectly. Might not really matter

Adds basic frame support. Initially explored adding a BrowsingContext and
embedding it in Page, with the goal of also having it embedded in a to-be
created Frame. But it turns out that 98% of Page _was_ BrowsingContext and
introducing a BrowsingContext as the primary interaction unit broke pretty much
_every_ single WebAPI. So Page was expanded:

- Added `_parent: ?*Page`, which is `null` for "root" page.
- Added `frame: ?*IFrame`, which is `null` for the "root" page. This is the
  HTMLIFrameElement for frame-pages.
- Added a _type: enum{root, frame}, which is currently only used to improve
  the logs
- Added a frames: std.ArrayList(*Page). This is a list of frames for the page.
  Note that a "frame-page" can itself haven nested frames.

Besides the above, there were 3 "big" changes.

1 - Adding frames (dynamically, parsed) has to create a new page, start
    navigation, track it (in the frames list). Part of this was just
    piggybacking off of code that handles <script>

2 - The page "load" event blocks on the frame "load" event. This cascades.
    when a page triggers it's load, it can do:
```zig
      if (self._parent) |p| {
        p.iframeLoaded(self);
      }
```
   Pages need to keep track of how many iframes they're waiting to load. When
   all iframes (and all scripts) are loaded, it can then triggers its own load
   event.

3 - Our JS execution expects 1 primary entered context (the pages). But we now
    have multiple page contexts, and we need to be in the correct one based
    on where javascript is being executed. There is no more an default entered
    context. Creating a Local.Scope enters the context, and ls.deinit() exits
    the context.
2026-02-19 23:47:33 +08:00
Karl Seguin
938cd5e136 Merge pull request #1582 from lightpanda-io/cdp_per_page_frame_id
Rework CDP frameIds (and loaderIds and requestIds and interceptorIds)
2026-02-19 22:16:52 +08:00
Karl Seguin
e2a1ce623c Rework CDP frameIds (and loaderIds and requestIds and interceptorIds)
Our BrowsingContext currently supports 1 target. So we have a per-BC target_id.
Previously, our target had 1 "frame" - our page. So we often treated the
targetId as the frameId. But to work with frames, we need page-specific
frameIds and loaderIds.

This tries to clean up our ids (a little). frameIds are now ids derived from
a new incrementing page.id. This page.id has to be passed around (via http
Requests and through notifications) in order to properly generate messages with
a frameId.
2026-02-19 13:01:41 +08:00
Karl Seguin
645da2e307 Reduce cost of various Element render-related properties.
Added a get-only `getStyle` which doesn't lazily create a new style if none
exists. This can be used in the (frequently used) `checkVisibility` to avoid
an allocation. Added a specialized getBoundingClientRectForVisible which
skips the checkVisibility check, since a few callers have already done their
own visibility check.

DOMRect is now off the heap. This avoids _a lot_ of allocation when a DOMRect
is only needed for internal calculation, e.g. in Document.elementFromPoint.
2026-02-19 09:45:56 +08:00
Nikolay Govorov
9296c10ca4 Use per-cdp connection HttpClient 2026-02-18 09:22:26 +00:00
egrs
628049cfd7 add cookie size to CDP response
Compute and include the cookie size field (name.len + value.len)
in Storage.getCookies and Network.getCookies CDP responses,
matching Chrome's behavior.
2026-02-17 13:08:02 +01:00
Nikolay Govorov
6553bb8147 Remove los interceptors feature 2026-02-16 15:48:18 +00:00
Karl Seguin
14112ed294 Remove Page.reset
Page.reset exists for 1 use case: multiple calls to the Page.navigate CDP
method. At an extreme, something like this in puppeteer:

```
await page.goto(baseURL + '/campfire-commerce/');
await page.goto(baseURL + '/campfire-commerce/');
```

Rather than handling this generically in Page, we now handle this case
specifically at the CDP layer. If the page isn't in its initial load state,
i.e. page._load_state != .waiting, then we reload the page from the session.

For reloading, my initial inclination was to do session.removePage then
session.createPage(). This behavior still seems potentially correct to me, but
compared to our `reset`, this would trigger extra notifications, namely:

self.notification.dispatch(.page_remove, .{});

and

self.notification.dispatch(.page_created, page);

Bacause of https://github.com/lightpanda-io/browser/pull/1265/ I guess that
could have side effects. So, to keep the behavior as close to the current
"reset", a new `session.replacePage()` has been added which behaves a lot like
removePage + createPage, but without the notifications being sent.

While I generally think this is just cleaner, this was largely driven by some
planning for frame support. The entity for a Frame will share a lot with the
Page (we'll extract that logic), so simplifying the Page, especially around
initialization, helps simplify frame support.
2026-02-11 13:53:49 +08:00
Karl Seguin
c4e82407ec Add Finalizers to events
At a high level, this does for Events what was recently done for XHR, Fetch and
Observers. Events are self-contained in their own arena from the ArenaPool and
are registered with v8 to be finalized.

But events are more complicated than those other types. For one, events have
a prototype chain. (XHR also does, but it's always the top-level object that's
created, whereas it's valid to create a base Event or something that inherits
from Event). But the _real_ complication is that Events, unlike previous types,
can be created from Zig or from V8.

This is something that Fetch had to deal with too, because the Response is only
given to V8 on success. So in Fetch, there's a period of time where Zig is
solely responsible for the Response, until it's passed to v8. But with events
it's a lot more subtle.

There are 3 possibilities:
1 - An Event is created from v8. This is the simplest, and it simply becomes a
    a weak reference for us. When v8 is done with it, the finalizer is called.
2 - An Event is created in Zig (e.g. window.load) and dispatched to v8. Again
    we can rely on the v8 finalizer.
3 - An event is created in Zig, but not dispatched to v8 (e.g. there are no
    listeners), Zig has to release the event.

(It's worth pointing out that one thing that still keeps this relatively
straightforward is that we never hold on to Events past some pretty clear point)

Now, it would seem that #3 is the only issue we have to deal with, and maybe
we can do something like:

```
if (event_manager.hasListener("load", capture)) {
   try event_manager.dispatch(event);
} else {
   event.deinit();
}
```

In fact, in many cases, we could use this to optimize not even creating the
event:

```
if (event_manager.hasListener("load, capture)) {
   const event = try createEvent("load", capture);
   try event_manager.dispatch(event);
}
```

And that's an optimization worth considering, but it isn't good enough to
properly manage memory. Do you see the issue? There could be a listener (so we
think v8 owns it), but we might never give the value to v8. Any failure between
hasListener and actually handing the value to v8 would result in a leak.

To solve this, the bridge will now set a _v8_handover flag (if present) once it
has created the finalizer_callback entry. So dispatching code now becomes:

```
const event = try createEvent("load", capture);
defer if (!event._v8_handover) event.deinit(false);
try event_manager.dispatch(event);
```

The v8 finalizer callback was also improved. Previously, we just embedded the
pointer to the zig object. In the v8 callback, we could cast that back to T
and call deinit. But, because of possible timing issues between when (if) v8
calls the finalizer, and our own cleanup, the code would check in the context to
see if the ptr was still valid. Wait, what? We're using the ptr to get the
context to see if the ptr is valid?

We now store a pointer to the FinalizerCallback which contains the context.
So instead of something stupid like:

```
// note, if the identity_map doesn't contain the value, then value is likely
// invalid, and value.page will segfault
value.page.js.identity_map.contains(@intFromPtr(value))
```

We do:
```
if (fc.ctx.finalizer_callbacks.contains(@intFromPtr(fc.value)) {
  // fc.value is safe to use
}
```
2026-02-09 16:56:43 +08:00
Karl Seguin
2eab4b84c9 Rename all ArrayListUnmanaged -> ArrayList
ArrayListAlignedUnmanaged has been deprecated for a while, and I occasionally
replace them, but doing one complete pass gets it done once and for all.
2026-02-05 11:49:15 +08:00
Nikolay Govorov
fd8c488dbd Move Notification from App to BrowserContext 2026-02-04 07:33:45 +00:00
Karl Seguin
1a4086c98c de-duplicate context shutdown in isolated worl deinit 2026-02-03 23:04:44 +08:00
Karl Seguin
c07b83335b add a few comments 2026-02-03 15:58:29 +08:00
Karl Seguin
7e575c501a Add a dedicated browser_context and page_arena to CDP.
The BrowserContext currently uses 3 arenas:
1 - Command-specific, which is like the call_arena, but for the processing of a
    single CDP command
2 - Notification-specific, which is similar, but for the processing of a single
    internal notification event
3 - Arena, which is just the session arena and lives for the duration of the
    BrowseContext/Session

This is pretty coarse and can results in significant memory accumulation if a
browser context is re-used for multiple navigations.

This commit introduces 3 changes:
1 - Rather than referencing Session.arena, the BrowerContext.arena is now its
    own arena. This doesn't really change anything, but it does help keep things
    a bit better separated.

2 - Introduces a page_arena (not to be confused with Page.arena). This arena
    exists for the duration of a 1 page, i.e. it's cleared when the
    BrowserContext receives the page_created internal notification. The
    `captured_responses` now uses this arena, which means captures only exist
    for the duration of the current page. This appears to be consistent with
    how chrome behaves (In fact, Chrome seems even more aggressive and doesn't
    appear to make any guarantees around captured responses). CDP refers to this
    lifetime as a "renderer" and has an experimental message, which we don't
    support, `Network.configureDurableMessages` to control this.

3 - Isolated Worlds are now more self contained with an arena from the ArenaPool.

There are currently 2 places where the BrowserContext.arena is still used:
1 - the isolated_world list
2 - the custom headers

Although this could be long lived, I believe the above is ok. We should just
really think twice whenever we want to use it for anything else.
2026-02-03 15:48:27 +08:00
Karl Seguin
181f265de5 Rework Inspector usage
V8's inspector world is made up of 4 components: Inspector, Client, Channel and
Session. Currently, we treat all 4 components as a single unit which is tied to
the lifetime of CDP BrowserContext - or, loosely speaking, 1 "Inspector Unit"
per page / v8::Context.

According to https://web.archive.org/web/20210622022956/https://hyperandroid.com/2020/02/12/v8-inspector-from-an-embedder-standpoint/
and conversation with Gemini, it's more typical to have 1 inspector per isolate.
The general breakdown is the Inspector is the top-level manager, the Client is
our implementation which control how the Inspector works (its function we expose
that v8 calls into). These should be tied to the Isolate. Channels and Sessions
are more closely tied to Context, where the Channel is v8->zig and the Session
us zig->v8.

This PR does a few things
1 - It creates 1 Inspector and Client per Isolate (Env.js)
2 - It creates 1 Session/Channel per BrowserContext
3 - It merges v8::Session and v8::Channel into Inspector.Session
4 - It moves the Inspector instance directly into the Env
5 - BrowserContext interacts with the Inspector.Session, not the Inspector

4 is arguably unnecessary with respect to the main goal of this commit, but
the end-goal is to tighten the integration. Specifically, rather than CDP having
to inform the inspector that a context was created/destroyed, the Env which
manages Contexts directly (https://github.com/lightpanda-io/browser/pull/1432)
and which now has direct access to the Inspector, is now equipped to keep this
in sync.
2026-01-30 15:59:33 +08:00
Karl Seguin
4a1d71b6b8 Merge pull request #1437 from lightpanda-io/remove_unused
Remove unused import
2026-01-30 06:55:11 +08:00
Karl Seguin
a19a125aec Remove unused import
And a few unused functions
2026-01-29 19:44:10 +08:00
Karl Seguin
1a05da9e55 Remove js.ExecutionWorld
The ExecutionWorld doesn't do anything meaningful. It doesn't map to, or
abstract any, v8 concepts. It creates a js.Context, destroys the context and
points to the context. Those all all things the Env can do (and it isn't like
the Env is over-burdened as-is).

Plus the benefit of going through the Env is that we can track/collect all
known Contexts for an isolate in 1 place (the Env), which can facilitate things
like context creation/deletion notifications.
2026-01-29 11:22:01 +08:00
Pierre Tachoire
68fbc0bde3 use inspector.resetContextGroup during cdp deinit
Ensure the inspector is correctly reset from context before deinit it.
It fixes the contextCollected crash in a better way.
2026-01-28 11:11:38 +01:00
Karl Seguin
066069baad Add defensiveness around Parser.appendCallback
We're seeing an assertion in Page.appendNew fail because the node has a parent.
According to html5ever, this shouldn't be possible (appendNew is only called
from the Parser). BUT, it's possible we're mutating the node in a way that
we shouldn't...maybe there's JavaScript executing as we're parsing which is
mutating the node.

In release, this will be more defensive. In debug, this still crashes. It's
possible this is valid (like I said, maybe there's JS interleaved which is
mutating the node), but if so, I'd like to know the exact scenario that produces
this case.
2026-01-28 07:33:04 +08:00
Karl Seguin
3b12240615 remove newString helper in favor of .wrap 2026-01-26 08:00:04 +08:00
Karl Seguin
a3d2dd8366 Convert most Attribute related calls from []const u8 -> String 2026-01-26 07:52:27 +08:00
Karl Seguin
16ef487871 Make "Safe" variants of Attribute work on String 2026-01-26 07:52:27 +08:00
Karl Seguin
54c45a0cfd Make js.Bridge aware of string.String for input parameters
Avoids having to allocate small strings when going from v8 -> Zig. Also
added a discriminatory type, string.Global which uses the arena, rather than
the call_arena, if an allocation _is_ necessary. (This is similar to a feature
we had before, but was lost in zigdom). Strings from v8 that need to be
persisted, can be allocated directly v8 -> arena, rather than v8 -> call_arena
-> arena.

I think there are a lot of places where we should use string.String - where
strings are expected to be short (e.g. attribute names). But started with just
document.querySelector and querySelectorAll.
2026-01-26 07:52:27 +08:00
Karl Seguin
830f759f0b zig fmt, remove unused code 2026-01-24 07:37:30 +08:00
Karl Seguin
9092651b5b Merge branch 'main' into fix_context_lifetime 2026-01-20 08:50:41 +08:00
Karl Seguin
2c53b48e0a add missing handlescope 2026-01-20 08:11:38 +08:00
Karl Seguin
a6e7ecd9e5 Move more asserts to custom asserter.
Deciding what should be an lp.assert, vs an std.debug.assert, vs a debug-only
assert is a little arbitrary.

debug-only asserts, guarded with an `if (comptime IS_DEBUG)` obviously avoid the
check in release and thus have a performance advantage. We also use them at
library boundaries. If libcurl says it will always emit a header line with a
trailing \r\n, is that really a check we need to do in production? I don't think
so. First, that code path is checked _a lot_ in debug. Second, it feels a bit
like we're testing libcurl (in production!)..why? A debug-only assertion should
be good enough to catch any changes in libcurl.
2026-01-19 09:12:16 +08:00
Karl Seguin
62aa564df1 Remove Global v8::Local<V8::Context>
When we create a js.Context, we create the underlying v8.Context and store it
for the duration of the page lifetime. This works because we have a global
HandleScope - the v8.Context (which is really a v8::Local<v8::Context>) is that
to the global HandleScope, effectively making it a global.

If we want to remove our global HandleScope, then we can no longer pin the
v8.Context in our js.Context. Our js.Context now only holds a v8.Global of the
v8.Context (v8::Global<v8::Context).

This PR introduces a new type, js.Local, which takes over a lot of the
functionality previously found in either js.Caller or js.Context. The simplest
way to think about it is:

1 - For v8 -> zig calls, we create a js.Caller (as always)
2 - For zig -> v8 calls, we go through the js.Context (as always)
3 - The shared functionality, which works on a v8.Context, now belongs to js.Local

For #1 (v8 -> zig), creating a js.Local for a js.Caller is really simple and
centralized. v8 largely gives us everything we need from the
FunctionCallbackInfo or PropertyCallbackInfo.  For #2, it's messier, because we
can only create a local v8::Context if we have a HandleScope, which we may or
may not.

Unfortunately, in many cases, what to do becomes the responsibility of the caller
and much of the code has to become aware of this local-ness. What does it means
for our code? The impact is on WebAPIs that store .Global. Because the global
can't do anything. You always need to convert that .Global to a local
(e.g. js.Function.Global -> js.Function).

If you're 100% sure the WebAPI is only being invoked by a v8 callback, you can
use `page.js.local.?.toLocal(some_global).call(...)` to get the local value.

If you're 100% sure the WebAPI is only being invoked by Zig, you need to create
 `js.Local.Scope` to get access to a local:

```zig
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
ls.toLocal(some_global).call(...)
// can also access `&ls.local` for APIs that require a *const js.Local
```
For functions that can be invoked by either V8 or Zig, you should generally push
the responsibility to the caller by accepting a `local: *const js.Local`. If the
caller is a v8 callback, it can pass `page.js.local.?`. If the caller is a Zig
callback, it can create a `Local.Scope`.

As an alternative, it is possible to simply pass the *Page, and check
`if page.js.local == null` and, if so, create a Local.Scope. But this should only
be done for performance reasons. We currently only do this in 1 place, and it's
because the Zig caller doesn't know whether a Local will actually be needed and
it's potentially called on every element creating from the parser.
2026-01-19 07:28:33 +08:00
Karl Seguin
0e4aa38aaa Merge pull request #1312 from lightpanda-io/stagehand-zigdom
Stagehand zigdom
2026-01-16 23:10:19 +00:00
Pierre Tachoire
4325b80d64 axnode: small fixes 2026-01-16 17:30:43 +01:00
Pierre Tachoire
fbe07836f9 cdp: return a valide response for Page.getFrameTree on STARTUP
Stagehand expects a valid response for this specific command.
Add also `Target.activateTarget`
2026-01-16 16:27:55 +01:00
Pierre Tachoire
cbc028b040 cdp: accept multiple attachToTarget calls 2026-01-16 09:10:41 +01:00
Pierre Tachoire
2074c0149f axnode: add aria-labelledby support 2026-01-16 09:01:39 +01:00
Pierre Tachoire
61ed97dd45 axnode: use writeString for content's name 2026-01-16 09:00:57 +01:00
Pierre Tachoire
a358c46b9f axnode: ignore script and style children 2026-01-16 08:28:16 +01:00
Pierre Tachoire
50c1e2472b axnode: encode json string into stripWhitespaces 2026-01-16 08:27:43 +01:00
Pierre Tachoire
d50e056114 axnode: ignore non-html tags 2026-01-15 16:42:40 +01:00
Pierre Tachoire
d7d956d966 axnode: fix invalid enum 2026-01-15 15:40:52 +01:00
Pierre Tachoire
bd3966bf8d axnode: add focus on webroot 2026-01-15 15:37:49 +01:00
Pierre Tachoire
74578ba274 axnode: implement list marker 2026-01-15 15:37:49 +01:00
Pierre Tachoire
cb89742d2f axnode: add li level 2026-01-15 15:37:48 +01:00
Pierre Tachoire
6d0f991c17 axnode: add hr properties 2026-01-15 15:37:48 +01:00
Pierre Tachoire
d126d2a0f9 axnode: ignore hidden input 2026-01-15 15:37:47 +01:00
Pierre Tachoire
b51cca5617 axnode: use select.getValue 2026-01-15 15:37:47 +01:00