use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::store_layout::{CACHE_DIR, WORKSPACES_DIR, cache_root, workspace_cache_dir};
const INFLIGHT_FILE: &str = "scan-inflight.json";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScanInflight {
pub started_unix: u64,
pub pid: u32,
pub version: String,
pub root: PathBuf,
pub phase: String,
pub candidates: Option<usize>,
}
pub fn inflight_path(cache_dir: &Path) -> PathBuf {
cache_dir.join(INFLIGHT_FILE)
}
pub fn read(cache_dir: &Path) -> Option<ScanInflight> {
let bytes = std::fs::read(inflight_path(cache_dir)).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn clear(cache_dir: &Path) -> bool {
std::fs::remove_file(inflight_path(cache_dir)).is_ok()
}
#[derive(Debug)]
pub struct ScanBreadcrumb {
path: PathBuf,
record: ScanInflight,
}
impl ScanBreadcrumb {
pub fn begin(root: &Path) -> Option<Self> {
Self::begin_in(&workspace_cache_dir(root), root)
}
pub fn begin_in(cache_dir: &Path, root: &Path) -> Option<Self> {
std::fs::create_dir_all(cache_dir).ok()?;
let record = ScanInflight {
started_unix: now_unix(),
pid: std::process::id(),
version: env!("CARGO_PKG_VERSION").to_string(),
root: root.to_path_buf(),
phase: PHASE_STARTING.to_string(),
candidates: None,
};
let breadcrumb = Self {
path: inflight_path(cache_dir),
record,
};
breadcrumb.write();
Some(breadcrumb)
}
pub fn advance(&mut self, phase: &'static str, candidates: Option<usize>) {
self.record.phase = phase.to_string();
if candidates.is_some() {
self.record.candidates = candidates;
}
self.write();
}
pub fn record(&self) -> &ScanInflight {
&self.record
}
pub fn path(&self) -> &Path {
&self.path
}
fn write(&self) {
let Ok(bytes) = serde_json::to_vec_pretty(&self.record) else {
return;
};
let temp = self.path.with_extension("json.tmp");
if std::fs::write(&temp, bytes).is_ok() && std::fs::rename(&temp, &self.path).is_err() {
let _ = std::fs::remove_file(&temp);
}
}
}
impl Drop for ScanBreadcrumb {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub const PHASE_STARTING: &str = "starting";
pub const PHASE_CANDIDATES: &str = "candidates";
pub const PHASE_EXTRACT: &str = "extract";
pub const PHASE_PURGE: &str = "purge";
pub const PHASE_FLUSH: &str = "flush";
pub const PHASE_RESOLVE: &str = "resolve";
pub const PHASE_LANES: &str = "lanes";
#[cfg(any(unix, windows))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StaleScan {
pub cache_dir: PathBuf,
pub record: ScanInflight,
}
#[cfg(any(unix, windows))]
pub fn stale_inflight_scans() -> Vec<StaleScan> {
stale_inflight_scans_in(&cache_root().join(CACHE_DIR).join(WORKSPACES_DIR))
}
#[cfg(any(unix, windows))]
pub fn stale_inflight_scans_in(workspaces_dir: &Path) -> Vec<StaleScan> {
let Ok(entries) = std::fs::read_dir(workspaces_dir) else {
return Vec::new();
};
let mut stale: Vec<StaleScan> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter_map(|cache_dir| {
let record = read(&cache_dir)?;
(!crate::daemon_lock::pid_is_live(record.pid)).then_some(StaleScan { cache_dir, record })
})
.collect();
stale.sort_by_key(|scan| std::cmp::Reverse(scan.record.started_unix));
stale
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0)
}
pub const MEMORY_LOG_EVERY: Duration = Duration::from_secs(5);
pub fn log_memory_snapshot(root: &Path, elapsed: Duration) {
let Some(reading) = crate::sysres::memory_reading() else {
return;
};
tracing::info!(
rss_mb = reading.used_bytes / (1 << 20),
ceiling_mb = reading.limit_bytes.map(|limit| limit / (1 << 20)),
ceiling_enforced = reading.limit_is_enforced,
source = reading.source.as_str(),
elapsed_secs = elapsed.as_secs(),
root = %root.display(),
"memory"
);
}
pub struct MemoryLog {
stop: Arc<(Mutex<bool>, Condvar)>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl MemoryLog {
pub fn start(root: &Path) -> Option<Self> {
let stop = Arc::new((Mutex::new(false), Condvar::new()));
let worker_stop = Arc::clone(&stop);
let root = root.to_path_buf();
let handle = std::thread::Builder::new()
.name("basemind-memlog".to_string())
.spawn(move || {
let started = std::time::Instant::now();
let (lock, cvar) = &*worker_stop;
let mut guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
loop {
if *guard {
return;
}
let (next, _) = cvar
.wait_timeout(guard, MEMORY_LOG_EVERY)
.unwrap_or_else(PoisonError::into_inner);
guard = next;
if *guard {
return;
}
drop(guard);
log_memory_snapshot(&root, started.elapsed());
guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
}
})
.ok()?;
Some(Self {
stop,
handle: Some(handle),
})
}
}
impl Drop for MemoryLog {
fn drop(&mut self) {
let (lock, cvar) = &*self.stop;
*lock.lock().unwrap_or_else(PoisonError::into_inner) = true;
cvar.notify_all();
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_memory_log_stops_when_dropped_instead_of_sleeping_out_its_interval() {
let started = std::time::Instant::now();
let log = MemoryLog::start(Path::new("/repo/under/test")).expect("sampler thread spawns");
drop(log);
let elapsed = started.elapsed();
assert!(
elapsed < MEMORY_LOG_EVERY,
"drop must join on the condvar, not wait out {MEMORY_LOG_EVERY:?} (took {elapsed:?})"
);
}
#[test]
fn a_breadcrumb_is_written_on_begin_and_removed_on_drop() {
let dir = tempfile::tempdir().expect("tempdir");
let cache = dir.path().join("workspaces").join("abc123");
let root = PathBuf::from("/repo/under/test");
assert_eq!(read(&cache), None, "nothing before the scan starts");
{
let breadcrumb = ScanBreadcrumb::begin_in(&cache, &root).expect("breadcrumb written");
let record = read(&cache).expect("the record is on disk while the scan runs");
assert_eq!(record.pid, std::process::id());
assert_eq!(record.root, root);
assert_eq!(record.phase, PHASE_STARTING);
assert_eq!(record.candidates, None);
assert_eq!(breadcrumb.path(), inflight_path(&cache));
}
assert_eq!(read(&cache), None, "a pass that returns retracts its claim");
}
#[test]
fn advance_rewrites_the_phase_and_retains_the_candidate_count() {
let dir = tempfile::tempdir().expect("tempdir");
let root = PathBuf::from("/repo");
let mut breadcrumb = ScanBreadcrumb::begin_in(dir.path(), &root).expect("breadcrumb written");
breadcrumb.advance(PHASE_EXTRACT, Some(4096));
let extracting = read(dir.path()).expect("record present");
assert_eq!(extracting.phase, PHASE_EXTRACT);
assert_eq!(extracting.candidates, Some(4096));
breadcrumb.advance(PHASE_RESOLVE, None);
let resolving = read(dir.path()).expect("record present");
assert_eq!(resolving.phase, PHASE_RESOLVE);
assert_eq!(resolving.candidates, Some(4096));
assert_eq!(resolving.started_unix, extracting.started_unix, "the pass began once");
}
#[test]
fn a_breadcrumb_survives_a_process_that_never_runs_its_destructors() {
let dir = tempfile::tempdir().expect("tempdir");
let breadcrumb = ScanBreadcrumb::begin_in(dir.path(), Path::new("/repo")).expect("breadcrumb written");
std::mem::forget(breadcrumb);
let record = read(dir.path()).expect("a hard death leaves the record behind");
assert_eq!(record.pid, std::process::id());
assert!(clear(dir.path()), "and it can be acknowledged");
assert_eq!(read(dir.path()), None);
}
#[cfg(any(unix, windows))]
#[test]
fn only_records_whose_process_is_gone_count_as_stale() {
let dir = tempfile::tempdir().expect("tempdir");
let workspaces = dir.path().join("workspaces");
let live = workspaces.join("live");
let live_crumb = ScanBreadcrumb::begin_in(&live, Path::new("/repo/live")).expect("live breadcrumb");
let dead = workspaces.join("dead");
std::fs::create_dir_all(&dead).expect("create dead cache dir");
let corpse = ScanInflight {
started_unix: 1,
pid: 0x7FFF_FFFE,
version: "0.0.0".to_string(),
root: PathBuf::from("/"),
phase: PHASE_EXTRACT.to_string(),
candidates: Some(9),
};
std::fs::write(
inflight_path(&dead),
serde_json::to_vec(&corpse).expect("encode corpse"),
)
.expect("write corpse");
let stale = stale_inflight_scans_in(&workspaces);
assert_eq!(stale.len(), 1, "the running scan is not a crash: {stale:?}");
assert_eq!(stale[0].cache_dir, dead);
assert_eq!(stale[0].record.root, PathBuf::from("/"));
assert_eq!(stale[0].record.phase, PHASE_EXTRACT);
drop(live_crumb);
}
#[cfg(any(unix, windows))]
#[test]
fn enumerating_a_missing_workspaces_dir_reports_nothing_rather_than_failing() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(stale_inflight_scans_in(&dir.path().join("absent")).is_empty());
}
#[test]
fn a_snapshot_can_be_logged_without_a_subscriber_installed() {
log_memory_snapshot(Path::new("/repo"), Duration::from_secs(7));
}
}