feat(net): make passive request/response callbacks detachable (#416)

on_request/on_response stored callbacks in append-only Vecs with no way
to remove one, so a crawler that installed a response-body capture hook
for one phase could never detach it: it fired for the shared client's
whole lifetime, re-registering across navigations piled up duplicates,
and (because the client is shared per context) it saw sibling pages'
requests too.

Tag each callback with a stable id. on_request/on_response now return
the id and off_request/off_response (remove_on_request/remove_on_response
on the client) detach a specific one. Storage is Vec<(u64, Callback)>;
all fire sites destructure the tuple. Closes #408.
This commit is contained in:
SGavrl authored and GitHub committed 2026-07-14 13:57:45 +00:00
1 parent d0b6fc4ffd
commit 99ece0e12d
5 files changed
+147 -23

No files matched your search

+21 -6
View File
@@ -114,16 +114,31 @@ impl Page {
/// Register a passive callback fired for every request the page makes
/// (navigation and JS `fetch()`/XHR), once its method/headers/body are known
/// and before it is sent. Non-blocking; use `enable_interception` to mutate
/// or block.
pub fn on_request(&mut self, cb: RequestCallback) {
self.inner.on_request(cb);
/// or block. Returns a stable id; pass it to `off_request` to detach.
pub fn on_request(&mut self, cb: RequestCallback) -> u64 {
self.inner.on_request(cb)
}
/// Register a passive callback fired with every response the page receives
/// (navigation and JS `fetch()`/XHR), including its body. Non-blocking. The
/// main path for capturing API response payloads from SPAs.
pub fn on_response(&mut self, cb: ResponseCallback) {
self.inner.on_response(cb);
/// main path for capturing API response payloads from SPAs. Returns a stable
/// id; pass it to `off_response` to detach.
pub fn on_response(&mut self, cb: ResponseCallback) -> u64 {
self.inner.on_response(cb)
}
/// Detach a request callback previously registered with `on_request`.
/// Returns true if a callback with that id was removed. Callbacks live on
/// the context's shared HTTP client, so detach them when a page or capture
/// phase ends to avoid firing for sibling pages (issue #408).
pub fn off_request(&mut self, id: u64) -> bool {
self.inner.off_request(id)
}
/// Detach a response callback previously registered with `on_response`.
/// Returns true if a callback with that id was removed.
pub fn off_response(&mut self, id: u64) -> bool {
self.inner.off_response(id)
}
}
+46
View File
@@ -165,3 +165,49 @@ async fn page_rewrites_request_url_via_interception() {
body
);
}
// Issue #408: a callback registered with on_response must be detachable via the
// returned id, so a crawler can stop capturing after a phase. Before the fix the
// vecs were append-only and a callback fired for the client's whole lifetime.
#[tokio::test]
async fn on_response_callback_can_be_detached() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let base = spawn_echo_server();
let browser = Browser::new().unwrap();
let mut page = browser.new_page().await.unwrap();
let hits = Arc::new(AtomicU32::new(0));
let h = hits.clone();
let id = page.on_response(Arc::new(move |info, _resp| {
if info.resource_type == ResourceType::Fetch {
h.fetch_add(1, Ordering::SeqCst);
}
}));
// First navigation: the callback fires for the JS fetch('/api').
page.goto(&base).await.unwrap();
for _ in 0..20 {
page.settle(200).await;
if hits.load(Ordering::SeqCst) >= 1 {
break;
}
}
let after_first = hits.load(Ordering::SeqCst);
assert!(after_first >= 1, "on_response should fire while attached");
// Detach it; off_response must report success and the id must be gone.
assert!(page.off_response(id), "off_response must remove the callback");
assert!(!page.off_response(id), "removing an already-removed id returns false");
// Second navigation: the detached callback must not fire again.
page.goto(&base).await.unwrap();
for _ in 0..10 {
page.settle(200).await;
}
assert_eq!(
hits.load(Ordering::SeqCst),
after_first,
"detached on_response callback must not fire after off_response"
);
}