//! M5.1 integration: spin up the minimal daemon + IPC, connect a fake //! extension over WebSocket, and exercise the session.start/stop round- //! trip through `IpcClient`. mod support; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use bsk::daemon::{self, DaemonConfig}; use bsk::ipc_client::IpcClient; use bsk_protocol::system::{HandshakeParams, HandshakeResult, StatusResult}; use bsk_protocol::tools::{SessionStartParams, SessionStartResult, SessionStopParams}; use bsk_protocol::{BrowserPeerInfo, Frame, Method, RequestFrame, ResponseBody, ResponseFrame}; use futures_util::{SinkExt, StreamExt}; use rand::Rng; use tokio_tungstenite::tungstenite::handshake::client::generate_key; use tokio_tungstenite::tungstenite::http::Request; use tokio_tungstenite::tungstenite::protocol::Message; use support::{wait_for_browser_count, wait_for_no_sessions, wait_for_session_count}; const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop"; fn tempfile_path(prefix: &str) -> PathBuf { let mut p = std::env::temp_dir(); let mut rng = rand::thread_rng(); let suffix: String = (0..8) .map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap()) .collect(); p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id())); p } async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { spawn_daemon_with_connect_wait(bsk::daemon::browsers::EXTENSION_CONNECT_WAIT).await } async fn spawn_daemon_with_connect_wait(connect_wait: Duration) -> (daemon::DaemonHandle, PathBuf) { let port = 0; let config = DaemonConfig::new(port).with_extension_connect_wait(connect_wait); let sock = tempfile_path("bsk-test-ipc"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); (handle, sock) } async fn spawn_daemon_with_session_idle(session_idle: Duration) -> (daemon::DaemonHandle, PathBuf) { let port = 0; let mut config = DaemonConfig::new(port); config.session_idle = session_idle; let sock = tempfile_path("bsk-test-ipc"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); (handle, sock) } async fn connect_ext( addr: std::net::SocketAddr, ) -> tokio_tungstenite::WebSocketStream> { let origin = format!("chrome-extension://{TEST_EXT_ID}"); let url = format!("ws://{addr}/"); let req = Request::builder() .method("GET") .uri(&url) .header("Host", addr.to_string()) .header("Upgrade", "websocket") .header("Connection", "Upgrade") .header("Sec-WebSocket-Version", "13") .header("Sec-WebSocket-Key", generate_key()) .header("Origin", origin) .body(()) .unwrap(); let (ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap(); ws } async fn handshake_as_ext( ws: &mut tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, ) -> HandshakeResult { let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_protocol: Some("1.0".into()), label: "Test".into(), }; let req = RequestFrame { id: "hs".into(), method: Method::SystemHandshake, params: Some(serde_json::to_value(params).unwrap()), }; ws.send(Message::Text(serde_json::to_string(&req).unwrap())) .await .unwrap(); let resp = ws.next().await.unwrap().unwrap(); let text = match resp { Message::Text(t) => t, _ => panic!(), }; let resp: ResponseFrame = serde_json::from_str(&text).unwrap(); match resp.body { ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(), ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"), } } #[tokio::test] async fn session_start_stop_round_trip_via_ipc() { let (handle, sock) = spawn_daemon().await; let mut ws = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut ws).await; // Spawn a fake extension responder that handles tool.session_start // and tool.session_stop. The Arc> is necessary because // tokio_tungstenite::WebSocketStream is !Sync but the outer task and // the IPC client thread both need to drive the same socket. let ws = Arc::new(tokio::sync::Mutex::new(ws)); let ws_clone = Arc::clone(&ws); let responder = tokio::spawn(async move { loop { let next = { let mut g = ws_clone.lock().await; g.next().await }; let msg = match next { Some(Ok(m)) => m, _ => break, }; let text = match msg { Message::Text(t) => t, Message::Close(_) => break, _ => continue, }; let frame: Frame = serde_json::from_str(&text).unwrap(); if let Frame::Request(req) = frame { let reply = match req.method { Method::ToolSessionStart => { let _: SessionStartParams = serde_json::from_value(req.params.clone().unwrap()).unwrap(); let result = SessionStartResult { agent_window_id: Some(4242), }; ResponseFrame { id: req.id, body: ResponseBody::Ok(serde_json::to_value(result).unwrap()), } } Method::ToolSessionStop => { let _: SessionStopParams = serde_json::from_value(req.params.clone().unwrap()).unwrap(); ResponseFrame { id: req.id, body: ResponseBody::Ok(serde_json::json!({})), } } _ => continue, }; let mut g = ws_clone.lock().await; g.send(Message::Text(serde_json::to_string(&reply).unwrap())) .await .unwrap(); } } }); let mut ipc = IpcClient::connect(&sock).await.unwrap(); #[derive(serde::Serialize)] struct StartParams { browser_instance_id: Option, } #[derive(serde::Deserialize, Debug)] struct StartReply { session_id: String, browser_instance_id: String, agent_window_id: Option, } let start: StartReply = ipc .call( "s-1", Method::SessionStart, Some(StartParams { browser_instance_id: None, }), Duration::from_secs(5), ) .await .unwrap() .expect("session.start returned error"); assert_eq!(start.session_id.len(), 4); assert!(start.session_id.chars().all(|c| c.is_ascii_lowercase())); assert_eq!(start.browser_instance_id, TEST_EXT_ID); assert_eq!(start.agent_window_id, Some(4242)); // status should reflect 1 session let status: StatusResult = ipc .call::<(), _>("s-2", Method::SystemStatus, None, Duration::from_secs(2)) .await .unwrap() .unwrap(); assert_eq!(status.sessions.len(), 1); assert_eq!(status.browsers[0].session_count, 1); #[derive(serde::Serialize)] struct StopParams { session_id: Option, all: bool, } #[derive(serde::Deserialize, Debug)] struct StopReply { stopped: Vec, } let stop: StopReply = ipc .call( "s-3", Method::SessionStop, Some(StopParams { session_id: Some(start.session_id.clone()), all: false, }), Duration::from_secs(5), ) .await .unwrap() .expect("session.stop returned error"); assert_eq!(stop.stopped, vec![start.session_id]); let status_after: StatusResult = ipc .call::<(), _>("s-4", Method::SystemStatus, None, Duration::from_secs(2)) .await .unwrap() .unwrap(); assert!(status_after.sessions.is_empty()); assert_eq!(status_after.browsers[0].session_count, 0); responder.abort(); handle.shutdown().await; } #[tokio::test] async fn session_idle_timeout_stops_and_unregisters_session() { let (handle, _sock) = spawn_daemon_with_session_idle(Duration::from_millis(50)).await; let mut ws = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut ws).await; let state = handle.state(); let session_id = bsk::daemon::sessions::SessionId("idle".into()); state.sessions.insert(bsk::daemon::sessions::Session { id: session_id.clone(), browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()), agent_window_id: Some(7), created_at_ms: 0, }); state.tool_queues.spawn(session_id); let request = tokio::time::timeout(Duration::from_secs(1), ws.next()) .await .expect("idle reaper did not contact extension") .expect("extension socket closed") .expect("extension socket failed"); let Message::Text(text) = request else { panic!("expected text request"); }; let request: RequestFrame = serde_json::from_str(&text).unwrap(); assert_eq!(request.method, Method::ToolSessionStop); ws.send(Message::Text( serde_json::to_string(&ResponseFrame { id: request.id, body: ResponseBody::Ok( serde_json::to_value(bsk_protocol::tools::SessionStopResult::default()).unwrap(), ), }) .unwrap(), )) .await .unwrap(); wait_for_no_sessions(&state).await; handle.shutdown().await; } #[tokio::test] async fn session_start_errors_without_browser() { let (handle, sock) = spawn_daemon_with_connect_wait(Duration::ZERO).await; let mut ipc = IpcClient::connect(&sock).await.unwrap(); let result: Result = ipc .call::<(), _>("s-1", Method::SessionStart, None, Duration::from_secs(2)) .await .unwrap(); let err = result.expect_err("expected no_browser_connected error"); assert_eq!(err.code, bsk_protocol::ErrorCode::NoBrowserConnected); handle.shutdown().await; } #[tokio::test] async fn session_start_waits_for_late_extension_handshake() { let (handle, sock) = spawn_daemon_with_connect_wait(Duration::from_millis(500)).await; let ws_addr = handle.ws_addr(); let sock_for_ipc = sock.clone(); let start_task = tokio::spawn(async move { let mut ipc = IpcClient::connect(&sock_for_ipc).await.unwrap(); ipc.call::<(), serde_json::Value>( "s-late", Method::SessionStart, None, Duration::from_secs(5), ) .await .unwrap() }); let ws = Arc::new(tokio::sync::Mutex::new(connect_ext(ws_addr).await)); let ws_clone = Arc::clone(&ws); let responder = tokio::spawn(async move { loop { let next = { let mut g = ws_clone.lock().await; g.next().await }; let msg = match next { Some(Ok(m)) => m, _ => break, }; let text = match msg { Message::Text(t) => t, Message::Close(_) => break, _ => continue, }; let frame: Frame = serde_json::from_str(&text).unwrap(); let Frame::Request(req) = frame else { continue; }; if req.method != Method::ToolSessionStart { continue; } let result = SessionStartResult { agent_window_id: Some(4242), }; let resp = Frame::Response(ResponseFrame { id: req.id, body: ResponseBody::Ok(serde_json::to_value(result).unwrap()), }); let mut g = ws_clone.lock().await; g.send(Message::Text(serde_json::to_string(&resp).unwrap())) .await .unwrap(); break; } }); { let mut g = ws.lock().await; let _ = handshake_as_ext(&mut g).await; } start_task .await .expect("join") .expect("session.start should succeed after extension connects"); responder.abort(); handle.shutdown().await; } /// Connect a second WebSocket extension with a distinct instance id / /// label / origin so the daemon's registry sees two browsers. async fn connect_second_ext( addr: std::net::SocketAddr, instance_id: &str, label: &str, ) -> tokio_tungstenite::WebSocketStream> { let url = format!("ws://{addr}/"); let req = Request::builder() .method("GET") .uri(&url) .header("Host", addr.to_string()) .header("Upgrade", "websocket") .header("Connection", "Upgrade") .header("Sec-WebSocket-Version", "13") .header("Sec-WebSocket-Key", generate_key()) .header( "Origin", "chrome-extension://abcdefghijklmnoppmnolkjihgfedcba".to_string(), ) .body(()) .unwrap(); let (mut b, _) = tokio_tungstenite::connect_async(req).await.unwrap(); let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), protocol_version: "1.0".into(), instance_id: instance_id.into(), browser: BrowserPeerInfo { name: "edge".into(), version: "130".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_protocol: Some("1.0".into()), label: label.into(), }; let hs = RequestFrame { id: "hs".into(), method: Method::SystemHandshake, params: Some(serde_json::to_value(params).unwrap()), }; b.send(Message::Text(serde_json::to_string(&hs).unwrap())) .await .unwrap(); let _ = b.next().await.unwrap(); b } #[tokio::test] async fn session_start_errors_with_multiple_browsers() { let (handle, sock) = spawn_daemon().await; let mut a = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut a).await; let _b = connect_second_ext(handle.ws_addr(), "second-browser", "Edge").await; let mut ipc = IpcClient::connect(&sock).await.unwrap(); let result: Result = ipc .call::<(), _>("s-1", Method::SessionStart, None, Duration::from_secs(2)) .await .unwrap(); let err = result.expect_err("expected multiple_browsers_online"); assert_eq!(err.code, bsk_protocol::ErrorCode::MultipleBrowsersOnline); let data = err .data .as_ref() .expect("multiple_browsers_online must include browser list in error.data"); let browsers = data .get("browsers") .and_then(|v| v.as_array()) .expect("error.data.browsers should be an array"); assert_eq!(browsers.len(), 2); let entries: Vec = browsers .iter() .map(|v| serde_json::from_value(v.clone()).unwrap()) .collect(); let ids: std::collections::HashSet<&str> = entries.iter().map(|e| e.instance_id.as_str()).collect(); assert!(ids.contains(TEST_EXT_ID)); assert!(ids.contains("second-browser")); handle.shutdown().await; } #[tokio::test] async fn session_start_with_browser_instance_id_picks_target() { let (handle, sock) = spawn_daemon().await; let mut a = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut a).await; let b = connect_second_ext(handle.ws_addr(), "second-browser", "Edge").await; // Spawn a fake responder for ext A only — we expect daemon to // route session.start to the requested instance id, NOT to ext B. let a = Arc::new(tokio::sync::Mutex::new(a)); let a_clone = Arc::clone(&a); let responder = tokio::spawn(async move { loop { let next = { let mut g = a_clone.lock().await; g.next().await }; let msg = match next { Some(Ok(m)) => m, _ => break, }; let text = match msg { Message::Text(t) => t, Message::Close(_) => break, _ => continue, }; let frame: Frame = serde_json::from_str(&text).unwrap(); if let Frame::Request(req) = frame && req.method == Method::ToolSessionStart { let result = SessionStartResult { agent_window_id: Some(123), }; let reply = ResponseFrame { id: req.id, body: ResponseBody::Ok(serde_json::to_value(result).unwrap()), }; let mut g = a_clone.lock().await; g.send(Message::Text(serde_json::to_string(&reply).unwrap())) .await .unwrap(); } } }); #[derive(serde::Serialize)] struct StartParams { browser_instance_id: Option, } #[derive(serde::Deserialize, Debug)] struct StartReply { #[allow(dead_code)] session_id: String, browser_instance_id: String, agent_window_id: Option, } let mut ipc = IpcClient::connect(&sock).await.unwrap(); let reply: StartReply = ipc .call( "s-pick", Method::SessionStart, Some(StartParams { browser_instance_id: Some(TEST_EXT_ID.into()), }), Duration::from_secs(5), ) .await .unwrap() .expect("session.start must succeed when --browser is unambiguous"); assert_eq!(reply.browser_instance_id, TEST_EXT_ID); assert_eq!(reply.agent_window_id, Some(123)); drop(b); responder.abort(); handle.shutdown().await; } #[tokio::test] async fn session_start_with_unknown_label_returns_not_found() { let (handle, sock) = spawn_daemon().await; let mut a = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut a).await; let _b = connect_second_ext(handle.ws_addr(), "second-browser", "Edge").await; #[derive(serde::Serialize)] struct StartParams { browser_instance_id: Option, } let mut ipc = IpcClient::connect(&sock).await.unwrap(); let result: Result = ipc .call( "s-miss", Method::SessionStart, Some(StartParams { browser_instance_id: Some("does-not-exist".into()), }), Duration::from_secs(2), ) .await .unwrap(); let err = result.expect_err("expected not_found"); assert_eq!(err.code, bsk_protocol::ErrorCode::NotFound); handle.shutdown().await; } #[tokio::test] async fn session_start_label_match_picks_target() { let (handle, sock) = spawn_daemon().await; let mut a = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut a).await; // Test label is "Test" (set via handshake_as_ext) on ext A; ext B has "Edge". let _b = connect_second_ext(handle.ws_addr(), "second-browser", "Edge").await; // Fake responder for ext A only — same drill as the instance_id test. let a = Arc::new(tokio::sync::Mutex::new(a)); let a_clone = Arc::clone(&a); let responder = tokio::spawn(async move { loop { let next = { let mut g = a_clone.lock().await; g.next().await }; let msg = match next { Some(Ok(m)) => m, _ => break, }; let text = match msg { Message::Text(t) => t, Message::Close(_) => break, _ => continue, }; let frame: Frame = serde_json::from_str(&text).unwrap(); if let Frame::Request(req) = frame && req.method == Method::ToolSessionStart { let result = SessionStartResult { agent_window_id: Some(456), }; let reply = ResponseFrame { id: req.id, body: ResponseBody::Ok(serde_json::to_value(result).unwrap()), }; let mut g = a_clone.lock().await; g.send(Message::Text(serde_json::to_string(&reply).unwrap())) .await .unwrap(); } } }); #[derive(serde::Serialize)] struct StartParams { browser_instance_id: Option, } #[derive(serde::Deserialize, Debug)] struct StartReply { browser_instance_id: String, } let mut ipc = IpcClient::connect(&sock).await.unwrap(); let reply: StartReply = ipc .call( "s-label", Method::SessionStart, Some(StartParams { browser_instance_id: Some("Test".into()), }), Duration::from_secs(5), ) .await .unwrap() .expect("session.start must succeed for unique label match"); assert_eq!(reply.browser_instance_id, TEST_EXT_ID); responder.abort(); handle.shutdown().await; } #[tokio::test] async fn session_start_ambiguous_label_returns_invalid_params() { let (handle, sock) = spawn_daemon().await; let mut a = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut a).await; // Both browsers share the label "Shared" — the daemon must // surface `invalid_params` with the ambiguous instance ids in // error.data. let _b = connect_second_ext(handle.ws_addr(), "second-browser", "Test").await; #[derive(serde::Serialize)] struct StartParams { browser_instance_id: Option, } let mut ipc = IpcClient::connect(&sock).await.unwrap(); let result: Result = ipc .call( "s-amb", Method::SessionStart, Some(StartParams { browser_instance_id: Some("Test".into()), }), Duration::from_secs(2), ) .await .unwrap(); let err = result.expect_err("expected ambiguous label invalid_params"); assert_eq!(err.code, bsk_protocol::ErrorCode::InvalidParams); let data = err.data.as_ref().expect("ambiguous label must carry data"); let label = data.get("label").and_then(|v| v.as_str()).unwrap(); let ids = data .get("instance_ids") .and_then(|v| v.as_array()) .expect("instance_ids array"); assert_eq!(label, "Test"); assert_eq!(ids.len(), 2); handle.shutdown().await; } #[tokio::test] async fn session_window_closed_event_purges_session() { let (handle, _sock) = spawn_daemon().await; let mut ws = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut ws).await; let state = handle.state(); let session = bsk::daemon::sessions::Session { id: bsk::daemon::sessions::SessionId("zzzz".into()), browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()), agent_window_id: Some(7), created_at_ms: 0, }; state.sessions.insert(session); assert_eq!(state.sessions.len(), 1); // Send `session.window_closed` event from the fake extension. let event = bsk_protocol::EventFrame { event: bsk_protocol::EventKind::SessionWindowClosed, payload: serde_json::json!({ "session_id": "zzzz", "reason": "user_closed_window", }), }; ws.send(Message::Text( serde_json::to_string(&bsk_protocol::Frame::Event(event)).unwrap(), )) .await .unwrap(); wait_for_no_sessions(&state).await; let _ = ws.close(None).await; handle.shutdown().await; } #[tokio::test] async fn browser_disconnect_purges_sessions() { let (handle, _sock) = spawn_daemon().await; let mut ws = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut ws).await; // Manually inject a session bound to this browser to simulate the // post-start state without bothering with the round-trip. let state = handle.state(); let session = bsk::daemon::sessions::Session { id: bsk::daemon::sessions::SessionId("zzzz".into()), browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()), agent_window_id: Some(7), created_at_ms: 0, }; state.sessions.insert(session); assert_eq!(state.sessions.len(), 1); let _ = ws.close(None).await; drop(ws); wait_for_no_sessions(&state).await; handle.shutdown().await; } #[tokio::test] async fn session_stop_self_heals_when_extension_reports_not_found() { // After an extension SW restart the daemon still owns the session // entry (Round 2 generation-guard) but the extension's // SessionManager is reset and answers `not_found`. Stop must // reconcile local state so `session.list` does not show an orphan // (review M4/M5 round 3 I-R3-2). let (handle, sock) = spawn_daemon().await; let mut ws = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut ws).await; // Inject a session bound to this browser (mimics surviving SW // restart from the daemon's point of view). let state = handle.state(); let session = bsk::daemon::sessions::Session { id: bsk::daemon::sessions::SessionId("yyyy".into()), browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()), agent_window_id: Some(99), created_at_ms: 1, }; state.sessions.insert(session); state .tool_queues .spawn(bsk::daemon::sessions::SessionId("yyyy".into())); assert_eq!(state.sessions.len(), 1); // Fake extension: always reply not_found to tool.session_stop. let ws = Arc::new(tokio::sync::Mutex::new(ws)); let ws_clone = Arc::clone(&ws); let responder = tokio::spawn(async move { loop { let next = { let mut g = ws_clone.lock().await; g.next().await }; let msg = match next { Some(Ok(m)) => m, _ => break, }; let text = match msg { Message::Text(t) => t, Message::Close(_) => break, _ => continue, }; let frame: Frame = match serde_json::from_str(&text) { Ok(f) => f, Err(_) => continue, }; if let Frame::Request(req) = frame && req.method == Method::ToolSessionStop { let reply = ResponseFrame { id: req.id, body: ResponseBody::Err(bsk_protocol::RpcError { code: bsk_protocol::ErrorCode::NotFound, message: "session unknown to extension".into(), data: None, }), }; let mut g = ws_clone.lock().await; g.send(Message::Text(serde_json::to_string(&reply).unwrap())) .await .unwrap(); } } }); #[derive(serde::Serialize)] struct StopParams { session_id: Option, all: bool, } let mut ipc = IpcClient::connect(&sock).await.unwrap(); let result: Result = ipc .call( "s-1", Method::SessionStop, Some(StopParams { session_id: Some("yyyy".into()), all: false, }), Duration::from_secs(3), ) .await .unwrap(); assert!( result.is_ok(), "stop must succeed when extension reports not_found (orphan reconciliation), got {result:?}" ); assert_eq!( state.sessions.len(), 0, "session must be forgotten locally after extension not_found" ); responder.abort(); handle.shutdown().await; } #[tokio::test] async fn reconnect_with_same_instance_id_does_not_clobber_new_browser() { // Spawn a daemon, connect ext A, register a session bound to it, // then connect ext B reusing the same instance_id (mimics a SW // restart / WS reconnect under MV3). When the OLD WS task tears // down, the generation guard MUST keep the new entry + its // session in the registry. Without the guard the stale cleanup // path would call browsers.remove() + sessions.purge_browser() // and the daemon would silently drop the live session. let (handle, _sock) = spawn_daemon().await; let mut ws_a = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut ws_a).await; let state = handle.state(); // Pretend ext A registered a real session. let session = bsk::daemon::sessions::Session { id: bsk::daemon::sessions::SessionId("xxxx".into()), browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()), agent_window_id: Some(11), created_at_ms: 1, }; state.sessions.insert(session); assert_eq!(state.sessions.len(), 1); // Open a second WS that reuses TEST_EXT_ID. let mut ws_b = connect_ext(handle.ws_addr()).await; let _ = handshake_as_ext(&mut ws_b).await; // Registry now holds the newer generation under the same id. assert_eq!(state.browsers.len(), 1); // Tear down ext A only; the cleanup path will run but must // observe that the registered generation no longer matches and // leave the new entry + session alone. let _ = ws_a.close(None).await; drop(ws_a); wait_for_browser_count(&state, 1).await; wait_for_session_count(&state, 1).await; assert_eq!( state.browsers.len(), 1, "new browser entry must survive old cleanup" ); assert_eq!( state.sessions.len(), 1, "session must survive old browser cleanup" ); let _ = ws_b.close(None).await; handle.shutdown().await; } #[tokio::test] async fn reserve_id_loops_until_vacant_and_caps_attempts() { // Pre-fill the registry with a known id and verify reserve_id // honours the existing placeholder rather than overwriting it. let registry = std::sync::Arc::new(bsk::daemon::sessions::SessionRegistry::new()); let browser = bsk::daemon::browsers::BrowserId("collision-browser".into()); // Reserve once so the registry holds a placeholder; the returned id // is random so we cannot assert its value, but we can assert that a // second reservation never produces the same id. let first = registry .reserve_id(browser.clone(), 64, || 1) .expect("first reservation should succeed"); let second = registry .reserve_id(browser.clone(), 64, || 2) .expect("second reservation should succeed"); assert_ne!(first, second, "reserve_id must avoid collisions"); assert_eq!(registry.len(), 2); // A capped attempts budget of 0 surfaces the IdExhausted condition. let exhausted = registry.reserve_id(browser, 0, || 3); assert!(exhausted.is_none(), "0-attempt budget should bail out"); // Cancelling makes the slot vacant again. registry.cancel_reservation(&first); assert_eq!(registry.len(), 1); }