Skip to main content

browser_control/session/
crash.rs

1//! CDP renderer-crash detection.
2//!
3//! Background: the recover-once wrappers (`with_scratch_recovery`,
4//! `with_named_tab_recovery`, bare-fetch recovery) classify
5//! `SessionError::TabCrashed` as recoverable — but until this module, no
6//! code path actually constructed that variant. The protocol-error
7//! classifier converts post-mortem "no target with given id" responses
8//! into `TargetGone`, which is fine for the *next* call but means an
9//! in-flight `Runtime.evaluate` against a crashing renderer only ever
10//! surfaces as `TabHung` after the timeout expires.
11//!
12//! This module fills the gap: while a CDP evaluate is in flight, watch
13//! the event stream for the renderer-crash signal and short-circuit the
14//! in-flight call with a typed `TabCrashed` immediately. The two
15//! observable crash signals are:
16//!
17//! - `Target.targetCrashed` — browser-level event with
18//!   `{targetId, status, errorCode}`. Requires
19//!   `Target.setDiscoverTargets({discover:true})` on the connection.
20//! - `Inspector.targetCrashed` — per-attached-session event (no params).
21//!   Requires `Inspector.enable` on the attached session.
22//!
23//! We listen for both and match on `session_id` (Inspector) or
24//! `targetId` (Target). BiDi has no equivalent protocol event — there,
25//! a context crash surfaces as `no such frame/context` on the next
26//! request, which the `TargetGone` classifier already handles.
27
28use std::time::Duration;
29
30use anyhow::Result;
31use serde_json::Value;
32use tokio::sync::broadcast;
33
34use crate::cdp::{CdpClient, CdpEvent};
35use crate::errors::SessionError;
36
37/// Run `fut` (a CDP request) while watching the client's event stream
38/// for a renderer-crash signal that matches `target_id` (browser-level
39/// `Target.targetCrashed`) or `session_id` (per-session
40/// `Inspector.targetCrashed`). On a matching crash event, return
41/// `SessionError::TabCrashed` instead of waiting for the request to
42/// time out.
43///
44/// `timeout` bounds the whole operation; on expiry returns
45/// `SessionError::TabHung`. Pass `None` to defer bounding to the caller
46/// (matches the unbounded path in `PageSession::evaluate`).
47pub async fn evaluate_with_crash_detection<T, Fut>(
48    client: &CdpClient,
49    target_id: &str,
50    session_id: Option<&str>,
51    fut: Fut,
52    timeout: Option<Duration>,
53) -> Result<T>
54where
55    Fut: std::future::Future<Output = Result<T>>,
56{
57    let events = client.subscribe();
58    let crash_watch = watch_for_crash(
59        events,
60        target_id.to_string(),
61        session_id.map(str::to_string),
62    );
63    tokio::pin!(fut);
64    tokio::pin!(crash_watch);
65
66    let race = async {
67        tokio::select! {
68            biased;
69            crash = &mut crash_watch => Err::<T, anyhow::Error>(crash),
70            result = &mut fut => result,
71        }
72    };
73
74    match timeout {
75        None => race.await,
76        Some(d) => match tokio::time::timeout(d, race).await {
77            Ok(r) => r,
78            Err(_) => Err(SessionError::TabHung {
79                target_id: Some(target_id.to_string()),
80                url: None,
81                timeout_ms: d.as_millis() as u64,
82                hint: "op-timeout",
83            }
84            .into()),
85        },
86    }
87}
88
89/// Drain events from `rx` until we see a renderer-crash signal whose
90/// scope matches our target or session. Returns the typed
91/// `SessionError::TabCrashed` ready to escalate to the caller.
92async fn watch_for_crash(
93    mut rx: broadcast::Receiver<CdpEvent>,
94    target_id: String,
95    session_id: Option<String>,
96) -> anyhow::Error {
97    loop {
98        match rx.recv().await {
99            Ok(ev) => {
100                if matches_crash(&ev, &target_id, session_id.as_deref()) {
101                    let reason = crash_reason(&ev.params);
102                    return SessionError::TabCrashed {
103                        target_id: target_id.clone(),
104                        reason,
105                    }
106                    .into();
107                }
108            }
109            // Lagged: an old event we'd care about may have been dropped.
110            // Keep listening rather than fabricating a crash.
111            Err(broadcast::error::RecvError::Lagged(_)) => continue,
112            // Channel closed — the connection is dead. We have no crash
113            // signal to report, and synthesising `TabCrashed` here would
114            // mislabel a normal disconnect as a crash and (via the biased
115            // select in `evaluate_with_crash_detection`) beat the real I/O
116            // error. Instead, never resolve: let the in-flight future lose
117            // its transport and surface the underlying error itself.
118            Err(broadcast::error::RecvError::Closed) => {
119                std::future::pending::<()>().await;
120                unreachable!("pending future never resolves");
121            }
122        }
123    }
124}
125
126fn matches_crash(ev: &CdpEvent, target_id: &str, session_id: Option<&str>) -> bool {
127    match ev.method.as_str() {
128        // Browser-level: payload identifies the dead target.
129        "Target.targetCrashed" => ev
130            .params
131            .get("targetId")
132            .and_then(|v| v.as_str())
133            .map(|t| t == target_id)
134            .unwrap_or(false),
135        // Per-session: event arrives on the session we attached to.
136        "Inspector.targetCrashed" => match (session_id, ev.session_id.as_deref()) {
137            (Some(want), Some(got)) => want == got,
138            // No attached session to match against; conservatively skip.
139            _ => false,
140        },
141        _ => false,
142    }
143}
144
145fn crash_reason(params: &Value) -> String {
146    let status = params.get("status").and_then(|v| v.as_str());
147    let code = params.get("errorCode").and_then(|v| v.as_i64());
148    match (status, code) {
149        (Some(s), Some(c)) => format!("status={s} errorCode={c}"),
150        (Some(s), None) => format!("status={s}"),
151        (None, Some(c)) => format!("errorCode={c}"),
152        _ => "renderer crash".into(),
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use futures_util::{SinkExt, StreamExt};
160    use serde_json::json;
161    use tokio::sync::oneshot;
162    use tokio_tungstenite::tungstenite::Message;
163
164    /// Mock CDP server that:
165    /// - answers `Inspector.enable` and `Target.setDiscoverTargets` immediately
166    /// - holds `Runtime.evaluate` indefinitely (no response)
167    /// - after `crash_delay`, pushes a `Target.targetCrashed` event for `target_id`
168    async fn spawn_crashing_mock(
169        target_id: &'static str,
170        crash_delay: Duration,
171    ) -> (String, oneshot::Sender<()>) {
172        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
173        let addr = listener.local_addr().unwrap();
174        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
175        tokio::spawn(async move {
176            let (stream, _) = listener.accept().await.unwrap();
177            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
178            // Push the crash event after `crash_delay`.
179            let (tx_crash, mut rx_crash) = tokio::sync::mpsc::channel::<()>(1);
180            tokio::spawn(async move {
181                tokio::time::sleep(crash_delay).await;
182                let _ = tx_crash.send(()).await;
183            });
184            loop {
185                tokio::select! {
186                    _ = &mut stop_rx => break,
187                    _ = rx_crash.recv() => {
188                        let ev = json!({
189                            "method": "Target.targetCrashed",
190                            "params": {"targetId": target_id, "status": "crashed", "errorCode": 11},
191                        });
192                        ws.send(Message::Text(ev.to_string())).await.unwrap();
193                    }
194                    msg = ws.next() => {
195                        let msg = match msg { Some(Ok(m)) => m, _ => break };
196                        if let Message::Text(t) = msg {
197                            let req: Value = serde_json::from_str(&t).unwrap();
198                            let id = req["id"].as_u64().unwrap();
199                            let method = req["method"].as_str().unwrap_or("");
200                            // Hold Runtime.evaluate indefinitely; answer everything else.
201                            if method == "Runtime.evaluate" { continue; }
202                            let resp = json!({"id": id, "result": {}});
203                            ws.send(Message::Text(resp.to_string())).await.unwrap();
204                        }
205                    }
206                }
207            }
208        });
209        (format!("ws://{addr}"), stop_tx)
210    }
211
212    #[tokio::test]
213    async fn returns_tab_crashed_when_target_crashed_event_fires() {
214        let (url, _stop) = spawn_crashing_mock("T1", Duration::from_millis(50)).await;
215        let client = CdpClient::connect(&url).await.unwrap();
216        let fut = async {
217            client
218                .send_with_session("Runtime.evaluate", json!({}), Some("S1"))
219                .await
220        };
221        let err = evaluate_with_crash_detection(
222            &client,
223            "T1",
224            Some("S1"),
225            fut,
226            Some(Duration::from_secs(2)),
227        )
228        .await
229        .expect_err("must surface TabCrashed");
230        match err.downcast_ref::<SessionError>() {
231            Some(SessionError::TabCrashed { target_id, reason }) => {
232                assert_eq!(target_id, "T1");
233                assert!(reason.contains("crashed"), "reason: {reason}");
234            }
235            other => panic!("expected TabCrashed, got {other:?}"),
236        }
237    }
238
239    #[tokio::test]
240    async fn ignores_crash_events_for_other_targets() {
241        // Crash event is for T1, but we're watching T2 — must NOT trip.
242        let (url, _stop) = spawn_crashing_mock("T1", Duration::from_millis(20)).await;
243        let client = CdpClient::connect(&url).await.unwrap();
244        let fut = async {
245            client
246                .send_with_session("Runtime.evaluate", json!({}), Some("Sx"))
247                .await
248        };
249        let err = evaluate_with_crash_detection(
250            &client,
251            "T2",
252            Some("Sx"),
253            fut,
254            Some(Duration::from_millis(200)),
255        )
256        .await
257        .expect_err("times out, since we don't match the foreign crash");
258        match err.downcast_ref::<SessionError>() {
259            Some(SessionError::TabHung { .. }) => {}
260            other => panic!("expected TabHung (foreign crash ignored), got {other:?}"),
261        }
262    }
263}