use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader, ErrorKind, Write};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError, Weak, mpsc};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use nix::sys::signal::kill;
use nix::unistd::{Pid, setsid};
use serde::{Deserialize, Serialize};
use crate::Trimmed;
use crate::fs::{Replacement, Shared};
use crate::store::{Base, Bases, Home, Lease, ROOT, Record};
use crate::update::{self, Updated};
const LINGER: Duration = Duration::from_secs(2);
const START: Duration = Duration::from_secs(10);
const MOUNT: Duration = Duration::from_secs(120);
const IDLE: Duration = Duration::from_secs(60);
#[cfg(target_os = "linux")]
const MAPS_KEPT: usize = 1024;
#[derive(Serialize, Deserialize)]
struct Request {
name: String,
dir: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
update: Option<Move>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
trim: bool,
}
#[derive(Serialize, Deserialize)]
struct Move {
next: Record,
given: bool,
}
impl Request {
fn dir(&self) -> PathBuf {
PathBuf::from(std::ffi::OsStr::from_bytes(&self.dir))
}
}
#[derive(Serialize, Deserialize)]
struct Reply {
error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
updated: Option<Updated>,
#[serde(default, skip_serializing_if = "Option::is_none")]
trimmed: Option<Trimmed>,
}
pub fn log_path(home: &Home) -> PathBuf {
home.run_dir().join("server.log")
}
fn socket_path(home: &Home) -> PathBuf {
home.run_dir().join("server.sock")
}
fn run_dir(home: &Home) -> Result<PathBuf> {
let dir = home.run_dir();
std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir)?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
Ok(dir)
}
pub fn mount(home: &Home, name: &str, dir: &Path) -> Result<()> {
let dir = dir.as_os_str().as_bytes().to_vec();
let request = serde_json::to_string(&Request { name: name.to_owned(), dir, update: None, trim: false })?;
let started = Instant::now();
let mut server: Option<Pid> = None;
let mut wait = Duration::from_millis(1);
loop {
if let Ok(stream) = UnixStream::connect(socket_path(home))
&& let Some(reply) = ask(stream, &request)?
{
return reply.error.map_or(Ok(()), |err| Err(anyhow!(err)));
}
if !server.is_some_and(|pid| kill(pid, None).is_ok()) {
server = Some(start(home)?);
}
if started.elapsed() > START {
bail!("the mount process did not start; see {}", log_path(home).display());
}
std::thread::sleep(wait);
wait = (wait * 2).min(Duration::from_millis(50));
}
}
pub fn update(home: &Home, name: &str, next: &Record, given: bool) -> Result<Updated> {
let update = Move { next: next.clone(), given };
let request =
serde_json::to_string(&Request { name: name.to_owned(), dir: Vec::new(), update: Some(update), trim: false })?;
let unserved =
|| anyhow!("{name} is mounted by a process that can't move it; unmount it first (sandfs unmount {name})");
let stream = UnixStream::connect(socket_path(home)).map_err(|_| unserved())?;
let reply = ask(stream, &request)?.ok_or_else(unserved)?;
match (reply.error, reply.updated) {
(Some(err), _) => Err(anyhow!(err)),
(None, Some(updated)) => Ok(updated),
(None, None) => Err(unserved()),
}
}
pub fn trim(home: &Home, name: &str) -> Result<Trimmed> {
let request = serde_json::to_string(&Request { name: name.to_owned(), dir: Vec::new(), update: None, trim: true })?;
let unserved = || anyhow!("{name} isn't mounted by this home's background process");
let Ok(stream) = UnixStream::connect(socket_path(home)) else {
return if name.is_empty() { Ok(Trimmed::default()) } else { Err(unserved()) };
};
match ask(stream, &request)? {
Some(Reply { error: Some(err), .. }) => Err(anyhow!(err)),
Some(Reply { trimmed: Some(trimmed), .. }) => Ok(trimmed),
Some(_) => Err(anyhow!("the background process can't trim; unmount and mount again to update it")),
None if name.is_empty() => Ok(Trimmed::default()),
None => Err(unserved()),
}
}
fn trim_served(name: &str) -> Result<Trimmed> {
let served: Vec<Arc<Served>> = served_list()
.iter()
.filter(|(served, _)| name.is_empty() || *served == name)
.filter_map(|(_, served)| served.upgrade())
.collect();
if !name.is_empty() && served.is_empty() {
bail!("{name} isn't mounted by this home's background process");
}
let mut failed = None;
for mount in &served {
if let Err(err) = mount.shared.trim() {
failed.get_or_insert(err);
}
}
let given_back = crate::memory::give_back().map(|bytes| bytes as u64);
match failed {
Some(err) => Err(err.context("saving before trimming")),
None => Ok(Trimmed { mounts: served.len(), given_back }),
}
}
fn trim_idle(idle: Duration) {
let idle: Vec<Arc<Served>> =
served_list().values().filter_map(Weak::upgrade).filter(|served| served.shared.idle_untrimmed(idle)).collect();
if idle.is_empty() {
return;
}
for mount in &idle {
if let Err(err) = mount.shared.trim() {
eprintln!("sandfs: trimming an unused mount: {err:#}");
}
}
crate::memory::give_back();
eprintln!("sandfs: trimmed {} mount(s) unused for {}s", idle.len(), IDLE.as_secs());
}
fn ask(mut stream: UnixStream, request: &str) -> Result<Option<Reply>> {
stream.set_read_timeout(Some(MOUNT))?;
if stream.write_all(format!("{request}\n").as_bytes()).is_err() {
return Ok(None);
}
let mut line = String::new();
match BufReader::new(stream).read_line(&mut line) {
Ok(0) => Ok(None),
Ok(_) => Ok(Some(serde_json::from_str(&line).context("reading the mount process's answer")?)),
Err(err) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
bail!("timed out waiting for the mount")
}
Err(err) if err.kind() == ErrorKind::ConnectionReset => Ok(None),
Err(err) => Err(err.into()),
}
}
fn start(home: &Home) -> Result<Pid> {
run_dir(home)?;
let log = std::fs::OpenOptions::new().create(true).append(true).open(log_path(home))?;
let mut command = crate::relaunch::command()?;
for (name, _) in std::env::vars_os() {
if name.to_str().is_some_and(names_a_repository) {
command.env_remove(name);
}
}
#[cfg(target_os = "linux")]
if std::env::var_os("MALLOC_MMAP_THRESHOLD_").is_none() {
command.env("MALLOC_MMAP_THRESHOLD_", "4194304");
}
let output = command
.arg("daemon")
.current_dir("/")
.env("SANDFS_HOME", home.root())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(log)
.output()
.context("starting the mount process")?;
let pid = String::from_utf8_lossy(&output.stdout).trim().parse::<i32>().ok();
match pid {
Some(pid) if output.status.success() => Ok(Pid::from_raw(pid)),
_ => bail!("the mount process did not start; see {}", log_path(home).display()),
}
}
fn names_a_repository(name: &str) -> bool {
const NAMES: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_IMPLICIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_GRAFT_FILE",
"GIT_SHALLOW_FILE",
"GIT_NO_REPLACE_OBJECTS",
"GIT_REPLACE_REF_BASE",
"GIT_NAMESPACE",
"GIT_PREFIX",
];
NAMES.contains(&name)
}
pub fn detach() -> Result<()> {
setsid().context("leaving the caller's session")?;
let server = crate::relaunch::command()?
.args(["daemon", "--serve"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()
.context("starting the mount process")?;
println!("{}", server.id());
Ok(())
}
pub fn run(home: Home) -> Result<()> {
let dir = run_dir(&home)?;
let lock = File::options().write(true).create(true).truncate(false).open(dir.join("server.lock"))?;
let deadline = Instant::now() + Duration::from_secs(1);
while lock.try_lock().is_err() {
if Instant::now() >= deadline {
return Ok(());
}
std::thread::sleep(Duration::from_millis(10));
}
let socket = socket_path(&home);
let _ = std::fs::remove_file(&socket);
let listener = UnixListener::bind(&socket).context("listening for mounts")?;
crate::host::raise_open_files();
let state = Arc::new(State::default());
let bases = Arc::new(Bases::default());
let accepting = state.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else {
std::thread::sleep(Duration::from_millis(10));
continue;
};
let Some(ticket) = accepting.enter() else { break };
let (home, bases) = (home.clone(), bases.clone());
std::thread::spawn(move || answer(&stream, &home, &bases, ticket));
}
});
std::thread::spawn(|| {
loop {
std::thread::sleep(IDLE / 4);
trim_idle(IDLE);
}
});
state.wait_until_idle(LINGER);
Ok(())
}
fn answer(mut stream: &UnixStream, home: &Home, bases: &Arc<Bases>, ticket: Ticket) {
let mut request = read_request(stream);
if let Ok(Request { name, trim: true, .. }) = &request {
let reply = match trim_served(name) {
Ok(trimmed) => Reply { error: None, updated: None, trimmed: Some(trimmed) },
Err(err) => Reply { error: Some(format!("{err:#}")), updated: None, trimmed: None },
};
if let Ok(reply) = serde_json::to_string(&reply) {
let _ = stream.write_all(format!("{reply}\n").as_bytes());
}
return;
}
if let Ok(Request { name, update: Some(next), .. }) = &mut request {
let reply = match move_served(home, bases, name, next) {
Ok(updated) => Reply { error: None, updated: Some(updated), trimmed: None },
Err(err) => Reply { error: Some(format!("{err:#}")), updated: None, trimmed: None },
};
if let Ok(reply) = serde_json::to_string(&reply) {
let _ = stream.write_all(format!("{reply}\n").as_bytes());
}
return;
}
let dir = request.as_ref().ok().map(Request::dir);
let mounted = request.and_then(|request| start_mount(home, bases, request, ticket));
let up = mounted.is_ok();
let reply = Reply { error: mounted.err().map(|err| format!("{err:#}")), updated: None, trimmed: None };
let told =
serde_json::to_string(&reply).is_ok_and(|reply| stream.write_all(format!("{reply}\n").as_bytes()).is_ok());
if let Some(dir) = dir.filter(|_| up && !told) {
eprintln!("sandfs: nobody waited for the mount at {}; unmounting it", dir.display());
let _ = crate::host::unmount(&dir, false);
}
}
fn read_request(stream: &UnixStream) -> Result<Request> {
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
let mut line = String::new();
BufReader::new(stream).read_line(&mut line)?;
serde_json::from_str(&line).context("reading a mount request")
}
fn start_mount(home: &Home, bases: &Arc<Bases>, request: Request, ticket: Ticket) -> Result<()> {
#[cfg(target_os = "linux")]
if let Some((left, max)) = crate::host::memory_maps_left(MAPS_KEPT)
&& left < MAPS_KEPT
{
bail!(
"this process serves as many mounts as its memory maps allow (vm.max_map_count = {max}); \
unmount some, or raise the limit, such as with `sysctl -w vm.max_map_count={}`",
max * 4
);
}
let (done, outcome) = mpsc::channel();
let up = done.clone();
let (home, bases) = (home.clone(), bases.clone());
std::thread::Builder::new()
.name(format!("mount {}", request.name))
.spawn(move || {
let _ticket = ticket;
let served = crate::serve(&home, &request.name, &request.dir(), &bases, move || {
let _ = up.send(Ok(()));
});
if let Err(err) = &served {
eprintln!("sandfs: {}: {err:#}", request.name);
}
crate::memory::give_back();
let _ = done.send(served);
})
.context("starting a mount")?;
outcome.recv().unwrap_or_else(|_| Err(anyhow!("the mount stopped unexpectedly")))
}
pub struct Served {
shared: Arc<Shared>,
lease: Mutex<Lease>,
moving: Mutex<()>,
retired: Mutex<Vec<File>>,
}
pub struct Serving {
name: String,
served: Arc<Served>,
}
pub fn serving(name: &str, shared: Arc<Shared>, lease: Lease) -> Serving {
let served =
Arc::new(Served { shared, lease: Mutex::new(lease), moving: Mutex::new(()), retired: Mutex::default() });
let mut all = served_list();
all.insert(name.to_owned(), Arc::downgrade(&served));
Serving { name: name.to_owned(), served }
}
fn served_list() -> std::sync::MutexGuard<'static, HashMap<String, Weak<Served>>> {
static SERVED: OnceLock<Mutex<HashMap<String, Weak<Served>>>> = OnceLock::new();
SERVED.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner)
}
impl Serving {
pub fn end(self, home: &Home) {
let retired = std::mem::take(&mut *self.served.retired.lock().unwrap_or_else(PoisonError::into_inner));
drop(self);
if retired.is_empty() {
return;
}
drop(retired);
let home = home.clone();
std::thread::spawn(move || {
if let Ok(_creating) = home.repos_lock(true) {
let _ = home.remove_unused_copies();
}
});
}
}
impl Drop for Serving {
fn drop(&mut self) {
let mut all = served_list();
if all.get(&self.name).is_some_and(|listed| std::ptr::eq(listed.as_ptr(), Arc::as_ptr(&self.served))) {
all.remove(&self.name);
}
}
}
fn move_served(home: &Home, bases: &Arc<Bases>, name: &str, next: &Move) -> Result<Updated> {
let served = served_list().get(name).and_then(Weak::upgrade);
let served = served.with_context(|| {
format!("{name} is mounted by a process that can't move it; unmount it first (sandfs unmount {name})")
})?;
let _moving = served.moving.lock().unwrap_or_else(PoisonError::into_inner);
let _repos = home.repos_lock(false)?;
let record = home.record(name)?;
let before = serde_json::to_value(&record)?;
let mut updated = None;
served.shared.replace(|| {
let moved = update::apply(home, name, next.next.clone(), next.given);
let failed = moved.is_err();
let changed = moved.as_ref().ok().and_then(|moved| moved.changed.clone());
updated = Some(moved);
if failed && serde_json::to_value(home.record(name)?)? == before {
return Ok(None);
}
let moved_off = match &record.base {
Base::Dir { path } if home.holds_copy(path) && home.record(name)?.base != record.base => {
Some(home.hold_copy(path)?)
}
_ => None,
};
let (opened, lease) = home.open_shared(name, bases)?;
*served.lease.lock().unwrap_or_else(PoisonError::into_inner) = lease;
served.retired.lock().unwrap_or_else(PoisonError::into_inner).extend(moved_off);
let full = |rel: &str| format!("{ROOT}/{rel}");
let git = matches!(opened.record.base, Base::Git { .. });
Ok(Some(Replacement {
times: crate::served_times(&opened),
ws: opened.ws,
saved: opened.saved,
dirs: opened.restored.dirs,
changed: changed.map(|changed| changed.iter().map(|rel| full(rel)).collect()),
whole: if git { vec![sandfs_core::VPath::parse(&full(".git"))?] } else { Vec::new() },
}))
})?;
updated.unwrap_or_else(|| Err(anyhow!("the mount stopped before {name} moved")))
}
#[derive(Default)]
struct State {
busy: Mutex<(usize, bool)>,
changed: Condvar,
}
struct Ticket(Arc<State>);
impl State {
fn enter(self: &Arc<Self>) -> Option<Ticket> {
let mut busy = self.busy.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if busy.1 {
return None;
}
busy.0 += 1;
Some(Ticket(self.clone()))
}
fn wait_until_idle(&self, linger: Duration) {
let mut busy = self.busy.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
loop {
if busy.0 > 0 {
busy = self.changed.wait_while(busy, |busy| busy.0 > 0).unwrap_or_else(|err| err.into_inner());
continue;
}
let (guard, idle) = self
.changed
.wait_timeout_while(busy, linger, |busy| busy.0 == 0)
.unwrap_or_else(|err| err.into_inner());
busy = guard;
if idle.timed_out() {
busy.1 = true;
return;
}
}
}
}
impl Drop for Ticket {
fn drop(&mut self) {
let mut busy = self.0.busy.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
busy.0 -= 1;
self.0.changed.notify_all();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_carry_any_directory_name() {
let dir = PathBuf::from(std::ffi::OsStr::from_bytes(b"/tmp/caf\xe9"));
let request =
Request { name: "ws".into(), dir: dir.as_os_str().as_bytes().to_vec(), update: None, trim: false };
let sent: Request = serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
assert_eq!(sent.dir(), dir);
}
#[test]
fn the_process_closes_only_after_lingering_idle() {
let state = Arc::new(State::default());
let ticket = state.enter().unwrap();
let waiter = {
let state = state.clone();
std::thread::spawn(move || {
state.wait_until_idle(Duration::from_millis(50));
Instant::now()
})
};
std::thread::sleep(Duration::from_millis(100));
assert!(!waiter.is_finished(), "a mount keeps the process");
let idle = Instant::now();
drop(ticket);
assert!(waiter.join().unwrap() >= idle + Duration::from_millis(50), "it lingers once idle");
assert!(state.enter().is_none(), "a closed process takes no more requests");
}
}