use std::io;
use std::path::{Path, PathBuf};
use crate::primitives::fs;
pub fn generate_boot_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let mut v = nanos as u64;
let mut s = String::with_capacity(10);
for _ in 0..4 {
s.push((b'a' + (v % 26) as u8) as char);
v /= 26;
}
s.push_str(&format!("-{}", std::process::id()));
s
}
pub fn is_valid_boot_id(name: &str) -> bool {
let Some((alpha, pid)) = name.split_once('-') else {
return false;
};
alpha.len() == 4
&& alpha.bytes().all(|b| b.is_ascii_lowercase())
&& !pid.is_empty()
&& pid.bytes().all(|b| b.is_ascii_digit())
}
#[cfg(unix)]
mod flock {
use std::io;
use std::os::unix::io::AsRawFd;
use std::path::Path;
use std::fs::{File, OpenOptions};
pub(super) fn acquire(path: &Path) -> io::Result<File> {
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(path)?;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
let err = io::Error::last_os_error();
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
format!("namespace lock held by another process: {err}"),
));
}
Ok(file)
}
pub(super) fn is_held(path: &Path) -> bool {
let Ok(file) = OpenOptions::new().read(true).open(path) else {
return false;
};
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
false
} else {
true
}
}
}
#[cfg(unix)]
pub(crate) fn acquire_namespace_lock(namespace_dir: &Path) -> io::Result<std::fs::File> {
fs::create_dir_all(namespace_dir)?;
flock::acquire(&namespace_dir.join(".lock"))
}
#[cfg(not(unix))]
pub(crate) fn acquire_namespace_lock(namespace_dir: &Path) -> io::Result<std::fs::File> {
fs::create_dir_all(namespace_dir)?;
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(namespace_dir.join(".lock"))
}
fn is_lock_held(namespace_dir: &Path) -> bool {
#[cfg(unix)]
{
flock::is_held(&namespace_dir.join(".lock"))
}
#[cfg(not(unix))]
{
let _ = namespace_dir;
true
}
}
fn is_recognized_artifact(name: &str) -> bool {
if name == ".lock" {
return true;
}
let Some((head, tail)) = name.split_once(".bin") else {
return false;
};
if !tail.is_empty() && tail != ".gz" && tail != ".active" {
return false;
}
matches!(head.rsplit_once('.'), Some((_, idx)) if !idx.is_empty() && idx.bytes().all(|b| b.is_ascii_digit()))
}
pub(crate) fn gc_dead_namespaces(parent_dir: &Path, own_boot_id: &str) {
let entries = match fs::read_dir(parent_dir) {
Ok(entries) => entries,
Err(e) => {
tracing::debug!(
target: "dial9_worker",
error = %e,
dir = %parent_dir.display(),
"namespace GC: failed to scan parent directory"
);
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name == own_boot_id || !is_valid_boot_id(name) || is_lock_held(&path) {
continue;
}
if let Err(e) = try_remove_namespace(&path) {
tracing::debug!(
target: "dial9_worker",
error = %e,
dir = %path.display(),
"namespace GC: failed to reclaim dead peer"
);
}
}
}
fn try_remove_namespace(dir: &Path) -> io::Result<()> {
let mut paths = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
match name.to_str() {
Some(name) if is_recognized_artifact(name) => paths.push(entry.path()),
_ => return Ok(()),
}
}
for path in paths {
fs::remove_file(&path)?;
}
fs::remove_dir(dir)
}
#[derive(Debug)]
pub struct Namespace {
pub boot_id: String,
pub dir: PathBuf,
pub lock: std::fs::File,
}
pub fn setup_namespace(trace_dir: &Path, gc: bool) -> io::Result<Namespace> {
let boot_id = generate_boot_id();
let dir = trace_dir.join(&boot_id);
let lock = acquire_namespace_lock(&dir)?;
if gc {
gc_dead_namespaces(trace_dir, &boot_id);
}
Ok(Namespace { boot_id, dir, lock })
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn generate_boot_id_matches_pattern() {
let id = generate_boot_id();
let (alpha, pid) = id.split_once('-').unwrap();
assert_eq!(alpha.len(), 4);
assert!(alpha.chars().all(|c| c.is_ascii_lowercase()));
assert!(!pid.is_empty());
assert!(pid.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn is_valid_boot_id_accepts_valid() {
assert!(is_valid_boot_id("abcd-1234"));
assert!(is_valid_boot_id("zzzz-1"));
assert!(is_valid_boot_id("aaaa-99999"));
}
#[test]
fn is_valid_boot_id_rejects_invalid() {
assert!(!is_valid_boot_id("abc-1234"));
assert!(!is_valid_boot_id("abcde-1234"));
assert!(!is_valid_boot_id("ABCD-1234"));
assert!(!is_valid_boot_id("abcd1234"));
assert!(!is_valid_boot_id("abcd-"));
assert!(!is_valid_boot_id("abcd-abc"));
assert!(!is_valid_boot_id(""));
}
#[test]
fn is_recognized_artifact_accepts_known() {
assert!(is_recognized_artifact(".lock"));
assert!(is_recognized_artifact("trace.0.bin"));
assert!(is_recognized_artifact("trace.0.bin.active"));
assert!(is_recognized_artifact("trace.0.bin.gz"));
assert!(is_recognized_artifact("my-app.42.bin"));
assert!(is_recognized_artifact("some.other.stem.7.bin.gz"));
}
#[test]
fn is_recognized_artifact_rejects_unknown() {
assert!(!is_recognized_artifact("README.md"));
assert!(!is_recognized_artifact("data.json"));
assert!(!is_recognized_artifact(".hidden"));
assert!(!is_recognized_artifact("trace.bin")); assert!(!is_recognized_artifact("trace.x.bin")); assert!(!is_recognized_artifact("trace.0.bin.tmp")); }
#[cfg(unix)]
#[test]
fn acquire_and_detect_lock() {
let dir = TempDir::new().unwrap();
let ns_dir = dir.path().join("abcd-1234");
assert!(!is_lock_held(&ns_dir));
let _lock = acquire_namespace_lock(&ns_dir).unwrap();
assert!(is_lock_held(&ns_dir));
drop(_lock);
assert!(!is_lock_held(&ns_dir));
}
#[cfg(unix)]
#[test]
fn setup_namespace_creates_subdir() {
let dir = TempDir::new().unwrap();
let ns = setup_namespace(dir.path(), true).unwrap();
assert!(is_valid_boot_id(&ns.boot_id));
assert_eq!(ns.dir, dir.path().join(&ns.boot_id));
assert!(dir.path().join(&ns.boot_id).exists());
assert!(dir.path().join(&ns.boot_id).join(".lock").exists());
}
#[cfg(unix)]
#[test]
fn setup_namespace_without_gc_keeps_dead_peer() {
let dir = TempDir::new().unwrap();
let dead_ns = dir.path().join("dead-9999");
std::fs::create_dir(&dead_ns).unwrap();
std::fs::write(dead_ns.join(".lock"), b"").unwrap();
std::fs::write(dead_ns.join("trace.0.bin"), b"data").unwrap();
let _ns = setup_namespace(dir.path(), false).unwrap();
assert!(dead_ns.exists());
}
#[cfg(unix)]
#[test]
fn second_acquire_conflicts_while_first_held() {
let dir = TempDir::new().unwrap();
let ns_dir = dir.path().join("abcd-1234");
let first = acquire_namespace_lock(&ns_dir).unwrap();
let second = acquire_namespace_lock(&ns_dir);
assert!(
matches!(&second, Err(e) if e.kind() == io::ErrorKind::WouldBlock),
"second acquire of a held lock must fail with WouldBlock, got {second:?}"
);
drop(first);
assert!(acquire_namespace_lock(&ns_dir).is_ok());
}
#[cfg(unix)]
#[test]
fn two_live_owners_get_isolated_namespaces() {
let dir = TempDir::new().unwrap();
let a = setup_namespace(dir.path(), true).unwrap();
let b = setup_namespace(dir.path(), true).unwrap();
assert_ne!(
a.boot_id, b.boot_id,
"two owners must get distinct boot_ids"
);
assert_eq!(a.dir, dir.path().join(&a.boot_id));
assert_eq!(b.dir, dir.path().join(&b.boot_id));
assert!(dir.path().join(&a.boot_id).join(".lock").exists());
assert!(dir.path().join(&b.boot_id).join(".lock").exists());
assert!(is_lock_held(&dir.path().join(&a.boot_id)));
assert!(is_lock_held(&dir.path().join(&b.boot_id)));
}
#[cfg(unix)]
#[test]
fn gc_preserves_live_peer_holding_lock() {
let dir = TempDir::new().unwrap();
let peer = dir.path().join("abcd-1");
let _peer_lock = acquire_namespace_lock(&peer).unwrap();
std::fs::write(peer.join("trace.0.bin"), b"live data").unwrap();
gc_dead_namespaces(dir.path(), "zzzz-2");
assert!(peer.exists(), "live peer must survive GC");
assert!(peer.join("trace.0.bin").exists());
}
#[cfg(unix)]
#[test]
fn gc_removes_dead_namespace() {
let dir = TempDir::new().unwrap();
let dead_ns = dir.path().join("dead-9999");
std::fs::create_dir(&dead_ns).unwrap();
std::fs::write(dead_ns.join(".lock"), b"").unwrap();
std::fs::write(dead_ns.join("trace.0.bin"), b"data").unwrap();
std::fs::write(dead_ns.join("trace.0.bin.gz"), b"data").unwrap();
gc_dead_namespaces(dir.path(), "live-1234");
assert!(!dead_ns.exists());
}
#[cfg(unix)]
#[test]
fn gc_skips_namespace_with_unrecognized_file() {
let dir = TempDir::new().unwrap();
let dead_ns = dir.path().join("dead-9999");
std::fs::create_dir(&dead_ns).unwrap();
std::fs::write(dead_ns.join(".lock"), b"").unwrap();
std::fs::write(dead_ns.join("important.txt"), b"keep me").unwrap();
gc_dead_namespaces(dir.path(), "live-1234");
assert!(dead_ns.exists());
assert!(dead_ns.join("important.txt").exists());
}
#[cfg(unix)]
#[test]
fn gc_skips_live_namespace() {
let dir = TempDir::new().unwrap();
let live_ns = dir.path().join("live-1234");
let _lock = acquire_namespace_lock(&live_ns).unwrap();
std::fs::write(live_ns.join("trace.0.bin"), b"data").unwrap();
gc_dead_namespaces(dir.path(), "other-5678");
assert!(live_ns.exists());
assert!(live_ns.join("trace.0.bin").exists());
}
#[cfg(unix)]
#[test]
fn gc_skips_non_boot_id_directories() {
let dir = TempDir::new().unwrap();
let other = dir.path().join("not-a-boot-id");
std::fs::create_dir(&other).unwrap();
std::fs::write(other.join("data.bin"), b"x").unwrap();
gc_dead_namespaces(dir.path(), "live-1234");
assert!(other.exists());
}
}