Skip to main content

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}