Cancel termination only when all context have a call_depth == 0. This ensures
that termination "sticks" all the way up a nested call. Without this, a nested
call could clear a terminate which targeted something up the stack.
A JS entry (JSEntry) clears V8's terminating state when the termination
exception unwinds past it, which is what tryCall and friends rely on.
A microtask checkpoint does not: when a requested termination (watchdog,
CDP-disconnect kill) lands inside a checkpoint-delivered callback such
as MutationObserver delivery, PerformCheckpoint returns with
IsExecutionTerminating still set and nothing ever clears it. Every
later V8 call in that isolate then runs against a terminating isolate
and can return empty where callers don't expect it, ending in SIGSEGV
during connection teardown.
Cancel the V8-level termination after a checkpoint in which a terminate
landed, keeping the sticky terminate_requested so the existing entry
gates keep refusing new JS, and skip the remaining context queues.
Production-observed sequence this covers (SIGSEGV/139 tails):
watchdog stall -> MutObserver.deliverRecords err=ExecutionTerminated
-> frame.deliverMutations -> closing connection (pending terminate)
-> SIGSEGV
Assisted-By: devx/95b8cb7f-8baf-4a28-93bf-61bc21612890
Adds the getHTML methods to Element and ShadowRoot. This is expected to add
another ~6K passing WPT tests (significantly improving out score in the
/shadow-dom/ category). It's also probably also a useful API to implement.
All add/remove node operations come down to Frame.removeNode and
Frame._insertNodeRelative. But not all remove/insert are the same, e.g. removing
a disconnect node is different than removing a connected node. Also, a "move"
operation is a remove+insert, but some move operations appear more atomic than
others. There is both a correctness and an optimization side to these
differences.
Previously, removeNode took a will_be_reconnected: bool option and insert took
a child_already_connected: bool. Essentially, remove wants to know the
post-removal intention, and insert wants to know the pre-insert state.
The simple booleans work for simple cases, but fail for ShadowDOM: it isn't
enough to know if `will_be_reconnected`...we need to know in what root it'll
be reconnected. Both remove and insert become tristate:
1 - is staying in the same root
2 - is staying connected, but in a different root
3 - is becoming connected for the first time
This commits changes `will_be_reconnected: bool` to `reconnect_to: ?*Node` and
`child_already_connected: bool` to `previous_root: ?*Node`. So most callsites
had to be updated - which is why so many files were touched. But the bulk of
the change is in those two Frame method which must now handle the tristate.
Correctly encapsulate events that happen within a shadowroot to stop from
leaking out. This involves calculating the related_target relative to the
potential node receiving the event.
Also Event.composedPath now re-uses the same path-building logic as EventManager
(previously, the same-ish algo in both places, that were out of sync).
Some smaller changes:
- input.list getter (showed up in the shadow dom wpt tests)
- activeElement getter added to ShadowDOM
- document activeElement is ShadowDOM-aware and won't cross
On a redirect, recalculate the Referer header based on the new target.
Also, allow SVG anchors to be clicked (doesn't seem related, but it came up
in referrer-policy WPT tests).
checkIdleNotifications was only called for conditions still pending, but
an idle notification needs a check 500ms+ after the condition first held
(Frame.IdleNotification). On a quiet page the same tick both starts that
hold and resolves the condition via is_done, so nothing advanced the
state machine for the rest of the wait. The CDP pump waits in 1s slices
(CDP.pageWait), so Page.lifecycleEvent networkIdle/networkAlmostIdle was
starved until a later slice built a fresh condition.
That event is what puppeteer's networkidle0/networkidle2 and
playwright's networkidle block on. Measured with puppeteer against
lightpanda serve, goto(waitUntil: networkidle0) on example.com, median
of 5: 2001ms before, 595ms after (the page loads in ~100ms, so ~600ms is
the floor: load plus the 500ms hold). Every pre-fix run landed within
1999-2195ms - the stall is quantized to whole pump slices, not network
variance. An ad-heavy page is unaffected either way (3196ms vs 2859ms,
overlapping ranges): pending timers keep is_done false, so its condition
never resolved early to begin with.
Non-CDP waits are deliberately unchanged: waitForFrame(.networkidle) on
a quiet page still resolves immediately via is_done rather than serving
the 500ms hold. Agent/MCP waitForState and fetch --wait-until networkidle
want "settled now", not chrome's lifecycle heuristic.
Per spec, CustomElement creation/upgrade errors should be reported to the window
This also adds more (mostly edge case) validation, such as an element that fails
to be upgraded can never be upgraded again.
Some models (gpt-5.x via codex) zero-fill optional tool params instead
of omitting them, so every tree/markdown/html call carried
backendNodeId: 0, failed with NodeNotFound, and got retried blind --
one 'go to hacker news' burned 19 tool calls and 18 full page loads.
Registry ids start at 1, so 0 can never name a real node. Make
0-means-omitted part of the tool contract: state it in the schema
descriptions and normalize it in parseValue. Required ids
(nodeDetails) are untouched.
In-band guidance alone was not enough -- the model re-emitted the
identical call even when the error spelled out the fix -- but keep it
for genuinely stale ids: NodeNotFound and FrameNotLoaded now carry an
actionable message in the agent, slash-command, and MCP paths instead
of a bare error name.
fetchThread and mcpThread caught their errors, logged fatal, and
returned void; the main thread joined and exited 0. A fetch whose
--wait-selector timed out (or any lp.fetch/browser-init error) printed
$level=fatal and still reported success to the shell. Same for mcp
stdio errors, and for three fatal paths in the mcp HTTP branch that ran
on the main thread and returned void.
Threads now hand the error back through an out-pointer (the agentThread
failed/cancelled pattern) and the dispatch returns it after join; the
mcp main-thread paths return their errors directly.
Clean mcp stdin EOF is unaffected: processRequests returns normally on
EOF (router.zig takeDelimiter orelse break), so only real failures
reach the exit code.
Out of scope: a failed navigation in fetch still exits 0 by design —
it renders the synthetic "Navigation failed" error page and dumps it;
--json carries the status for detection.