Skip to content

fix(cdp): deliver Runtime.bindingCalled to the session that subscribed - #632

Open
xrip wants to merge 1 commit into
h4ckf0r0day:mainfrom
xrip:fix/binding-called-session
Open

fix(cdp): deliver Runtime.bindingCalled to the session that subscribed#632
xrip wants to merge 1 commit into
h4ckf0r0day:mainfrom
xrip:fix/binding-called-session

Conversation

@xrip

@xrip xrip commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What changed

Runtime.bindingCalled was addressed to one arbitrary session of the page, so page.exposeFunction() fired its callback into a session no client was listening on.

drain_binding_calls built a page_id -> session_id map by collapsing every session of a page into one HashMap entry:

// page_id -> session_id (any one session that holds this page).
let page_to_session: HashMap<String, String> = ctx
    .sessions
    .iter()
    .map(|(sid, pid)| (pid.clone(), sid.clone()))
    .collect();

A page normally has more than one session. Target.createTarget opens one, and the Target.attachToTarget that follows opens another — and a client discards any event whose sessionId is not the one it attached with. Which of the two survived that .collect() was down to map ordering.

Against obscura serve, over /devtools/browser, doing createTargetattachToTarget {flatten:true}addBinding → call it:

before : my sessionId page-1-session-1,  bindingCalled -> page-1-session      (dropped by the client)
after  : my sessionId page-1-session-1,  bindingCalled -> page-1-session-1

Runtime.addBinding is a session-scoped subscription in CDP, so the fix records which sessions asked for each name and delivers the call back to those, narrowed to the page it came from. A binding with no recorded subscriber — installed as a preload, or by an embedder driving dispatch without a session — still goes to every session on the page rather than being dropped, so nothing that works today starts failing.

Found while fixing #623; this is the same one-arbitrary-session flaw the frame events had, in the drain next door. Split out because it is pre-existing on main and independent of the frame work.

Validation

cargo nextest run --release --no-default-features -p obscura-js -p obscura-browser -p obscura-cdp   # 445/446
cargo build --release -p obscura-cli --bins --no-default-features                                   # clean

The one failure is obscura-cdp::max_connections_cap max_connections_refuses_then_recovers, which fails identically on an unmodified main worktree in this environment.

New test crates/obscura-cdp/tests/binding_called_session.rs (3 cases) goes through the real Target.createTarget + Target.attachToTarget handshake rather than inserting a session for a hand-made page.

The regression guard needed care. Asserting only "my session received it" passes roughly half the time on the old code, because it is a coin flip on map ordering — a flaky test would be worse than none. So the guard has two sessions subscribe to one name, making the correct result two events: addressing a single session of the page can only ever produce one, whichever way the ordering falls. Verified failing on unmodified main across three consecutive runs:

test result: FAILED. 1 passed; 2 failed
test result: FAILED. 1 passed; 2 failed
test result: FAILED. 1 passed; 2 failed

Rendering

Not applicable.

Performance

One HashMap<&str, Vec<&str>> built per drain instead of one HashMap<String, String>, borrowing session ids rather than cloning them, plus a subscriber lookup per queued call. A page with no binding calls returns before any of it.

Checklist

  • The change is focused and does not remove existing behavior without justification.
  • Tests cover the failure or feature.
  • Existing tests pass, including render and no-render configurations when affected.
  • I checked for CPU, latency, and memory regressions.
  • Public API or user-facing behavior changes are documented.

drain_binding_calls collapsed every session of a page into one entry of a
HashMap and addressed the event to whichever one came out first. A client
that opens a page the ordinary way holds two sessions, because
Target.createTarget opens one and the Target.attachToTarget after it opens
another, and it discards any event whose sessionId is not the one it
attached with. So `page.exposeFunction()` registered its binding, the page
called it, and the callback fired on a session no client was listening to.

Against `obscura serve`, over the browser socket:

  before: my sessionId page-1-session-1, bindingCalled -> page-1-session
  after : my sessionId page-1-session-1, bindingCalled -> page-1-session-1

Runtime.addBinding is a session-scoped subscription in CDP, so record which
sessions asked for each name and deliver the call back to those, narrowed to
the page it came from. A binding with no recorded subscriber — installed as
a preload, or by an embedder driving dispatch without a session — still goes
to every session on the page rather than being dropped.

The regression guard has two sessions subscribe to one name, so the correct
result is two events. Addressing a single session of the page can only ever
produce one, which fails whichever session the map ordering picks; asserting
only "my session got it" passes about half the time on that ordering.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants