CDP: navigation Network.* events are delivered only to the session that issued Page.navigate
Summary
Network.requestWillBeSent / responseReceived / loadingFinished for a
navigation are emitted only to the session that issued Page.navigate. Chrome
delivers a target's events to every session attached to it.
This silently loses the entire navigation for any second session on the page —
which is the normal Playwright shape, because browserContext.newCDPSession(page)
opens a session distinct from the one Playwright drives page.goto() on.
Impact
An observer attached via newCDPSession sees almost no traffic and gets no
error. On https://www.zoom.com/:
| events | |
|---|---|
| produced internally during the navigation | 181 |
delivered to a newCDPSession listener |
28 |
The 28 that do arrive are only the later sync_live_page_network_events
batches (fonts/images loaded after the navigation resolved). Every request that
belonged to the navigation itself — the document, scripts, stylesheets — is
dropped.
Reproduction
const browser = await chromium.connectOverCDP({ endpointURL: 'ws://127.0.0.1:9222' })
const ctx = browser.contexts()[0]
const page = ctx.pages()[0] ?? await ctx.newPage()
const cdp = await ctx.newCDPSession(page) // <-- second session
await cdp.send('Network.enable')
let n = 0
cdp.on('Network.requestWillBeSent', () => n++)
await page.goto('https://www.zoom.com/') // <-- navigates on Playwright's session
await new Promise(r => setTimeout(r, 8000))
console.log(n) // 28Swapping the navigation onto the listening session makes them appear:
await cdp.send('Page.navigate', { url: 'https://www.zoom.com/' }) // same session
// -> 174Same binary, same page, same moment. The only variable is which session issued the navigate.
Cause
crates/obscura-cdp/src/server.rs: session_for_events is taken from the
navigating request —
let session_for_events = req.session_id.clone();— and passed straight to emit_navigation_events, so the events are addressed
to that one session. By contrast sync_live_page_network_events looks the
session up from ctx.sessions by page id, which is why only those batches
arrive.
Suggested fix
Replay the network trio to the other sessions attached to the same page, after
the existing call. emit_runtime_network_events is already exactly "emit these
network events to this session, without replaying frame lifecycle", so it fits:
if !network_events.is_empty() {
let others: Vec<String> = ctx
.sessions
.iter()
.filter(|(sid, pid)| {
*pid == &page_id_for_events && Some(*sid) != session_for_events.as_ref()
})
.map(|(sid, _)| sid.clone())
.collect();
for sid in others {
crate::domains::page::emit_runtime_network_events(
ctx, &Some(sid), &frame_id, &page_url, &page_id_for_events, &network_events,
);
}
}Frame lifecycle and loaderId bookkeeping stay with the navigating session, so
page.goto() still resolves to a Response as before (#189).
With this applied, the Playwright reproduction above goes from 28 to 148 delivered events.
Environment
- obscura 0.2.2,
main@ 4b70288 - built
--release -p obscura-cli --bins --features render,stealth - Ubuntu 24.04, x86_64
Tests
With the change applied, cargo test --release -p obscura-cdp --features render,stealth --tests
passes: 30 test binaries, 205 tests, 0 failures.
Source: h4ckf0r0day/obscura