Skip to main content

browser_control/session/
scratch.rs

1//! Scratch-tab recover-once wrapper. The architectural answer to the iLO
2//! failure mode: lock-free ops (`eval`, `fetch` with no explicit tab) run
3//! against a daemon-style scratch tab tracked in the `scratches` SQLite
4//! row, not against the user's first page.
5//!
6//! Two reasons this is enough:
7//!
8//! 1. The scratch tab is daemon-owned `about:blank`. It can't be the
9//!    weird-renderer that won't service `Runtime.evaluate`, because we
10//!    created it for that purpose. If the user has an iLO admin tab open,
11//!    nothing routes there by default.
12//! 2. If the scratch tab *itself* goes bad (browser restarted between
13//!    invocations and the cached `target_id` is stale; or a previous op
14//!    legitimately wedged the renderer), the wrapper closes+recreates the
15//!    scratch and retries the op once before escalating typed errors.
16//!    This implements the "always proceed" rule on the direct path.
17
18use anyhow::Result;
19
20use crate::registry::Registry;
21use crate::session::backend::TabBackend;
22
23/// Run `op` against the daemon-style scratch tab for `browser_name`, with
24/// one round of recover-and-retry on tab failures.
25///
26/// The op receives the [`TabBackend`] and the live scratch `target_id`
27/// and is expected to drive whatever protocol calls it needs. On a
28/// structured failure that suggests the scratch tab is dead (`TabHung`,
29/// `TabCrashed`, or a CDP/BiDi "no target / no context" protocol error),
30/// the wrapper:
31///
32/// 1. Best-effort closes the dead tab (`Target.closeTarget` on CDP,
33///    `browsingContext.close` on BiDi — handled by [`TabBackend`]).
34/// 2. Deletes the SQLite scratch row.
35/// 3. Creates a fresh tab via the backend and upserts the new row.
36/// 4. Retries `op` once. If the retry also fails, escalates the typed
37///    error to the caller.
38///
39/// Why one retry, not many: per ADR-002 follow-up policy ("retry once,
40/// then `TabHung`") — a second failure usually means the browser itself
41/// is sick, and unbounded retries would mask that.
42pub 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    // Resolve the scratch target id to use for attempt 1: reuse the existing
53    // row if present, else create a fresh `about:blank` and register it.
54    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            // Scratch tab is wedged / dead / vanished. Tear down + recreate +
70            // single retry. Fall through.
71            let _ = backend.close_tab(&attempt_one_target).await;
72            registry.scratch_delete(browser_name)?;
73        }
74        Err(e) => return Err(e),
75    }
76
77    // Attempt 2: fresh scratch tab. If this one also fails, escalate.
78    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
87/// Does this error suggest the scratch tab is dead and we should retry on
88/// a fresh one? Delegates to the shared classifier in `errors` so the
89/// scratch / named-tab / origin-bound recovery wrappers can never drift.
90///
91/// We treat three categories as recoverable:
92/// - `SessionError::TabHung` — per-op timeout fired with no reply.
93/// - `SessionError::TabCrashed` — renderer crash event reached us.
94/// - CDP/BiDi protocol errors mentioning a missing target/session/context.
95fn 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    /// Mock CDP server with configurable per-method behaviour. Returns
112    /// (ws_url, recreate-count-handle, stop).
113    ///
114    /// `eval_behaviour` controls `Runtime.evaluate`:
115    /// - `Wedge(n)`: drop the first `n` evaluate requests on the floor
116    ///   (forcing client-side timeouts), then answer normally.
117    /// - `Always`: always answer with `{result: {value: 42}}`.
118    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                            // Runtime.evaluate may be dropped on the floor
145                            // to simulate a wedged renderer.
146                            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    /// Op closure that runs an engine-agnostic `evaluate("1")` against
195    /// the given scratch target with a tight timeout, mirroring what a
196    /// real lock-free op would do.
197    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, &reg, "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, &reg, "b", eval_op)
230            .await
231            .unwrap();
232        with_scratch_recovery(&backend, &reg, "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    /// First evaluate wedges → wrapper closes + recreates + retries once →
243    /// retry succeeds. Caller sees a value, not an error.
244    #[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, &reg, "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    /// `is_scratch_failure` matches on the typed `TargetGone` variant
264    /// (primary path), the typed hung/crashed variants, and falls back to
265    /// substring matching for un-classified raw errors.
266    #[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        // Hung and crashed remain recoverable.
284        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        // Substring fallback still works for raw anyhow errors.
294        let raw: anyhow::Error = anyhow::anyhow!("No target with given id found: T1");
295        assert!(is_scratch_failure(&raw));
296
297        // Unrelated errors are NOT failures.
298        let unrelated: anyhow::Error = anyhow::anyhow!("network unreachable");
299        assert!(!is_scratch_failure(&unrelated));
300    }
301
302    /// Both attempts wedge → caller sees typed `TabHung`. We do NOT keep
303    /// retrying forever.
304    #[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, &reg, "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        // We tried twice: initial + one retry after recovery.
321        assert_eq!(create_count.load(Ordering::SeqCst), 2);
322    }
323}