//! The saver: one background process per machine that saves any workspace //! a few seconds after its files change, so work between commands isn't //! lost. Commands start it when it isn't running; it stops after half an //! hour with nothing to do. //! //! It watches the folders that hold tracked files (one watch each on Linux, //! one per workspace on macOS) and also looks at every active workspace //! every 30 seconds, for anything the watches missed. Set ONSET_SAVER=0 to //! turn it off. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::{Duration, Instant}; use notify::{RecursiveMode, Watcher}; use crate::app::App; use crate::out::{Out, Report}; use crate::paths; use crate::ws::{self, Index}; const QUIET: Duration = Duration::from_millis(1500); const SWEEP: Duration = Duration::from_secs(30); const IDLE_EXIT: Duration = Duration::from_secs(30 * 60); /// Workspaces used in the last day are watched. const ACTIVE_SECS: i64 = 24 * 3600; fn enabled() -> bool { crate::paths::var("SAVER").is_none_or(|v| v != "0") } /// Starts the saver in the background if it isn't running. pub fn ensure_running() { if !enabled() { return; } let lock_path = paths::home().join("saver.lock"); let Ok(file) = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&lock_path) else { return }; use fs4::fs_std::FileExt; match file.try_lock_exclusive() { Ok(true) => { // Nobody holds it: release and start one. let _ = fs4::fs_std::FileExt::unlock(&file); } _ => return, } let Ok(exe) = std::env::current_exe() else { return }; let mut cmd = std::process::Command::new(exe); cmd.arg("saver").stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); crate::sys::detach(&mut cmd); let _ = cmd.spawn(); } pub fn run(app: &App) -> Out { let lock_path = paths::home().join("saver.lock"); let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&lock_path)?; use fs4::fs_std::FileExt; if !matches!(file.try_lock_exclusive(), Ok(true)) { let mut r = Report::new(); r.line("saver", "already running"); return Ok(r); } let (tx, rx) = mpsc::channel::(); let mut watcher = notify::recommended_watcher(move |event: notify::Result| { if let Ok(e) = event { for p in e.paths { let _ = tx.send(p); } } }) .map_err(|e| anyhow::anyhow!("starting the file watcher: {e}"))?; let mut watched: HashSet = HashSet::new(); let mut roots: Vec = Vec::new(); let mut pending: HashMap = HashMap::new(); let mut last_activity = Instant::now(); let mut last_sweep = Instant::now() - SWEEP; // Once a day, the saver cleans up what nothing needs any more. crate::gc::daily(app); loop { // Refresh the set of active workspaces and what to watch. if last_sweep.elapsed() >= SWEEP { roots = active_roots(app); let wanted = watch_set(&roots); for dir in wanted.difference(&watched.clone()) { let mode = if cfg!(target_os = "macos") { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive }; if watcher.watch(dir, mode).is_ok() { watched.insert(dir.clone()); } } for dir in watched.clone().difference(&wanted) { let _ = watcher.unwatch(dir); watched.remove(dir); } // The sweep: save anything that changed without an event. for root in &roots { if save(app, root) { last_activity = Instant::now(); } } last_sweep = Instant::now(); } // Collect events for up to a second. let deadline = Instant::now() + Duration::from_secs(1); while let Ok(path) = rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { if path.components().any(|c| c.as_os_str() == ws::DIR) { continue; } if let Some(root) = roots.iter().filter(|r| path.starts_with(r)).max_by_key(|r| r.components().count()) { pending.insert(root.clone(), Instant::now()); } } // Save workspaces that have been quiet for a moment. let ready: Vec = pending.iter().filter(|(_, at)| at.elapsed() >= QUIET).map(|(r, _)| r.clone()).collect(); for root in ready { pending.remove(&root); if save(app, &root) { last_activity = Instant::now(); } } if last_activity.elapsed() >= IDLE_EXIT && pending.is_empty() { break; } } let mut r = Report::new(); r.line("saver", "stopped after 30 minutes with nothing to save"); Ok(r) } fn active_roots(app: &App) -> Vec { let now = crate::ids::now(); let rows: Vec<(String, i64)> = app .db .conn .prepare("SELECT path, used FROM workspaces") .and_then(|mut s| s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect()) .unwrap_or_default(); rows.into_iter() .filter(|(_, used)| now - used < ACTIVE_SECS) .map(|(p, _)| PathBuf::from(p)) .filter(|p| ws::is_workspace(p) && p.join(ws::DIR).join("workspace").is_file()) .collect() } /// The folders to watch: every folder holding a tracked file (Linux), or /// each workspace root (macOS, where one watch covers a tree). fn watch_set(roots: &[PathBuf]) -> HashSet { let mut out = HashSet::new(); for root in roots { out.insert(root.clone()); if cfg!(target_os = "macos") { continue; } for path in Index::load(root).entries.keys() { let mut dir = Path::new(path).parent(); while let Some(d) = dir { if d.as_os_str().is_empty() || !out.insert(root.join(d)) { break; } dir = d.parent(); } } } out } /// Saves one workspace unless a command is busy in it. True if it saved /// something new. fn save(app: &App, root: &Path) -> bool { let Ok(Some(_probe)) = ws::try_lock(root) else { return false }; drop(_probe); let Ok(mut session) = app.session_at(root) else { return false }; let before = app.db.change(&session.ws.change).ok().flatten().map(|c| c.saves); match app.save(&mut session) { Ok(c) => Some(c.saves) != before, Err(_) => false, } }