use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};
use ahash::AHashMap;
use serde::{Deserialize, Serialize};
use crate::config::{self, Config};
use crate::scanner::{self, EmbedMode, ScanCancel, ScanSource, ScanStats};
use crate::store::{self, LockHolder, Store, VIEW_WORKING};
pub(crate) const DEFAULT_HOT_CAP: usize = 4;
#[cfg(all(feature = "comms", any(unix, windows)))]
static READ_STACK_BUILD_GATE: std::sync::LazyLock<std::sync::Arc<tokio::sync::Semaphore>> =
std::sync::LazyLock::new(|| std::sync::Arc::new(tokio::sync::Semaphore::new(1)));
#[cfg(all(feature = "comms", any(unix, windows)))]
const READ_STACK_BUILD_GATE_WAIT: Duration = Duration::from_secs(15 * 60);
#[cfg(not(test))]
const STORE_LOCK_WAIT: Duration = Duration::from_secs(10);
#[cfg(test)]
const STORE_LOCK_WAIT: Duration = Duration::from_millis(300);
#[cfg(all(feature = "comms", any(unix, windows)))]
async fn run_gated_build<T: Send + 'static>(
gate: std::sync::Arc<tokio::sync::Semaphore>,
wait: Duration,
build: impl FnOnce() -> T + Send + 'static,
) -> anyhow::Result<T> {
let permit = tokio::time::timeout(wait, gate.acquire_owned())
.await
.map_err(|_| anyhow::anyhow!("timed out after {wait:?} waiting for the read stack build gate"))?
.map_err(|closed| anyhow::anyhow!("read stack build gate closed: {closed}"))?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
build()
})
.await
.map_err(|join| anyhow::anyhow!("hosted read stack build panicked: {join}"))
}
pub(crate) const DEFAULT_WARM_READ_STACK_CAP: usize = 3;
pub(crate) const WARM_READ_STACK_CAP_ENV: &str = "BASEMIND_WARM_READ_STACKS";
pub(crate) const READ_STACK_IDLE_TTL: Duration = Duration::from_secs(10 * 60);
pub(crate) fn warm_read_stack_cap() -> usize {
match std::env::var(WARM_READ_STACK_CAP_ENV) {
Ok(raw) => match raw.trim().parse::<usize>() {
Ok(n) if n > 0 => n,
_ => DEFAULT_WARM_READ_STACK_CAP,
},
Err(_) => DEFAULT_WARM_READ_STACK_CAP,
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum WorkspacePoolError {
#[error("open workspace store: {0}")]
Store(#[from] store::StoreError),
#[error("scan workspace: {0}")]
Scan(#[from] scanner::ScanError),
#[error("load workspace config: {0}")]
Config(#[from] config::ConfigError),
#[error("workspace {root} is busy (scanning); retry shortly")]
Busy { root: PathBuf },
#[error("{message}")]
RootRefused { root: PathBuf, message: String },
}
struct WorkspaceEntry {
store: Mutex<Store>,
config: std::sync::RwLock<std::sync::Arc<Config>>,
config_stamp: Mutex<config::daemon::ConfigStamp>,
root: PathBuf,
key: String,
last_used: Mutex<Instant>,
full_scan_gen: std::sync::atomic::AtomicU64,
last_full: Mutex<Option<(u64, EmbedMode, ScanStats)>>,
#[cfg(all(feature = "comms", any(unix, windows)))]
serve_state: tokio::sync::Mutex<Option<crate::mcp::HostedReadStack>>,
active_conns: std::sync::atomic::AtomicUsize,
busy: std::sync::atomic::AtomicUsize,
}
struct BusyGuard(std::sync::Arc<WorkspaceEntry>);
impl Drop for BusyGuard {
fn drop(&mut self) {
self.0.busy.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
}
}
impl WorkspaceEntry {
fn begin_busy(self: &std::sync::Arc<Self>) -> BusyGuard {
self.busy.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
BusyGuard(std::sync::Arc::clone(self))
}
fn evictable(&self) -> bool {
use std::sync::atomic::Ordering::Acquire;
self.active_conns.load(Acquire) == 0 && self.busy.load(Acquire) == 0
}
fn lock_store_bounded(&self) -> Result<std::sync::MutexGuard<'_, Store>, WorkspacePoolError> {
let deadline = Instant::now() + STORE_LOCK_WAIT;
loop {
match self.store.try_lock() {
Ok(guard) => return Ok(guard),
Err(std::sync::TryLockError::Poisoned(poisoned)) => return Ok(poisoned.into_inner()),
Err(std::sync::TryLockError::WouldBlock) => {
if Instant::now() >= deadline {
return Err(WorkspacePoolError::Busy {
root: self.root.clone(),
});
}
std::thread::sleep(Duration::from_millis(20));
}
}
}
}
fn config(&self) -> std::sync::Arc<Config> {
self.config.read().unwrap_or_else(PoisonError::into_inner).clone()
}
fn last_used(&self) -> Instant {
*self.last_used.lock().unwrap_or_else(PoisonError::into_inner)
}
fn touch(&self) {
*self.last_used.lock().unwrap_or_else(PoisonError::into_inner) = Instant::now();
}
}
#[cfg(all(feature = "comms", any(unix, windows)))]
pub(crate) struct ServeConnGuard {
entry: std::sync::Arc<WorkspaceEntry>,
}
#[cfg(all(feature = "comms", any(unix, windows)))]
impl Drop for ServeConnGuard {
fn drop(&mut self) {
self.entry
.active_conns
.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessedWorkspace {
pub root: PathBuf,
pub key: String,
pub idle_secs: u64,
}
pub(crate) struct WorkspacePool {
map: Mutex<AHashMap<String, std::sync::Arc<WorkspaceEntry>>>,
open_lock: Mutex<()>,
cap: usize,
scan_cancel: ScanCancel,
cleanup_signal: std::sync::Arc<tokio::sync::Notify>,
}
impl WorkspacePool {
pub(crate) fn new(cap: usize) -> Self {
Self {
map: Mutex::new(AHashMap::new()),
open_lock: Mutex::new(()),
cap: cap.max(1),
scan_cancel: ScanCancel::new(),
cleanup_signal: std::sync::Arc::new(tokio::sync::Notify::new()),
}
}
pub(crate) fn cleanup_signal(&self) -> std::sync::Arc<tokio::sync::Notify> {
std::sync::Arc::clone(&self.cleanup_signal)
}
pub(crate) fn scan_cancel(&self) -> ScanCancel {
self.scan_cancel.clone()
}
fn lock_map(&self) -> std::sync::MutexGuard<'_, AHashMap<String, std::sync::Arc<WorkspaceEntry>>> {
self.map.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn rescan(
&self,
root: &Path,
paths: Option<Vec<PathBuf>>,
full: bool,
embed: bool,
cancel: &ScanCancel,
) -> Result<(ScanStats, bool), WorkspacePoolError> {
let entry = self.get_or_open(root)?;
entry.touch();
let _busy = entry.begin_busy();
let mode = if embed { EmbedMode::Inline } else { EmbedMode::Deferred };
let config = entry.config();
let incremental = matches!(paths, Some(ref p) if !full && !p.is_empty());
let gen_before = entry.full_scan_gen.load(std::sync::atomic::Ordering::Acquire);
let mut store = entry.store.lock().unwrap_or_else(PoisonError::into_inner);
if !incremental {
let last = entry.last_full.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((generation, last_mode, stats)) = *last
&& generation > gen_before
&& (last_mode == EmbedMode::Inline || mode == EmbedMode::Deferred)
{
return Ok((stats, false));
}
}
if cancel.is_cancelled() {
return Ok((ScanStats::default(), true));
}
#[cfg(feature = "intelligence")]
let _embed_pass = (matches!(mode, EmbedMode::Inline) && !incremental).then(crate::embeddings::begin_embed_pass);
let report = if incremental {
let paths = paths.as_deref().unwrap_or_default();
scanner::scan_paths_with_cancel(&entry.root, &mut store, &config, paths, mode, cancel)?
} else {
scanner::scan_with_cancel(&entry.root, &mut store, &config, ScanSource::WorkingTree, mode, cancel)?
};
if report.stats.displaced_artifacts() || std::mem::take(&mut store.reset_stale_schema) {
self.cleanup_signal.notify_one();
}
if !incremental && !report.cancelled {
let generation = entry.full_scan_gen.fetch_add(1, std::sync::atomic::Ordering::AcqRel) + 1;
*entry.last_full.lock().unwrap_or_else(PoisonError::into_inner) = Some((generation, mode, report.stats));
}
Ok((report.stats, report.cancelled))
}
pub(crate) fn with_workspace<R>(&self, root: &Path, f: impl FnOnce(&Store) -> R) -> Result<R, WorkspacePoolError> {
let entry = self.get_or_open(root)?;
entry.touch();
let _busy = entry.begin_busy();
let store = entry.lock_store_bounded()?;
Ok(f(&store))
}
#[cfg(feature = "memory")]
pub(crate) fn with_workspace_mut<R>(
&self,
root: &Path,
f: impl FnOnce(&mut Store) -> R,
) -> Result<R, WorkspacePoolError> {
let entry = self.get_or_open(root)?;
entry.touch();
let _busy = entry.begin_busy();
let mut store = entry.lock_store_bounded()?;
Ok(f(&mut store))
}
#[cfg(all(feature = "comms", any(unix, windows)))]
pub(crate) async fn get_or_build_serve_state(
self: &std::sync::Arc<Self>,
root: &Path,
host: std::sync::Arc<dyn crate::mcp::HostBackend>,
git_history_host: std::sync::Arc<dyn crate::git_history::remote::HistoryHost>,
) -> anyhow::Result<std::sync::Arc<crate::mcp::SharedReadStack>> {
let entry = self.get_or_open_async(root).await.map_err(anyhow::Error::new)?;
entry.touch();
let (shared, built) = {
let mut slot = entry.serve_state.lock().await;
match slot.as_ref() {
Some(hosted) => (hosted.shared(), false),
None => {
let root_buf = root.to_path_buf();
let hosted = run_gated_build(
std::sync::Arc::clone(&READ_STACK_BUILD_GATE),
READ_STACK_BUILD_GATE_WAIT,
move || crate::mcp::BasemindServer::build_hosted_read_stack(&root_buf, host, git_history_host),
)
.await??;
let shared = hosted.shared();
*slot = Some(hosted);
(shared, true)
}
}
};
if built {
self.trim_warm_read_stacks(&entry.key);
}
Ok(shared)
}
#[cfg(all(feature = "comms", any(unix, windows)))]
fn trim_warm_read_stacks(&self, just_built: &str) {
use std::sync::atomic::Ordering::Acquire;
let cap = warm_read_stack_cap();
let entries: Vec<std::sync::Arc<WorkspaceEntry>> = self.lock_map().values().cloned().collect();
let mut warm = 0usize;
let mut evictable: Vec<std::sync::Arc<WorkspaceEntry>> = Vec::new();
for entry in entries {
let is_warm = match entry.serve_state.try_lock() {
Ok(slot) => slot.is_some(),
Err(_) => continue,
};
if !is_warm {
continue;
}
warm += 1;
if entry.key != just_built && entry.active_conns.load(Acquire) == 0 {
evictable.push(entry);
}
}
let Some(mut over) = warm.checked_sub(cap).filter(|over| *over > 0) else {
return;
};
evictable.sort_by_key(|entry| entry.last_used());
for entry in evictable {
if over == 0 {
break;
}
if let Ok(mut slot) = entry.serve_state.try_lock()
&& slot.take().is_some()
{
over -= 1;
tracing::info!(root = %entry.root.display(), cap, "daemon: shed a warm read stack over the cap");
}
}
}
#[cfg(all(feature = "comms", any(unix, windows)))]
pub(crate) fn evict_idle_read_stacks(&self, idle: Duration) -> usize {
use std::sync::atomic::Ordering::Acquire;
let entries: Vec<std::sync::Arc<WorkspaceEntry>> = self.lock_map().values().cloned().collect();
let mut dropped = 0;
for entry in entries {
if entry.active_conns.load(Acquire) != 0 || entry.last_used().elapsed() < idle {
continue;
}
if let Ok(mut slot) = entry.serve_state.try_lock()
&& slot.take().is_some()
{
dropped += 1;
}
}
dropped
}
#[cfg(all(test, feature = "comms", any(unix, windows)))]
pub(crate) fn warm_read_stacks(&self) -> usize {
let entries: Vec<std::sync::Arc<WorkspaceEntry>> = self.lock_map().values().cloned().collect();
entries
.iter()
.filter(|entry| entry.serve_state.try_lock().is_ok_and(|slot| slot.is_some()))
.count()
}
#[cfg(all(feature = "comms", any(unix, windows)))]
pub(crate) fn begin_conn(&self, root: &Path) -> Result<ServeConnGuard, WorkspacePoolError> {
Ok(Self::register_conn(self.get_or_open(root)?))
}
#[cfg(all(feature = "comms", any(unix, windows)))]
pub(crate) async fn begin_conn_async(
self: &std::sync::Arc<Self>,
root: &Path,
) -> Result<ServeConnGuard, WorkspacePoolError> {
Ok(Self::register_conn(self.get_or_open_async(root).await?))
}
#[cfg(all(feature = "comms", any(unix, windows)))]
fn register_conn(entry: std::sync::Arc<WorkspaceEntry>) -> ServeConnGuard {
entry.touch();
entry.active_conns.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
ServeConnGuard { entry }
}
fn refresh_config(&self, entry: &WorkspaceEntry) {
let now = config::daemon::ConfigStamp::of(&entry.root);
{
let mut stamp = entry.config_stamp.lock().unwrap_or_else(PoisonError::into_inner);
if *stamp == now {
return;
}
*stamp = now;
}
let new = match config::daemon::load_daemon(&entry.root) {
Ok(new) => new,
Err(error) => {
tracing::warn!(root = %entry.root.display(), %error, "config changed but does not load; keeping the previous config");
return;
}
};
let old = entry.config();
if old.resources.scan_threads != new.resources.scan_threads {
tracing::warn!(
root = %entry.root.display(),
"config changed: resources.scan_threads is fixed for the process lifetime; restart the daemon for it to take effect"
);
}
tracing::info!(root = %entry.root.display(), stamp = %now, "config changed; reloaded");
*entry.config.write().unwrap_or_else(PoisonError::into_inner) = std::sync::Arc::new(new);
#[cfg(all(feature = "comms", any(unix, windows)))]
if entry.active_conns.load(std::sync::atomic::Ordering::Acquire) == 0
&& let Ok(mut slot) = entry.serve_state.try_lock()
{
*slot = None;
}
}
#[cfg(all(feature = "comms", any(unix, windows)))]
async fn get_or_open_async(
self: &std::sync::Arc<Self>,
root: &Path,
) -> Result<std::sync::Arc<WorkspaceEntry>, WorkspacePoolError> {
let key = store::workspace_key(root);
let hit = self.lock_map().get(&key).cloned();
if let Some(entry) = hit {
self.refresh_config(&entry);
return Ok(entry);
}
let pool = std::sync::Arc::clone(self);
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || pool.get_or_open(&root))
.await
.map_err(|join| {
WorkspacePoolError::Store(store::StoreError::Io {
path: PathBuf::new(),
source: std::io::Error::other(format!("workspace open task failed: {join}")),
})
})?
}
fn get_or_open(&self, root: &Path) -> Result<std::sync::Arc<WorkspaceEntry>, WorkspacePoolError> {
let key = store::workspace_key(root);
{
let hit = self.lock_map().get(&key).cloned();
if let Some(entry) = hit {
self.refresh_config(&entry);
return Ok(entry);
}
}
let _opening = self.open_lock.lock().unwrap_or_else(PoisonError::into_inner);
{
let map = self.lock_map();
if let Some(entry) = map.get(&key) {
return Ok(entry.clone());
}
}
let resolved = match config::root_guard::workspace_root_verdict(root) {
Ok(resolved) => resolved,
Err(refusal) => {
return Err(WorkspacePoolError::RootRefused {
root: root.to_path_buf(),
message: config::root_guard::refusal_message(root, refusal),
});
}
};
let store = Store::open_with_holder(&resolved, VIEW_WORKING, LockHolder::Rescan)?;
let config_stamp = config::daemon::ConfigStamp::of(&resolved);
let config = config::daemon::load_daemon(&resolved)?;
let entry = std::sync::Arc::new(WorkspaceEntry {
store: Mutex::new(store),
config: std::sync::RwLock::new(std::sync::Arc::new(config)),
config_stamp: Mutex::new(config_stamp),
root: resolved,
key: key.clone(),
last_used: Mutex::new(Instant::now()),
full_scan_gen: std::sync::atomic::AtomicU64::new(0),
last_full: Mutex::new(None),
#[cfg(all(feature = "comms", any(unix, windows)))]
serve_state: tokio::sync::Mutex::new(None),
active_conns: std::sync::atomic::AtomicUsize::new(0),
busy: std::sync::atomic::AtomicUsize::new(0),
});
let mut evicted = Vec::new();
let mut map = self.lock_map();
while map.len() >= self.cap {
let victim = map
.values()
.filter(|e| e.evictable())
.min_by_key(|e| e.last_used())
.map(|e| e.key.clone());
match victim.and_then(|key| map.remove(&key)) {
Some(entry) => evicted.push(entry),
None => break,
}
}
map.insert(key, entry.clone());
drop(map);
drop(evicted);
Ok(entry)
}
pub(crate) fn accessed(&self) -> Vec<AccessedWorkspace> {
let map = self.lock_map();
let mut rows: Vec<AccessedWorkspace> = map
.values()
.map(|e| AccessedWorkspace {
root: e.root.clone(),
key: e.key.clone(),
idle_secs: e.last_used().elapsed().as_secs(),
})
.collect();
rows.sort_by_key(|r| r.idle_secs);
rows
}
pub(crate) fn evict_idle(&self, idle: Duration) -> usize {
let mut map = self.lock_map();
let stale: Vec<String> = map
.values()
.filter(|e| e.last_used().elapsed() >= idle && e.evictable())
.map(|e| e.key.clone())
.collect();
let removed: Vec<_> = stale.iter().filter_map(|key| map.remove(key)).collect();
drop(map);
let count = removed.len();
drop(removed);
count
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.lock_map().len()
}
}
#[cfg(all(feature = "comms", any(unix, windows)))]
impl crate::mcp::HostBackend for WorkspacePool {
fn host_rescan(
&self,
root: &Path,
paths: Option<Vec<PathBuf>>,
full: bool,
embed: bool,
) -> Result<ScanStats, String> {
self.rescan(root, paths, full, embed, &self.scan_cancel)
.map(|(stats, _cancelled)| stats)
.map_err(|error| error.to_string())
}
#[cfg(feature = "code-search")]
fn host_code_search_lanes(
&self,
root: &Path,
query: crate::comms::code_search_proto::CodeSearchLaneQuery,
) -> Result<crate::comms::code_search_proto::CodeSearchLaneResult, String> {
self.with_workspace(root, |store| {
super::daemon_forward_handlers::code_search_lanes_against(store, &query)
})
.map_err(|error| error.to_string())?
}
fn host_index_read(
&self,
root: &Path,
query: crate::comms::index_read_proto::IndexReadQuery,
) -> Result<crate::comms::index_read_proto::IndexReadResult, String> {
super::daemon_forward_handlers::index_read_via_pool(self, root, &query)
}
fn host_resolved_refs(
&self,
root: &Path,
query: crate::comms::resolved_proto::ResolvedRefQuery,
) -> Result<crate::comms::resolved_proto::ResolvedRefResult, String> {
self.with_workspace(root, |store| {
super::daemon_forward_handlers::resolve_refs_against(store, &query)
})
.map_err(|error| error.to_string())
}
#[cfg(feature = "memory")]
fn host_memory(
&self,
root: &Path,
scope: &str,
op: crate::comms::memory_proto::MemoryOp,
) -> Result<crate::comms::memory_proto::MemoryOutcome, String> {
self.with_workspace_mut(root, |store| {
let idx = store
.index_db
.as_ref()
.ok_or(crate::mcp::memory_ops::MemoryOpError::IndexUnavailable)?;
crate::mcp::memory_ops::run_memory_op(idx, scope, &op)
})
.map_err(|error| error.to_string())
.and_then(|result| result.map_err(|error| error.to_string()))
}
#[cfg(feature = "memory")]
fn host_governance(
&self,
root: &Path,
scope: &str,
op: crate::comms::proposals_proto::GovernanceOp,
) -> Result<crate::comms::proposals_proto::GovernanceOutcome, String> {
self.with_workspace_mut(root, |store| {
let idx = store
.index_db
.as_ref()
.ok_or(crate::mcp::memory_ops::MemoryOpError::IndexUnavailable)?;
crate::mcp::proposals_ops::run_governance_op(idx, scope, &op)
})
.map_err(|error| error.to_string())
.and_then(|result| result.map_err(|error| error.to_string()))
}
}
#[cfg(test)]
#[path = "workspace_pool_tests.rs"]
mod tests;