use std::{
path::{Path, PathBuf},
time::Duration,
};
use crate::error::{LogoxideError, Result};
pub const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
const POLL: Duration = Duration::from_millis(50);
#[derive(Debug)]
#[must_use = "the lock is released when the guard is dropped"]
pub struct Guard {
#[cfg(not(target_arch = "wasm32"))]
_file: Option<std::fs::File>,
}
pub fn lock_dir() -> PathBuf {
crate::library::env_dir("XDG_RUNTIME_DIR")
.map(|d| d.join("logoxide"))
.unwrap_or_else(|| crate::Library::default_cache().join("locks"))
}
pub fn lock_path_in(dir: &Path, folder: &Path) -> PathBuf {
use sha2::{Digest, Sha256};
let canonical = folder
.canonicalize()
.or_else(|_| std::path::absolute(folder))
.unwrap_or_else(|_| folder.to_path_buf());
let hash = Sha256::digest(canonical.to_string_lossy().as_bytes());
let hex: String = hash.iter().take(16).map(|b| format!("{b:02x}")).collect();
dir.join(format!("{hex}.lock"))
}
pub fn lock_path(folder: &Path) -> PathBuf {
lock_path_in(&lock_dir(), folder)
}
pub fn lock(folder: &Path, what: &str) -> Result<Guard> {
lock_with(folder, what, LOCK_TIMEOUT)
}
pub fn lock_with(folder: &Path, what: &str, timeout: Duration) -> Result<Guard> {
lock_file(
&lock_path(folder),
&format!("{what} {}", folder.display()),
timeout,
)
}
pub fn lock_all(folders: &[(&Path, &str)]) -> Result<Vec<Guard>> {
let mut wanted: Vec<(PathBuf, &Path, &str)> = folders
.iter()
.map(|(folder, what)| (lock_path(folder), *folder, *what))
.collect();
wanted.sort_by(|a, b| a.0.cmp(&b.0));
wanted.dedup_by(|a, b| a.0 == b.0);
wanted
.iter()
.map(|(path, folder, what)| {
lock_file(path, &format!("{what} {}", folder.display()), LOCK_TIMEOUT)
})
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn lock_file(path: &Path, shown: &str, timeout: Duration) -> Result<Guard> {
use std::io::{Read, Seek, Write};
let name = path.display().to_string();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| LogoxideError::io(&name, e))?;
}
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(|e| LogoxideError::io(&name, e))?;
let started = std::time::Instant::now();
loop {
match file.try_lock() {
Ok(()) => break,
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Err(LogoxideError::io(&name, e)),
}
if started.elapsed() >= timeout {
let mut holder = String::new();
let _ = file.read_to_string(&mut holder);
let holder = match holder.trim() {
"" => String::from("another process"),
who => format!("another process ({})", crate::text::printable(who)),
};
return Err(LogoxideError::Busy {
what: shown.to_string(),
message: format!(
"in use by {holder} for more than {} s: try again when it's done",
timeout.as_secs()
),
});
}
std::thread::sleep(POLL);
}
let _ = file
.set_len(0)
.and_then(|()| file.rewind())
.and_then(|()| file.write_all(holder_name().as_bytes()));
Ok(Guard { _file: Some(file) })
}
#[cfg(target_arch = "wasm32")]
pub fn lock_file(_path: &Path, _shown: &str, _timeout: Duration) -> Result<Guard> {
Ok(Guard {})
}
#[cfg(not(target_arch = "wasm32"))]
fn holder_name() -> String {
let words: Vec<String> = std::env::args()
.take(2)
.enumerate()
.map(|(i, arg)| match i {
0 => Path::new(&arg)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or(arg),
_ => arg,
})
.filter(|a| !a.starts_with('-'))
.collect();
format!("{}, pid {}", words.join(" "), std::process::id())
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn a_held_lock_makes_the_next_taker_busy() {
let dir = std::env::temp_dir().join(format!("logoxide-locking-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let held = lock(&dir, "the library at").unwrap();
let error = lock_with(&dir, "the library at", Duration::from_millis(120)).unwrap_err();
assert!(matches!(error, LogoxideError::Busy { .. }), "{error}");
let text = error.to_string();
assert!(
text.contains("in use by another process (") && text.contains("pid"),
"{text}"
);
drop(held);
let _again = lock_with(&dir, "the library at", Duration::from_millis(120)).unwrap();
assert_eq!(lock_path(&dir), lock_path(&dir.join(".")));
assert_ne!(lock_path(&dir), lock_path(&dir.join("new")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn locks_on_one_folder_are_taken_once() {
let dir = std::env::temp_dir().join(format!("logoxide-lock-all-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let guards = lock_all(&[(&dir, "the library at"), (&dir, "the project at")]).unwrap();
assert_eq!(guards.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
}