use std::fs::{self, OpenOptions};
use std::io;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
pub const TUI_OWNERSHIP_ID: &str = "tui";
pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
issues_dir.join(".alive").join(session_id)
}
pub fn acquire(issues_dir: &Path, session_id: &str) -> io::Result<AliveGuard> {
let dir = issues_dir.join(".alive");
fs::create_dir_all(&dir)?;
let path = dir.join(session_id);
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
let fd = file.as_raw_fd();
try_flock_exclusive(fd)?;
Ok(AliveGuard { _file: file, path })
}
pub fn is_session_alive(issues_dir: &Path, session_id: &str) -> bool {
let path = alive_path(issues_dir, session_id);
if !path.exists() {
return false;
}
let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
return false;
};
let fd = file.as_raw_fd();
probe_flock_shared(fd).is_err()
}
fn try_flock_exclusive(fd: i32) -> io::Result<()> {
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
fn probe_flock_shared(fd: i32) -> io::Result<()> {
let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
if rc == 0 {
unsafe { libc::flock(fd, libc::LOCK_UN) };
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
pub const ORPHAN_AGE_SECS: u64 = 3600;
pub fn reap_orphans(issues_dir: &Path) -> io::Result<usize> {
let dir = issues_dir.join(".alive");
let rd = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(e),
};
let now = std::time::SystemTime::now();
let mut removed = 0;
for entry in rd.flatten() {
let sid = entry.file_name();
let sid = sid.to_string_lossy();
if is_session_alive(issues_dir, &sid) {
continue; }
let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
let age = now.duration_since(mtime).map(|d| d.as_secs()).unwrap_or(0);
if age < ORPHAN_AGE_SECS {
continue; }
if fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
Ok(removed)
}
#[derive(Debug)]
pub struct AliveGuard {
_file: fs::File,
path: PathBuf,
}
impl AliveGuard {
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for AliveGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acquire_then_alive() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let sid = "s1";
let _g = acquire(&dir, sid).unwrap();
assert!(is_session_alive(&dir, sid));
drop(_g);
assert!(!is_session_alive(&dir, sid));
}
#[test]
fn second_acquire_fails_while_held() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let sid = "s2";
let g = acquire(&dir, sid).unwrap();
let second = acquire(&dir, sid);
assert!(second.is_err(), "second acquire should fail while held");
assert!(is_session_alive(&dir, sid));
drop(g);
assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
}
fn backdate(path: &std::path::Path, secs: u64) {
use std::fs::FileTimes;
let then = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
let f = std::fs::File::open(path)
.or_else(|_| {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false) .open(path)
})
.unwrap();
f.set_times(FileTimes::new().set_modified(then)).unwrap();
}
#[test]
fn reap_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
assert_eq!(reap_orphans(&dir).unwrap(), 0);
fs::create_dir_all(dir.join(".alive")).unwrap();
assert_eq!(reap_orphans(&dir).unwrap(), 0);
assert_eq!(reap_orphans(&dir).unwrap(), 0);
}
#[test]
fn reap_skips_recent_dead_files() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join(".alive")).unwrap();
let recent = dir.join(".alive").join("dead-recent");
fs::write(&recent, b"").unwrap();
assert_eq!(reap_orphans(&dir).unwrap(), 0);
assert!(
recent.exists(),
"recent dead orphan must be preserved by the age gate"
);
}
#[test]
fn reap_removes_old_dead_and_keeps_alive() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let _g_live = acquire(&dir, "alive-session").unwrap();
fs::create_dir_all(dir.join(".alive")).unwrap();
let old = dir.join(".alive").join("dead-old");
fs::write(&old, b"").unwrap();
backdate(&old, ORPHAN_AGE_SECS + 60);
let removed = reap_orphans(&dir).unwrap();
assert_eq!(removed, 1, "only the old dead orphan should be reaped");
assert!(!old.exists(), "old dead orphan must be removed");
assert!(
is_session_alive(&dir, "alive-session"),
"live lock must survive reap"
);
}
}