Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/extension/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ export default defineConfig({
},
define: {
__BSK_EXT_VERSION__: JSON.stringify(pkg.version),
__BSK_DAEMON_WS_URL__: JSON.stringify(
process.env.BSK_DAEMON_WS_URL ?? "ws://127.0.0.1:52800",
),
__BSK_DAEMON_WS_URL__: JSON.stringify(process.env.BSK_DAEMON_WS_URL ?? "ws://127.0.0.1:52800"),
},
resolve: {
alias: {
Expand Down
8 changes: 7 additions & 1 deletion crates/bsk-cli/src/daemon/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,16 @@ pub async fn run(
Some(path) => Some(ipc::IpcServer::new(Arc::clone(&state)).bind(path).await?),
None => None,
};
let session_idle_task = start::spawn_session_idle_reaper(Arc::clone(&state));
// Ensure WS/IPC accept loops have polled `shutdown.notified()` before any
// caller can invoke `DaemonHandle::shutdown()` — `Notify::notify_waiters()`
// drops wakeups when nothing is registered yet, which otherwise hangs
// shutdown forever (hit by tests that connect/shutdown immediately).
tokio::task::yield_now().await;
Ok(DaemonHandle::new(state, ws_handle, ipc_handle))
Ok(DaemonHandle::new(
state,
ws_handle,
ipc_handle,
session_idle_task,
))
}
11 changes: 9 additions & 2 deletions crates/bsk-cli/src/daemon/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,8 @@ impl ToolQueueRegistry {
queue_state.busy = true;
(entry.sender.clone(), Arc::clone(&entry.state))
};
dispatch_with_sender(
self.sessions.touch(sid);
let outcome = dispatch_with_sender(
sender,
state,
sid.clone(),
Expand All @@ -310,7 +311,13 @@ impl ToolQueueRegistry {
false,
inflight,
)
.await
.await;
// A long-running request may outlive the idle threshold. Touching
// after completion prevents the reaper from immediately closing it;
// while it is running, session.stop observes SessionBusy and retries
// on a later sweep rather than interrupting the tool.
self.sessions.touch(sid);
outcome
}

/// Stop accepting new jobs for `sid`, enqueue one final control RPC,
Expand Down
75 changes: 69 additions & 6 deletions crates/bsk-cli/src/daemon/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Instant, SystemTime, UNIX_EPOCH};

use bsk_protocol::system::{BrowserStatusEntry, SessionStatusEntry};
use bsk_protocol::tools::{
Expand Down Expand Up @@ -66,6 +66,9 @@ impl Session {
#[derive(Debug, Default)]
pub struct SessionRegistry {
inner: Mutex<HashMap<SessionId, Session>>,
/// Operational metadata kept outside the public `Session` wire/domain
/// shape so idle enforcement does not break external struct users.
last_activity: Mutex<HashMap<SessionId, Instant>>,
}

impl SessionRegistry {
Expand Down Expand Up @@ -100,10 +103,13 @@ impl SessionRegistry {
}

pub fn insert(&self, session: Session) {
self.inner
let session_id = session.id.clone();
let mut sessions = self.inner.lock().expect("session registry poisoned");
sessions.insert(session_id.clone(), session);
self.last_activity
.lock()
.expect("session registry poisoned")
.insert(session.id.clone(), session);
.expect("session activity registry poisoned")
.insert(session_id, Instant::now());
}

/// Reserve a fresh, collision-free [`SessionId`] under the registry
Expand Down Expand Up @@ -141,6 +147,10 @@ impl SessionRegistry {
created_at_ms: now_ms_fn(),
},
);
self.last_activity
.lock()
.expect("session activity registry poisoned")
.insert(candidate.clone(), Instant::now());
return Some(candidate);
}
None
Expand Down Expand Up @@ -168,13 +178,23 @@ impl SessionRegistry {
.lock()
.expect("session registry poisoned")
.remove(session_id);
self.last_activity
.lock()
.expect("session activity registry poisoned")
.remove(session_id);
}

pub fn remove(&self, id: &SessionId) -> Option<Session> {
self.inner
let removed = self
.inner
.lock()
.expect("session registry poisoned")
.remove(id)
.remove(id);
self.last_activity
.lock()
.expect("session activity registry poisoned")
.remove(id);
removed
}

pub fn get(&self, id: &SessionId) -> Option<Session> {
Expand All @@ -185,6 +205,42 @@ impl SessionRegistry {
.cloned()
}

/// Record accepted or completed tool activity for a live session.
pub fn touch(&self, id: &SessionId) -> bool {
if !self
.inner
.lock()
.expect("session registry poisoned")
.contains_key(id)
{
return false;
}
self.last_activity
.lock()
.expect("session activity registry poisoned")
.insert(id.clone(), Instant::now());
true
}

/// Return sessions whose last tool activity is at least `idle_for`
/// old. The caller supplies `now` to keep boundary tests deterministic.
pub fn idle_ids_at(&self, idle_for: Duration, now: Instant) -> Vec<SessionId> {
let sessions = self.inner.lock().expect("session registry poisoned");
let activity = self
.last_activity
.lock()
.expect("session activity registry poisoned");
sessions
.values()
.filter(|session| {
activity
.get(&session.id)
.is_some_and(|last| now.saturating_duration_since(*last) >= idle_for)
})
.map(|session| session.id.clone())
.collect()
}

/// Drop all sessions owned by `browser_id` (e.g. on disconnect).
pub fn purge_browser(&self, browser_id: &BrowserId) -> Vec<Session> {
let mut guard = self.inner.lock().expect("session registry poisoned");
Expand All @@ -196,6 +252,13 @@ impl SessionRegistry {
for s in &drained {
guard.remove(&s.id);
}
let mut activity = self
.last_activity
.lock()
.expect("session activity registry poisoned");
for session in &drained {
activity.remove(&session.id);
}
drained
}

Expand Down
59 changes: 57 additions & 2 deletions crates/bsk-cli/src/daemon/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ use tracing::{debug, info, warn};

use crate::cli::daemon::StartArgs;
use crate::daemon::{
browsers::EXTENSION_CONNECT_WAIT, info as daemon_info, ipc, lockfile, paths,
state::DaemonState, ws,
browsers::EXTENSION_CONNECT_WAIT,
info as daemon_info, ipc, lockfile, paths,
sessions::{StopSessionError, forget_session, stop_session},
state::DaemonState,
ws,
};

/// Internal env-var contract: the parent sets this on the spawned child
Expand Down Expand Up @@ -196,6 +199,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
.with_context(|| format!("bind IPC endpoint {}", sock_path.display()))?;

let state = Arc::new(DaemonState::new(cfg.clone()));
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
let ws_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), cfg.ws_port);
let ws_handle = ws::WsServer::new(Arc::clone(&state))
.bind(ws_addr)
Expand Down Expand Up @@ -355,6 +359,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {

let _ = ipc_shutdown_tx.send(());
let _ = ipc_task.await;
session_idle_task.abort();
let _ = session_idle_task.await;
ws_handle.shutdown.notify_waiters();
let _ = ws_handle.task.await;

Expand All @@ -367,6 +373,55 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
Ok(())
}

/// Spawn the cooperative session-idle reaper shared by the production
/// foreground daemon and the test/embed daemon entry point.
pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let session_idle = state.config.session_idle;
let tick = (session_idle / 4)
.max(Duration::from_millis(100))
.min(Duration::from_secs(30));
let mut ticker = tokio::time::interval(tick);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// `interval`'s first tick is immediate. Consume it so a zero/very
// short test setting still gets one real inactivity window.
ticker.tick().await;

loop {
ticker.tick().await;
let idle_ids = state.sessions.idle_ids_at(session_idle, Instant::now());
for session_id in idle_ids {
match stop_session(
&state.browsers,
&state.sessions,
&state.tool_queues,
&state.session_interrupts,
&session_id,
Duration::from_secs(10),
)
.await
{
Ok(_) => info!(session = %session_id, "idle session stopped"),
Err(StopSessionError::SessionBusy | StopSessionError::Stopping) => {
debug!(session = %session_id, "idle session still active; retrying later");
}
Err(StopSessionError::NotFound | StopSessionError::BrowserGone) => {
forget_session(
&state.sessions,
&state.tool_queues,
&state.session_interrupts,
&session_id,
);
}
Err(err) => {
warn!(session = %session_id, error = %err, "failed to stop idle session");
}
}
}
}
})
}

