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