//! Lazy workspaces (`on get --lazy`, Linux and macOS): the workspace folder //! is a mount (FUSE on Linux, an NFS server on localhost on macOS, both over //! the same overlay) that shows the change's whole tree at once and fetches //! a file from the host the first time it's opened. What's written goes to a real //! folder, the workspace's backing (ONSET_HOME/lazy/), which the //! mount shows over the tree; a file deleted from the tree is remembered as //! gone. Saves read the workspace like any other: its index starts with //! every file in the tree, at the sizes and times the mount shows them, so //! only what changed is read. `on mount` starts the mount again (after a //! restart, say); any Onset command run in the folder does that too. use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use crate::app::App; use crate::db::Workspace; use crate::paths; use crate::ws; #[cfg(target_os = "linux")] mod fuse; #[cfg(target_os = "macos")] mod nfs; #[cfg(any(target_os = "linux", target_os = "macos"))] mod overlay; /// The time a file the mount hasn't fetched yet shows (2000-01-01), in ns. pub const MTIME_NS: i64 = 946_684_800_123_456_789; /// Where a lazy workspace's own files are. pub fn backing(workspace: &str) -> PathBuf { paths::home().join("lazy").join(workspace) } pub fn is_lazy(workspace: &str) -> bool { ws::is_workspace(&backing(workspace)) } /// The inode number a path starts with, the same each time the mount /// starts, as the index records it. pub fn ino(path: &str) -> u64 { if path.is_empty() { return 1; } let id = onset_core::id::sha256(&[b"vcs inode\n", path.as_bytes()]); u64::from_le_bytes(id.0[..8].try_into().unwrap_or_default()).max(2) } /// Whether something is mounted at `root`. #[cfg(not(target_os = "macos"))] pub fn mounted(root: &Path) -> bool { let Ok(info) = std::fs::read_to_string("/proc/self/mountinfo") else { return false }; let want = root.to_string_lossy(); // Field 5 is the mount point, with spaces and such as octal escapes. info.lines().any(|line| line.split(' ').nth(4).is_some_and(|m| unescape(m) == want)) } #[cfg(not(target_os = "macos"))] fn unescape(text: &str) -> String { let bytes = text.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'\\' && i + 3 < bytes.len() && let Ok(n) = u8::from_str_radix(&text[i + 1..i + 4], 8) { out.push(n); i += 4; } else { out.push(bytes[i]); i += 1; } } String::from_utf8_lossy(&out).into_owned() } #[cfg(target_os = "macos")] pub use nfs::mounted; /// The lazy workspace whose folder is `root` or holds it (this machine's). pub fn workspace_at(app: &App, root: &Path) -> Result> { 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())) } /// Starts the mount for a lazy workspace, if it isn't running: `on mount /// --serve` in the background, and back once the folder shows the tree. pub fn mount(w: &Workspace) -> Result<()> { // A mount whose process is gone (a crash) comes down first. #[cfg(target_os = "macos")] nfs::clear_dead(&w.path, &backing(&w.id))?; if mounted(&w.path) { return Ok(()); } std::fs::create_dir_all(&w.path)?; let log = backing(&w.id).join(ws::DIR).join("mount.log"); let out = std::fs::File::create(&log)?; let mut command = std::process::Command::new(std::env::current_exe()?); command.arg("mount").arg("--serve").arg(&w.path).stdin(std::process::Stdio::null()).stdout(out.try_clone()?).stderr(out); #[cfg(unix)] { use std::os::unix::process::CommandExt; command.process_group(0); } let mut child = command.spawn()?; for _ in 0..200 { if mounted(&w.path) { return Ok(()); } if let Ok(Some(status)) = child.try_wait() { let said = std::fs::read_to_string(&log).unwrap_or_default(); return Err(stopped(&w.path, status, &said)); } std::thread::sleep(std::time::Duration::from_millis(50)); } Err(anyhow!("the mount at {} didn't start; see {}", w.path.display(), log.display())) } /// Why the mount process stopped before the mount was up. #[cfg(not(target_os = "macos"))] fn stopped(root: &Path, status: std::process::ExitStatus, said: &str) -> anyhow::Error { anyhow!("the mount at {} stopped ({status}): {}", root.display(), said.trim()) } #[cfg(target_os = "macos")] fn stopped(root: &Path, _status: std::process::ExitStatus, said: &str) -> anyhow::Error { nfs::stopped(root, said) } /// Stops a lazy workspace's mount (its backing keeps everything). #[cfg(not(target_os = "macos"))] pub fn unmount(root: &Path) -> Result<()> { if !mounted(root) { return Ok(()); } for tool in ["fusermount3", "fusermount"] { if std::process::Command::new(tool).arg("-u").arg(root).status().is_ok_and(|s| s.success()) { return Ok(()); } } Err(anyhow!("couldn't unmount {} (is something still using it?)", root.display())) } #[cfg(target_os = "macos")] pub use nfs::unmount; /// `on mount [folder] [--stop]`: a lazy workspace's mount, started again or stopped. pub fn run(app: &App, folder: Option, stop: bool, serve_now: bool) -> crate::out::Out { use crate::out::{Fail, Report}; let path = crate::sys::canonical(&app.cwd.join(folder.unwrap_or_else(|| PathBuf::from(".")))).map_err(|e| Fail::new("not_found", e.to_string()))?; if serve_now { serve(app, &path)?; return Ok(Report::new()); } let w = workspace_at(app, &path)?.ok_or_else(|| { Fail::new("not_found", format!("{} isn't a lazy workspace", path.display())).state("nothing changed").next("on get --lazy makes one") })?; let mut r = Report::new(); if stop { unmount(&w.path)?; r.line("stopped", format!("{} (its work is kept; on mount starts it again)", w.path.display())); } else { mount(&w)?; r.line("mounted", w.path.display().to_string()); } r.set("workspace", w.path.display().to_string()).set("mounted", !stop); Ok(r) } /// Whether this system has lazy workspaces. const LAZY: bool = cfg!(any(target_os = "linux", target_os = "macos")); /// What a file the mount hasn't fetched yet shows: its size, the fixed /// time, and the inode its path starts with. fn shown(path: &str, size: u64) -> ws::Stat { ws::Stat { size, mtime_ns: MTIME_NS, ino: ino(path) } } /// Makes workspace `workspace` a lazy one of `tree`: its folders and its /// files' sizes from the host, and the index the mount matches. Answers /// that index (the workspace's first). pub fn prepare(app: &App, project: &crate::db::Project, workspace: &str, tree: &onset_core::Id) -> Result { use onset_core::tree::{self, EntryKind}; if !LAZY { return Err(anyhow!("lazy workspaces need Linux or macOS for now; on get without --lazy works here")); } let remote = crate::remote::Remote::for_project(project); app.store.ingest(&remote.get_bytes(&format!("/trees/{tree}?folders=1"))?)?; #[derive(serde::Deserialize)] struct Sizes { sizes: std::collections::HashMap, } let sizes: Sizes = remote.get_json(&format!("/trees/{tree}/sizes"))?; // Sealed folders: ones this machine can't open are left out (a save keeps them as the change has them); // the rest come now, since the host's sizes are their sealed ones. let seals = crate::seal::Seals::load(&app.db, &project.id)?; let closed = seals.unreadable(); let all = tree::flatten(&app.store, tree)?; let sealed: Vec<&String> = all.keys().filter(|p| seals.folder_of(p).is_some() && !closed.iter().any(|f| tree::under(p, f))).collect(); if !sealed.is_empty() { app.store.ingest(&remote.post_for_bytes(&format!("/trees/{tree}/files"), &serde_json::json!({ "paths": sealed }))?)?; } let mut base = ws::Index::default(); for (path, e) in all { if closed.iter().any(|f| tree::under(&path, f)) { continue; } let size = if e.kind == EntryKind::Gitlink { 0 } else if seals.folder_of(&path).is_some() { app.store.read_file(&e.id).map(|b| b.len() as u64).unwrap_or(0) } else { sizes.sizes.get(&e.id.hex()).copied().unwrap_or(0) }; let stat = shown(&path, size); base.entries.insert(path, ws::IndexEntry { entry: e, stat }); } base.tree = Some(*tree); let dir = backing(workspace); std::fs::create_dir_all(dir.join(ws::DIR))?; base.save_as(&dir, "lazy-base")?; Ok(base) } #[cfg(target_os = "linux")] pub use fuse::serve; #[cfg(target_os = "macos")] pub use nfs::serve; #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn serve(_app: &App, _root: &Path) -> Result<()> { Err(anyhow!("lazy workspaces need Linux or macOS for now")) }