zendriver_interception/paused.rs
1//! [`PausedRequest`] — the per-event handle handed to stream consumers.
2//!
3//! Each `Fetch.requestPaused` event surfaces a `PausedRequest`. Code must
4//! dispatch exactly one of [`continue_`], [`abort`], [`respond`],
5//! [`modify_and_continue`], or [`continue_response`] to release the paused
6//! request — Chrome holds the request open until one of these arrives.
7//! [`body`] is a read-only side-call usable at the `Response` stage to inspect
8//! the upstream body before deciding what to do; it does not release the
9//! pause.
10//!
11//! [`continue_`]: PausedRequest::continue_
12//! [`abort`]: PausedRequest::abort
13//! [`respond`]: PausedRequest::respond
14//! [`modify_and_continue`]: PausedRequest::modify_and_continue
15//! [`continue_response`]: PausedRequest::continue_response
16//! [`body`]: PausedRequest::body
17//!
18//! Internally `PausedRequest` carries a [`SessionHandle`] (not a full `Tab`)
19//! so the type lives in this crate without a reverse dependency on
20//! `zendriver`. The builder in T6/T7 constructs each instance from the actor
21//! loop's session + the decoded event payload.
22
23use base64::Engine as _;
24use base64::engine::general_purpose::STANDARD as BASE64;
25use serde_json::{Map, Value, json};
26use zendriver_transport::SessionHandle;
27
28use crate::error::InterceptionError;
29use crate::types::{AbortReason, RequestInfo, RequestOverrides, ResponseInfo};
30
31/// A request paused by Chrome at the configured [`RequestStage`].
32///
33/// `PausedRequest` is consumed by exactly one of the action methods
34/// ([`continue_`](Self::continue_), [`abort`](Self::abort),
35/// [`respond`](Self::respond),
36/// [`modify_and_continue`](Self::modify_and_continue),
37/// [`continue_response`](Self::continue_response)) to release the pause.
38/// [`body`](Self::body) is a read-only side-channel (`&self`) usable at the
39/// `Response` stage to inspect the upstream body before deciding which
40/// terminal action to take.
41///
42/// If a `PausedRequest` is dropped without one of the terminal actions
43/// firing (e.g. the consuming task panicked or a `select!` arm cancelled
44/// mid-handler), [`Drop`] dispatches a best-effort `Fetch.continueRequest`
45/// in a detached task so Chrome doesn't hold the request open indefinitely.
46/// See cdpdriver/zendriver#126 for the freeze pattern this prevents.
47///
48/// [`RequestStage`]: crate::types::RequestStage
49#[derive(Debug)]
50pub struct PausedRequest {
51 /// Opaque CDP request id (`requestId` on `Fetch.requestPaused`). Must be
52 /// echoed back on whichever terminal action releases the pause.
53 pub request_id: String,
54 /// Decoded request payload as Chrome surfaced it.
55 pub request: RequestInfo,
56 /// Decoded response payload — populated only when Chrome paused at the
57 /// `Response` stage. `None` at the `Request` stage.
58 pub response: Option<ResponseInfo>,
59 session: SessionHandle,
60 /// Flipped to `true` by every terminal action (`continue_`, `abort`,
61 /// `respond`, `modify_and_continue`, `continue_response`) before the CDP
62 /// call is dispatched.
63 /// [`Drop`] inspects this to decide whether to fire a fallback
64 /// `Fetch.continueRequest` — set means "already released, don't
65 /// double-fire"; clear means "owner forgot us, release Chrome".
66 released: bool,
67}
68
69impl PausedRequest {
70 /// Construct a `PausedRequest` from the actor/builder. `pub(crate)` so
71 /// the public API stays "consume one of the action methods" — callers
72 /// never assemble a `PausedRequest` by hand.
73 pub(crate) fn new(
74 request_id: impl Into<String>,
75 request: RequestInfo,
76 response: Option<ResponseInfo>,
77 session: SessionHandle,
78 ) -> Self {
79 Self {
80 request_id: request_id.into(),
81 request,
82 response,
83 session,
84 released: false,
85 }
86 }
87
88 /// Release the pause and let Chrome send the request as-is.
89 ///
90 /// Dispatches `Fetch.continueRequest { requestId }` with no overrides.
91 /// Use [`modify_and_continue`](Self::modify_and_continue) instead if any
92 /// field needs to be rewritten.
93 ///
94 /// ```no_run
95 /// # use futures::StreamExt;
96 /// # async fn ex(tab: &zendriver_transport::SessionHandle)
97 /// # -> Result<(), zendriver_interception::InterceptionError> {
98 /// use zendriver_interception::InterceptBuilder;
99 ///
100 /// let mut stream = Box::pin(InterceptBuilder::new(tab).subscribe());
101 /// while let Some(req) = stream.next().await {
102 /// req.continue_().await?;
103 /// }
104 /// # Ok(()) }
105 /// ```
106 pub async fn continue_(mut self) -> Result<(), InterceptionError> {
107 self.released = true;
108 self.session
109 .call(
110 "Fetch.continueRequest",
111 json!({ "requestId": self.request_id }),
112 )
113 .await?;
114 Ok(())
115 }
116
117 /// Abort the request with the given Chrome [`Network.ErrorReason`].
118 ///
119 /// Dispatches `Fetch.failRequest { requestId, errorReason }`. The exact
120 /// reason surfaces to JS as the rejected `fetch()` / `XHR` error.
121 ///
122 /// ```no_run
123 /// # use futures::StreamExt;
124 /// # async fn ex(tab: &zendriver_transport::SessionHandle)
125 /// # -> Result<(), zendriver_interception::InterceptionError> {
126 /// use zendriver_interception::{AbortReason, InterceptBuilder};
127 ///
128 /// let mut stream = Box::pin(InterceptBuilder::new(tab).subscribe());
129 /// if let Some(req) = stream.next().await {
130 /// req.abort(AbortReason::BlockedByClient).await?;
131 /// }
132 /// # Ok(()) }
133 /// ```
134 ///
135 /// [`Network.ErrorReason`]: https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ErrorReason
136 pub async fn abort(mut self, reason: AbortReason) -> Result<(), InterceptionError> {
137 self.released = true;
138 self.session
139 .call(
140 "Fetch.failRequest",
141 json!({
142 "requestId": self.request_id,
143 "errorReason": reason.as_cdp_str(),
144 }),
145 )
146 .await?;
147 Ok(())
148 }
149
150 /// Synthesize a response and serve it in place of the upstream one.
151 ///
152 /// Dispatches `Fetch.fulfillRequest { requestId, responseCode,
153 /// responseHeaders: [{name, value}, ...], body: base64(body) }`. Headers
154 /// are CDP's name/value array form; `body` is base64-encoded on the wire
155 /// per CDP spec (callers pass raw bytes).
156 pub async fn respond(
157 mut self,
158 status: u16,
159 headers: Vec<(String, String)>,
160 body: Vec<u8>,
161 ) -> Result<(), InterceptionError> {
162 self.released = true;
163 let response_headers = crate::actor::headers_to_cdp(&headers);
164 self.session
165 .call(
166 "Fetch.fulfillRequest",
167 json!({
168 "requestId": self.request_id,
169 "responseCode": status,
170 "responseHeaders": response_headers,
171 "body": BASE64.encode(&body),
172 }),
173 )
174 .await?;
175 Ok(())
176 }
177
178 /// Release the pause with per-field overrides applied.
179 ///
180 /// Dispatches `Fetch.continueRequest { requestId, url?, method?, headers?,
181 /// postData? }`. Only fields set on [`RequestOverrides`] are forwarded —
182 /// `None` leaves Chrome's original value untouched. Per CDP, `postData`
183 /// crosses the wire base64-encoded and `headers` is the CDP name/value
184 /// array form (replacement, not merge).
185 pub async fn modify_and_continue(
186 mut self,
187 overrides: RequestOverrides,
188 ) -> Result<(), InterceptionError> {
189 self.released = true;
190 let mut params = Map::new();
191 params.insert("requestId".into(), Value::String(self.request_id.clone()));
192 if let Some(url) = overrides.url {
193 params.insert("url".into(), Value::String(url));
194 }
195 if let Some(method) = overrides.method {
196 params.insert("method".into(), Value::String(method));
197 }
198 if let Some(headers) = overrides.headers {
199 params.insert(
200 "headers".into(),
201 Value::Array(crate::actor::headers_to_cdp(&headers)),
202 );
203 }
204 if let Some(post_data) = overrides.post_data {
205 params.insert("postData".into(), Value::String(BASE64.encode(&post_data)));
206 }
207 self.session
208 .call("Fetch.continueRequest", Value::Object(params))
209 .await?;
210 Ok(())
211 }
212
213 /// Forward the upstream response, optionally rewriting its status and/or
214 /// headers, while keeping Chrome's original body.
215 ///
216 /// Only valid at the `Response` stage — Chrome only accepts
217 /// `Fetch.continueResponse` once the upstream response headers have
218 /// arrived. Called on a `Request`-stage `PausedRequest` (where
219 /// [`response`](Self::response) is `None`), it returns
220 /// [`InterceptionError::WrongStage`] without dispatching anything; use
221 /// [`respond`](Self::respond) to serve a synthetic response at the
222 /// `Request` stage instead.
223 ///
224 /// Dispatches `Fetch.continueResponse { requestId, responseCode?,
225 /// responsePhrase?, responseHeaders? }`, omitting any argument left `None`
226 /// so Chrome keeps its original value. Per CDP, `responseHeaders` is the
227 /// name/value array form and is *replacement*, not merge — pass every
228 /// header you want forwarded.
229 ///
230 /// ```no_run
231 /// # use futures::StreamExt;
232 /// # async fn ex(tab: &zendriver_transport::SessionHandle)
233 /// # -> Result<(), zendriver_interception::InterceptionError> {
234 /// use zendriver_interception::InterceptBuilder;
235 ///
236 /// let mut stream = Box::pin(
237 /// InterceptBuilder::new(tab).pattern("*").at_response().subscribe(),
238 /// );
239 /// if let Some(req) = stream.next().await {
240 /// // Rewrite to 204 No Content, keep the upstream body.
241 /// req.continue_response(Some(204), None, None).await?;
242 /// }
243 /// # Ok(()) }
244 /// ```
245 pub async fn continue_response(
246 mut self,
247 status: Option<u16>,
248 phrase: Option<String>,
249 headers: Option<Vec<(String, String)>>,
250 ) -> Result<(), InterceptionError> {
251 // Mark released before the stage check: Chrome rejects
252 // continueResponse at the Request stage, but the pause is still ours
253 // to release. Suppressing the Drop fallback here would strand it, so
254 // we leave `released` clear on the WrongStage path and let Drop fire
255 // a best-effort continueRequest — matching the "always release Chrome"
256 // contract the other terminals uphold via the happy path.
257 if self.response.is_none() {
258 return Err(InterceptionError::WrongStage);
259 }
260 self.released = true;
261 let mut params = Map::new();
262 params.insert("requestId".into(), Value::String(self.request_id.clone()));
263 if let Some(status) = status {
264 params.insert("responseCode".into(), Value::from(status));
265 }
266 if let Some(phrase) = phrase {
267 params.insert("responsePhrase".into(), Value::String(phrase));
268 }
269 if let Some(headers) = headers {
270 params.insert(
271 "responseHeaders".into(),
272 Value::Array(crate::actor::headers_to_cdp(&headers)),
273 );
274 }
275 self.session
276 .call("Fetch.continueResponse", Value::Object(params))
277 .await?;
278 Ok(())
279 }
280
281 /// Fetch the upstream response body. Only useful at the `Response`
282 /// stage — at the `Request` stage Chrome has no body to return.
283 ///
284 /// Dispatches `Fetch.getResponseBody { requestId }`. Per CDP the result
285 /// carries a `body` string plus a `base64Encoded: bool` flag: when true,
286 /// we base64-decode; when false, we return the UTF-8 bytes verbatim. Keeps
287 /// `&self` (not `self`) so callers can inspect-then-decide.
288 pub async fn body(&self) -> Result<Vec<u8>, InterceptionError> {
289 let res = self
290 .session
291 .call(
292 "Fetch.getResponseBody",
293 json!({ "requestId": self.request_id }),
294 )
295 .await?;
296 let body = res.get("body").and_then(Value::as_str).ok_or_else(|| {
297 InterceptionError::InvalidResponse(
298 "Fetch.getResponseBody returned no body field".into(),
299 )
300 })?;
301 let base64_encoded = res
302 .get("base64Encoded")
303 .and_then(Value::as_bool)
304 .unwrap_or(false);
305 if base64_encoded {
306 BASE64
307 .decode(body)
308 .map_err(|e| InterceptionError::InvalidResponse(format!("invalid base64: {e}")))
309 } else {
310 Ok(body.as_bytes().to_vec())
311 }
312 }
313}
314
315/// Safety net for cdpdriver/zendriver#126: a paused request that gets dropped
316/// without a terminal action (panicked handler, cancelled `select!` arm,
317/// stream consumer exited early) would otherwise leave Chrome waiting on
318/// `requestId` forever and freeze the whole client. Spawning a detached
319/// `Fetch.continueRequest` releases the pause best-effort; failures are
320/// logged at `debug!` because the session may already be torn down (e.g. tab
321/// closed mid-pause), which is the same condition that produced the original
322/// freeze and not an actionable error.
323impl Drop for PausedRequest {
324 fn drop(&mut self) {
325 if self.released {
326 return;
327 }
328 let session = self.session.clone();
329 let request_id = std::mem::take(&mut self.request_id);
330 tokio::spawn(async move {
331 if let Err(e) = session
332 .call("Fetch.continueRequest", json!({ "requestId": request_id }))
333 .await
334 {
335 tracing::debug!(
336 error = %e,
337 request_id = %request_id,
338 "PausedRequest::drop: best-effort Fetch.continueRequest failed (session likely closed)"
339 );
340 }
341 });
342 }
343}
344
345#[cfg(test)]
346#[allow(clippy::panic, clippy::unwrap_used)]
347mod tests {
348 use super::*;
349 use crate::types::ResourceType;
350 use zendriver_transport::SessionHandle;
351 use zendriver_transport::testing::MockConnection;
352
353 fn make_request_info() -> RequestInfo {
354 RequestInfo {
355 url: "https://example.test/widget".into(),
356 method: "GET".into(),
357 headers: Vec::new(),
358 post_data: None,
359 resource_type: ResourceType::XHR,
360 }
361 }
362
363 fn make_response_info() -> ResponseInfo {
364 ResponseInfo {
365 status: 200,
366 status_text: "OK".into(),
367 headers: Vec::new(),
368 }
369 }
370
371 #[tokio::test]
372 async fn continue_dispatches_fetch_continue_request() {
373 let (mut mock, conn) = MockConnection::pair();
374 let sess = SessionHandle::new(conn.clone(), "S1");
375 let req = PausedRequest::new("REQ-1", make_request_info(), None, sess);
376
377 let fut = tokio::spawn(async move { req.continue_().await });
378
379 let id = mock.expect_cmd("Fetch.continueRequest").await;
380 let sent = mock.last_sent();
381 assert_eq!(sent["params"]["requestId"], "REQ-1");
382 // No overrides on plain continue_: requestId is the only param.
383 let params_obj = sent["params"].as_object().unwrap();
384 assert_eq!(params_obj.len(), 1);
385 mock.reply(id, serde_json::json!({})).await;
386
387 fut.await.unwrap().unwrap();
388 conn.shutdown();
389 }
390
391 #[tokio::test]
392 async fn drop_without_terminal_action_fires_fallback_continue() {
393 // cdpdriver/zendriver#126: a PausedRequest that goes out of scope
394 // without continue_/abort/respond/modify_and_continue must release
395 // Chrome — otherwise the InterceptionId stays armed forever and
396 // freezes the next render.
397 let (mut mock, conn) = MockConnection::pair();
398 let sess = SessionHandle::new(conn.clone(), "S1");
399 {
400 let req = PausedRequest::new("REQ-DROP", make_request_info(), None, sess);
401 drop(req);
402 }
403 // The Drop spawns the call on the current runtime; expect_cmd waits
404 // for it to land.
405 let id = mock.expect_cmd("Fetch.continueRequest").await;
406 let sent = mock.last_sent();
407 assert_eq!(sent["params"]["requestId"], "REQ-DROP");
408 mock.reply(id, serde_json::json!({})).await;
409 conn.shutdown();
410 }
411
412 #[tokio::test]
413 async fn continue_does_not_double_fire_on_drop() {
414 // After continue_() consumes self and returns, Drop runs on the moved
415 // value. `released = true` set by continue_() must suppress the Drop
416 // fallback — otherwise every successful path would send two CDP
417 // round-trips.
418 let (mut mock, conn) = MockConnection::pair();
419 let sess = SessionHandle::new(conn.clone(), "S1");
420 let req = PausedRequest::new("REQ-ONCE", make_request_info(), None, sess);
421
422 let fut = tokio::spawn(async move { req.continue_().await });
423
424 let id = mock.expect_cmd("Fetch.continueRequest").await;
425 assert_eq!(mock.last_sent()["params"]["requestId"], "REQ-ONCE");
426 mock.reply(id, serde_json::json!({})).await;
427 fut.await.unwrap().unwrap();
428
429 // Give the runtime a tick — if Drop had spawned a second call it
430 // would be queued by now. `try_recv_cmd` returns None if the channel
431 // is empty.
432 tokio::task::yield_now().await;
433 assert!(
434 mock.try_recv_cmd().is_none(),
435 "Drop fired a second Fetch.continueRequest after continue_ already released"
436 );
437 conn.shutdown();
438 }
439
440 #[tokio::test]
441 async fn respond_dispatches_fetch_fulfill_with_base64_body() {
442 let (mut mock, conn) = MockConnection::pair();
443 let sess = SessionHandle::new(conn.clone(), "S1");
444 let req = PausedRequest::new("REQ-2", make_request_info(), None, sess);
445
446 let body = b"hello world".to_vec();
447 let expected_b64 = BASE64.encode(&body);
448 let fut = tokio::spawn(async move {
449 req.respond(
450 200,
451 vec![("content-type".into(), "text/plain".into())],
452 body,
453 )
454 .await
455 });
456
457 let id = mock.expect_cmd("Fetch.fulfillRequest").await;
458 let sent = mock.last_sent();
459 assert_eq!(sent["params"]["requestId"], "REQ-2");
460 assert_eq!(sent["params"]["responseCode"], 200);
461 assert_eq!(sent["params"]["body"], expected_b64);
462 let headers = sent["params"]["responseHeaders"].as_array().unwrap();
463 assert_eq!(headers.len(), 1);
464 assert_eq!(headers[0]["name"], "content-type");
465 assert_eq!(headers[0]["value"], "text/plain");
466 mock.reply(id, serde_json::json!({})).await;
467
468 fut.await.unwrap().unwrap();
469 conn.shutdown();
470 }
471
472 #[tokio::test]
473 async fn continue_response_dispatches_fetch_continue_response() {
474 let (mut mock, conn) = MockConnection::pair();
475 let sess = SessionHandle::new(conn.clone(), "S1");
476 let req = PausedRequest::new(
477 "REQ-CR",
478 make_request_info(),
479 Some(make_response_info()),
480 sess,
481 );
482
483 let fut = tokio::spawn(async move {
484 req.continue_response(Some(204), None, Some(vec![("x".into(), "y".into())]))
485 .await
486 });
487
488 let id = mock.expect_cmd("Fetch.continueResponse").await;
489 let sent = mock.last_sent();
490 assert_eq!(sent["params"]["requestId"], "REQ-CR");
491 assert_eq!(sent["params"]["responseCode"], 204);
492 // `phrase: None` must be omitted entirely, not sent as null.
493 assert!(
494 sent["params"]
495 .as_object()
496 .unwrap()
497 .get("responsePhrase")
498 .is_none()
499 );
500 let headers = sent["params"]["responseHeaders"].as_array().unwrap();
501 assert_eq!(headers.len(), 1);
502 assert_eq!(headers[0]["name"], "x");
503 assert_eq!(headers[0]["value"], "y");
504 mock.reply(id, serde_json::json!({})).await;
505
506 fut.await.unwrap().unwrap();
507 conn.shutdown();
508 }
509
510 #[tokio::test]
511 async fn continue_response_wrong_stage_errs() {
512 // Called on a Request-stage PausedRequest (response: None), Chrome
513 // would reject continueResponse — we short-circuit with WrongStage
514 // and dispatch nothing.
515 let (mut mock, conn) = MockConnection::pair();
516 let sess = SessionHandle::new(conn.clone(), "S1");
517 let req = PausedRequest::new("REQ-WS", make_request_info(), None, sess);
518
519 let err = req
520 .continue_response(Some(200), None, None)
521 .await
522 .expect_err("continue_response at Request stage must error");
523 assert!(matches!(err, InterceptionError::WrongStage));
524
525 // No Fetch.continueResponse was sent. (A Drop fallback
526 // continueRequest *may* land — that's the freeze-safety net, not a
527 // continueResponse dispatch.) Give the runtime a tick for any spawned
528 // Drop task to enqueue, then drain whatever queued.
529 tokio::task::yield_now().await;
530 while let Some((method, _id)) = mock.try_recv_cmd() {
531 assert_ne!(
532 method, "Fetch.continueResponse",
533 "WrongStage path must not dispatch Fetch.continueResponse"
534 );
535 }
536 conn.shutdown();
537 }
538
539 #[tokio::test]
540 async fn continue_response_no_double_fire_on_drop() {
541 // After continue_response() consumes self and returns, Drop runs on
542 // the moved value. `released = true` set by continue_response() must
543 // suppress the Drop fallback continueRequest — otherwise the happy
544 // path would send two CDP round-trips.
545 let (mut mock, conn) = MockConnection::pair();
546 let sess = SessionHandle::new(conn.clone(), "S1");
547 let req = PausedRequest::new(
548 "REQ-CR-ONCE",
549 make_request_info(),
550 Some(make_response_info()),
551 sess,
552 );
553
554 let fut = tokio::spawn(async move { req.continue_response(Some(200), None, None).await });
555
556 let id = mock.expect_cmd("Fetch.continueResponse").await;
557 assert_eq!(mock.last_sent()["params"]["requestId"], "REQ-CR-ONCE");
558 mock.reply(id, serde_json::json!({})).await;
559 fut.await.unwrap().unwrap();
560
561 tokio::task::yield_now().await;
562 assert!(
563 mock.try_recv_cmd().is_none(),
564 "Drop fired a fallback continueRequest after continue_response already released"
565 );
566 conn.shutdown();
567 }
568}