diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index dd84b5e..bd06e9a 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -73,12 +73,22 @@ pub fn emit_navigation_events( }); } + // executionContextsCleared invalidates every prior context id, so a + // Runtime.evaluate / callFunctionOn targeting a pre-navigation context + // must be rejected (Chrome: "Cannot find context with specified id"). The + // default world (id 2) and isolated worlds are re-registered below as their + // executionContextCreated events are emitted. Issue #407: previously this + // set was insert-only, so stale ids kept validating and grew unbounded. + ctx.valid_context_ids.clear(); let mut phase1 = vec![ CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() }, CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() }, CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": nav_mime, "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() }, ]; + // The default world is re-created as context id 2; re-register it. Isolated + // worlds register themselves via next_isolated_context in the loop below. + ctx.valid_context_ids.insert(2); let world_names: Vec = if ctx.isolated_worlds.is_empty() { vec!["__puppeteer_utility_world__24.40.0".to_string()] } else { diff --git a/crates/obscura-cdp/tests/execution_context_pruned_on_navigation.rs b/crates/obscura-cdp/tests/execution_context_pruned_on_navigation.rs new file mode 100644 index 0000000..8762112 --- /dev/null +++ b/crates/obscura-cdp/tests/execution_context_pruned_on_navigation.rs @@ -0,0 +1,69 @@ +// Regression for issue #407: `CdpContext.valid_context_ids` was insert-only, +// so `Runtime.executionContextsCleared` on navigation never pruned the old +// context ids. A `Runtime.callFunctionOn` targeting a pre-navigation context +// still ran (Chrome rejects it with "Cannot find context with specified id"), +// and the set grew by one id per navigation on a long-lived connection. + +use obscura_cdp::dispatch::CdpContext; +use obscura_cdp::domains::page::emit_navigation_events; +use obscura_browser::lifecycle::WaitUntil; + +fn navigate(ctx: &mut CdpContext, page_id: &str) { + emit_navigation_events( + ctx, + &Some("session-1".to_string()), + "frame-1", + "loader-1", + "http://127.0.0.1/page", + page_id, + &[], + WaitUntil::Load, + true, + ); +} + +#[test] +fn navigation_prunes_stale_execution_context_ids() { + let mut ctx = CdpContext::new(); + // Seed a stale id that no navigation re-creates (simulates a context from + // a prior page state, or simply the pre-navigation main id 1). + ctx.valid_context_ids.insert(999); + + navigate(&mut ctx, "page-1"); + + // The stale id is gone after executionContextsCleared; the default world + // (id 2) and the first isolated world (id 100, counter starts at 100) remain. + assert!( + !ctx.valid_context_ids.contains(&999), + "stale execution context id must be pruned on navigation" + ); + assert!(ctx.valid_context_ids.contains(&2), "default world id 2 must be re-registered"); + assert!( + ctx.valid_context_ids.contains(&100), + "isolated world id 100 must be registered: {:?}", + ctx.valid_context_ids + ); + + navigate(&mut ctx, "page-1"); + + // Second navigation: the first nav's isolated id (100) is now stale, and a + // fresh id (101) takes its place. The set must not grow across navigations. + assert!( + !ctx.valid_context_ids.contains(&100), + "previous navigation's isolated context id must be pruned" + ); + assert!(ctx.valid_context_ids.contains(&101), "fresh isolated id 101 must be registered"); + assert!(ctx.valid_context_ids.contains(&2), "default world id 2 must survive navigation"); + + // Unbounded-growth check: two navigations leave exactly the default world + // plus the current isolated world(s), not an accumulating union. + let count_after_two_navs = ctx.valid_context_ids.len(); + navigate(&mut ctx, "page-1"); + navigate(&mut ctx, "page-1"); + assert_eq!( + ctx.valid_context_ids.len(), + count_after_two_navs, + "valid_context_ids must not grow across navigations (was {count_after_two_navs}, now {})", + ctx.valid_context_ids.len() + ); +}