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 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
172pub 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 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 #[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 if row.daemon_created {
235 let _ = backend.close_tab(&row.target_id).await;
236 }
237 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 op(backend.clone(), ready_target).await
269 }
270 Err(e) => Err(e),
271 }
272}
273
274fn 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 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 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 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 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 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 #[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, ®, "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, ®, "b", Some("scrape"), None)
478 .await
479 .unwrap();
480 let b = tab_open(&backend, ®, "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, ®, "b", Some("nav"), Some("https://a"))
492 .await
493 .unwrap();
494 let b = tab_open(&backend, ®, "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, ®, "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, ®, "b", Some("live"), None)
519 .await
520 .unwrap();
521 reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
522 let rows = tab_list(&backend, ®, "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, ®, "b", "nope")
534 .await
535 .unwrap()
536 .is_none());
537 reg.tab_upsert("b", "ghost", "T999", "", true).unwrap();
538 assert!(resolve_tab(&backend, ®, "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, ®, "b", Some("hot"), None)
550 .await
551 .unwrap();
552 let resolved = resolve_tab(&backend, ®, "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 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 #[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, ®, "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, ®, "ff", Some("scrape"), None)
589 .await
590 .unwrap();
591 let b = tab_open(&backend, ®, "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, ®, "ff", Some("nav"), Some("https://a"))
602 .await
603 .unwrap();
604 let b = tab_open(&backend, ®, "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, ®, "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, ®, "ff", Some("live"), None)
628 .await
629 .unwrap();
630 reg.tab_upsert("ff", "ghost", "C999", "", true).unwrap();
631 let rows = tab_list(&backend, ®, "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, ®, "ff", Some("hot"), None)
642 .await
643 .unwrap();
644 let resolved = resolve_tab(&backend, ®, "ff", "hot")
645 .await
646 .unwrap()
647 .unwrap();
648 assert_eq!(resolved.target_id, opened.target_id);
649 }
650
651 use crate::errors::SessionError;
654
655 #[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, ®, "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 #[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, ®, "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 #[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, ®, "b", Some("flaky"), None)
705 .await
706 .unwrap();
707 let original_target = opened.target_id.clone();
708
709 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, ®, "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 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 assert_eq!(result, serde_json::json!(format!("ok:{}", row.target_id)));
742 }
743
744 #[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, ®, "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, ®, "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 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 let row = reg.tab_get("b", "doomed").unwrap().unwrap();
793 assert_ne!(row.target_id, original_target);
794 }
795
796 #[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, ®, "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 #[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 #[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 let opened = tab_open(
893 &backend,
894 ®,
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, ®, "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 #[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, ®, "b", Some("doomed"), None)
940 .await
941 .unwrap();
942 let err =
943 with_named_tab_recovery(&backend, ®, "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}