Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | //! It watches the folders that hold tracked files (one watch each on Linux, |
| 7 | //! one per workspace on macOS) and also looks at every active workspace |
| 8 | //! every 30 seconds, for anything the watches missed. Set ONSET_SAVER=0 to |
| 9 | //! turn it off. |
| 10 |
| 11 | use std::collections::{HashMap, HashSet}; |
| 12 | use std::path::{Path, PathBuf}; |
| 13 | use std::sync::mpsc; |
| 14 | use std::time::{Duration, Instant}; |
| 15 |
| 16 | use notify::{RecursiveMode, Watcher}; |
| 17 |
| 18 | use crate::app::App; |
| 19 | use crate::out::{Out, Report}; |
| 20 | use crate::paths; |
| 21 | use crate::ws::{self, Index}; |
| 22 |
| 23 | const QUIET: Duration = Duration::from_millis(1500); |
| 24 | const SWEEP: Duration = Duration::from_secs(30); |
| 25 | const IDLE_EXIT: Duration = Duration::from_secs(30 * 60); |
| 26 | /// Workspaces used in the last day are watched. |
| 27 | const ACTIVE_SECS: i64 = 24 * 3600; |
| 28 |
| 29 | fn enabled() -> bool { |
| 30 | crate::paths::var("SAVER").is_none_or(|v| v != "0") |
| 31 | } |
| 32 |
| 33 | /// Starts the saver in the background if it isn't running. |
| 34 | pub fn ensure_running() { |
| 35 | if !enabled() { |
| 36 | return; |
| 37 | } |
| 38 | let lock_path = paths::home().join("saver.lock"); |
| 39 | let Ok(file) = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&lock_path) else { return }; |
| 40 | use fs4::fs_std::FileExt; |
| 41 | match file.try_lock_exclusive() { |
| 42 | Ok(true) => { |
| 43 | // Nobody holds it: release and start one. |
| 44 | let _ = fs4::fs_std::FileExt::unlock(&file); |
| 45 | } |
| 46 | _ => return, |
| 47 | } |
| 48 | let Ok(exe) = std::env::current_exe() else { return }; |
| 49 | let mut cmd = std::process::Command::new(exe); |
| 50 | cmd.arg("saver").stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); |
| 51 | crate::sys::detach(&mut cmd); |
| 52 | let _ = cmd.spawn(); |
| 53 | } |
| 54 |
| 55 | pub fn run(app: &App) -> Out { |
| 56 | let lock_path = paths::home().join("saver.lock"); |
| 57 | let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&lock_path)?; |
| 58 | use fs4::fs_std::FileExt; |
| 59 | if !matches!(file.try_lock_exclusive(), Ok(true)) { |
| 60 | let mut r = Report::new(); |
| 61 | r.line("saver", "already running"); |
| 62 | return Ok(r); |
| 63 | } |
| 64 | let (tx, rx) = mpsc::channel::<PathBuf>(); |
| 65 | let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| { |
| 66 | if let Ok(e) = event { |
| 67 | for p in e.paths { |
| 68 | let _ = tx.send(p); |
| 69 | } |
| 70 | } |
| 71 | }) |
| 72 | .map_err(|e| anyhow::anyhow!("starting the file watcher: {e}"))?; |
| 73 |
| 74 | let mut watched: HashSet<PathBuf> = HashSet::new(); |
| 75 | let mut roots: Vec<PathBuf> = Vec::new(); |
| 76 | let mut pending: HashMap<PathBuf, Instant> = HashMap::new(); |
| 77 | let mut last_activity = Instant::now(); |
| 78 | let mut last_sweep = Instant::now() - SWEEP; |
| 79 | // Once a day, the saver cleans up what nothing needs any more. |
| 80 | crate::gc::daily(app); |
| 81 |
| 82 | loop { |
| 83 | // Refresh the set of active workspaces and what to watch. |
| 84 | if last_sweep.elapsed() >= SWEEP { |
| 85 | roots = active_roots(app); |
| 86 | let wanted = watch_set(&roots); |
| 87 | for dir in wanted.difference(&watched.clone()) { |
| 88 | let mode = if cfg!(target_os = "macos") { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive }; |
| 89 | if watcher.watch(dir, mode).is_ok() { |
| 90 | watched.insert(dir.clone()); |
| 91 | } |
| 92 | } |
| 93 | for dir in watched.clone().difference(&wanted) { |
| 94 | let _ = watcher.unwatch(dir); |
| 95 | watched.remove(dir); |
| 96 | } |
| 97 | // The sweep: save anything that changed without an event. |
| 98 | for root in &roots { |
| 99 | if save(app, root) { |
| 100 | last_activity = Instant::now(); |
| 101 | } |
| 102 | } |
| 103 | last_sweep = Instant::now(); |
| 104 | } |
| 105 | // Collect events for up to a second. |
| 106 | let deadline = Instant::now() + Duration::from_secs(1); |
| 107 | while let Ok(path) = rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { |
| 108 | if path.components().any(|c| c.as_os_str() == ws::DIR) { |
| 109 | continue; |
| 110 | } |
| 111 | if let Some(root) = roots.iter().filter(|r| path.starts_with(r)).max_by_key(|r| r.components().count()) { |
| 112 | pending.insert(root.clone(), Instant::now()); |
| 113 | } |
| 114 | } |
| 115 | // Save workspaces that have been quiet for a moment. |
| 116 | let ready: Vec<PathBuf> = pending.iter().filter(|(_, at)| at.elapsed() >= QUIET).map(|(r, _)| r.clone()).collect(); |
| 117 | for root in ready { |
| 118 | pending.remove(&root); |
| 119 | if save(app, &root) { |
| 120 | last_activity = Instant::now(); |
| 121 | } |
| 122 | } |
| 123 | if last_activity.elapsed() >= IDLE_EXIT && pending.is_empty() { |
| 124 | break; |
| 125 | } |
| 126 | } |
| 127 | let mut r = Report::new(); |
| 128 | r.line("saver", "stopped after 30 minutes with nothing to save"); |
| 129 | Ok(r) |
| 130 | } |
| 131 |
| 132 | fn active_roots(app: &App) -> Vec<PathBuf> { |
| 133 | let now = crate::ids::now(); |
| 134 | let rows: Vec<(String, i64)> = app |
| 135 | .db |
| 136 | .conn |
| 137 | .prepare("SELECT path, used FROM workspaces") |
| 138 | .and_then(|mut s| s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect()) |
| 139 | .unwrap_or_default(); |
| 140 | rows.into_iter() |
| 141 | .filter(|(_, used)| now - used < ACTIVE_SECS) |
| 142 | .map(|(p, _)| PathBuf::from(p)) |
| 143 | .filter(|p| ws::is_workspace(p) && p.join(ws::DIR).join("workspace").is_file()) |
| 144 | .collect() |
| 145 | } |
| 146 |
| 147 | /// The folders to watch: every folder holding a tracked file (Linux), or |
| 148 | /// each workspace root (macOS, where one watch covers a tree). |
| 149 | fn watch_set(roots: &[PathBuf]) -> HashSet<PathBuf> { |
| 150 | let mut out = HashSet::new(); |
| 151 | for root in roots { |
| 152 | out.insert(root.clone()); |
| 153 | if cfg!(target_os = "macos") { |
| 154 | continue; |
| 155 | } |
| 156 | for path in Index::load(root).entries.keys() { |
| 157 | let mut dir = Path::new(path).parent(); |
| 158 | while let Some(d) = dir { |
| 159 | if d.as_os_str().is_empty() || !out.insert(root.join(d)) { |
| 160 | break; |
| 161 | } |
| 162 | dir = d.parent(); |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | out |
| 167 | } |
| 168 |
| 169 | /// Saves one workspace unless a command is busy in it. True if it saved |
| 170 | /// something new. |
| 171 | fn save(app: &App, root: &Path) -> bool { |
| 172 | let Ok(Some(_probe)) = ws::try_lock(root) else { return false }; |
| 173 | drop(_probe); |
| 174 | let Ok(mut session) = app.session_at(root) else { return false }; |
| 175 | let before = app.db.change(&session.ws.change).ok().flatten().map(|c| c.saves); |
| 176 | match app.save(&mut session) { |
| 177 | Ok(c) => Some(c.saves) != before, |
| 178 | Err(_) => false, |
| 179 | } |
| 180 | } |
| 181 |