1use anyhow::Result;
19
20use crate::registry::Registry;
21use crate::session::backend::TabBackend;
22
23pub async fn with_scratch_recovery<F, T, Fut>(
43 backend: &TabBackend,
44 registry: &Registry,
45 browser_name: &str,
46 mut op: F,
47) -> Result<T>
48where
49 F: FnMut(TabBackend, String) -> Fut,
50 Fut: std::future::Future<Output = Result<T>>,
51{
52 let attempt_one_target = match registry.scratch_get(browser_name)? {
55 Some(row) => row.target_id,
56 None => {
57 let new_target = backend.create_tab("about:blank").await?;
58 registry.scratch_upsert(browser_name, &new_target)?;
59 new_target
60 }
61 };
62
63 match op(backend.clone(), attempt_one_target.clone()).await {
64 Ok(value) => {
65 let _ = registry.scratch_touch(browser_name);
66 return Ok(value);
67 }
68 Err(e) if is_scratch_failure(&e) => {
69 let _ = backend.close_tab(&attempt_one_target).await;
72 registry.scratch_delete(browser_name)?;
73 }
74 Err(e) => return Err(e),
75 }
76
77 let new_target = backend.create_tab("about:blank").await?;
79 registry.scratch_upsert(browser_name, &new_target)?;
80 let result = op(backend.clone(), new_target).await;
81 if result.is_ok() {
82 let _ = registry.scratch_touch(browser_name);
83 }
84 result
85}
86
87fn is_scratch_failure(err: &anyhow::Error) -> bool {
96 crate::errors::is_recoverable_tab_failure(err)
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::cdp::CdpClient;
103 use crate::errors::SessionError;
104 use futures_util::{SinkExt, StreamExt};
105 use serde_json::{json, Value};
106 use std::sync::atomic::{AtomicU32, Ordering};
107 use std::sync::Arc;
108 use tokio::sync::oneshot;
109 use tokio_tungstenite::tungstenite::Message;
110
111 async fn spawn_mock(
119 eval_behaviour: EvalBehaviour,
120 ) -> (String, Arc<AtomicU32>, oneshot::Sender<()>) {
121 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
122 let addr = listener.local_addr().unwrap();
123 let create_count = Arc::new(AtomicU32::new(0));
124 let cc = create_count.clone();
125 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
126 tokio::spawn(async move {
127 let (stream, _) = listener.accept().await.unwrap();
128 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
129 let mut next_target = 0u32;
130 let mut next_session = 0u32;
131 let mut evals_seen = 0u32;
132 loop {
133 tokio::select! {
134 _ = &mut stop_rx => break,
135 msg = ws.next() => {
136 let msg = match msg {
137 Some(Ok(m)) => m,
138 _ => break,
139 };
140 if let Message::Text(t) = msg {
141 let req: Value = serde_json::from_str(&t).unwrap();
142 let id = req["id"].as_u64().unwrap();
143 let method = req["method"].as_str().unwrap_or("");
144 if method == "Runtime.evaluate" {
147 evals_seen += 1;
148 if eval_behaviour.should_drop(evals_seen) {
149 continue;
150 }
151 }
152 let result = match method {
153 "Target.createTarget" => {
154 next_target += 1;
155 cc.fetch_add(1, Ordering::SeqCst);
156 serde_json::json!({"targetId": format!("T{next_target}")})
157 }
158 "Target.closeTarget" => serde_json::json!({"success": true}),
159 "Target.attachToTarget" => {
160 next_session += 1;
161 serde_json::json!({"sessionId": format!("S{next_session}")})
162 }
163 "Target.detachFromTarget" => serde_json::json!({}),
164 "Runtime.evaluate" => {
165 serde_json::json!({"result": {"value": 42}})
166 }
167 _ => serde_json::json!({}),
168 };
169 let resp = serde_json::json!({"id": id, "result": result});
170 ws.send(Message::Text(resp.to_string())).await.unwrap();
171 }
172 }
173 }
174 }
175 });
176 (format!("ws://{addr}"), create_count, stop_tx)
177 }
178
179 #[derive(Copy, Clone)]
180 enum EvalBehaviour {
181 Always,
182 Wedge(u32),
183 }
184
185 impl EvalBehaviour {
186 fn should_drop(self, eval_index: u32) -> bool {
187 match self {
188 EvalBehaviour::Always => false,
189 EvalBehaviour::Wedge(n) => eval_index <= n,
190 }
191 }
192 }
193
194 async fn eval_op(backend: TabBackend, target_id: String) -> Result<Value> {
198 backend
199 .evaluate(
200 &target_id,
201 "1",
202 false,
203 std::time::Duration::from_millis(200),
204 )
205 .await
206 }
207
208 #[tokio::test]
209 async fn first_call_creates_scratch_and_returns_value() {
210 let (url, create_count, _stop) = spawn_mock(EvalBehaviour::Always).await;
211 let client = Arc::new(CdpClient::connect(&url).await.unwrap());
212 let backend = TabBackend::Cdp(client);
213 let reg = Registry::open_in_memory().unwrap();
214 let v = with_scratch_recovery(&backend, ®, "brave-twilight", eval_op)
215 .await
216 .unwrap();
217 assert_eq!(v, json!(42));
218 assert_eq!(create_count.load(Ordering::SeqCst), 1, "one scratch tab");
219 let row = reg.scratch_get("brave-twilight").unwrap().unwrap();
220 assert_eq!(row.target_id, "T1");
221 }
222
223 #[tokio::test]
224 async fn second_call_reuses_scratch_row() {
225 let (url, create_count, _stop) = spawn_mock(EvalBehaviour::Always).await;
226 let client = Arc::new(CdpClient::connect(&url).await.unwrap());
227 let backend = TabBackend::Cdp(client);
228 let reg = Registry::open_in_memory().unwrap();
229 with_scratch_recovery(&backend, ®, "b", eval_op)
230 .await
231 .unwrap();
232 with_scratch_recovery(&backend, ®, "b", eval_op)
233 .await
234 .unwrap();
235 assert_eq!(
236 create_count.load(Ordering::SeqCst),
237 1,
238 "second call reused the row, no new target"
239 );
240 }
241
242 #[tokio::test]
245 async fn recovers_after_one_wedge() {
246 let (url, create_count, _stop) = spawn_mock(EvalBehaviour::Wedge(1)).await;
247 let client = Arc::new(CdpClient::connect(&url).await.unwrap());
248 let backend = TabBackend::Cdp(client);
249 let reg = Registry::open_in_memory().unwrap();
250 let v = with_scratch_recovery(&backend, ®, "b", eval_op)
251 .await
252 .unwrap();
253 assert_eq!(v, json!(42));
254 assert_eq!(
255 create_count.load(Ordering::SeqCst),
256 2,
257 "second target created after the wedge"
258 );
259 let row = reg.scratch_get("b").unwrap().unwrap();
260 assert_eq!(row.target_id, "T2");
261 }
262
263 #[test]
267 fn is_scratch_failure_recognizes_typed_target_gone() {
268 use crate::errors::TargetKind;
269 let typed: anyhow::Error = SessionError::TargetGone {
270 kind: TargetKind::Cdp,
271 details: "CDP error -32000: No target with given id found: T1".into(),
272 }
273 .into();
274 assert!(is_scratch_failure(&typed));
275
276 let typed_bidi: anyhow::Error = SessionError::TargetGone {
277 kind: TargetKind::Bidi,
278 details: "BiDi error no such context: C1".into(),
279 }
280 .into();
281 assert!(is_scratch_failure(&typed_bidi));
282
283 let hung: anyhow::Error = SessionError::TabHung {
285 target_id: None,
286 url: None,
287 timeout_ms: 100,
288 hint: "test",
289 }
290 .into();
291 assert!(is_scratch_failure(&hung));
292
293 let raw: anyhow::Error = anyhow::anyhow!("No target with given id found: T1");
295 assert!(is_scratch_failure(&raw));
296
297 let unrelated: anyhow::Error = anyhow::anyhow!("network unreachable");
299 assert!(!is_scratch_failure(&unrelated));
300 }
301
302 #[tokio::test]
305 async fn escalates_after_second_wedge() {
306 let (url, create_count, _stop) = spawn_mock(EvalBehaviour::Wedge(99)).await;
307 let client = Arc::new(CdpClient::connect(&url).await.unwrap());
308 let backend = TabBackend::Cdp(client);
309 let reg = Registry::open_in_memory().unwrap();
310 let err = with_scratch_recovery(&backend, ®, "b", eval_op)
311 .await
312 .expect_err("must escalate");
313 let typed = err
314 .downcast_ref::<SessionError>()
315 .expect("typed SessionError");
316 assert!(
317 matches!(typed, SessionError::TabHung { .. }),
318 "expected TabHung, got {typed:?}"
319 );
320 assert_eq!(create_count.load(Ordering::SeqCst), 2);
322 }
323}