feat(cdp): report the page's real child frame tree - #623
Conversation
A child iframe's document was fetched and parsed, but the frame never got a scripting context: its <script> elements sat in the DOM as inert nodes. A parser-created <iframe src> was worse, because nothing started its load at all, so only a frame whose src was assigned from script was ever fetched. A frame now gets a realm of its own, a second v8::Context in the page's isolate. The startup snapshot already contains the bootstrap, so building one is a context restore rather than a re-parse. deno_core binds ops into the main context only, so bootstrap hands the host its op table and the host copies those functions into each realm; the handoff global is deleted in the same step, before page script runs. Ops resolve the realm that called them from the entered-or-microtask context, never the current one, which reports the realm an op was bound in rather than the caller's. Without that a frame's deferred work writes to the parent's document. Frame timers cannot use deno_core's queue. op_timer_queue reads per-context state that only a deno_core-created context carries, so queueing from a snapshot realm dereferences uninitialized memory and aborts the process. A frame schedules through op_sleep instead, whose continuation is an ordinary microtask, which V8 reports with the frame as the microtask context. Refs h4ckf0r0day#600.
A frame could run its scripts but not report anything back, which is what an embedded widget exists to do. `iframe.contentWindow.postMessage` dispatched the event on the parent's own window, so a page never reached the document inside its frame, and `window.postMessage` was a no-op stub, so a page posting to itself waited forever. A realm cannot reach another realm's context on its own, so postMessage is queued for the host and delivered into the target realm, the same shape as frame attachment. A delivered event is marked trusted, because the user agent delivers it rather than script dispatching it: embedders gate on the flag and drop an untrusted message silently, so getting this wrong looks like the widget hanging rather than like an error. The receiver's `event.source` is the sending window, so a reply reaches the sender. A framed realm gets real `parent` and `top` windows. `parent === window` is how a document decides it is top-level, so they are installed before any of the frame's own scripts run. The queue is capped by entries and bytes. Script can post in a synchronous loop while the host drains only between event loop turns, and this buffer is on the process heap rather than V8's, so an unbounded queue would let a page grow memory in the one place the heap-limit guard cannot see. Refs h4ckf0r0day#600.
Page.getFrameTree returned `childFrames: []` however many frames a page had built, and no frame lifecycle event was ever emitted, so Playwright and Puppeteer saw a single-frame page and could not address a child at all. getFrameTree now carries the live hierarchy, nesting included, and each child is announced with Page.frameAttached, Page.frameNavigated and Page.frameStoppedLoading, with a Page.frameDetached when it goes away. The events come from a diff drawn after every dispatch, next to the binding-call drain, rather than from the navigation handler: script can add an iframe at any time, so a frame that first appears on a later command still gets reported exactly once. Loading a document includes loading the frames in it, so navigation now builds them instead of leaving it to a caller that settles. A client that only navigates was otherwise told the page had no frames. Pages with no iframe skip that work after one native selector query. Fixes h4ckf0r0day#600.
|
I built and tested this stack against #600 (I filed that issue). The engine half is a Setup. Merge-base of the stack is What works:
|
| loopback repro via CLI | base 6fa87e1 |
stack 4fd0654 |
|---|---|---|
child <script> runs (window.__ran) |
undefined |
"YES" |
child document.title |
"" |
"RAN-IN-CHILD" |
child → parent postMessage |
[] |
["FROM-CHILD"] |
It also covers more than I asked for. A cross-origin child (a second loopback port)
runs its script and reaches the parent, and so does the exact shape Turnstile uses — a
cross-origin iframe inside a closed shadow root — for both insertion orders
(iframe into shadow then host into document, and the reverse):
cross-origin child : parentGot ["FROM-XO-CHILD"]
closed shadow root + x-origin : got ["FROM-XO-CHILD", "FROM-XO-CHILD"]
What does not: obscura serve + CDP
Same fixtures, same binary, through the CDP server instead of the CLI. No change from
base at all — including the childFrames this PR is about:
child frame via serve + CDP |
base | stack |
|---|---|---|
child <script> runs |
undefined |
undefined |
child → parent postMessage |
[] |
[] |
Page.getFrameTree → childFrames |
[] |
[] |
Page.frameAttached events |
0 | 0 |
Controls I ran so this is not an artifact of my client:
- with
--stealthand without it — identical - sampled at 0 / 2 / 5 / 9 s after
Page.navigate, plus aRuntime.evaluatesettle
nudge mirroring whatchild_frame_tree.rsdoes between navigate andgetFrameTree - attaching to the reused startup target (
Target.createTargetreturns the
existingpage-1) and to a freshly created one (page-2) — identical - only
Page.frameNavigated(1) andPage.frameStoppedLoading(1) are ever emitted;
noPage.frameAttached
Verbatim tree from the stack build:
{"frameTree": {"frame": {"id": "page-1", "loaderId": "initial-loader",
"url": "http://127.0.0.1:49361/parent.html", "domainAndRegistry": "",
"securityOrigin": "http://127.0.0.1:49361/parent.html", "mimeType": "text/html",
"adFrameStatus": {"adFrameType": "none"}}, "childFrames": []}}Why the test passes anyway
crates/obscura-cdp/tests/child_frame_tree.rs passes in-tree (1/1, and
child_frame_scripts.rs 5/5). It drives the protocol like this:
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
ctx.sessions.insert(session.to_string(), page_id);
// ... dispatch(&CdpRequest { .., session_id: Some(session) }, &mut ctx)so it calls dispatch() against a page from ctx.create_page() with a hand-inserted
session entry. A real client instead gets its page and session through
Target.createTarget → Target.attachToTarget {flatten: true} against
ws://…/devtools/browser. The frame work appears not to be wired into that path — and
it is the only path Puppeteer/Playwright can take, which is the scenario the test's own
doc comment describes ("a Playwright or Puppeteer client saw a single-frame page").
A regression test that goes through Target.createTarget + Target.attachToTarget
would close the gap.
Reproduction
Fixtures (two files) and a static server
child.html and parent.html are exactly the two files from #600. Serve them on
loopback:
python3 -m http.server 8899 --bind 127.0.0.1 --directory ./fixtures &CLI — fixed on the stack, broken on base:
obscura --allow-private-network fetch http://127.0.0.1:8899/parent.html --wait 3 -q \
--eval '(function(){var f=document.querySelector("iframe");return JSON.stringify({
childRan:(function(){try{return String(f.contentWindow.__ran)}catch(e){return "blocked"}})(),
childTitle:(function(){try{return f.contentDocument.title}catch(e){return "blocked"}})(),
parentGot: window.__res ? window.__res.parentGot : "no-res"});})()'
# base 6fa87e1 : {"childRan":"undefined","childTitle":"","parentGot":[]}
# stack 4fd0654 : {"childRan":"YES","childTitle":"RAN-IN-CHILD","parentGot":["FROM-CHILD"]}CDP — unchanged on the stack. Start obscura serve --port 9222 --allow-private-network,
then take the browser socket from /json/version, Target.createTarget →
Target.attachToTarget {flatten:true}, pass the sessionId on every command,
Page.navigate to the same parent.html, wait ~6 s, Runtime.evaluate once to settle,
then read window.__res and Page.getFrameTree:
__res = {"parentGot":[],"childRan":"undefined","childTitle":"","iframeLoaded":true}
childFrames = []
frameAttached events = 0
Secondary notes
- Still broken, exactly as fix(js): give child iframes a realm so their scripts run #621 documents in its known limitations:
srcdoc
iframes never fireonload(base and stack alike), anddata:text/htmliframes fire
onloadbut their inline script stays inert. Flagging only so it is on the record —
not a request to widen this PR. - Turnstile is unchanged:
300030, no token, hang at ~43 s, on both the CLI and CDP
paths. A logging CONNECT proxy (--proxyis honored — a dead port fails with
ProxyConnect) shows base and stack are network-identical: 2 connections to
challenges.cloudflare.comeach. That measures connections, not requests, so it does
not say whether the challenge frame itself fetched anything. - For calibration, headless Chrome 145 on the same page makes 5 requests to
challenges.cloudflare.com, including
/cdn-cgi/challenge-platform/h/g/orchestrate/jsch/v1and a second versioned
api.js. Note that run also had no token and an empty status at 50 s, so Chrome is
not automatically a pass on that page either. - Correcting two things I put in Child iframes never get a scripting context: <script> in a child frame never executes, Page.getFrameTree reports no childFrames #600 that turned out to be bad instrumentation, in
case they mislead anyone here:document.querySelectorAll('iframe').length === 0is
not evidence of a missing widget frame (Chrome reports 0 too — the frame is in a
closed shadow root), and request counts taken from CDP network events are unusable
against obscura, which does not reportfetch/XHR traffic.
Environment
macOS 26.6.1 arm64 (Apple Silicon), Rust 1.97.1, cmake 4.4.2,
LIBCLANG_PATH=/Library/Developer/CommandLineTools/usr/lib. Baseline build 2 m 01 s,
stack 26 s incremental. Reference browser: Google Chrome 145 headless.
navigate_single returns as soon as wait_until is DomContentLoaded, and build_document_frames sat past that return. Page.navigate defaults to DomContentLoaded, so a Puppeteer or Playwright client, which sends no waitUntil at all, drove the one path that never built the frames and got a tree with no children. Only a caller asking for `load` reached the build, which is why the CLI half of h4ckf0r0day#600 worked and the CDP half did not. Build the frames before any wait_until can return: they belong to the document rather than to one readiness level. The events also went to a single arbitrary session per page. A client that reaches a page the ordinary way holds two of them, because Target.createTarget opens a session and the Target.attachToTarget that follows opens another, and a client discards any event whose sessionId is not the one it attached with. Announce to every session on the page. The test hid both: it drove Page.navigate with an explicit waitUntil "load" against a hand-inserted session. It now does the real Target.createTarget and Target.attachToTarget handshake, lets waitUntil default, and requires every session on the page to be told the frame attached.
|
Thank you — this reproduced exactly, and the test really was blind to it. Fixed in The cause turned out not to be the target path. It is the
self.lifecycle = LifecycleState::DomContentLoaded;
if wait_until == crate::lifecycle::WaitUntil::DomContentLoaded {
return Ok(());
}and this PR put Your controls were all sound; the variable that mattered was one neither of us was holding. On your own repro against the unfixed stack, adding The frames are now built before any A second defect your setup exposedWith that fixed, So your instinct that the target path was implicated was right, just one layer over: it is not that the frame work is unwired there, it is that the extra session that path creates was swallowing the announcements. Verified the way you askedReal binary, real
The second one needed care: asserting only "my session got it" passes roughly half the time on ValidationThe one failure is On your secondary notes
Also noted, and being handled as their own PRs rather than widened into this one:
Thanks again for building both binaries and running the controls — the |
|
Follow-up: #624 has been rebased onto this PR's fix ( While reviewing #624 on top of the corrected #623, one of its own changes turned out to break the case you had specifically confirmed working. The shadow-root frame was being torn down#624 discards a frame realm when its iframe element leaves the document, which it decided with: [...document.querySelectorAll('iframe')].map(f => f._frameId >>> 0).filter(id => id)That query does not reach into a shadow root. You made exactly this point in your review here — It is the one shape where the whole four-PR stack matters most, and it is the shape you verified: Measured on the same fixture, same binary, before and after: Liveness is now asked of the element through A failed liveness query now also leaves the frame tree alone instead of reading as "every frame is gone" — holding a frame is bounded by the existing cap, whereas destroying a live one is not recoverable. Two regression tests cover both directions: a shadow-root frame survives the sweep and stays reachable, and a genuinely removed iframe still has its realm released with every reference the page realm held for it cleared, so nothing is retained. On the rest of the stackCross-origin frames still stay opaque — the frame loads and can Performance, against this PR's head on the same machine: Suite after the rebase: Two things on the record, not in this PR
Separately, and also found while working on this stack: |
What changed
Third of three stacked PRs for #600. Fixes #600 once all three land.
Depends on #621 and #622 and is stacked on them. GitHub will not let a cross-fork PR use a fork branch as its base, so this targets
mainand therefore also contains their commits. Merge #621, then #622, then this. Its own commit isfeat(cdp): report the page's real child frame tree.Page.getFrameTreereturned"childFrames": []however many frames a page had built, and no frame lifecycle event was ever emitted, so Playwright and Puppeteer saw a single-frame page and could not address a child at all. That is the protocol half of the issue report.getFrameTreenow carries the live hierarchy, nesting included. A child's protocol id is derived from its page's frame id and stays stable for the life of the frame, which is what lets a client match an attach event to the frame it later sees in the tree.Page.frameAttached,Page.frameNavigatedandPage.frameStoppedLoading, and retracted withPage.frameDetached. Attach is emitted before navigate, because a client builds its frame from the attach event and treats navigation of a frame it has never seen as a protocol error.One behavior change worth review attention: loading a document now includes building the frames in it, so navigation does that work instead of leaving it to a caller that settles. Without this a CDP client that only navigates was told the page had no frames, because nothing had given them realms yet. Pages with no iframe skip it after a single native
query_selector("iframe"), so the common case pays one selector query and no event-loop time. The pumping is bounded (8 rounds x 50ms) because a frame can add a frame and an unbounded loop would never finish on a page that adds one every turn.Validation
New test
crates/obscura-cdp/tests/child_frame_tree.rsdrives real CDP against a local server whose page embeds a child that itself embeds a grandchild. It asserts the nested tree with correctparentIdlinks, thatframeAttachedprecedesframeNavigatedfor the same frame, and that a second command does not announce the same frame twice.The one workspace failure is
obscura-cdp::max_connections_cap max_connections_refuses_then_recovers, which fails identically on an unmodifiedmainworktree in this environment.Obstacle course: 29/33, identical to unmodified
mainrun back to back on the same machine (observer-intersection,textdecoder,charset-shiftjis,fingerprint; the middle two are Windows console codepage mojibake and the last is local timezone). No regression from this change, but I cannot attest to 33/33 from this environment.Rendering
Not applicable.
Performance
Navigation of a page without an iframe adds one native selector query and nothing else. A page with frames pays bounded pumping while its frames load, which is work the page needs done regardless.
Checklist