Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 1 | //! Lazy workspaces (`on get --lazy`, Linux, macOS and Windows): the workspace folder |
| 2 | //! is a mount (FUSE on Linux, an NFS server on localhost on macOS, both over |
| 3 | //! the same overlay) that shows the change's whole tree at once and fetches |
| 4 | //! a file from the host the first time it's opened. What's written goes to a real |
| 5 |
| 6 | //! mount shows over the tree; a file deleted from the tree is remembered as |
| 7 | //! gone. Saves read the workspace like any other: its index starts with |
| 8 | //! every file in the tree, at the sizes and times the mount shows them, so |
| 9 | //! only what changed is read. `on mount` starts the mount again (after a |
| 10 | //! restart, say); any Onset command run in the folder does that too. |
| 11 | //! Windows has no mount: lazy_windows.rs makes the folder a Cloud Files |
| 12 | //! sync root of placeholders instead. |
| 13 |
| 14 | use std::path::{Path, PathBuf}; |
| 15 |
| 16 | use anyhow::{Result, anyhow}; |
| 17 |
| 18 | use crate::app::App; |
| 19 | use crate::db::Workspace; |
| 20 | use crate::paths; |
| 21 | use crate::ws; |
| 22 |
| 23 | #[cfg(target_os = "linux")] |
| 24 | mod fuse; |
| 25 | #[cfg(target_os = "macos")] |
| 26 | mod nfs; |
| 27 | #[cfg(any(target_os = "linux", target_os = "macos"))] |
| 28 | mod overlay; |
| 29 |
| 30 | /// The time a file the mount hasn't fetched yet shows (2000-01-01), in ns. |
| 31 | pub const MTIME_NS: i64 = 946_684_800_123_456_789; |
| 32 |
| 33 | /// Where a lazy workspace's own files are. |
| 34 | pub fn backing(workspace: &str) -> PathBuf { |
| 35 | paths::home().join("lazy").join(workspace) |
| 36 | } |
| 37 |
| 38 | pub fn is_lazy(workspace: &str) -> bool { |
| 39 | ws::is_workspace(&backing(workspace)) |
| 40 | } |
| 41 |
| 42 | /// Where a lazy workspace's own files (`.onset`, copied folders) go: the |
| 43 | /// backing, which the mount shows; on Windows the folder itself, where |
| 44 | /// placeholders sit beside ordinary files. |
| 45 | pub fn home(w: &Workspace) -> PathBuf { |
| 46 | if cfg!(windows) { w.path.clone() } else { backing(&w.id) } |
| 47 | } |
| 48 |
| 49 | /// Lets go of a lazy workspace's folder before it's deleted (Windows: it |
| 50 | /// stops being a sync root). |
| 51 | pub fn forget(root: &Path) { |
| 52 | #[cfg(windows)] |
| 53 | crate::lazy_windows::forget(root); |
| 54 | #[cfg(not(windows))] |
| 55 | let _ = root; |
| 56 | } |
| 57 |
| 58 | /// The inode number a path starts with, the same each time the mount |
| 59 | /// starts, as the index records it. |
| 60 | pub fn ino(path: &str) -> u64 { |
| 61 | if path.is_empty() { |
| 62 | return 1; |
| 63 | } |
| 64 | let id = onset_core::id::sha256(&[b"vcs inode\n", path.as_bytes()]); |
| 65 | u64::from_le_bytes(id.0[..8].try_into().unwrap_or_default()).max(2) |
| 66 | } |
| 67 |
| 68 | #[cfg(windows)] |
| 69 | pub use crate::lazy_windows::{mount, mounted, serve, unmount}; |
| 70 |
| 71 | /// Whether something is mounted at `root`. |
| 72 | #[cfg(not(any(target_os = "macos", windows)))] |
| 73 | pub fn mounted(root: &Path) -> bool { |
| 74 | let Ok(info) = std::fs::read_to_string("/proc/self/mountinfo") else { return false }; |
| 75 | let want = root.to_string_lossy(); |
| 76 | // Field 5 is the mount point, with spaces and such as octal escapes. |
| 77 | info.lines().any(|line| line.split(' ').nth(4).is_some_and(|m| unescape(m) == want)) |
| 78 | } |
| 79 |
| 80 | #[cfg(not(any(target_os = "macos", windows)))] |
| 81 | fn unescape(text: &str) -> String { |
| 82 | let bytes = text.as_bytes(); |
| 83 | let mut out = Vec::with_capacity(bytes.len()); |
| 84 | let mut i = 0; |
| 85 | while i < bytes.len() { |
| 86 | if bytes[i] == b'\\' |
| 87 | && i + 3 < bytes.len() |
| 88 | && let Ok(n) = u8::from_str_radix(&text[i + 1..i + 4], 8) |
| 89 | { |
| 90 | out.push(n); |
| 91 | i += 4; |
| 92 | } else { |
| 93 | out.push(bytes[i]); |
| 94 | i += 1; |
| 95 | } |
| 96 | } |
| 97 | String::from_utf8_lossy(&out).into_owned() |
| 98 | } |
| 99 |
| 100 | #[cfg(target_os = "macos")] |
| 101 | pub use nfs::mounted; |
| 102 |
| 103 | /// The lazy workspace whose folder is `root` or holds it (this machine's). |
| 104 | pub fn workspace_at(app: &App, root: &Path) -> Result<Option<Workspace>> { |
| 105 | Ok(app.db.all_workspaces()?.into_iter().filter(|w| root.starts_with(&w.path) && is_lazy(&w.id)).max_by_key(|w| w.path.as_os_str().len())) |
| 106 | } |
| 107 |
| 108 | /// Starts the mount for a lazy workspace, if it isn't running: `on mount |
| 109 | /// --serve` in the background, and back once the folder shows the tree. |
| 110 | #[cfg(not(windows))] |
| 111 | pub fn mount(w: &Workspace) -> Result<()> { |
| 112 | // A mount whose process is gone (a crash) comes down first. |
| 113 | #[cfg(target_os = "macos")] |
| 114 | nfs::clear_dead(&w.path, &backing(&w.id))?; |
| 115 | #[cfg(not(target_os = "macos"))] |
| 116 | clear_dead(&w.path)?; |
| 117 | if mounted(&w.path) { |
| 118 | return Ok(()); |
| 119 | } |
| 120 | std::fs::create_dir_all(&w.path)?; |
| 121 | let log = backing(&w.id).join(ws::DIR).join("mount.log"); |
| 122 | let out = std::fs::File::create(&log)?; |
| 123 | let mut command = std::process::Command::new(std::env::current_exe()?); |
| 124 | command.arg("mount").arg("--serve").arg(&w.path).stdin(std::process::Stdio::null()).stdout(out.try_clone()?).stderr(out); |
| 125 | #[cfg(unix)] |
| 126 | { |
| 127 | use std::os::unix::process::CommandExt; |
| 128 | command.process_group(0); |
| 129 | } |
| 130 | let mut child = command.spawn()?; |
| 131 | for _ in 0..200 { |
| 132 | if mounted(&w.path) { |
| 133 | return Ok(()); |
| 134 | } |
| 135 | if let Ok(Some(status)) = child.try_wait() { |
| 136 | let said = std::fs::read_to_string(&log).unwrap_or_default(); |
| 137 | return Err(stopped(&w.path, status, &said)); |
| 138 | } |
| 139 | std::thread::sleep(std::time::Duration::from_millis(50)); |
| 140 | } |
| 141 | Err(anyhow!("the mount at {} didn't start; see {}", w.path.display(), log.display())) |
| 142 | } |
| 143 |
| 144 | /// Takes down a FUSE mount whose process is gone, so a new one can start: |
| 145 | /// it stays listed, and its folder answers "not connected" to everything. |
| 146 | #[cfg(not(any(target_os = "macos", windows)))] |
| 147 | fn clear_dead(root: &Path) -> Result<()> { |
| 148 | const NOT_CONNECTED: i32 = 107; |
| 149 | if !mounted(root) || std::fs::metadata(root).err().and_then(|e| e.raw_os_error()) != Some(NOT_CONNECTED) { |
| 150 | return Ok(()); |
| 151 | } |
| 152 | for tool in ["fusermount3", "fusermount"] { |
| 153 | if std::process::Command::new(tool).args(["-u", "-z"]).arg(root).status().is_ok_and(|s| s.success()) { |
| 154 | return Ok(()); |
| 155 | } |
| 156 | } |
| 157 | Err(anyhow!("couldn't take down the stopped mount at {}", root.display())) |
| 158 | } |
| 159 |
| 160 | /// Why the mount process stopped before the mount was up. |
| 161 | #[cfg(not(any(target_os = "macos", windows)))] |
| 162 | fn stopped(root: &Path, status: std::process::ExitStatus, said: &str) -> anyhow::Error { |
| 163 | anyhow!("the mount at {} stopped ({status}): {}", root.display(), said.trim()) |
| 164 | } |
| 165 |
| 166 | #[cfg(target_os = "macos")] |
| 167 | fn stopped(root: &Path, _status: std::process::ExitStatus, said: &str) -> anyhow::Error { |
| 168 | nfs::stopped(root, said) |
| 169 | } |
| 170 |
| 171 | /// Stops a lazy workspace's mount (its backing keeps everything). |
| 172 | #[cfg(not(any(target_os = "macos", windows)))] |
| 173 | pub fn unmount(root: &Path) -> Result<()> { |
| 174 | if !mounted(root) { |
| 175 | return Ok(()); |
| 176 | } |
| 177 | for tool in ["fusermount3", "fusermount"] { |
| 178 | if std::process::Command::new(tool).arg("-u").arg(root).status().is_ok_and(|s| s.success()) { |
| 179 | return Ok(()); |
| 180 | } |
| 181 | } |
| 182 | Err(anyhow!("couldn't unmount {} (is something still using it?)", root.display())) |
| 183 | } |
| 184 |
| 185 | #[cfg(target_os = "macos")] |
| 186 | pub use nfs::unmount; |
| 187 |
| 188 | /// `on mount [folder] [--stop]`: a lazy workspace's mount, started again or stopped. |
| 189 | pub fn run(app: &App, folder: Option<PathBuf>, stop: bool, serve_now: bool) -> crate::out::Out { |
| 190 | use crate::out::{Fail, Report}; |
| 191 | let path = crate::sys::canonical(&app.cwd.join(folder.unwrap_or_else(|| PathBuf::from(".")))).map_err(|e| Fail::new("not_found", e.to_string()))?; |
| 192 | if serve_now { |
| 193 | serve(app, &path)?; |
| 194 | return Ok(Report::new()); |
| 195 | } |
| 196 | let w = workspace_at(app, &path)?.ok_or_else(|| { |
| 197 | Fail::new("not_found", format!("{} isn't a lazy workspace", path.display())).state("nothing changed").next("on get <project> <folder> --lazy makes one") |
| 198 | })?; |
| 199 | let mut r = Report::new(); |
| 200 | if stop { |
| 201 | unmount(&w.path)?; |
| 202 | r.line("stopped", format!("{} (its work is kept; on mount starts it again)", w.path.display())); |
| 203 | } else { |
| 204 | mount(&w)?; |
| 205 | r.line("mounted", w.path.display().to_string()); |
| 206 | } |
| 207 | r.set("workspace", w.path.display().to_string()).set("mounted", !stop); |
| 208 | Ok(r) |
| 209 | } |
| 210 |
| 211 | /// Whether this system has lazy workspaces. |
| 212 | const LAZY: bool = cfg!(any(target_os = "linux", target_os = "macos", windows)); |
| 213 |
| 214 | /// What a file the mount hasn't fetched yet shows: its size, the fixed |
| 215 | /// time, and the inode its path starts with. |
| 216 | #[cfg(not(windows))] |
| 217 | fn shown(path: &str, size: u64) -> ws::Stat { |
| 218 | ws::Stat { size, mtime_ns: MTIME_NS, ino: ino(path) } |
| 219 | } |
| 220 |
| 221 | /// Windows: a placeholder's size, and a time within a second of the fixed |
| 222 | /// one, in Windows' 100 ns steps, that its path picks (files have no inode |
| 223 | /// numbers there, so a placeholder moved over another still shows it). |
| 224 | #[cfg(windows)] |
| 225 | fn shown(path: &str, size: u64) -> ws::Stat { |
| 226 | let step = (ino(path) % 10_000_000) as i64; |
| 227 | ws::Stat { size, mtime_ns: MTIME_NS / 100 * 100 + step * 100, ino: 0 } |
| 228 | } |
| 229 |
| 230 | /// Makes workspace `workspace` a lazy one of `tree`: its folders and its |
| 231 | /// files' sizes from the host, and the index the mount matches. Answers |
| 232 | /// that index (the workspace's first). |
| 233 | pub fn prepare(app: &App, project: &crate::db::Project, workspace: &str, tree: &onset_core::Id) -> Result<ws::Index> { |
| 234 | use onset_core::tree::{self, EntryKind}; |
| 235 | if !LAZY { |
| 236 | return Err(anyhow!("lazy workspaces need Linux, macOS or Windows; on get without --lazy works here")); |
| 237 | } |
| 238 | let remote = crate::remote::Remote::for_project(project); |
| 239 | app.store.ingest(&remote.get_bytes(&format!("/trees/{tree}?folders=1"))?)?; |
| 240 | #[derive(serde::Deserialize)] |
| 241 | struct Sizes { |
| 242 | sizes: std::collections::HashMap<String, u64>, |
| 243 | } |
| 244 | let sizes: Sizes = remote.get_json(&format!("/trees/{tree}/sizes"))?; |
| 245 | // Sealed folders: ones this machine can't open are left out (a save keeps them as the change has them); |
| 246 | // the rest come now, since the host's sizes are their sealed ones. |
| 247 | let seals = crate::seal::Seals::load(&app.db, &project.id)?; |
| 248 | let closed = seals.unreadable(); |
| 249 | let all = tree::flatten(&app.store, tree)?; |
| 250 | let sealed: Vec<&String> = all.keys().filter(|p| seals.folder_of(p).is_some() && !closed.iter().any(|f| tree::under(p, f))).collect(); |
| 251 | if !sealed.is_empty() { |
| 252 | app.store.ingest(&remote.post_for_bytes(&format!("/trees/{tree}/files"), &serde_json::json!({ "paths": sealed }))?)?; |
| 253 | } |
| 254 | let mut base = ws::Index::default(); |
| 255 | for (path, e) in all { |
| 256 | if closed.iter().any(|f| tree::under(&path, f)) { |
| 257 | continue; |
| 258 | } |
| 259 | let size = if e.kind == EntryKind::Gitlink { |
| 260 | 0 |
| 261 | } else if seals.folder_of(&path).is_some() { |
| 262 | app.store.read_file(&e.id).map(|b| b.len() as u64).unwrap_or(0) |
| 263 | } else { |
| 264 | sizes.sizes.get(&e.id.hex()).copied().unwrap_or(0) |
| 265 | }; |
| 266 | let stat = shown(&path, size); |
| 267 | base.entries.insert(path, ws::IndexEntry { entry: e, stat }); |
| 268 | } |
| 269 | base.tree = Some(*tree); |
| 270 | let dir = backing(workspace); |
| 271 | std::fs::create_dir_all(dir.join(ws::DIR))?; |
| 272 | base.save_as(&dir, "lazy-base")?; |
| 273 | Ok(base) |
| 274 | } |
| 275 |
| 276 |
| 277 | #[cfg(target_os = "linux")] |
| 278 | pub use fuse::serve; |
| 279 |
| 280 | #[cfg(target_os = "macos")] |
| 281 | pub use nfs::serve; |
| 282 |
| 283 | #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] |
| 284 | pub fn serve(_app: &App, _root: &Path) -> Result<()> { |
| 285 | Err(anyhow!("lazy workspaces need Linux, macOS or Windows")) |
| 286 | } |
| 287 |