Skip to main content

browser_control/session/
tabs.rs

1//! Named-tab orchestration: SQLite + the engine-agnostic [`TabBackend`]
2//! working together.
3//!
4//! The registry layer (`crate::registry::tabs`) is pure SQL CRUD. This
5//! module adds the logic the agent contract actually requires:
6//!
7//! - **Get-or-create**: `tab_open(_, name, url)` returns an existing
8//!   live tab under that name, or creates a fresh `about:blank` (or the
9//!   given `url`) and registers it.
10//! - **Navigate-on-mismatch**: if the existing tab's `last_url` doesn't
11//!   match the requested `url`, run navigate first.
12//! - **Sweep-on-read**: stale rows whose `target_id` no longer exists in
13//!   the live browser are dropped before they're returned to the caller.
14//! - **Budget pressure**: when daemon-created rows exceed
15//!   [`HARD_CAP`], the LRU is closed and recycled.
16//! - **Cute names**: agents who pass no `--name` get
17//!   `tab-<cute-word>` from the same generator that names browsers.
18//!
19//! The same code path serves CDP (Chromium) and BiDi (Firefox) browsers
20//! via [`TabBackend`] — CDP `targetId` and BiDi `context` are both opaque
21//! ids stored in the `tabs.target_id` column. The registry doesn't care
22//! which engine produced the id.
23
24use anyhow::{anyhow, Context, Result};
25use rand::Rng;
26
27use crate::errors::SessionError;
28use crate::registry::{words::WORDS, Registry, TabRow};
29use crate::session::backend::TabBackend;
30
31/// Hard cap on `daemon_created` rows per browser. Hitting this triggers
32/// LRU close+recreate of the oldest daemon-created tab. Chromium itself
33/// starts to struggle around a few hundred tabs depending on RAM; 50 is
34/// comfortably under that ceiling and large enough that even busy
35/// scraping agents won't routinely hit it.
36pub const HARD_CAP: usize = 50;
37
38/// Open a named tab (get-or-create), navigating if `url` differs from the
39/// existing tab's `last_url`. Returns the up-to-date row.
40///
41/// Behaviour:
42/// - `name = None`: always create a fresh tab; assign a cute name.
43/// - `name = Some(n)`:
44///   - if row `(browser, n)` exists and its `target_id` is alive →
45///     navigate-on-mismatch and return.
46///   - if row exists but `target_id` is stale → close (best-effort),
47///     delete row, fall through to create.
48///   - if no row → create.
49/// - `url = None`: defaults to `about:blank`.
50///
51/// On create, if `daemon_created` rows ≥ [`HARD_CAP`], close the LRU
52/// first (`Target.closeTarget` + `tab_delete`) to free a slot.
53pub async fn tab_open(
54    backend: &TabBackend,
55    registry: &Registry,
56    browser_name: &str,
57    name: Option<&str>,
58    url: Option<&str>,
59) -> Result<TabRow> {
60    let want_url = url.unwrap_or("about:blank");
61
62    // Fast path: named tab exists and is alive → maybe navigate, return.
63    if let Some(requested_name) = name {
64        if let Some(existing) = registry.tab_get(browser_name, requested_name)? {
65            let mut died_mid_navigate = false;
66            if target_alive(backend, &existing.target_id).await? {
67                if !want_url.is_empty() && want_url != existing.last_url && url.is_some() {
68                    // Navigate-on-mismatch. The target was alive a moment
69                    // ago, but a probe-then-act race means it may have died
70                    // (crash/hang) between `target_alive` and here. A
71                    // recoverable tab failure must NOT surface raw at the
72                    // caller — fall through to the stale-row close+delete+
73                    // create path below, mirroring the eval/fetch/storage
74                    // contract enforced by `with_named_tab_recovery`.
75                    match backend.navigate(&existing.target_id, want_url).await {
76                        Ok(()) => {
77                            registry.tab_set_url(browser_name, requested_name, want_url)?;
78                        }
79                        Err(e) if is_tab_failure(&e) => died_mid_navigate = true,
80                        Err(e) => {
81                            return Err(e).with_context(|| {
82                                format!("navigating {browser_name}/{requested_name} to {want_url}")
83                            });
84                        }
85                    }
86                } else {
87                    registry.tab_touch(browser_name, requested_name)?;
88                }
89                if !died_mid_navigate {
90                    return registry
91                        .tab_get(browser_name, requested_name)?
92                        .ok_or_else(|| anyhow!("tab row vanished between lookups"));
93                }
94            }
95            // Stale row (probe failed) or the tab died mid-navigate → close
96            // best-effort + delete + fall through to create.
97            let _ = backend.close_tab(&existing.target_id).await;
98            registry.tab_delete(browser_name, requested_name)?;
99        }
100    }
101
102    // Create path. Enforce budget first.
103    if registry.tabs_count_daemon_created(browser_name)? >= HARD_CAP {
104        if let Some(victim) = registry.tabs_lru_daemon_created(browser_name)? {
105            let _ = backend.close_tab(&victim.target_id).await;
106            registry.tab_delete(&victim.browser_name, &victim.name)?;
107        }
108    }
109
110    let assigned_name = match name {
111        Some(n) => n.to_string(),
112        None => fresh_cute_name(registry, browser_name)?,
113    };
114    let new_target_id = backend.create_tab(want_url).await?;
115    registry.tab_upsert(browser_name, &assigned_name, &new_target_id, want_url, true)?;
116    registry
117        .tab_get(browser_name, &assigned_name)?
118        .ok_or_else(|| anyhow!("tab row missing immediately after upsert"))
119}
120
121/// `tab list <browser>` backend with sweep-on-read.
122///
123/// Asks the [`TabBackend`] for the live id set and drops any tab rows
124/// whose `target_id` is no longer present (closed externally, browser
125/// restarted, etc.).
126pub async fn tab_list(
127    backend: &TabBackend,
128    registry: &Registry,
129    browser_name: &str,
130) -> Result<Vec<TabRow>> {
131    let live_targets = backend.live_target_ids().await?;
132    let mut rows = registry.tabs_list_for(browser_name)?;
133    let mut keep = Vec::with_capacity(rows.len());
134    rows.retain(|r| {
135        let alive = live_targets.contains(&r.target_id);
136        if !alive {
137            let _ = registry.tab_delete(&r.browser_name, &r.name);
138        }
139        alive
140    });
141    keep.append(&mut rows);
142    Ok(keep)
143}
144
145/// Resolve `<browser>/<name>` to a live tab row for cross-command routing
146/// (eval, fetch, etc.). Returns `Ok(None)` if no row matches or the row's
147/// `target_id` no longer exists in the browser (`TabNotFound` at the call
148/// site).
149pub async fn resolve_tab(
150    backend: &TabBackend,
151    registry: &Registry,
152    browser_name: &str,
153    name: &str,
154) -> Result<Option<TabRow>> {
155    let Some(row) = registry.tab_get(browser_name, name)? else {
156        return Ok(None);
157    };
158    if target_alive(backend, &row.target_id).await? {
159        registry.tab_touch(browser_name, name)?;
160        Ok(Some(row))
161    } else {
162        registry.tab_delete(browser_name, name)?;
163        Ok(None)
164    }
165}
166
167async fn target_alive(backend: &TabBackend, target_id: &str) -> Result<bool> {
168    let live = backend.live_target_ids().await?;
169    Ok(live.contains(target_id))
170}
171
172/// Run `op` against the named tab `<browser>/<name>` with one round of
173/// recover-and-retry on tab failures. Mirrors [`crate::session::with_scratch_recovery`]
174/// but for the agent-owned named-tab path.
175///
176/// Semantics, in order:
177///
178/// 1. Resolve the row via [`resolve_tab`]. If the row is missing or its
179///    `target_id` is stale, surface a typed `SessionError::TabNotFound` —
180///    we do NOT auto-create a tab the agent never asked for.
181/// 2. Run `op` against the live `target_id`.
182/// 3. If `op` returns a recoverable failure (`TabHung`, `TabCrashed`, or a
183///    CDP/BiDi "no target / no context" protocol error), the tab died
184///    between resolve and op. For daemon-created rows, close the failed
185///    target so repeated recovery cannot orphan unbounded browser tabs.
186///    User-adopted tabs are left alone because closing a tab the user
187///    explicitly adopted would be surprising. Create a fresh tab, navigate
188///    it to the row's `last_url` (best-effort; falls back to `about:blank`
189///    if the rehydration navigation itself fails), and re-point the registry
190///    row at it under the **same name**, then retry `op` once.
191/// 4. If the retry also fails, escalate the typed error to the caller.
192pub async fn with_named_tab_recovery<F, T, Fut>(
193    backend: &TabBackend,
194    registry: &Registry,
195    browser_name: &str,
196    tab_name: &str,
197    mut op: F,
198) -> Result<T>
199where
200    F: FnMut(TabBackend, String) -> Fut,
201    Fut: std::future::Future<Output = Result<T>>,
202{
203    // Step 1: resolve. `resolve_tab` already sweeps stale rows internally
204    // (deletes the row if its target_id no longer exists in the browser).
205    let row = match resolve_tab(backend, registry, browser_name, tab_name).await? {
206        Some(r) => r,
207        None => {
208            return Err(SessionError::TabNotFound {
209                browser: browser_name.to_string(),
210                name: tab_name.to_string(),
211            }
212            .into());
213        }
214    };
215
216    // Step 2: first attempt.
217    // The `Ok(value)` branch returns early; the failure branch falls
218    // through to attempt 2. Clippy reads the early return as "needless"
219    // because the failure branch also has `return Err(e)` — but
220    // restructuring (e.g. via `if let`) makes the recover-once
221    // contract less obvious.
222    #[allow(clippy::needless_return)]
223    match op(backend.clone(), row.target_id.clone()).await {
224        Ok(value) => return Ok(value),
225        Err(e) if is_tab_failure(&e) => {
226            // Step 3: recover. Tab died between resolve and op.
227            //
228            // Daemon-created tabs are owned by browser-control, so close
229            // the failed target before replacing it. Leaving these targets
230            // around creates unbounded orphan tabs during repeated
231            // recoveries, and the registry LRU cannot see them once the row
232            // is re-pointed. User-adopted tabs are not closed here because
233            // they were not created by the daemon.
234            if row.daemon_created {
235                let _ = backend.close_tab(&row.target_id).await;
236            }
237            // The registry row gets re-pointed at a fresh tab under the
238            // same name — addressing-by-name now resolves to the new live
239            // tab.
240            //
241            // Rehydrate `last_url`: if the dead tab was at a real URL,
242            // navigate the new tab there before retrying so the agent
243            // sees its addressable state preserved. Best-effort — if
244            // navigation itself fails (origin gone, network down) we
245            // fall back to blank rather than block recovery.
246            let rehydrate_url = if row.last_url.is_empty() || row.last_url == "about:blank" {
247                "about:blank".to_string()
248            } else {
249                row.last_url.clone()
250            };
251            let new_target_id = backend.create_tab("about:blank").await?;
252            let (stored_url, ready_target) = if rehydrate_url != "about:blank" {
253                match backend.navigate(&new_target_id, &rehydrate_url).await {
254                    Ok(()) => (rehydrate_url, new_target_id),
255                    Err(nav_err) => {
256                        tracing::warn!(
257                            target = "session::tabs",
258                            "rehydrating {browser_name}/{tab_name} to {rehydrate_url} failed: {nav_err:#}; falling back to about:blank"
259                        );
260                        ("about:blank".to_string(), new_target_id)
261                    }
262                }
263            } else {
264                ("about:blank".to_string(), new_target_id)
265            };
266            registry.tab_upsert(browser_name, tab_name, &ready_target, &stored_url, true)?;
267            // Step 4: retry once.
268            op(backend.clone(), ready_target).await
269        }
270        Err(e) => Err(e),
271    }
272}
273
274/// Does this error suggest the named tab is dead and we should retry on
275/// a fresh one? Delegates to the shared classifier in `errors` so the
276/// scratch / named-tab / origin-bound recovery wrappers can never drift.
277fn is_tab_failure(err: &anyhow::Error) -> bool {
278    crate::errors::is_recoverable_tab_failure(err)
279}
280
281fn fresh_cute_name(registry: &Registry, browser_name: &str) -> Result<String> {
282    let mut rng = rand::thread_rng();
283    for _ in 0..20 {
284        let word = WORDS[rng.gen_range(0..WORDS.len())];
285        let base = format!("tab-{word}");
286        if registry.tab_get(browser_name, &base)?.is_none() {
287            return Ok(base);
288        }
289        for n in 2..=1000 {
290            let candidate = format!("tab-{word}-{n}");
291            if registry.tab_get(browser_name, &candidate)?.is_none() {
292                return Ok(candidate);
293            }
294        }
295    }
296    Err(anyhow!(
297        "failed to generate a unique tab name after 20 attempts"
298    ))
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::cdp::CdpClient;
305    use crate::detect::Engine;
306    use futures_util::{SinkExt, StreamExt};
307    use serde_json::{json, Value};
308    use std::sync::Arc;
309    use tokio::sync::oneshot;
310    use tokio_tungstenite::tungstenite::Message;
311
312    /// Build a CDP TabBackend backed by `spawn_mock`. Each test holds the
313    /// returned `_stop` to keep the mock alive.
314    async fn cdp_backend() -> (TabBackend, oneshot::Sender<()>) {
315        let (url, stop) = spawn_mock().await;
316        let client = Arc::new(CdpClient::connect(&url).await.unwrap());
317        (TabBackend::Cdp(client), stop)
318    }
319
320    /// Build a BiDi TabBackend backed by `spawn_bidi_mock`. Mirror of
321    /// `cdp_backend` so the same test logic exercises both engines.
322    async fn bidi_backend() -> (TabBackend, oneshot::Sender<()>) {
323        let (url, stop) = spawn_bidi_mock().await;
324        let backend = crate::session::backend::open_backend(&url, Engine::Bidi)
325            .await
326            .unwrap();
327        (backend, stop)
328    }
329
330    /// Mock CDP server backing tabs tests. Tracks created targets,
331    /// supports closing, attach/navigate/detach.
332    async fn spawn_mock() -> (String, oneshot::Sender<()>) {
333        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
334        let addr = listener.local_addr().unwrap();
335        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
336        tokio::spawn(async move {
337            let (stream, _) = listener.accept().await.unwrap();
338            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
339            let mut next_target = 0u32;
340            let mut next_session = 0u32;
341            // We track which target ids are currently alive so
342            // Target.getTargets responses are correct.
343            let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
344            loop {
345                tokio::select! {
346                    _ = &mut stop_rx => break,
347                    msg = ws.next() => {
348                        let msg = match msg {
349                            Some(Ok(m)) => m,
350                            _ => break,
351                        };
352                        if let Message::Text(t) = msg {
353                            let req: Value = serde_json::from_str(&t).unwrap();
354                            let id = req["id"].as_u64().unwrap();
355                            let method = req["method"].as_str().unwrap_or("");
356                            let result = match method {
357                                "Target.createTarget" => {
358                                    next_target += 1;
359                                    let tid = format!("T{next_target}");
360                                    live.insert(tid.clone());
361                                    json!({"targetId": tid})
362                                }
363                                "Target.closeTarget" => {
364                                    if let Some(tid) = req
365                                        .pointer("/params/targetId")
366                                        .and_then(|v| v.as_str())
367                                    {
368                                        live.remove(tid);
369                                    }
370                                    json!({"success": true})
371                                }
372                                "Target.attachToTarget" => {
373                                    next_session += 1;
374                                    json!({"sessionId": format!("S{next_session}")})
375                                }
376                                "Target.detachFromTarget" => json!({}),
377                                "Page.navigate" => json!({}),
378                                "Target.getTargets" => {
379                                    let infos: Vec<Value> = live
380                                        .iter()
381                                        .map(|tid| {
382                                            json!({"targetId": tid, "type": "page", "url": ""})
383                                        })
384                                        .collect();
385                                    json!({"targetInfos": infos})
386                                }
387                                _ => json!({}),
388                            };
389                            let resp = json!({"id": id, "result": result});
390                            ws.send(Message::Text(resp.to_string())).await.unwrap();
391                        }
392                    }
393                }
394            }
395        });
396        (format!("ws://{addr}"), stop_tx)
397    }
398
399    /// Mock BiDi server for parallel-engine tests. Mirrors the shape of
400    /// `spawn_mock` (CDP), tracking live contexts so getTree responses
401    /// stay accurate after create/close.
402    async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
403        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
404        let addr = listener.local_addr().unwrap();
405        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
406        tokio::spawn(async move {
407            let (stream, _) = listener.accept().await.unwrap();
408            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
409            let mut next_ctx = 0u32;
410            let mut live = std::collections::HashSet::<String>::new();
411            loop {
412                tokio::select! {
413                    _ = &mut stop_rx => break,
414                    msg = ws.next() => {
415                        let msg = match msg {
416                            Some(Ok(m)) => m,
417                            _ => break,
418                        };
419                        if let Message::Text(t) = msg {
420                            let req: Value = serde_json::from_str(&t).unwrap();
421                            let id = req["id"].as_u64().unwrap();
422                            let method = req["method"].as_str().unwrap_or("");
423                            let result = match method {
424                                "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
425                                "browsingContext.create" => {
426                                    next_ctx += 1;
427                                    let c = format!("C{next_ctx}");
428                                    live.insert(c.clone());
429                                    json!({"context": c})
430                                }
431                                "browsingContext.close" => {
432                                    if let Some(c) = req
433                                        .pointer("/params/context")
434                                        .and_then(|v| v.as_str())
435                                    {
436                                        live.remove(c);
437                                    }
438                                    json!({})
439                                }
440                                "browsingContext.navigate" => json!({"navigation": "N1"}),
441                                "browsingContext.getTree" => {
442                                    let contexts: Vec<Value> = live
443                                        .iter()
444                                        .map(|c| json!({"context": c, "url": "", "children": []}))
445                                        .collect();
446                                    json!({"contexts": contexts})
447                                }
448                                _ => json!({}),
449                            };
450                            let resp = json!({"type": "success", "id": id, "result": result});
451                            ws.send(Message::Text(resp.to_string())).await.unwrap();
452                        }
453                    }
454                }
455            }
456        });
457        (format!("ws://{addr}"), stop_tx)
458    }
459
460    // ---- CDP-engine tests ------------------------------------------------
461
462    #[tokio::test]
463    async fn open_without_name_assigns_cute_name_cdp() {
464        let (backend, _stop) = cdp_backend().await;
465        let reg = Registry::open_in_memory().unwrap();
466        let row = tab_open(&backend, &reg, "brave", None, None).await.unwrap();
467        assert!(row.name.starts_with("tab-"));
468        assert_eq!(row.target_id, "T1");
469        assert!(row.daemon_created);
470        assert_eq!(row.last_url, "about:blank");
471    }
472
473    #[tokio::test]
474    async fn open_with_name_is_idempotent_cdp() {
475        let (backend, _stop) = cdp_backend().await;
476        let reg = Registry::open_in_memory().unwrap();
477        let a = tab_open(&backend, &reg, "b", Some("scrape"), None)
478            .await
479            .unwrap();
480        let b = tab_open(&backend, &reg, "b", Some("scrape"), None)
481            .await
482            .unwrap();
483        assert_eq!(a.target_id, b.target_id);
484        assert_eq!(a.name, b.name);
485    }
486
487    #[tokio::test]
488    async fn open_with_mismatched_url_navigates_cdp() {
489        let (backend, _stop) = cdp_backend().await;
490        let reg = Registry::open_in_memory().unwrap();
491        let a = tab_open(&backend, &reg, "b", Some("nav"), Some("https://a"))
492            .await
493            .unwrap();
494        let b = tab_open(&backend, &reg, "b", Some("nav"), Some("https://b"))
495            .await
496            .unwrap();
497        assert_eq!(a.target_id, b.target_id, "same target across nav");
498        assert_eq!(b.last_url, "https://b");
499    }
500
501    #[tokio::test]
502    async fn open_with_stale_target_recreates_cdp() {
503        let (backend, _stop) = cdp_backend().await;
504        let reg = Registry::open_in_memory().unwrap();
505        reg.tab_upsert("b", "ghost", "T999", "about:blank", true)
506            .unwrap();
507        let row = tab_open(&backend, &reg, "b", Some("ghost"), None)
508            .await
509            .unwrap();
510        assert_ne!(row.target_id, "T999", "stale target was recreated");
511        assert_eq!(row.name, "ghost", "same name preserved");
512    }
513
514    #[tokio::test]
515    async fn list_sweeps_stale_rows_cdp() {
516        let (backend, _stop) = cdp_backend().await;
517        let reg = Registry::open_in_memory().unwrap();
518        let _ = tab_open(&backend, &reg, "b", Some("live"), None)
519            .await
520            .unwrap();
521        reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
522        let rows = tab_list(&backend, &reg, "b").await.unwrap();
523        let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
524        assert!(names.contains(&"live"));
525        assert!(!names.contains(&"ghost"));
526        assert!(reg.tab_get("b", "ghost").unwrap().is_none());
527    }
528
529    #[tokio::test]
530    async fn resolve_returns_none_for_missing_and_stale_cdp() {
531        let (backend, _stop) = cdp_backend().await;
532        let reg = Registry::open_in_memory().unwrap();
533        assert!(resolve_tab(&backend, &reg, "b", "nope")
534            .await
535            .unwrap()
536            .is_none());
537        reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
538        assert!(resolve_tab(&backend, &reg, "b", "ghost")
539            .await
540            .unwrap()
541            .is_none());
542        assert!(reg.tab_get("b", "ghost").unwrap().is_none(), "swept");
543    }
544
545    #[tokio::test]
546    async fn resolve_returns_alive_row_and_touches_cdp() {
547        let (backend, _stop) = cdp_backend().await;
548        let reg = Registry::open_in_memory().unwrap();
549        let opened = tab_open(&backend, &reg, "b", Some("hot"), None)
550            .await
551            .unwrap();
552        let resolved = resolve_tab(&backend, &reg, "b", "hot")
553            .await
554            .unwrap()
555            .unwrap();
556        assert_eq!(resolved.target_id, opened.target_id);
557    }
558
559    #[tokio::test]
560    async fn budget_pressure_picks_lru_daemon_row() {
561        // Verifies the SQL helper that the create path uses. End-to-end
562        // budget-pressure exercise would need HARD_CAP+1 tabs and is
563        // covered by registry::tabs::tests instead.
564        let reg = Registry::open_in_memory().unwrap();
565        reg.tab_upsert("b", "old", "T-OLD", "", true).unwrap();
566        std::thread::sleep(std::time::Duration::from_millis(1100));
567        reg.tab_upsert("b", "new", "T-NEW", "", true).unwrap();
568        let lru = reg.tabs_lru_daemon_created("b").unwrap().unwrap();
569        assert_eq!(lru.name, "old");
570    }
571
572    // ---- BiDi-engine tests (same behaviour, different protocol) ---------
573
574    #[tokio::test]
575    async fn open_without_name_assigns_cute_name_bidi() {
576        let (backend, _stop) = bidi_backend().await;
577        let reg = Registry::open_in_memory().unwrap();
578        let row = tab_open(&backend, &reg, "ff", None, None).await.unwrap();
579        assert!(row.name.starts_with("tab-"));
580        assert_eq!(row.target_id, "C1");
581        assert!(row.daemon_created);
582    }
583
584    #[tokio::test]
585    async fn open_with_name_is_idempotent_bidi() {
586        let (backend, _stop) = bidi_backend().await;
587        let reg = Registry::open_in_memory().unwrap();
588        let a = tab_open(&backend, &reg, "ff", Some("scrape"), None)
589            .await
590            .unwrap();
591        let b = tab_open(&backend, &reg, "ff", Some("scrape"), None)
592            .await
593            .unwrap();
594        assert_eq!(a.target_id, b.target_id);
595    }
596
597    #[tokio::test]
598    async fn open_with_mismatched_url_navigates_bidi() {
599        let (backend, _stop) = bidi_backend().await;
600        let reg = Registry::open_in_memory().unwrap();
601        let a = tab_open(&backend, &reg, "ff", Some("nav"), Some("https://a"))
602            .await
603            .unwrap();
604        let b = tab_open(&backend, &reg, "ff", Some("nav"), Some("https://b"))
605            .await
606            .unwrap();
607        assert_eq!(a.target_id, b.target_id);
608        assert_eq!(b.last_url, "https://b");
609    }
610
611    #[tokio::test]
612    async fn open_with_stale_target_recreates_bidi() {
613        let (backend, _stop) = bidi_backend().await;
614        let reg = Registry::open_in_memory().unwrap();
615        reg.tab_upsert("ff", "ghost", "C999", "", true).unwrap();
616        let row = tab_open(&backend, &reg, "ff", Some("ghost"), None)
617            .await
618            .unwrap();
619        assert_ne!(row.target_id, "C999");
620        assert_eq!(row.name, "ghost");
621    }
622
623    #[tokio::test]
624    async fn list_sweeps_stale_rows_bidi() {
625        let (backend, _stop) = bidi_backend().await;
626        let reg = Registry::open_in_memory().unwrap();
627        let _ = tab_open(&backend, &reg, "ff", Some("live"), None)
628            .await
629            .unwrap();
630        reg.tab_upsert("ff", "ghost", "C999", "", true).unwrap();
631        let rows = tab_list(&backend, &reg, "ff").await.unwrap();
632        let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
633        assert!(names.contains(&"live"));
634        assert!(!names.contains(&"ghost"));
635    }
636
637    #[tokio::test]
638    async fn resolve_returns_alive_row_and_touches_bidi() {
639        let (backend, _stop) = bidi_backend().await;
640        let reg = Registry::open_in_memory().unwrap();
641        let opened = tab_open(&backend, &reg, "ff", Some("hot"), None)
642            .await
643            .unwrap();
644        let resolved = resolve_tab(&backend, &reg, "ff", "hot")
645            .await
646            .unwrap()
647            .unwrap();
648        assert_eq!(resolved.target_id, opened.target_id);
649    }
650
651    // ---- with_named_tab_recovery -----------------------------------------
652
653    use crate::errors::SessionError;
654
655    /// Missing row → typed `TabNotFound`.
656    #[tokio::test]
657    async fn recover_missing_row_returns_tab_not_found() {
658        let (backend, _stop) = cdp_backend().await;
659        let reg = Registry::open_in_memory().unwrap();
660        let err = with_named_tab_recovery(&backend, &reg, "b", "nope", |_, _| async {
661            Ok::<_, anyhow::Error>(serde_json::json!(null))
662        })
663        .await
664        .expect_err("must error");
665        let typed = err
666            .downcast_ref::<SessionError>()
667            .expect("typed SessionError");
668        match typed {
669            SessionError::TabNotFound { browser, name } => {
670                assert_eq!(browser, "b");
671                assert_eq!(name, "nope");
672            }
673            other => panic!("expected TabNotFound, got {other:?}"),
674        }
675    }
676
677    /// Stale row whose target_id is gone → resolve_tab sweeps it →
678    /// also `TabNotFound` (not silent recreate — the agent never asked
679    /// for the recreate at resolve time).
680    #[tokio::test]
681    async fn recover_stale_row_returns_tab_not_found_after_sweep() {
682        let (backend, _stop) = cdp_backend().await;
683        let reg = Registry::open_in_memory().unwrap();
684        reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
685        let err = with_named_tab_recovery(&backend, &reg, "b", "ghost", |_, _| async {
686            Ok::<_, anyhow::Error>(serde_json::json!(null))
687        })
688        .await
689        .expect_err("must error after sweep");
690        assert!(matches!(
691            err.downcast_ref::<SessionError>(),
692            Some(SessionError::TabNotFound { .. })
693        ));
694        assert!(reg.tab_get("b", "ghost").unwrap().is_none(), "swept");
695    }
696
697    /// First op call wedges (returns `TabHung`); wrapper closes the failed
698    /// daemon-created tab, recreates a fresh blank under the same name, and
699    /// retries. Caller sees the retry value.
700    #[tokio::test]
701    async fn recover_after_op_returns_tab_hung() {
702        let (backend, _stop) = cdp_backend().await;
703        let reg = Registry::open_in_memory().unwrap();
704        let opened = tab_open(&backend, &reg, "b", Some("flaky"), None)
705            .await
706            .unwrap();
707        let original_target = opened.target_id.clone();
708
709        // Op that returns TabHung the first call, ok the second.
710        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
711        let calls_clone = calls.clone();
712        let result = with_named_tab_recovery(&backend, &reg, "b", "flaky", move |_, target_id| {
713            let calls = calls_clone.clone();
714            async move {
715                let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
716                if n == 0 {
717                    Err(SessionError::TabHung {
718                        target_id: Some(target_id.clone()),
719                        url: None,
720                        timeout_ms: 100,
721                        hint: "test",
722                    }
723                    .into())
724                } else {
725                    Ok::<_, anyhow::Error>(serde_json::json!(format!("ok:{target_id}")))
726                }
727            }
728        })
729        .await
730        .expect("recover succeeded");
731
732        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
733        // The row now points at the fresh target, not the dead one.
734        let row = reg.tab_get("b", "flaky").unwrap().unwrap();
735        assert_ne!(
736            row.target_id, original_target,
737            "row updated to fresh target after recovery"
738        );
739        assert_eq!(row.last_url, "about:blank", "recovered tab is blank");
740        // The op was called with the fresh target on the second attempt.
741        assert_eq!(result, serde_json::json!(format!("ok:{}", row.target_id)));
742    }
743
744    /// Recovery closes daemon-created failed tabs before replacing the row.
745    /// Otherwise repeated recoveries orphan live targets that the registry
746    /// cap cannot see once the row has been re-pointed.
747    #[tokio::test]
748    async fn recovery_closes_daemon_named_tab_in_browser() {
749        let (backend, _stop) = cdp_backend().await;
750        let reg = Registry::open_in_memory().unwrap();
751        let opened = tab_open(&backend, &reg, "b", Some("doomed"), None)
752            .await
753            .unwrap();
754        let original_target = opened.target_id.clone();
755
756        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
757        let calls_clone = calls.clone();
758        let _ = with_named_tab_recovery(&backend, &reg, "b", "doomed", move |_, target_id| {
759            let calls = calls_clone.clone();
760            async move {
761                let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
762                if n == 0 {
763                    Err(SessionError::TabHung {
764                        target_id: Some(target_id),
765                        url: None,
766                        timeout_ms: 100,
767                        hint: "test",
768                    }
769                    .into())
770                } else {
771                    Ok::<_, anyhow::Error>(serde_json::json!("ok"))
772                }
773            }
774        })
775        .await
776        .expect("recover succeeded");
777
778        // One live target after recovery: the fresh replacement. The failed
779        // daemon-created target was closed.
780        let live = backend.live_target_ids().await.unwrap();
781        assert!(
782            !live.contains(&original_target),
783            "daemon-created failed tab must be closed; live = {live:?}, original = {original_target}"
784        );
785        assert_eq!(
786            live.len(),
787            1,
788            "expected only fresh replacement; got {live:?}"
789        );
790
791        // The registry row points at the fresh tab, not the dead one.
792        let row = reg.tab_get("b", "doomed").unwrap().unwrap();
793        assert_ne!(row.target_id, original_target);
794    }
795
796    /// Adopted tabs are user-owned. Recovery still re-points the name to a
797    /// fresh daemon-created replacement, but it must not close the original
798    /// user tab.
799    #[tokio::test]
800    async fn recovery_leaves_user_adopted_tab_in_browser() {
801        let (backend, _stop) = cdp_backend().await;
802        let reg = Registry::open_in_memory().unwrap();
803        let original_target = backend.create_tab("https://example.com/app").await.unwrap();
804        reg.tab_upsert(
805            "b",
806            "adopted",
807            &original_target,
808            "https://example.com/app",
809            false,
810        )
811        .unwrap();
812
813        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
814        let calls_clone = calls.clone();
815        let _ = with_named_tab_recovery(&backend, &reg, "b", "adopted", move |_, target_id| {
816            let calls = calls_clone.clone();
817            async move {
818                let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
819                if n == 0 {
820                    Err(SessionError::TabHung {
821                        target_id: Some(target_id),
822                        url: None,
823                        timeout_ms: 100,
824                        hint: "test",
825                    }
826                    .into())
827                } else {
828                    Ok::<_, anyhow::Error>(serde_json::json!("ok"))
829                }
830            }
831        })
832        .await
833        .expect("recover succeeded");
834
835        let live = backend.live_target_ids().await.unwrap();
836        assert!(
837            live.contains(&original_target),
838            "adopted user tab must not be closed; live = {live:?}, original = {original_target}"
839        );
840        assert_eq!(live.len(), 2, "expected user tab + fresh replacement");
841
842        let row = reg.tab_get("b", "adopted").unwrap().unwrap();
843        assert_ne!(row.target_id, original_target);
844        assert!(row.daemon_created, "replacement is daemon-owned");
845    }
846
847    /// `is_tab_failure` matches the typed `TargetGone` variant first
848    /// (primary path) and falls back to substring matching for
849    /// un-classified raw errors.
850    #[test]
851    fn is_tab_failure_recognizes_typed_target_gone() {
852        use crate::errors::TargetKind;
853        let typed: anyhow::Error = SessionError::TargetGone {
854            kind: TargetKind::Cdp,
855            details: "CDP error -32000: target closed".into(),
856        }
857        .into();
858        assert!(is_tab_failure(&typed));
859
860        let typed_bidi: anyhow::Error = SessionError::TargetGone {
861            kind: TargetKind::Bidi,
862            details: "BiDi error no such frame: C1".into(),
863        }
864        .into();
865        assert!(is_tab_failure(&typed_bidi));
866
867        let hung: anyhow::Error = SessionError::TabHung {
868            target_id: None,
869            url: None,
870            timeout_ms: 100,
871            hint: "t",
872        }
873        .into();
874        assert!(is_tab_failure(&hung));
875
876        let raw: anyhow::Error = anyhow::anyhow!("Target closed");
877        assert!(is_tab_failure(&raw));
878
879        let unrelated: anyhow::Error = anyhow::anyhow!("dns failure");
880        assert!(!is_tab_failure(&unrelated));
881    }
882
883    /// Recovery rehydrates the dead tab's `last_url` onto the fresh tab
884    /// instead of dropping the agent back to `about:blank`. The agent
885    /// addresses by name and expects the name to point at the same URL
886    /// after a transient renderer failure.
887    #[tokio::test]
888    async fn recover_rehydrates_last_url_onto_fresh_tab() {
889        let (backend, _stop) = cdp_backend().await;
890        let reg = Registry::open_in_memory().unwrap();
891        // Seed a row whose last_url is a real URL (not about:blank).
892        let opened = tab_open(
893            &backend,
894            &reg,
895            "b",
896            Some("pinned"),
897            Some("https://example.com/app"),
898        )
899        .await
900        .unwrap();
901        let original_target = opened.target_id.clone();
902        assert_eq!(opened.last_url, "https://example.com/app");
903
904        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
905        let calls_clone = calls.clone();
906        let _ = with_named_tab_recovery(&backend, &reg, "b", "pinned", move |_, target_id| {
907            let calls = calls_clone.clone();
908            async move {
909                let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
910                if n == 0 {
911                    Err(SessionError::TabHung {
912                        target_id: Some(target_id),
913                        url: None,
914                        timeout_ms: 100,
915                        hint: "test",
916                    }
917                    .into())
918                } else {
919                    Ok::<_, anyhow::Error>(serde_json::json!("ok"))
920                }
921            }
922        })
923        .await
924        .expect("recover succeeded");
925
926        let row = reg.tab_get("b", "pinned").unwrap().unwrap();
927        assert_ne!(row.target_id, original_target, "row points at fresh tab");
928        assert_eq!(
929            row.last_url, "https://example.com/app",
930            "last_url rehydrated on recovery instead of falling back to about:blank"
931        );
932    }
933
934    /// Both attempts return `TabHung` → escalate to the caller.
935    #[tokio::test]
936    async fn recover_escalates_when_retry_also_fails() {
937        let (backend, _stop) = cdp_backend().await;
938        let reg = Registry::open_in_memory().unwrap();
939        tab_open(&backend, &reg, "b", Some("doomed"), None)
940            .await
941            .unwrap();
942        let err =
943            with_named_tab_recovery(&backend, &reg, "b", "doomed", |_, target_id| async move {
944                Err::<serde_json::Value, _>(
945                    SessionError::TabHung {
946                        target_id: Some(target_id),
947                        url: None,
948                        timeout_ms: 100,
949                        hint: "test",
950                    }
951                    .into(),
952                )
953            })
954            .await
955            .expect_err("must escalate");
956        assert!(matches!(
957            err.downcast_ref::<SessionError>(),
958            Some(SessionError::TabHung { .. })
959        ));
960    }
961}