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        return Ok(Some(row));
161    }
162    // The handle may be stale rather than the tab gone — see relocate_by_url.
163    if let Some(repaired) = relocate_by_url(backend, registry, browser_name, name, &row).await? {
164        return Ok(Some(repaired));
165    }
166    registry.tab_delete(browser_name, name)?;
167    Ok(None)
168}
169
170/// Re-find a named tab whose stored `target_id` no longer exists, and repair
171/// the row.
172///
173/// Firefox regenerates browsing-context ids for **every BiDi session**.
174/// Measured: the same four tabs report entirely different ids in two
175/// consecutive `session.new` connections. Because each CLI invocation is its
176/// own process and therefore its own session, a `target_id` persisted by one
177/// command is always dead to the next — so on Firefox a named tab was
178/// unusable the moment it was created. CDP has no such problem: a `targetId`
179/// is stable for the life of the tab.
180///
181/// The tab itself is still there; only its handle changed. The last URL we
182/// navigated it to is the identity that survives, so match on that and write
183/// the fresh id back.
184///
185/// Limits, both preferable to the row being deleted: if two tabs share a URL
186/// the first is taken, and if the page navigated itself since we last looked
187/// the URL is stale and no match is found — which lands on exactly the
188/// "tab not found" behaviour that existed before.
189async fn relocate_by_url(
190    backend: &TabBackend,
191    registry: &Registry,
192    browser_name: &str,
193    name: &str,
194    row: &TabRow,
195) -> Result<Option<TabRow>> {
196    if !backend.ids_are_session_scoped() || row.last_url.is_empty() {
197        return Ok(None);
198    }
199    let Some(hit) = backend
200        .live_targets()
201        .await?
202        .into_iter()
203        .find(|t| t.url == row.last_url)
204    else {
205        return Ok(None);
206    };
207    registry.tab_upsert(
208        browser_name,
209        name,
210        &hit.id,
211        &row.last_url,
212        row.daemon_created,
213    )?;
214    registry.tab_get(browser_name, name)
215}
216
217async fn target_alive(backend: &TabBackend, target_id: &str) -> Result<bool> {
218    let live = backend.live_target_ids().await?;
219    Ok(live.contains(target_id))
220}
221
222/// Run `op` against the named tab `<browser>/<name>` with one round of
223/// recover-and-retry on tab failures. Mirrors [`crate::session::with_scratch_recovery`]
224/// but for the agent-owned named-tab path.
225///
226/// Semantics, in order:
227///
228/// 1. Resolve the row via [`resolve_tab`]. If the row is missing or its
229///    `target_id` is stale, surface a typed `SessionError::TabNotFound` —
230///    we do NOT auto-create a tab the agent never asked for.
231/// 2. Run `op` against the live `target_id`.
232/// 3. If `op` returns a recoverable failure (`TabHung`, `TabCrashed`, or a
233///    CDP/BiDi "no target / no context" protocol error), the tab died
234///    between resolve and op. For daemon-created rows, close the failed
235///    target so repeated recovery cannot orphan unbounded browser tabs.
236///    User-adopted tabs are left alone because closing a tab the user
237///    explicitly adopted would be surprising. Create a fresh tab, navigate
238///    it to the row's `last_url` (best-effort; falls back to `about:blank`
239///    if the rehydration navigation itself fails), and re-point the registry
240///    row at it under the **same name**, then retry `op` once.
241/// 4. If the retry also fails, escalate the typed error to the caller.
242pub async fn with_named_tab_recovery<F, T, Fut>(
243    backend: &TabBackend,
244    registry: &Registry,
245    browser_name: &str,
246    tab_name: &str,
247    mut op: F,
248) -> Result<T>
249where
250    F: FnMut(TabBackend, String) -> Fut,
251    Fut: std::future::Future<Output = Result<T>>,
252{
253    // Step 1: resolve. `resolve_tab` already sweeps stale rows internally
254    // (deletes the row if its target_id no longer exists in the browser).
255    let row = match resolve_tab(backend, registry, browser_name, tab_name).await? {
256        Some(r) => r,
257        None => {
258            return Err(SessionError::TabNotFound {
259                browser: browser_name.to_string(),
260                name: tab_name.to_string(),
261            }
262            .into());
263        }
264    };
265
266    // Step 2: first attempt.
267    // The `Ok(value)` branch returns early; the failure branch falls
268    // through to attempt 2. Clippy reads the early return as "needless"
269    // because the failure branch also has `return Err(e)` — but
270    // restructuring (e.g. via `if let`) makes the recover-once
271    // contract less obvious.
272    #[allow(clippy::needless_return)]
273    match op(backend.clone(), row.target_id.clone()).await {
274        Ok(value) => return Ok(value),
275        Err(e) if is_tab_failure(&e) => {
276            // Step 3: recover. Tab died between resolve and op.
277            //
278            // Daemon-created tabs are owned by browser-control, so close
279            // the failed target before replacing it. Leaving these targets
280            // around creates unbounded orphan tabs during repeated
281            // recoveries, and the registry LRU cannot see them once the row
282            // is re-pointed. User-adopted tabs are not closed here because
283            // they were not created by the daemon.
284            if row.daemon_created {
285                let _ = backend.close_tab(&row.target_id).await;
286            }
287            // The registry row gets re-pointed at a fresh tab under the
288            // same name — addressing-by-name now resolves to the new live
289            // tab.
290            //
291            // Rehydrate `last_url`: if the dead tab was at a real URL,
292            // navigate the new tab there before retrying so the agent
293            // sees its addressable state preserved. Best-effort — if
294            // navigation itself fails (origin gone, network down) we
295            // fall back to blank rather than block recovery.
296            let rehydrate_url = if row.last_url.is_empty() || row.last_url == "about:blank" {
297                "about:blank".to_string()
298            } else {
299                row.last_url.clone()
300            };
301            let new_target_id = backend.create_tab("about:blank").await?;
302            let (stored_url, ready_target) = if rehydrate_url != "about:blank" {
303                match backend.navigate(&new_target_id, &rehydrate_url).await {
304                    Ok(()) => (rehydrate_url, new_target_id),
305                    Err(nav_err) => {
306                        tracing::warn!(
307                            target = "session::tabs",
308                            "rehydrating {browser_name}/{tab_name} to {rehydrate_url} failed: {nav_err:#}; falling back to about:blank"
309                        );
310                        ("about:blank".to_string(), new_target_id)
311                    }
312                }
313            } else {
314                ("about:blank".to_string(), new_target_id)
315            };
316            registry.tab_upsert(browser_name, tab_name, &ready_target, &stored_url, true)?;
317            // Step 4: retry once.
318            op(backend.clone(), ready_target).await
319        }
320        Err(e) => Err(e),
321    }
322}
323
324/// Does this error suggest the named tab is dead and we should retry on
325/// a fresh one? Delegates to the shared classifier in `errors` so the
326/// scratch / named-tab / origin-bound recovery wrappers can never drift.
327fn is_tab_failure(err: &anyhow::Error) -> bool {
328    crate::errors::is_recoverable_tab_failure(err)
329}
330
331fn fresh_cute_name(registry: &Registry, browser_name: &str) -> Result<String> {
332    let mut rng = rand::thread_rng();
333    for _ in 0..20 {
334        let word = WORDS[rng.gen_range(0..WORDS.len())];
335        let base = format!("tab-{word}");
336        if registry.tab_get(browser_name, &base)?.is_none() {
337            return Ok(base);
338        }
339        for n in 2..=1000 {
340            let candidate = format!("tab-{word}-{n}");
341            if registry.tab_get(browser_name, &candidate)?.is_none() {
342                return Ok(candidate);
343            }
344        }
345    }
346    Err(anyhow!(
347        "failed to generate a unique tab name after 20 attempts"
348    ))
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::cdp::CdpClient;
355    use crate::detect::Engine;
356    use futures_util::{SinkExt, StreamExt};
357    use serde_json::{json, Value};
358    use std::sync::Arc;
359    use tokio::sync::oneshot;
360    use tokio_tungstenite::tungstenite::Message;
361
362    /// Build a CDP TabBackend backed by `spawn_mock`. Each test holds the
363    /// returned `_stop` to keep the mock alive.
364    async fn cdp_backend() -> (TabBackend, oneshot::Sender<()>) {
365        let (url, stop) = spawn_mock().await;
366        let client = Arc::new(CdpClient::connect(&url).await.unwrap());
367        (TabBackend::Cdp(client), stop)
368    }
369
370    /// Build a BiDi TabBackend backed by `spawn_bidi_mock`. Mirror of
371    /// `cdp_backend` so the same test logic exercises both engines.
372    async fn bidi_backend() -> (TabBackend, oneshot::Sender<()>) {
373        let (url, stop) = spawn_bidi_mock().await;
374        let backend = crate::session::backend::open_backend(&url, Engine::Bidi)
375            .await
376            .unwrap();
377        (backend, stop)
378    }
379
380    /// Mock CDP server backing tabs tests. Tracks created targets,
381    /// supports closing, attach/navigate/detach.
382    async fn spawn_mock() -> (String, oneshot::Sender<()>) {
383        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
384        let addr = listener.local_addr().unwrap();
385        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
386        tokio::spawn(async move {
387            let (stream, _) = listener.accept().await.unwrap();
388            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
389            let mut next_target = 0u32;
390            let mut next_session = 0u32;
391            // We track which target ids are currently alive so
392            // Target.getTargets responses are correct.
393            let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
394            loop {
395                tokio::select! {
396                    _ = &mut stop_rx => break,
397                    msg = ws.next() => {
398                        let msg = match msg {
399                            Some(Ok(m)) => m,
400                            _ => break,
401                        };
402                        if let Message::Text(t) = msg {
403                            let req: Value = serde_json::from_str(&t).unwrap();
404                            let id = req["id"].as_u64().unwrap();
405                            let method = req["method"].as_str().unwrap_or("");
406                            let result = match method {
407                                "Target.createTarget" => {
408                                    next_target += 1;
409                                    let tid = format!("T{next_target}");
410                                    live.insert(tid.clone());
411                                    json!({"targetId": tid})
412                                }
413                                "Target.closeTarget" => {
414                                    if let Some(tid) = req
415                                        .pointer("/params/targetId")
416                                        .and_then(|v| v.as_str())
417                                    {
418                                        live.remove(tid);
419                                    }
420                                    json!({"success": true})
421                                }
422                                "Target.attachToTarget" => {
423                                    next_session += 1;
424                                    json!({"sessionId": format!("S{next_session}")})
425                                }
426                                "Target.detachFromTarget" => json!({}),
427                                "Page.navigate" => json!({}),
428                                "Target.getTargets" => {
429                                    let infos: Vec<Value> = live
430                                        .iter()
431                                        .map(|tid| {
432                                            json!({"targetId": tid, "type": "page", "url": ""})
433                                        })
434                                        .collect();
435                                    json!({"targetInfos": infos})
436                                }
437                                _ => json!({}),
438                            };
439                            let resp = json!({"id": id, "result": result});
440                            ws.send(Message::Text(resp.to_string())).await.unwrap();
441                        }
442                    }
443                }
444            }
445        });
446        (format!("ws://{addr}"), stop_tx)
447    }
448
449    /// Mock BiDi server for parallel-engine tests. Mirrors the shape of
450    /// `spawn_mock` (CDP), tracking live contexts so getTree responses
451    /// stay accurate after create/close.
452    async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
453        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
454        let addr = listener.local_addr().unwrap();
455        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
456        tokio::spawn(async move {
457            let (stream, _) = listener.accept().await.unwrap();
458            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
459            let mut next_ctx = 0u32;
460            // context -> url. The real Firefox reports a URL per context in
461            // getTree, and the tab registry now relies on it to re-find a tab
462            // whose id was regenerated, so the mock has to track it too.
463            let mut live = std::collections::BTreeMap::<String, String>::new();
464            loop {
465                tokio::select! {
466                    _ = &mut stop_rx => break,
467                    msg = ws.next() => {
468                        let msg = match msg {
469                            Some(Ok(m)) => m,
470                            _ => break,
471                        };
472                        if let Message::Text(t) = msg {
473                            let req: Value = serde_json::from_str(&t).unwrap();
474                            let id = req["id"].as_u64().unwrap();
475                            let method = req["method"].as_str().unwrap_or("");
476                            let result = match method {
477                                "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
478                                "browsingContext.create" => {
479                                    next_ctx += 1;
480                                    let c = format!("C{next_ctx}");
481                                    live.insert(c.clone(), String::from("about:blank"));
482                                    json!({"context": c})
483                                }
484                                "browsingContext.close" => {
485                                    if let Some(c) = req
486                                        .pointer("/params/context")
487                                        .and_then(|v| v.as_str())
488                                    {
489                                        live.remove(c);
490                                    }
491                                    json!({})
492                                }
493                                "browsingContext.navigate" => {
494                                    if let (Some(c), Some(u)) = (
495                                        req.pointer("/params/context").and_then(|v| v.as_str()),
496                                        req.pointer("/params/url").and_then(|v| v.as_str()),
497                                    ) {
498                                        live.insert(c.to_string(), u.to_string());
499                                    }
500                                    json!({"navigation": "N1"})
501                                }
502                                "browsingContext.getTree" => {
503                                    let contexts: Vec<Value> = live
504                                        .iter()
505                                        .map(|(c, u)| {
506                                            json!({"context": c, "url": u, "children": []})
507                                        })
508                                        .collect();
509                                    json!({"contexts": contexts})
510                                }
511                                _ => json!({}),
512                            };
513                            let resp = json!({"type": "success", "id": id, "result": result});
514                            ws.send(Message::Text(resp.to_string())).await.unwrap();
515                        }
516                    }
517                }
518            }
519        });
520        (format!("ws://{addr}"), stop_tx)
521    }
522
523    // ---- CDP-engine tests ------------------------------------------------
524
525    #[tokio::test]
526    async fn open_without_name_assigns_cute_name_cdp() {
527        let (backend, _stop) = cdp_backend().await;
528        let reg = Registry::open_in_memory().unwrap();
529        let row = tab_open(&backend, &reg, "brave", None, None).await.unwrap();
530        assert!(row.name.starts_with("tab-"));
531        assert_eq!(row.target_id, "T1");
532        assert!(row.daemon_created);
533        assert_eq!(row.last_url, "about:blank");
534    }
535
536    #[tokio::test]
537    async fn open_with_name_is_idempotent_cdp() {
538        let (backend, _stop) = cdp_backend().await;
539        let reg = Registry::open_in_memory().unwrap();
540        let a = tab_open(&backend, &reg, "b", Some("scrape"), None)
541            .await
542            .unwrap();
543        let b = tab_open(&backend, &reg, "b", Some("scrape"), None)
544            .await
545            .unwrap();
546        assert_eq!(a.target_id, b.target_id);
547        assert_eq!(a.name, b.name);
548    }
549
550    #[tokio::test]
551    async fn open_with_mismatched_url_navigates_cdp() {
552        let (backend, _stop) = cdp_backend().await;
553        let reg = Registry::open_in_memory().unwrap();
554        let a = tab_open(&backend, &reg, "b", Some("nav"), Some("https://a"))
555            .await
556            .unwrap();
557        let b = tab_open(&backend, &reg, "b", Some("nav"), Some("https://b"))
558            .await
559            .unwrap();
560        assert_eq!(a.target_id, b.target_id, "same target across nav");
561        assert_eq!(b.last_url, "https://b");
562    }
563
564    #[tokio::test]
565    async fn open_with_stale_target_recreates_cdp() {
566        let (backend, _stop) = cdp_backend().await;
567        let reg = Registry::open_in_memory().unwrap();
568        reg.tab_upsert("b", "ghost", "T999", "about:blank", true)
569            .unwrap();
570        let row = tab_open(&backend, &reg, "b", Some("ghost"), None)
571            .await
572            .unwrap();
573        assert_ne!(row.target_id, "T999", "stale target was recreated");
574        assert_eq!(row.name, "ghost", "same name preserved");
575    }
576
577    #[tokio::test]
578    async fn list_sweeps_stale_rows_cdp() {
579        let (backend, _stop) = cdp_backend().await;
580        let reg = Registry::open_in_memory().unwrap();
581        let _ = tab_open(&backend, &reg, "b", Some("live"), None)
582            .await
583            .unwrap();
584        reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
585        let rows = tab_list(&backend, &reg, "b").await.unwrap();
586        let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
587        assert!(names.contains(&"live"));
588        assert!(!names.contains(&"ghost"));
589        assert!(reg.tab_get("b", "ghost").unwrap().is_none());
590    }
591
592    #[tokio::test]
593    async fn resolve_returns_none_for_missing_and_stale_cdp() {
594        let (backend, _stop) = cdp_backend().await;
595        let reg = Registry::open_in_memory().unwrap();
596        assert!(resolve_tab(&backend, &reg, "b", "nope")
597            .await
598            .unwrap()
599            .is_none());
600        reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
601        assert!(resolve_tab(&backend, &reg, "b", "ghost")
602            .await
603            .unwrap()
604            .is_none());
605        assert!(reg.tab_get("b", "ghost").unwrap().is_none(), "swept");
606    }
607
608    #[tokio::test]
609    async fn resolve_returns_alive_row_and_touches_cdp() {
610        let (backend, _stop) = cdp_backend().await;
611        let reg = Registry::open_in_memory().unwrap();
612        let opened = tab_open(&backend, &reg, "b", Some("hot"), None)
613            .await
614            .unwrap();
615        let resolved = resolve_tab(&backend, &reg, "b", "hot")
616            .await
617            .unwrap()
618            .unwrap();
619        assert_eq!(resolved.target_id, opened.target_id);
620    }
621
622    #[tokio::test]
623    async fn budget_pressure_picks_lru_daemon_row() {
624        // Verifies the SQL helper that the create path uses. End-to-end
625        // budget-pressure exercise would need HARD_CAP+1 tabs and is
626        // covered by registry::tabs::tests instead.
627        let reg = Registry::open_in_memory().unwrap();
628        reg.tab_upsert("b", "old", "T-OLD", "", true).unwrap();
629        std::thread::sleep(std::time::Duration::from_millis(1100));
630        reg.tab_upsert("b", "new", "T-NEW", "", true).unwrap();
631        let lru = reg.tabs_lru_daemon_created("b").unwrap().unwrap();
632        assert_eq!(lru.name, "old");
633    }
634
635    // ---- BiDi-engine tests (same behaviour, different protocol) ---------
636
637    #[tokio::test]
638    async fn open_without_name_assigns_cute_name_bidi() {
639        let (backend, _stop) = bidi_backend().await;
640        let reg = Registry::open_in_memory().unwrap();
641        let row = tab_open(&backend, &reg, "ff", None, None).await.unwrap();
642        assert!(row.name.starts_with("tab-"));
643        assert_eq!(row.target_id, "C1");
644        assert!(row.daemon_created);
645    }
646
647    #[tokio::test]
648    async fn open_with_name_is_idempotent_bidi() {
649        let (backend, _stop) = bidi_backend().await;
650        let reg = Registry::open_in_memory().unwrap();
651        let a = tab_open(&backend, &reg, "ff", Some("scrape"), None)
652            .await
653            .unwrap();
654        let b = tab_open(&backend, &reg, "ff", Some("scrape"), None)
655            .await
656            .unwrap();
657        assert_eq!(a.target_id, b.target_id);
658    }
659
660    #[tokio::test]
661    async fn open_with_mismatched_url_navigates_bidi() {
662        let (backend, _stop) = bidi_backend().await;
663        let reg = Registry::open_in_memory().unwrap();
664        let a = tab_open(&backend, &reg, "ff", Some("nav"), Some("https://a"))
665            .await
666            .unwrap();
667        let b = tab_open(&backend, &reg, "ff", Some("nav"), Some("https://b"))
668            .await
669            .unwrap();
670        assert_eq!(a.target_id, b.target_id);
671        assert_eq!(b.last_url, "https://b");
672    }
673
674    #[tokio::test]
675    async fn open_with_stale_target_recreates_bidi() {
676        let (backend, _stop) = bidi_backend().await;
677        let reg = Registry::open_in_memory().unwrap();
678        reg.tab_upsert("ff", "ghost", "C999", "", true).unwrap();
679        let row = tab_open(&backend, &reg, "ff", Some("ghost"), None)
680            .await
681            .unwrap();
682        assert_ne!(row.target_id, "C999");
683        assert_eq!(row.name, "ghost");
684    }
685
686    #[tokio::test]
687    async fn list_sweeps_stale_rows_bidi() {
688        let (backend, _stop) = bidi_backend().await;
689        let reg = Registry::open_in_memory().unwrap();
690        let _ = tab_open(&backend, &reg, "ff", Some("live"), None)
691            .await
692            .unwrap();
693        reg.tab_upsert("ff", "ghost", "C999", "", true).unwrap();
694        let rows = tab_list(&backend, &reg, "ff").await.unwrap();
695        let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
696        assert!(names.contains(&"live"));
697        assert!(!names.contains(&"ghost"));
698    }
699
700    #[tokio::test]
701    async fn resolve_returns_alive_row_and_touches_bidi() {
702        let (backend, _stop) = bidi_backend().await;
703        let reg = Registry::open_in_memory().unwrap();
704        let opened = tab_open(&backend, &reg, "ff", Some("hot"), None)
705            .await
706            .unwrap();
707        let resolved = resolve_tab(&backend, &reg, "ff", "hot")
708            .await
709            .unwrap()
710            .unwrap();
711        assert_eq!(resolved.target_id, opened.target_id);
712    }
713
714    // ---- with_named_tab_recovery -----------------------------------------
715
716    use crate::errors::SessionError;
717
718    /// Missing row → typed `TabNotFound`.
719    #[tokio::test]
720    async fn recover_missing_row_returns_tab_not_found() {
721        let (backend, _stop) = cdp_backend().await;
722        let reg = Registry::open_in_memory().unwrap();
723        let err = with_named_tab_recovery(&backend, &reg, "b", "nope", |_, _| async {
724            Ok::<_, anyhow::Error>(serde_json::json!(null))
725        })
726        .await
727        .expect_err("must error");
728        let typed = err
729            .downcast_ref::<SessionError>()
730            .expect("typed SessionError");
731        match typed {
732            SessionError::TabNotFound { browser, name } => {
733                assert_eq!(browser, "b");
734                assert_eq!(name, "nope");
735            }
736            other => panic!("expected TabNotFound, got {other:?}"),
737        }
738    }
739
740    /// Stale row whose target_id is gone → resolve_tab sweeps it →
741    /// also `TabNotFound` (not silent recreate — the agent never asked
742    /// for the recreate at resolve time).
743    #[tokio::test]
744    async fn recover_stale_row_returns_tab_not_found_after_sweep() {
745        let (backend, _stop) = cdp_backend().await;
746        let reg = Registry::open_in_memory().unwrap();
747        reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
748        let err = with_named_tab_recovery(&backend, &reg, "b", "ghost", |_, _| async {
749            Ok::<_, anyhow::Error>(serde_json::json!(null))
750        })
751        .await
752        .expect_err("must error after sweep");
753        assert!(matches!(
754            err.downcast_ref::<SessionError>(),
755            Some(SessionError::TabNotFound { .. })
756        ));
757        assert!(reg.tab_get("b", "ghost").unwrap().is_none(), "swept");
758    }
759
760    /// First op call wedges (returns `TabHung`); wrapper closes the failed
761    /// daemon-created tab, recreates a fresh blank under the same name, and
762    /// retries. Caller sees the retry value.
763    #[tokio::test]
764    async fn recover_after_op_returns_tab_hung() {
765        let (backend, _stop) = cdp_backend().await;
766        let reg = Registry::open_in_memory().unwrap();
767        let opened = tab_open(&backend, &reg, "b", Some("flaky"), None)
768            .await
769            .unwrap();
770        let original_target = opened.target_id.clone();
771
772        // Op that returns TabHung the first call, ok the second.
773        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
774        let calls_clone = calls.clone();
775        let result = with_named_tab_recovery(&backend, &reg, "b", "flaky", move |_, target_id| {
776            let calls = calls_clone.clone();
777            async move {
778                let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
779                if n == 0 {
780                    Err(SessionError::TabHung {
781                        target_id: Some(target_id.clone()),
782                        url: None,
783                        timeout_ms: 100,
784                        hint: "test",
785                    }
786                    .into())
787                } else {
788                    Ok::<_, anyhow::Error>(serde_json::json!(format!("ok:{target_id}")))
789                }
790            }
791        })
792        .await
793        .expect("recover succeeded");
794
795        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
796        // The row now points at the fresh target, not the dead one.
797        let row = reg.tab_get("b", "flaky").unwrap().unwrap();
798        assert_ne!(
799            row.target_id, original_target,
800            "row updated to fresh target after recovery"
801        );
802        assert_eq!(row.last_url, "about:blank", "recovered tab is blank");
803        // The op was called with the fresh target on the second attempt.
804        assert_eq!(result, serde_json::json!(format!("ok:{}", row.target_id)));
805    }
806
807    /// Recovery closes daemon-created failed tabs before replacing the row.
808    /// Otherwise repeated recoveries orphan live targets that the registry
809    /// cap cannot see once the row has been re-pointed.
810    #[tokio::test]
811    async fn recovery_closes_daemon_named_tab_in_browser() {
812        let (backend, _stop) = cdp_backend().await;
813        let reg = Registry::open_in_memory().unwrap();
814        let opened = tab_open(&backend, &reg, "b", Some("doomed"), None)
815            .await
816            .unwrap();
817        let original_target = opened.target_id.clone();
818
819        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
820        let calls_clone = calls.clone();
821        let _ = with_named_tab_recovery(&backend, &reg, "b", "doomed", move |_, target_id| {
822            let calls = calls_clone.clone();
823            async move {
824                let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
825                if n == 0 {
826                    Err(SessionError::TabHung {
827                        target_id: Some(target_id),
828                        url: None,
829                        timeout_ms: 100,
830                        hint: "test",
831                    }
832                    .into())
833                } else {
834                    Ok::<_, anyhow::Error>(serde_json::json!("ok"))
835                }
836            }
837        })
838        .await
839        .expect("recover succeeded");
840
841        // One live target after recovery: the fresh replacement. The failed
842        // daemon-created target was closed.
843        let live = backend.live_target_ids().await.unwrap();
844        assert!(
845            !live.contains(&original_target),
846            "daemon-created failed tab must be closed; live = {live:?}, original = {original_target}"
847        );
848        assert_eq!(
849            live.len(),
850            1,
851            "expected only fresh replacement; got {live:?}"
852        );
853
854        // The registry row points at the fresh tab, not the dead one.
855        let row = reg.tab_get("b", "doomed").unwrap().unwrap();
856        assert_ne!(row.target_id, original_target);
857    }
858
859    /// Adopted tabs are user-owned. Recovery still re-points the name to a
860    /// fresh daemon-created replacement, but it must not close the original
861    /// user tab.
862    #[tokio::test]
863    async fn recovery_leaves_user_adopted_tab_in_browser() {
864        let (backend, _stop) = cdp_backend().await;
865        let reg = Registry::open_in_memory().unwrap();
866        let original_target = backend.create_tab("https://example.com/app").await.unwrap();
867        reg.tab_upsert(
868            "b",
869            "adopted",
870            &original_target,
871            "https://example.com/app",
872            false,
873        )
874        .unwrap();
875
876        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
877        let calls_clone = calls.clone();
878        let _ = with_named_tab_recovery(&backend, &reg, "b", "adopted", move |_, target_id| {
879            let calls = calls_clone.clone();
880            async move {
881                let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
882                if n == 0 {
883                    Err(SessionError::TabHung {
884                        target_id: Some(target_id),
885                        url: None,
886                        timeout_ms: 100,
887                        hint: "test",
888                    }
889                    .into())
890                } else {
891                    Ok::<_, anyhow::Error>(serde_json::json!("ok"))
892                }
893            }
894        })
895        .await
896        .expect("recover succeeded");
897
898        let live = backend.live_target_ids().await.unwrap();
899        assert!(
900            live.contains(&original_target),
901            "adopted user tab must not be closed; live = {live:?}, original = {original_target}"
902        );
903        assert_eq!(live.len(), 2, "expected user tab + fresh replacement");
904
905        let row = reg.tab_get("b", "adopted").unwrap().unwrap();
906        assert_ne!(row.target_id, original_target);
907        assert!(row.daemon_created, "replacement is daemon-owned");
908    }
909
910    /// `is_tab_failure` matches the typed `TargetGone` variant first
911    /// (primary path) and falls back to substring matching for
912    /// un-classified raw errors.
913    #[test]
914    fn is_tab_failure_recognizes_typed_target_gone() {
915        use crate::errors::TargetKind;
916        let typed: anyhow::Error = SessionError::TargetGone {
917            kind: TargetKind::Cdp,
918            details: "CDP error -32000: target closed".into(),
919        }
920        .into();
921        assert!(is_tab_failure(&typed));
922
923        let typed_bidi: anyhow::Error = SessionError::TargetGone {
924            kind: TargetKind::Bidi,
925            details: "BiDi error no such frame: C1".into(),
926        }
927        .into();
928        assert!(is_tab_failure(&typed_bidi));
929
930        let hung: anyhow::Error = SessionError::TabHung {
931            target_id: None,
932            url: None,
933            timeout_ms: 100,
934            hint: "t",
935        }
936        .into();
937        assert!(is_tab_failure(&hung));
938
939        let raw: anyhow::Error = anyhow::anyhow!("Target closed");
940        assert!(is_tab_failure(&raw));
941
942        let unrelated: anyhow::Error = anyhow::anyhow!("dns failure");
943        assert!(!is_tab_failure(&unrelated));
944    }
945
946    /// Recovery rehydrates the dead tab's `last_url` onto the fresh tab
947    /// instead of dropping the agent back to `about:blank`. The agent
948    /// addresses by name and expects the name to point at the same URL
949    /// after a transient renderer failure.
950    #[tokio::test]
951    async fn recover_rehydrates_last_url_onto_fresh_tab() {
952        let (backend, _stop) = cdp_backend().await;
953        let reg = Registry::open_in_memory().unwrap();
954        // Seed a row whose last_url is a real URL (not about:blank).
955        let opened = tab_open(
956            &backend,
957            &reg,
958            "b",
959            Some("pinned"),
960            Some("https://example.com/app"),
961        )
962        .await
963        .unwrap();
964        let original_target = opened.target_id.clone();
965        assert_eq!(opened.last_url, "https://example.com/app");
966
967        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
968        let calls_clone = calls.clone();
969        let _ = with_named_tab_recovery(&backend, &reg, "b", "pinned", move |_, target_id| {
970            let calls = calls_clone.clone();
971            async move {
972                let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
973                if n == 0 {
974                    Err(SessionError::TabHung {
975                        target_id: Some(target_id),
976                        url: None,
977                        timeout_ms: 100,
978                        hint: "test",
979                    }
980                    .into())
981                } else {
982                    Ok::<_, anyhow::Error>(serde_json::json!("ok"))
983                }
984            }
985        })
986        .await
987        .expect("recover succeeded");
988
989        let row = reg.tab_get("b", "pinned").unwrap().unwrap();
990        assert_ne!(row.target_id, original_target, "row points at fresh tab");
991        assert_eq!(
992            row.last_url, "https://example.com/app",
993            "last_url rehydrated on recovery instead of falling back to about:blank"
994        );
995    }
996
997    /// Both attempts return `TabHung` → escalate to the caller.
998    #[tokio::test]
999    async fn recover_escalates_when_retry_also_fails() {
1000        let (backend, _stop) = cdp_backend().await;
1001        let reg = Registry::open_in_memory().unwrap();
1002        tab_open(&backend, &reg, "b", Some("doomed"), None)
1003            .await
1004            .unwrap();
1005        let err =
1006            with_named_tab_recovery(&backend, &reg, "b", "doomed", |_, target_id| async move {
1007                Err::<serde_json::Value, _>(
1008                    SessionError::TabHung {
1009                        target_id: Some(target_id),
1010                        url: None,
1011                        timeout_ms: 100,
1012                        hint: "test",
1013                    }
1014                    .into(),
1015                )
1016            })
1017            .await
1018            .expect_err("must escalate");
1019        assert!(matches!(
1020            err.downcast_ref::<SessionError>(),
1021            Some(SessionError::TabHung { .. })
1022        ));
1023    }
1024    // ---- session-scoped ids (Firefox) ------------------------------------
1025
1026    #[tokio::test]
1027    async fn cdp_ids_are_not_session_scoped_but_bidi_ids_are() {
1028        let (cdp, _a) = cdp_backend().await;
1029        let (bidi, _b) = bidi_backend().await;
1030        assert!(!cdp.ids_are_session_scoped());
1031        assert!(bidi.ids_are_session_scoped());
1032    }
1033
1034    #[tokio::test]
1035    async fn a_regenerated_bidi_id_relocates_by_url_instead_of_dropping_the_row() {
1036        // Firefox mints new browsing-context ids for every BiDi session, so a
1037        // stored id is always dead to the next process. The tab is still
1038        // there; only the handle changed.
1039        let (backend, _stop) = bidi_backend().await;
1040        let reg = Registry::open_in_memory().unwrap();
1041        let row = tab_open(&backend, &reg, "ff", Some("nt"), Some("https://x"))
1042            .await
1043            .unwrap();
1044
1045        // Simulate the next CLI process: same tab, different id.
1046        reg.tab_upsert(
1047            "ff",
1048            "nt",
1049            "stale-id-from-a-dead-session",
1050            &row.last_url,
1051            true,
1052        )
1053        .unwrap();
1054
1055        let resolved = resolve_tab(&backend, &reg, "ff", "nt").await.unwrap();
1056        let resolved = resolved.expect("named tab must survive an id regeneration");
1057        assert_eq!(resolved.target_id, row.target_id, "row should be repaired");
1058        assert_eq!(resolved.last_url, "https://x");
1059    }
1060
1061    #[tokio::test]
1062    async fn relocation_does_not_resurrect_a_tab_that_is_really_gone() {
1063        let (backend, _stop) = bidi_backend().await;
1064        let reg = Registry::open_in_memory().unwrap();
1065        // A row whose URL matches no live context: the tab was genuinely closed.
1066        reg.tab_upsert("ff", "gone", "stale", "https://nowhere", true)
1067            .unwrap();
1068        let resolved = resolve_tab(&backend, &reg, "ff", "gone").await.unwrap();
1069        assert!(resolved.is_none(), "must not invent a tab");
1070        assert!(reg.tab_get("ff", "gone").unwrap().is_none(), "row swept");
1071    }
1072
1073    #[tokio::test]
1074    async fn a_stale_cdp_row_is_still_dropped_not_relocated() {
1075        // CDP ids are stable, so a missing id means the tab really is gone.
1076        // Relocating there would silently retarget a different tab.
1077        let (backend, _stop) = cdp_backend().await;
1078        let reg = Registry::open_in_memory().unwrap();
1079        let row = tab_open(&backend, &reg, "brave", Some("nt"), Some("https://x"))
1080            .await
1081            .unwrap();
1082        reg.tab_upsert("brave", "nt", "T-does-not-exist", &row.last_url, true)
1083            .unwrap();
1084        let resolved = resolve_tab(&backend, &reg, "brave", "nt").await.unwrap();
1085        assert!(resolved.is_none());
1086    }
1087}