fn record_activity(activity: &Arc<Mutex<Instant>>) {
if let Ok(mut a) = activity.lock() {
*a = Instant::now();
Expand Down
17 changes: 15 additions & 2 deletions crates/bsk-cli/src/daemon/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,22 @@ pub struct DaemonHandle {
state: Arc<DaemonState>,
ws: WsHandle,
ipc: Option<IpcHandle>,
session_idle_task: JoinHandle<()>,
}

impl DaemonHandle {
pub(crate) fn new(state: Arc<DaemonState>, ws: WsHandle, ipc: Option<IpcHandle>) -> Self {
Self { state, ws, ipc }
pub(crate) fn new(
state: Arc<DaemonState>,
ws: WsHandle,
ipc: Option<IpcHandle>,
session_idle_task: JoinHandle<()>,
) -> Self {
Self {
state,
ws,
ipc,
session_idle_task,
}
}

pub fn state(&self) -> Arc<DaemonState> {
Expand All @@ -103,6 +114,8 @@ impl DaemonHandle {
/// Stop the WS server (and IPC if running). Returns once both join
/// handles complete.
pub async fn shutdown(self) {
self.session_idle_task.abort();
let _ = await_join(self.session_idle_task).await;
self.ws.shutdown.notify_waiters();
let _ = await_join(self.ws.task).await;
if let Some(ipc) = self.ipc {
Expand Down
5 changes: 1 addition & 4 deletions crates/bsk-cli/tests/browser_list_ordering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ use bsk_protocol::system::BrowserStatusEntry;
use bsk_protocol::{ErrorCode, Method};
use rand::Rng;
use serde::Deserialize;
use tokio::net::TcpListener;
use tokio::sync::mpsc;

fn tempfile_path(prefix: &str) -> PathBuf {
Expand All @@ -30,9 +29,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}

async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;

let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-browser-list");
Expand Down
5 changes: 1 addition & 4 deletions crates/bsk-cli/tests/browser_wait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ use bsk_protocol::system::{BrowserListParams, HandshakeParams, HandshakeResult,
use bsk_protocol::{BrowserPeerInfo, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
Expand Down Expand Up @@ -77,9 +76,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}

async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;

let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-browser-wait");
Expand Down
5 changes: 1 addition & 4 deletions crates/bsk-cli/tests/cancel_forwarding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ use bsk_protocol::{
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::json;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
Expand All @@ -40,9 +39,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}

async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;

let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-cancel");
Expand Down
5 changes: 1 addition & 4 deletions crates/bsk-cli/tests/handshake_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use bsk_protocol::system::{HandshakeParams, HandshakeResult, StatusResult};
use bsk_protocol::{BrowserPeerInfo, ErrorCode, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
Expand All @@ -28,9 +27,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}

async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;

let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-handshake");
Expand Down
5 changes: 1 addition & 4 deletions crates/bsk-cli/tests/per_session_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde::Deserialize;
use serde_json::json;
use tokio::net::TcpListener;
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
Expand All @@ -46,9 +45,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}

async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let sock = tempfile_path("bsk-test-queue");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await
Expand Down
Loading