#994·obscura

CDP: navigation Network.* events are delivered only to the session that issued Page.navigate

Author: prinfraCreated Sep 15, 2026Updated Sep 15, 2026

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

javascript
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)                                       // 28

Swapping the navigation onto the listening session makes them appear:

javascript
await cdp.send('Page.navigate', { url: 'https://www.zoom.com/' })   // same session
// -> 174

Same 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 —

rust
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:

rust
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.