1use 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
31pub const HARD_CAP: usize = 50;
37
38pub 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 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 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 let _ = backend.close_tab(&existing.target_id).await;
98 registry.tab_delete(browser_name, requested_name)?;
99 }
100 }
101
102 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
121pub 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
145pub 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 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
170async 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
222pub 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 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 #[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 if row.daemon_created {
285 let _ = backend.close_tab(&row.target_id).await;
286 }
287 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 op(backend.clone(), ready_target).await
319 }
320 Err(e) => Err(e),
321 }
322}
323
324fn 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 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 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 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 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 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 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 #[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, ®, "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, ®, "b", Some("scrape"), None)
541 .await
542 .unwrap();
543 let b = tab_open(&backend, ®, "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, ®, "b", Some("nav"), Some("https://a"))
555 .await
556 .unwrap();
557 let b = tab_open(&backend, ®, "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, ®, "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, ®, "b", Some("live"), None)
582 .await
583 .unwrap();
584 reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
585 let rows = tab_list(&backend, ®, "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, ®, "b", "nope")
597 .await
598 .unwrap()
599 .is_none());
600 reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
601 assert!(resolve_tab(&backend, ®, "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, ®, "b", Some("hot"), None)
613 .await
614 .unwrap();
615 let resolved = resolve_tab(&backend, ®, "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 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 #[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, ®, "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, ®, "ff", Some("scrape"), None)
652 .await
653 .unwrap();
654 let b = tab_open(&backend, ®, "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, ®, "ff", Some("nav"), Some("https://a"))
665 .await
666 .unwrap();
667 let b = tab_open(&backend, ®, "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, ®, "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, ®, "ff", Some("live"), None)
691 .await
692 .unwrap();
693 reg.tab_upsert("ff", "ghost", "C999", "", true).unwrap();
694 let rows = tab_list(&backend, ®, "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, ®, "ff", Some("hot"), None)
705 .await
706 .unwrap();
707 let resolved = resolve_tab(&backend, ®, "ff", "hot")
708 .await
709 .unwrap()
710 .unwrap();
711 assert_eq!(resolved.target_id, opened.target_id);
712 }
713
714 use crate::errors::SessionError;
717
718 #[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, ®, "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 #[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, ®, "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 #[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, ®, "b", Some("flaky"), None)
768 .await
769 .unwrap();
770 let original_target = opened.target_id.clone();
771
772 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, ®, "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 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 assert_eq!(result, serde_json::json!(format!("ok:{}", row.target_id)));
805 }
806
807 #[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, ®, "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, ®, "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 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 let row = reg.tab_get("b", "doomed").unwrap().unwrap();
856 assert_ne!(row.target_id, original_target);
857 }
858
859 #[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, ®, "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 #[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 #[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 let opened = tab_open(
956 &backend,
957 ®,
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, ®, "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 #[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, ®, "b", Some("doomed"), None)
1003 .await
1004 .unwrap();
1005 let err =
1006 with_named_tab_recovery(&backend, ®, "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 #[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 let (backend, _stop) = bidi_backend().await;
1040 let reg = Registry::open_in_memory().unwrap();
1041 let row = tab_open(&backend, ®, "ff", Some("nt"), Some("https://x"))
1042 .await
1043 .unwrap();
1044
1045 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, ®, "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 reg.tab_upsert("ff", "gone", "stale", "https://nowhere", true)
1067 .unwrap();
1068 let resolved = resolve_tab(&backend, ®, "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 let (backend, _stop) = cdp_backend().await;
1078 let reg = Registry::open_in_memory().unwrap();
1079 let row = tab_open(&backend, ®, "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, ®, "brave", "nt").await.unwrap();
1085 assert!(resolved.is_none());
1086 }
1087}