use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FreezeMarker {
pub pid: u32,
pub reason: String,
pub incident: bool,
pub at: String,
}
impl FreezeMarker {
pub fn remedy(&self) -> &'static str {
if self.incident {
":incident END"
} else {
":thaw-deploys"
}
}
}
fn marker_path() -> PathBuf {
crate::util::cache_dir().join("freeze.json")
}
pub fn write_marker(reason: &str, incident: bool) -> std::io::Result<()> {
write_marker_at(&marker_path(), std::process::id(), reason, incident)
}
fn write_marker_at(path: &Path, pid: u32, reason: &str, incident: bool) -> std::io::Result<()> {
let body = format!(
"{{\"pid\":{pid},\"reason\":{},\"incident\":{incident},\"at\":{}}}\n",
crate::util::json_string(reason),
crate::util::json_string(&chrono::Utc::now().to_rfc3339()),
);
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
crate::util::write_secure(&tmp, body.as_bytes())?;
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
tracing::warn!(error = %e, "could not persist freeze marker — cross-process enforcement inactive");
return Err(e);
}
Ok(())
}
pub fn clear_marker_if_own() {
clear_if_pid_at(&marker_path(), std::process::id());
}
fn clear_if_pid_at(path: &Path, own_pid: u32) {
if let Some(m) = parse_file(path) {
if m.pid == own_pid {
let _ = std::fs::remove_file(path);
}
}
}
pub fn read_active() -> Option<FreezeMarker> {
read_active_with(&marker_path(), pid_alive)
}
fn read_active_with(path: &Path, alive: impl Fn(u32) -> bool) -> Option<FreezeMarker> {
let m = parse_file(path)?;
if alive(m.pid) {
return Some(m);
}
if parse_file(path).map(|m2| m2.pid) == Some(m.pid) {
let _ = std::fs::remove_file(path);
}
None
}
fn parse_file(path: &Path) -> Option<FreezeMarker> {
let text = std::fs::read_to_string(path).ok()?;
parse_marker(&text)
}
pub fn parse_marker(text: &str) -> Option<FreezeMarker> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
Some(FreezeMarker {
pid: v.get("pid")?.as_u64()? as u32,
reason: v.get("reason")?.as_str()?.to_string(),
incident: v.get("incident").and_then(|b| b.as_bool()).unwrap_or(false),
at: v
.get("at")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string(),
})
}
#[cfg(unix)]
fn pid_alive(pid: u32) -> bool {
let r = unsafe { libc::kill(pid as libc::pid_t, 0) };
r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
fn pid_alive(_pid: u32) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("ebman-freeze-{}-{name}.json", std::process::id()))
}
#[test]
fn marker_round_trips() {
let p = tmp("rt");
write_marker_at(&p, 4242, "checkout 5xx", true).unwrap();
let m = parse_file(&p).expect("parses");
assert_eq!(m.pid, 4242);
assert_eq!(m.reason, "checkout 5xx");
assert!(m.incident);
assert_eq!(m.remedy(), ":incident END");
let _ = std::fs::remove_file(&p);
}
#[test]
fn dead_pid_marker_is_ignored_and_cleaned() {
let p = tmp("dead");
write_marker_at(&p, 4242, "stale", false).unwrap();
assert!(read_active_with(&p, |_| false).is_none());
assert!(!p.exists(), "stale marker must be removed by the reader");
}
#[test]
fn live_pid_marker_is_active() {
let p = tmp("live");
write_marker_at(&p, 4242, "deploy freeze", false).unwrap();
let m = read_active_with(&p, |_| true).expect("active");
assert_eq!(m.remedy(), ":thaw-deploys");
assert!(p.exists());
let _ = std::fs::remove_file(&p);
}
#[test]
fn clear_only_removes_own_marker() {
let p = tmp("own");
write_marker_at(&p, 111, "someone else's", false).unwrap();
clear_if_pid_at(&p, 222);
assert!(p.exists(), "another session's marker survives");
clear_if_pid_at(&p, 111);
assert!(!p.exists());
}
#[test]
fn reader_cleanup_does_not_delete_a_freshly_written_live_marker() {
let p = tmp("toctou");
write_marker_at(&p, 4242, "dead session", false).unwrap();
let overwritten = std::cell::Cell::new(false);
let result = read_active_with(&p, |_pid| {
if !overwritten.get() {
write_marker_at(&p, 111, "new live freeze", true).unwrap();
overwritten.set(true);
}
false });
assert!(
result.is_none(),
"original dead marker not returned as active"
);
assert!(p.exists(), "the freshly-written live marker must survive");
let m = parse_file(&p).unwrap();
assert_eq!(m.pid, 111, "live marker intact");
let _ = std::fs::remove_file(&p);
}
#[test]
fn corrupt_marker_never_blocks() {
let p = tmp("corrupt");
let _ = crate::util::write_secure(&p, b"not json at all");
assert!(read_active_with(&p, |_| true).is_none());
let _ = std::fs::remove_file(&p);
}
#[test]
fn reason_with_quotes_survives() {
let p = tmp("quotes");
write_marker_at(&p, 1, "the \"big\" one\nline2", false).unwrap();
let m = parse_file(&p).expect("parses");
assert_eq!(m.reason, "the \"big\" one\nline2");
let _ = std::fs::remove_file(&p);
}
}