1use 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
37pub async fn evaluate_with_crash_detection<Fut>(
48 client: &CdpClient,
49 target_id: &str,
50 session_id: Option<&str>,
51 fut: Fut,
52 timeout: Option<Duration>,
53) -> Result<Value>
54where
55 Fut: std::future::Future<Output = Result<Value>>,
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::<Value, 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
89async 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 Err(broadcast::error::RecvError::Lagged(_)) => continue,
112 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 "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 "Inspector.targetCrashed" => match (session_id, ev.session_id.as_deref()) {
137 (Some(want), Some(got)) => want == got,
138 _ => 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 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 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 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 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}