use std::time::Duration;
use anyhow::Result;
use serde_json::Value;
use tokio::sync::broadcast;
use crate::cdp::{CdpClient, CdpEvent};
use crate::errors::SessionError;
pub async fn evaluate_with_crash_detection<Fut>(
client: &CdpClient,
target_id: &str,
session_id: Option<&str>,
fut: Fut,
timeout: Option<Duration>,
) -> Result<Value>
where
Fut: std::future::Future<Output = Result<Value>>,
{
let events = client.subscribe();
let crash_watch = watch_for_crash(
events,
target_id.to_string(),
session_id.map(str::to_string),
);
tokio::pin!(fut);
tokio::pin!(crash_watch);
let race = async {
tokio::select! {
biased;
crash = &mut crash_watch => Err::<Value, anyhow::Error>(crash),
result = &mut fut => result,
}
};
match timeout {
None => race.await,
Some(d) => match tokio::time::timeout(d, race).await {
Ok(r) => r,
Err(_) => Err(SessionError::TabHung {
target_id: Some(target_id.to_string()),
url: None,
timeout_ms: d.as_millis() as u64,
hint: "op-timeout",
}
.into()),
},
}
}
async fn watch_for_crash(
mut rx: broadcast::Receiver<CdpEvent>,
target_id: String,
session_id: Option<String>,
) -> anyhow::Error {
loop {
match rx.recv().await {
Ok(ev) => {
if matches_crash(&ev, &target_id, session_id.as_deref()) {
let reason = crash_reason(&ev.params);
return SessionError::TabCrashed {
target_id: target_id.clone(),
reason,
}
.into();
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => {
std::future::pending::<()>().await;
unreachable!("pending future never resolves");
}
}
}
}
fn matches_crash(ev: &CdpEvent, target_id: &str, session_id: Option<&str>) -> bool {
match ev.method.as_str() {
"Target.targetCrashed" => ev
.params
.get("targetId")
.and_then(|v| v.as_str())
.map(|t| t == target_id)
.unwrap_or(false),
"Inspector.targetCrashed" => match (session_id, ev.session_id.as_deref()) {
(Some(want), Some(got)) => want == got,
_ => false,
},
_ => false,
}
}
fn crash_reason(params: &Value) -> String {
let status = params.get("status").and_then(|v| v.as_str());
let code = params.get("errorCode").and_then(|v| v.as_i64());
match (status, code) {
(Some(s), Some(c)) => format!("status={s} errorCode={c}"),
(Some(s), None) => format!("status={s}"),
(None, Some(c)) => format!("errorCode={c}"),
_ => "renderer crash".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::{SinkExt, StreamExt};
use serde_json::json;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message;
async fn spawn_crashing_mock(
target_id: &'static str,
crash_delay: Duration,
) -> (String, oneshot::Sender<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
let (tx_crash, mut rx_crash) = tokio::sync::mpsc::channel::<()>(1);
tokio::spawn(async move {
tokio::time::sleep(crash_delay).await;
let _ = tx_crash.send(()).await;
});
loop {
tokio::select! {
_ = &mut stop_rx => break,
_ = rx_crash.recv() => {
let ev = json!({
"method": "Target.targetCrashed",
"params": {"targetId": target_id, "status": "crashed", "errorCode": 11},
});
ws.send(Message::Text(ev.to_string())).await.unwrap();
}
msg = ws.next() => {
let msg = match msg { Some(Ok(m)) => m, _ => break };
if let Message::Text(t) = msg {
let req: Value = serde_json::from_str(&t).unwrap();
let id = req["id"].as_u64().unwrap();
let method = req["method"].as_str().unwrap_or("");
if method == "Runtime.evaluate" { continue; }
let resp = json!({"id": id, "result": {}});
ws.send(Message::Text(resp.to_string())).await.unwrap();
}
}
}
}
});
(format!("ws://{addr}"), stop_tx)
}
#[tokio::test]
async fn returns_tab_crashed_when_target_crashed_event_fires() {
let (url, _stop) = spawn_crashing_mock("T1", Duration::from_millis(50)).await;
let client = CdpClient::connect(&url).await.unwrap();
let fut = async {
client
.send_with_session("Runtime.evaluate", json!({}), Some("S1"))
.await
};
let err = evaluate_with_crash_detection(
&client,
"T1",
Some("S1"),
fut,
Some(Duration::from_secs(2)),
)
.await
.expect_err("must surface TabCrashed");
match err.downcast_ref::<SessionError>() {
Some(SessionError::TabCrashed { target_id, reason }) => {
assert_eq!(target_id, "T1");
assert!(reason.contains("crashed"), "reason: {reason}");
}
other => panic!("expected TabCrashed, got {other:?}"),
}
}
#[tokio::test]
async fn ignores_crash_events_for_other_targets() {
let (url, _stop) = spawn_crashing_mock("T1", Duration::from_millis(20)).await;
let client = CdpClient::connect(&url).await.unwrap();
let fut = async {
client
.send_with_session("Runtime.evaluate", json!({}), Some("Sx"))
.await
};
let err = evaluate_with_crash_detection(
&client,
"T2",
Some("Sx"),
fut,
Some(Duration::from_millis(200)),
)
.await
.expect_err("times out, since we don't match the foreign crash");
match err.downcast_ref::<SessionError>() {
Some(SessionError::TabHung { .. }) => {}
other => panic!("expected TabHung (foreign crash ignored), got {other:?}"),
}
}
}