use std::fs;
use std::io::{ErrorKind, Write as _};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Context as _;
const STALE_LOCK: Duration = Duration::from_secs(10 * 60);
pub(super) struct Scratch {
pub(super) path: PathBuf,
}
impl Scratch {
pub(super) fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[derive(Debug)]
pub(super) struct UpdateLock {
path: PathBuf,
}
impl UpdateLock {
pub(super) fn acquire(exe: &Path) -> anyhow::Result<Self> {
let path = append_to_name(exe, ".update-lock");
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
let _ = write!(file, "{}", std::process::id());
Ok(Self { path })
}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
if is_stale_lock(&path) {
let _ = fs::remove_file(&path);
return Self::acquire(exe);
}
Err(anyhow::anyhow!(
"another `pmpx self update` looks like it is running ({}).\n\
If it is not, delete that file and try again.",
path.display()
))
}
Err(e) => Err(e).with_context(|| format!("cannot create {}", path.display())),
}
}
}
impl Drop for UpdateLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn is_stale_lock(path: &Path) -> bool {
fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(lock_is_stale)
}
fn lock_is_stale(age: Duration) -> bool {
age > STALE_LOCK
}
pub(super) fn remove_dir_if_exists(dir: &Path) {
let _ = fs::remove_dir_all(dir);
}
#[cfg(unix)]
pub(super) fn replace(exe: &Path, staged: &Path) -> anyhow::Result<()> {
fs::rename(staged, exe).with_context(|| format!("cannot replace {}", exe.display()))
}
#[cfg(windows)]
pub(super) fn replace(exe: &Path, staged: &Path) -> anyhow::Result<()> {
let old = append_to_name(exe, ".old");
let removed_old = match fs::remove_file(&old) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
};
fs::rename(exe, &old).with_context(|| match &removed_old {
Ok(()) => format!(
"cannot move the running binary aside to {} -- is that directory writable by you?",
old.display()
),
Err(e) => format!(
"cannot replace the leftover {} ({e}) -- close whatever is still running it and try \
again",
old.display()
),
})?;
if let Err(error) = fs::rename(staged, exe) {
let error = anyhow::Error::new(error).context(format!(
"cannot install the new binary at {}",
exe.display()
));
return Err(match fs::rename(&old, exe) {
Ok(()) => {
let _ = fs::remove_file(staged);
error
}
Err(rollback) => error.context(format!(
"putting the old binary back failed too ({rollback}); it is still at {}",
old.display()
)),
});
}
let _ = fs::remove_file(&old);
Ok(())
}
pub fn cleanup_stale_old() {
let Ok(exe) = std::env::current_exe() else {
return;
};
let _ = fs::remove_file(append_to_name(&exe, ".new"));
#[cfg(windows)]
let _ = fs::remove_file(append_to_name(&exe, ".old"));
}
pub(super) fn append_to_name(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replacing_puts_the_new_binary_in_place_of_the_old() {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("pmpx");
let staged = append_to_name(&exe, ".new");
std::fs::write(&exe, b"old").unwrap();
std::fs::write(&staged, b"new").unwrap();
replace(&exe, &staged).expect("should replace");
assert_eq!(std::fs::read(&exe).unwrap(), b"new");
assert!(!staged.exists(), "the staged file should have been moved");
}
#[test]
fn the_backup_name_keeps_the_extension() {
assert_eq!(
append_to_name(Path::new("/usr/local/bin/pmpx"), ".new"),
PathBuf::from("/usr/local/bin/pmpx.new")
);
assert_eq!(
append_to_name(Path::new(r"C:\bin\pmpx.exe"), ".old"),
PathBuf::from(r"C:\bin\pmpx.exe.old")
);
}
#[test]
fn a_second_update_is_refused_while_the_lock_is_held() {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("pmpx");
let held = UpdateLock::acquire(&exe).expect("the first lock should be free");
let second = UpdateLock::acquire(&exe);
assert!(second.is_err(), "a second update must not start");
assert!(
second.unwrap_err().to_string().contains(".update-lock"),
"the message should name the file to remove"
);
drop(held);
UpdateLock::acquire(&exe).expect("after the lock is released it is free again");
}
#[test]
fn an_old_lock_counts_as_a_leftover() {
assert!(!lock_is_stale(Duration::from_secs(1)));
assert!(!lock_is_stale(STALE_LOCK));
assert!(lock_is_stale(STALE_LOCK + Duration::from_secs(1)));
}
}