use std::fs;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Context;
use serde::Serialize;
use tokio::sync::Notify;
use tracing::{info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct BinFp {
pub dev: u64,
pub ino: u64,
pub mtime: i64,
pub size: u64,
}
impl BinFp {
pub fn snapshot(path: &Path) -> std::io::Result<Self> {
let m = fs::metadata(path)?;
Ok(Self {
dev: m.dev(),
ino: m.ino(),
mtime: m.mtime(),
size: m.size(),
})
}
}
pub struct ReloadCtx {
pub exe_path: PathBuf,
pub startup_fp: BinFp,
pub argv_tail: Vec<String>,
swap_in_flight: AtomicBool,
shutdown: std::sync::Arc<Notify>,
}
static CTX: OnceLock<ReloadCtx> = OnceLock::new();
pub fn init(
exe_path: PathBuf,
argv_tail: Vec<String>,
shutdown: std::sync::Arc<Notify>,
) -> anyhow::Result<()> {
let startup_fp = BinFp::snapshot(&exe_path)
.with_context(|| format!("snapshot fingerprint for {}", exe_path.display()))?;
let _ = CTX.set(ReloadCtx {
exe_path,
startup_fp,
argv_tail,
swap_in_flight: AtomicBool::new(false),
shutdown,
});
Ok(())
}
pub fn ctx() -> Option<&'static ReloadCtx> {
CTX.get()
}
pub fn current_fp() -> Option<BinFp> {
let c = ctx()?;
BinFp::snapshot(&c.exe_path).ok()
}
pub fn is_stale() -> bool {
let Some(c) = ctx() else {
return false;
};
match BinFp::snapshot(&c.exe_path) {
Ok(cur) => cur != c.startup_fp,
Err(e) => {
warn!(error = %e, "fingerprint stat failed");
false
}
}
}
pub fn was_swap_requested() -> bool {
ctx()
.map(|c| c.swap_in_flight.load(Ordering::SeqCst))
.unwrap_or(false)
}
pub fn request_swap_if_stale() {
let Some(c) = ctx() else {
return;
};
if !is_stale() {
return;
}
if c.swap_in_flight.swap(true, Ordering::SeqCst) {
return;
}
info!(exe = %c.exe_path.display(), "binary changed on disk; initiating swap");
c.shutdown.notify_waiters();
}
pub fn replace_process_image() -> anyhow::Result<std::convert::Infallible> {
use std::os::unix::process::CommandExt;
use std::process::Command;
let c = ctx().context("reload ctx not initialised")?;
info!(
exe = %c.exe_path.display(),
argv = ?c.argv_tail,
"replacing process image"
);
let mut cmd = Command::new(&c.exe_path);
cmd.args(&c.argv_tail);
let replace: fn(&mut Command) -> std::io::Error = CommandExt::exec;
let err = replace(&mut cmd);
Err(anyhow::anyhow!("process replacement failed: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn snapshot_then_modify_changes_fingerprint() {
let mut f = tempfile_shim::NamedTemp::new("alpha");
let p = f.path().to_owned();
let a = BinFp::snapshot(&p).unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
f.as_file_mut().write_all(b"-beta").unwrap();
f.as_file_mut().sync_all().unwrap();
let b = BinFp::snapshot(&p).unwrap();
assert_ne!(a, b, "fingerprint must change after file mutation");
}
#[test]
fn snapshot_stable_for_unchanged_file() {
let f = tempfile_shim::NamedTemp::new("steady");
let p = f.path().to_owned();
let a = BinFp::snapshot(&p).unwrap();
let b = BinFp::snapshot(&p).unwrap();
assert_eq!(a, b);
}
mod tempfile_shim {
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
pub struct NamedTemp {
path: PathBuf,
file: File,
}
impl NamedTemp {
pub fn new(contents: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("cek-reload-test-{nanos}"));
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&path)
.unwrap();
file.write_all(contents.as_bytes()).unwrap();
file.sync_all().unwrap();
Self { path, file }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn as_file_mut(&mut self) -> &mut File {
&mut self.file
}
}
impl Drop for NamedTemp {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
}
}