use std::path::PathBuf;
use anyhow::{Context, Result};
const LOCK_FILE: &str = "watch.lock";
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Holder {
pid: u32,
module: String,
}
pub struct WatchLock {
path: PathBuf,
}
impl WatchLock {
pub fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for WatchLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub fn acquire(module: &str) -> Result<WatchLock> {
let directory = crate::auth::config_dir()?;
std::fs::create_dir_all(&directory)
.with_context(|| format!("create {}", directory.display()))?;
let path = directory.join(LOCK_FILE);
if let Some(holder) = live_holder(&path) {
anyhow::bail!(
"portaki dev --watch is already running on {} (pid {}) — stop it first, or run \
this one without --watch",
holder.module,
holder.pid
);
}
let _ = std::fs::remove_file(&path);
write(&path, module)?;
Ok(WatchLock { path })
}
fn write(path: &std::path::Path, module: &str) -> Result<()> {
use std::io::Write as _;
let holder = Holder {
pid: std::process::id(),
module: module.to_string(),
};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| {
format!(
"another portaki took the watch lock at {} just now",
path.display()
)
})?;
file.write_all(serde_json::to_string(&holder)?.as_bytes())
.with_context(|| format!("write {}", path.display()))?;
Ok(())
}
fn live_holder(path: &std::path::Path) -> Option<Holder> {
let raw = std::fs::read_to_string(path).ok()?;
let holder: Holder = serde_json::from_str(&raw).ok()?;
alive(holder.pid).then_some(holder)
}
fn alive(pid: u32) -> bool {
let Ok(output) = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
else {
return true;
};
if !output.status.success() {
return false;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.rsplit('/')
.next()
.is_some_and(|name| name.starts_with("portaki"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_running_process_holds_its_lock() {
assert!(alive(std::process::id()));
}
#[test]
fn a_dead_process_holds_nothing() {
assert!(!alive(0));
}
#[test]
fn a_recycled_pid_belonging_to_another_program_holds_nothing() {
assert!(!alive(1));
}
#[test]
fn a_damaged_lock_reads_as_free() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join(LOCK_FILE);
std::fs::write(&path, "pas du json").unwrap();
assert!(live_holder(&path).is_none());
}
#[test]
fn a_lock_naming_a_dead_process_reads_as_free() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join(LOCK_FILE);
std::fs::write(&path, r#"{"pid":0,"module":"weather"}"#).unwrap();
assert!(live_holder(&path).is_none());
}
#[test]
fn a_held_lock_names_who_holds_it() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join(LOCK_FILE);
write(&path, "wifi-guest").unwrap();
let holder = live_holder(&path).expect("le processus courant tient le verrou");
assert_eq!(holder.module, "wifi-guest");
assert_eq!(holder.pid, std::process::id());
}
#[test]
fn two_writers_cannot_both_take_it() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join(LOCK_FILE);
write(&path, "first").unwrap();
assert!(write(&path, "second").is_err());
}
}