use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub const SCHEMA_VERSION: u32 = 1;
pub const MAX_KEY_LENGTH: usize = 256;
pub const MAX_ENCODED_FILENAME_BYTES: usize = 240;
pub const MIN_TTL_MS: i64 = 60_000;
pub const MAX_TTL_MS: i64 = 86_400_000;
const CLAIMS_DIRNAME: &str = ".fno/claims";
const EXPIRED_SUBDIR: &str = ".expired";
const RECOVERY_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(20);
const RECOVERY_LOCK_MAX_WAIT: Duration = Duration::from_secs(5);
const ACQUIRE_MAX_ATTEMPTS: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaimState {
Free,
Live,
Suspect,
Stale,
Corrupted,
}
impl ClaimState {
pub fn as_str(&self) -> &'static str {
match self {
ClaimState::Free => "free",
ClaimState::Live => "live",
ClaimState::Suspect => "suspect",
ClaimState::Stale => "stale",
ClaimState::Corrupted => "corrupted",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClaimRecord {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
pub key: String,
pub holder: String,
pub acquired_at: i64,
pub pid: i32,
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine_id: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Map<String, Value>,
}
fn default_schema_version() -> u32 {
SCHEMA_VERSION
}
#[derive(Debug, Default, Clone)]
pub struct AcquireOpts {
pub pid: Option<u32>,
pub ttl_ms: Option<i64>,
pub reason: Option<String>,
pub metadata: Option<Map<String, Value>>,
pub root: Option<PathBuf>,
pub events_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AcquireOutcome {
Acquired(ClaimRecord),
HeldByOther {
holder: String,
pid: i32,
host: String,
},
Error(String),
}
pub fn encode_key(key: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(key.len());
for b in key.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'~' | b'-' => {
out.push(b as char)
}
_ => {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0xF) as usize] as char);
}
}
}
out
}
const GLOBAL_ID_PREFIXES: &[&str] = &["node", "dispatch", "reconcile", "session"];
pub fn global_claims_root() -> Option<PathBuf> {
global_claims_root_from(
std::env::var_os("FNO_CLAIMS_ROOT"),
std::env::var_os("HOME"),
)
}
pub fn global_claims_root_from(
claims_root: Option<OsString>,
home: Option<OsString>,
) -> Option<PathBuf> {
let non_empty = |v: OsString| (!v.is_empty()).then_some(v);
claims_root
.and_then(non_empty)
.or_else(|| home.and_then(non_empty))
.map(PathBuf::from)
}
pub fn claims_root_for(key: &str) -> Option<PathBuf> {
match key.split_once(':') {
Some((prefix, _)) if GLOBAL_ID_PREFIXES.contains(&prefix) => global_claims_root(),
_ => None,
}
}
fn claims_dir(key: &str, root: Option<&Path>) -> Result<PathBuf, String> {
if let Some(r) = root {
return Ok(r.join(CLAIMS_DIRNAME));
}
match claims_root_for(key) {
Some(r) => Ok(r.join(CLAIMS_DIRNAME)),
None => Err(format!(
"no claims root for key {key:?}: not a global-id prefix and no explicit root given"
)),
}
}
pub fn claim_path(key: &str, root: Option<&Path>) -> Result<PathBuf, String> {
Ok(claims_dir(key, root)?.join(format!("{}.lock", encode_key(key))))
}
pub(crate) fn claims_dir_for(root: Option<&Path>) -> Option<PathBuf> {
match root {
Some(r) => Some(r.join(CLAIMS_DIRNAME)),
None => global_claims_root().map(|r| r.join(CLAIMS_DIRNAME)),
}
}
pub fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn hostname() -> String {
let mut buf = [0u8; 256];
let rc = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
if rc != 0 {
return String::new();
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).into_owned()
}
#[cfg(target_os = "macos")]
fn platform_machine_id() -> String {
let out = match std::process::Command::new("/usr/sbin/ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
{
Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
Err(_) => return String::new(),
};
out.split_once("\"IOPlatformUUID\" = \"")
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(id, _)| id.to_string())
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
fn platform_machine_id() -> String {
let mut base = String::new();
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(text) = std::fs::read_to_string(path) {
let value = text.trim();
if !value.is_empty() {
base = value.to_string();
break;
}
}
}
if base.is_empty() {
return base;
}
use std::os::unix::fs::MetadataExt;
match std::fs::metadata("/proc/self/ns/pid") {
Ok(md) => format!("{base}:{}", md.ino()),
Err(_) => base,
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn platform_machine_id() -> String {
String::new()
}
fn machine_id() -> String {
static CACHE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
CACHE.get_or_init(platform_machine_id).clone()
}
fn is_same_machine(host: &str, machine: Option<&str>) -> bool {
let mine = machine_id();
if let Some(m) = machine.filter(|m| !m.is_empty()) {
if !mine.is_empty() {
return m == mine;
}
return true;
}
if host.is_empty() {
return false;
}
host == hostname()
}
#[cfg(target_os = "macos")]
pub fn process_create_time_ms(pid: i32) -> Option<i64> {
use std::mem;
if pid <= 0 {
return None;
}
let mut info: libc::proc_bsdinfo = unsafe { mem::zeroed() };
let size = mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
let written = unsafe {
libc::proc_pidinfo(
pid as libc::c_int,
libc::PROC_PIDTBSDINFO,
0,
&mut info as *mut _ as *mut libc::c_void,
size,
)
};
if written != size {
return None;
}
Some((info.pbi_start_tvsec as i64) * 1000 + (info.pbi_start_tvusec as i64) / 1000)
}
#[cfg(target_os = "linux")]
pub fn process_create_time_ms(pid: i32) -> Option<i64> {
if pid <= 0 {
return None;
}
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after = stat.rsplit_once(')')?.1;
let starttime: i64 = after.split_whitespace().nth(19)?.parse().ok()?;
let btime = linux_boot_time_s()?;
let tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
if tck <= 0 {
return None;
}
Some(btime * 1000 + starttime * 1000 / tck as i64)
}
#[cfg(target_os = "linux")]
fn linux_boot_time_s() -> Option<i64> {
static BTIME: std::sync::OnceLock<Option<i64>> = std::sync::OnceLock::new();
*BTIME.get_or_init(|| {
let stat = std::fs::read_to_string("/proc/stat").ok()?;
for line in stat.lines() {
if let Some(rest) = line.strip_prefix("btime ") {
return rest.trim().parse().ok();
}
}
None
})
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn process_create_time_ms(_pid: i32) -> Option<i64> {
None
}
fn is_live(rec: &ClaimRecord) -> bool {
if !is_same_machine(&rec.host, rec.machine_id.as_deref()) {
return false;
}
match process_create_time_ms(rec.pid) {
Some(create_ms) => create_ms <= rec.acquired_at,
None => false,
}
}
fn is_expired(rec: &ClaimRecord, now: i64) -> bool {
match rec.expires_at {
Some(exp) => now >= exp,
None => false,
}
}
pub fn classify(rec: &ClaimRecord, now: Option<i64>) -> ClaimState {
let now = now.unwrap_or_else(now_ms);
if is_expired(rec, now) {
return if is_live(rec) {
ClaimState::Live
} else {
ClaimState::Stale
};
}
if rec.expires_at.is_none() {
return if is_live(rec) {
ClaimState::Live
} else {
ClaimState::Stale
};
}
if is_live(rec) {
ClaimState::Live
} else {
ClaimState::Suspect
}
}
#[derive(Debug)]
pub(crate) enum ReadError {
GoneAway,
Corrupted(String),
}
fn serialize_claim(rec: &ClaimRecord) -> Result<String, String> {
serde_yaml_ng::to_string(rec).map_err(|e| format!("claim YAML serialize failed: {e}"))
}
fn parse_claim_str(text: &str) -> Result<ClaimRecord, ReadError> {
let rec: ClaimRecord = serde_yaml_ng::from_str(text)
.map_err(|e| ReadError::Corrupted(format!("claim parse/schema failed: {e}")))?;
if rec.schema_version > SCHEMA_VERSION {
return Err(ReadError::Corrupted(format!(
"claim schema_version={} > supported={SCHEMA_VERSION}; refusing to read from a newer writer",
rec.schema_version
)));
}
if rec.key.is_empty() || rec.holder.is_empty() {
return Err(ReadError::Corrupted(
"claim key/holder must be non-empty".into(),
));
}
Ok(rec)
}
pub(crate) fn read_claim_file(path: &Path) -> Result<ClaimRecord, ReadError> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(ReadError::GoneAway),
Err(e) => return Err(ReadError::Corrupted(format!("claim read failed: {e}"))),
};
parse_claim_str(&text)
}
enum CreateError {
AlreadyHeld,
Io(String),
}
fn atomic_create_exclusive(path: &Path, content: &str) -> Result<(), CreateError> {
let parent = match path.parent() {
Some(p) => p,
None => return Err(CreateError::Io("claim path has no parent".into())),
};
match create_via_link(parent, path, content) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(CreateError::AlreadyHeld),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(parent).map_err(|e| CreateError::Io(e.to_string()))?;
match create_via_link(parent, path, content) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
Err(CreateError::AlreadyHeld)
}
Err(e) => Err(CreateError::Io(e.to_string())),
}
}
Err(e) => Err(CreateError::Io(e.to_string())),
}
}
fn create_via_link(parent: &Path, path: &Path, content: &str) -> std::io::Result<()> {
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let tmp = parent.join(format!(
".claim-tmp-{}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
f.write_all(content.as_bytes())?;
}
let res = std::fs::hard_link(&tmp, path);
let _ = std::fs::remove_file(&tmp);
res
}
fn atomic_replace(path: &Path, content: &str) -> Result<(), String> {
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let tmp = path.with_extension(format!(
"lock.tmp.{}.{}",
std::process::id(),
TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let write = std::fs::write(&tmp, content)
.and_then(|()| std::fs::rename(&tmp, path))
.map_err(|e| e.to_string());
if write.is_err() {
let _ = std::fs::remove_file(&tmp);
}
write
}
fn archive_claim(path: &Path, ts_ms: i64) -> std::io::Result<()> {
let (Some(parent), Some(name)) = (path.parent(), path.file_name().and_then(|n| n.to_str()))
else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid claim path for archive",
));
};
let stem = name.strip_suffix(".lock").unwrap_or(name);
let archive_dir = parent.join(EXPIRED_SUBDIR);
std::fs::create_dir_all(&archive_dir)?;
match std::fs::rename(path, archive_dir.join(format!("{stem}.{ts_ms}.lock"))) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn emit_claim_event(events_dir: Option<&Path>, type_name: &str, data: Map<String, Value>) {
let base = events_dir
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let events_path = base.join(".fno/events.jsonl");
let event = json!({
"ts": chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"type": type_name,
"source": "fno-loop",
"data": Value::Object(data),
});
if let Err(e) = append_event_line(&events_path, &event, Duration::from_secs(2)) {
eprintln!("claims: failed to emit {type_name:?}: {e}");
}
}
const STALE_MUTEX_STEAL: Duration = Duration::from_secs(120);
fn steal_if_stale(lock_dir: &Path) -> bool {
let before = match std::fs::symlink_metadata(lock_dir) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return true,
Err(_) => return false, };
let age = before
.modified()
.map(|t| t.elapsed().unwrap_or_default())
.unwrap_or_default();
if age <= STALE_MUTEX_STEAL {
return false;
}
let before_token = read_owner(lock_dir);
static REAP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let reaped = lock_dir.with_file_name(format!(
"{}.reap.{}.{}",
lock_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
std::process::id(),
REAP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
match std::fs::rename(lock_dir, &reaped) {
Ok(()) => {
if !same_owner(&reaped, &before_token) {
if std::fs::rename(&reaped, lock_dir).is_err() {
eprintln!(
"claims: stole a live mutex at {} and could not restore it",
lock_dir.display()
);
}
return false;
}
eprintln!(
"claims: stole stale mutex {} (age {}s)",
lock_dir.display(),
age.as_secs()
);
remove_reaped(&reaped);
true
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
Err(e) => {
eprintln!(
"claims: could not steal stale mutex {}: {e}",
lock_dir.display()
);
false
}
}
}
fn owner_token() -> String {
let ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{}:{}:{}", hostname(), std::process::id(), ns)
}
fn read_owner(lock_dir: &Path) -> String {
std::fs::read_to_string(lock_dir.join("owner")).unwrap_or_default()
}
fn stamp_owner(lock_dir: &Path) -> String {
let token = owner_token();
let _ = std::fs::write(lock_dir.join("owner"), &token);
token
}
fn acquire_dir_mutex(lock_dir: &Path, timeout: Duration, steal: bool) -> Option<String> {
let deadline = Instant::now() + timeout;
loop {
match std::fs::create_dir(lock_dir) {
Ok(()) => return Some(stamp_owner(lock_dir)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if steal && steal_if_stale(lock_dir) {
continue;
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(100));
}
Err(_) => {
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
}
fn release_dir_mutex(lock_dir: &Path, token: &str) {
if read_owner(lock_dir) == token {
let _ = std::fs::remove_dir_all(lock_dir);
return;
}
eprintln!(
"claims: release_dir_mutex {} no longer owned by {}; left intact",
lock_dir.display(),
token
);
}
fn same_owner(path: &Path, before_token: &str) -> bool {
let after = read_owner(path);
after.is_empty() || after == before_token
}
fn remove_reaped(path: &Path) {
if std::fs::remove_file(path).is_ok() {
return;
}
if let Err(e) = std::fs::remove_dir_all(path) {
if e.kind() != std::io::ErrorKind::NotFound {
eprintln!(
"claims: could not remove reaped mutex {}: {e}",
path.display()
);
}
}
}
fn append_event_line(
events_path: &Path,
event: &Value,
lock_timeout: Duration,
) -> Result<(), String> {
if let Some(parent) = events_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let lock_dir = events_path.with_file_name(format!(
"{}.lock.d",
events_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "events.jsonl".into())
));
let token = acquire_dir_mutex(&lock_dir, lock_timeout, true)
.ok_or_else(|| format!("events.jsonl lock timeout: {}", lock_dir.display()))?;
let res = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(events_path)
.and_then(|mut f| writeln!(f, "{event}"))
.map_err(|e| e.to_string());
release_dir_mutex(&lock_dir, &token);
res
}
fn common_event_data(rec: &ClaimRecord) -> Map<String, Value> {
let mut m = Map::new();
m.insert("key".into(), Value::String(rec.key.clone()));
m.insert("holder".into(), Value::String(rec.holder.clone()));
m.insert("pid".into(), Value::Number(rec.pid.into()));
m.insert("host".into(), Value::String(rec.host.clone()));
m.insert("acquired_at".into(), Value::Number(rec.acquired_at.into()));
m.insert(
"expires_at".into(),
rec.expires_at.map(Value::from).unwrap_or(Value::Null),
);
m
}
fn validate_inputs(key: &str, holder: &str, ttl_ms: Option<i64>) -> Result<(), String> {
if key.is_empty() {
return Err("key must be non-empty".into());
}
if key.len() > MAX_KEY_LENGTH {
return Err(format!(
"key length {} exceeds MAX_KEY_LENGTH={MAX_KEY_LENGTH}",
key.len()
));
}
let encoded_len = encode_key(key).len();
if encoded_len > MAX_ENCODED_FILENAME_BYTES {
return Err(format!(
"URL-encoded key length {encoded_len} exceeds MAX_ENCODED_FILENAME_BYTES={MAX_ENCODED_FILENAME_BYTES}"
));
}
if holder.is_empty() {
return Err("holder must be non-empty".into());
}
if let Some(ttl) = ttl_ms {
if !(MIN_TTL_MS..=MAX_TTL_MS).contains(&ttl) {
return Err(format!(
"ttl_ms={ttl} out of range [{MIN_TTL_MS}, {MAX_TTL_MS}]"
));
}
}
Ok(())
}
const HARNESS_SESSION_MARKERS: &[(&str, &str)] = &[
("CODEX_THREAD_ID", "codex"),
("CLAUDE_CODE_SESSION_ID", "claude"),
("CODEX_SESSION_ID", "codex"),
("GEMINI_SESSION_ID", "gemini"),
("OPENCODE_SESSION_ID", "opencode"),
];
pub fn resolve_harness() -> Option<String> {
resolve_harness_from(|k| std::env::var(k).ok())
}
pub fn resolve_harness_from(get: impl Fn(&str) -> Option<String>) -> Option<String> {
for (marker, harness) in HARNESS_SESSION_MARKERS {
if get(marker).map(|v| !v.trim().is_empty()).unwrap_or(false) {
return Some((*harness).to_string());
}
}
None
}
fn make_claim(key: &str, holder: &str, opts: &AcquireOpts) -> ClaimRecord {
let acquired = now_ms();
ClaimRecord {
schema_version: SCHEMA_VERSION,
key: key.into(),
holder: holder.into(),
acquired_at: acquired,
pid: opts.pid.unwrap_or_else(std::process::id) as i32,
host: hostname(),
machine_id: Some(machine_id()).filter(|m| !m.is_empty()),
expires_at: opts.ttl_ms.map(|ttl| acquired + ttl),
reason: opts.reason.clone(),
harness: resolve_harness(),
metadata: opts.metadata.clone().unwrap_or_default(),
}
}
pub fn acquire(key: &str, holder: &str, opts: AcquireOpts) -> AcquireOutcome {
if let Err(e) = validate_inputs(key, holder, opts.ttl_ms) {
return AcquireOutcome::Error(e);
}
let path = match claim_path(key, opts.root.as_deref()) {
Ok(p) => p,
Err(e) => return AcquireOutcome::Error(e),
};
let events_dir = opts.events_dir.clone();
for _attempt in 0..ACQUIRE_MAX_ATTEMPTS {
let new_claim = make_claim(key, holder, &opts);
let payload = match serialize_claim(&new_claim) {
Ok(p) => p,
Err(e) => return AcquireOutcome::Error(e),
};
match atomic_create_exclusive(&path, &payload) {
Ok(()) => {
emit_claim_event(
events_dir.as_deref(),
"claim_acquired",
acquired_event_data(&new_claim),
);
return AcquireOutcome::Acquired(new_claim);
}
Err(CreateError::AlreadyHeld) => {}
Err(CreateError::Io(e)) => return AcquireOutcome::Error(e),
}
let existing = match read_claim_file(&path) {
Ok(rec) => rec,
Err(ReadError::GoneAway) => continue, Err(ReadError::Corrupted(e)) => {
return AcquireOutcome::Error(e);
}
};
if existing.holder == holder {
return idempotent_reacquire(
&path,
key,
holder,
&opts,
&existing,
events_dir.as_deref(),
);
}
if !matches!(
classify(&existing, None),
ClaimState::Live | ClaimState::Suspect
) {
match recover_stale(&path, key, holder, &opts, events_dir.as_deref()) {
RecoverResult::Done(outcome) => return outcome,
RecoverResult::Retry => continue,
}
} else {
return AcquireOutcome::HeldByOther {
holder: existing.holder,
pid: existing.pid,
host: existing.host,
};
}
}
AcquireOutcome::Error(format!(
"acquire gave up after {ACQUIRE_MAX_ATTEMPTS} contention retries on {key:?}"
))
}
fn acquired_event_data(rec: &ClaimRecord) -> Map<String, Value> {
let mut data = common_event_data(rec);
if let Some(r) = &rec.reason {
data.insert("reason".into(), Value::String(r.clone()));
}
data
}
fn idempotent_reacquire(
path: &Path,
key: &str,
holder: &str,
opts: &AcquireOpts,
existing: &ClaimRecord,
events_dir: Option<&Path>,
) -> AcquireOutcome {
let refreshed = make_claim(key, holder, opts);
let payload = match serialize_claim(&refreshed) {
Ok(p) => p,
Err(e) => return AcquireOutcome::Error(e),
};
if let Err(e) = atomic_replace(path, &payload) {
return AcquireOutcome::Error(e);
}
let mut data = common_event_data(&refreshed);
data.insert(
"previous_acquired_at".into(),
Value::Number(existing.acquired_at.into()),
);
emit_claim_event(events_dir, "claim_idempotent_reacquired", data);
AcquireOutcome::Acquired(refreshed)
}
enum RecoverResult {
Done(AcquireOutcome),
Retry,
}
fn recover_stale(
path: &Path,
key: &str,
holder: &str,
opts: &AcquireOpts,
events_dir: Option<&Path>,
) -> RecoverResult {
let recovery_lock = path.with_file_name(format!(
"{}.recovery.d",
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
));
let token = match std::fs::create_dir(&recovery_lock) {
Ok(()) => stamp_owner(&recovery_lock),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if !steal_if_stale(&recovery_lock) {
wait_for_recovery_release(&recovery_lock, RECOVERY_LOCK_MAX_WAIT);
}
return RecoverResult::Retry;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return RecoverResult::Retry,
Err(e) => return RecoverResult::Done(AcquireOutcome::Error(e.to_string())),
};
let result = recover_stale_locked(path, key, holder, opts, events_dir);
release_dir_mutex(&recovery_lock, &token);
result
}
fn recover_stale_locked(
path: &Path,
key: &str,
holder: &str,
opts: &AcquireOpts,
events_dir: Option<&Path>,
) -> RecoverResult {
let new_claim = make_claim(key, holder, opts);
let payload = match serialize_claim(&new_claim) {
Ok(p) => p,
Err(e) => return RecoverResult::Done(AcquireOutcome::Error(e)),
};
let existing = match read_claim_file(path) {
Err(ReadError::GoneAway) => {
return match atomic_create_exclusive(path, &payload) {
Ok(()) => {
emit_claim_event(
events_dir,
"claim_acquired",
acquired_event_data(&new_claim),
);
RecoverResult::Done(AcquireOutcome::Acquired(new_claim))
}
Err(CreateError::AlreadyHeld) => RecoverResult::Retry,
Err(CreateError::Io(e)) => RecoverResult::Done(AcquireOutcome::Error(e)),
};
}
Err(ReadError::Corrupted(e)) => return RecoverResult::Done(AcquireOutcome::Error(e)),
Ok(rec) => rec,
};
if existing.holder == holder {
return RecoverResult::Done(idempotent_reacquire(
path, key, holder, opts, &existing, events_dir,
));
}
if matches!(
classify(&existing, None),
ClaimState::Live | ClaimState::Suspect
) {
return RecoverResult::Done(AcquireOutcome::HeldByOther {
holder: existing.holder,
pid: existing.pid,
host: existing.host,
});
}
if let Err(e) = archive_claim(path, now_ms()) {
return RecoverResult::Done(AcquireOutcome::Error(format!(
"failed to archive stale claim: {e}"
)));
}
match atomic_create_exclusive(path, &payload) {
Ok(()) => {
let mut data = common_event_data(&new_claim);
data.insert(
"previous_holder".into(),
Value::String(existing.holder.clone()),
);
data.insert("previous_pid".into(), Value::Number(existing.pid.into()));
emit_claim_event(events_dir, "claim_stale_reclaimed", data);
RecoverResult::Done(AcquireOutcome::Acquired(new_claim))
}
Err(CreateError::AlreadyHeld) => RecoverResult::Retry,
Err(CreateError::Io(e)) => RecoverResult::Done(AcquireOutcome::Error(e)),
}
}
fn wait_for_recovery_release(recovery_lock: &Path, max_wait: Duration) {
let deadline = Instant::now() + max_wait;
while std::fs::symlink_metadata(recovery_lock).is_ok() && Instant::now() < deadline {
std::thread::sleep(RECOVERY_LOCK_POLL_INTERVAL);
}
}
pub fn release(
key: &str,
holder: &str,
root: Option<&Path>,
events_dir: Option<&Path>,
) -> Result<(), String> {
if key.is_empty() || holder.is_empty() {
return Err("key and holder must be non-empty".into());
}
let path = claim_path(key, root)?;
let existing = match read_claim_file(&path) {
Ok(rec) => rec,
Err(ReadError::GoneAway) => return Ok(()),
Err(ReadError::Corrupted(_)) => return Ok(()),
};
if existing.holder != holder {
return Ok(());
}
let duration_ms = (now_ms() - existing.acquired_at).max(0);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.to_string()),
}
let mut data = common_event_data(&existing);
data.insert("duration_held_ms".into(), Value::Number(duration_ms.into()));
emit_claim_event(events_dir, "claim_released", data);
Ok(())
}
pub fn status(key: &str, root: Option<&Path>) -> (ClaimState, Option<ClaimRecord>) {
let path = match claim_path(key, root) {
Ok(p) => p,
Err(_) => return (ClaimState::Free, None),
};
if !path.exists() {
return (ClaimState::Free, None);
}
match read_claim_file(&path) {
Ok(rec) => (classify(&rec, None), Some(rec)),
Err(ReadError::GoneAway) => (ClaimState::Free, None),
Err(ReadError::Corrupted(_)) => (ClaimState::Corrupted, None),
}
}
pub fn parse_ttl_ms(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num, mult) = if let Some(n) = s.strip_suffix('h') {
(n, 3_600_000)
} else if let Some(n) = s.strip_suffix('m') {
(n, 60_000)
} else if let Some(n) = s.strip_suffix('s') {
(n, 1_000)
} else {
(s, 1_000) };
num.trim()
.parse::<i64>()
.ok()
.map(|v| v.saturating_mul(mult))
.filter(|v| *v > 0)
}
pub fn renew(key: &str, holder: &str, ttl_ms: i64, root: Option<&Path>) -> Result<bool, String> {
if key.is_empty() || holder.is_empty() {
return Err("key and holder must be non-empty".into());
}
if ttl_ms <= 0 {
return Err("ttl_ms must be positive".into());
}
let path = claim_path(key, root)?;
match read_claim_file(&path) {
Ok(rec) if rec.holder == holder && rec.expires_at.is_some() => {}
Ok(_) => return Ok(false),
Err(ReadError::GoneAway) => return Ok(false),
Err(ReadError::Corrupted(_)) => return Ok(false),
}
let recovery_lock = path.with_file_name(format!(
"{}.recovery.d",
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
));
let token = if std::fs::create_dir(&recovery_lock).is_ok() {
stamp_owner(&recovery_lock)
} else if steal_if_stale(&recovery_lock) && std::fs::create_dir(&recovery_lock).is_ok() {
stamp_owner(&recovery_lock)
} else {
return Ok(false);
};
let result = renew_locked(&path, holder, ttl_ms);
release_dir_mutex(&recovery_lock, &token);
result
}
fn renew_locked(path: &Path, holder: &str, ttl_ms: i64) -> Result<bool, String> {
let mut existing = match read_claim_file(path) {
Ok(rec) => rec,
Err(ReadError::GoneAway) => return Ok(false),
Err(ReadError::Corrupted(_)) => return Ok(false),
};
if existing.holder != holder {
return Ok(false); }
if existing.expires_at.is_none() {
return Ok(false); }
if is_expired(&existing, now_ms()) {
return Ok(false); }
existing.expires_at = Some(now_ms() + ttl_ms);
let payload = serialize_claim(&existing)?;
atomic_replace(path, &payload)?;
Ok(true)
}
#[cfg(test)]
pub fn test_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn opts_in(root: &TempDir) -> AcquireOpts {
AcquireOpts {
root: Some(root.path().to_path_buf()),
events_dir: Some(root.path().to_path_buf()),
..Default::default()
}
}
fn lockfile(root: &TempDir, key: &str) -> PathBuf {
claim_path(key, Some(root.path())).unwrap()
}
fn read_events(root: &TempDir) -> Vec<Value> {
let text =
std::fs::read_to_string(root.path().join(".fno/events.jsonl")).unwrap_or_default();
text.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
fn read_claim(root: &TempDir, key: &str) -> ClaimRecord {
read_claim_file(&lockfile(root, key)).unwrap()
}
#[test]
fn parse_ttl_ms_matches_python_units() {
assert_eq!(parse_ttl_ms("2h"), Some(7_200_000));
assert_eq!(parse_ttl_ms("30m"), Some(1_800_000));
assert_eq!(parse_ttl_ms("3600s"), Some(3_600_000));
assert_eq!(parse_ttl_ms("120"), Some(120_000)); assert_eq!(parse_ttl_ms(" 1h "), Some(3_600_000));
assert_eq!(parse_ttl_ms(""), None);
assert_eq!(parse_ttl_ms("abc"), None);
assert_eq!(parse_ttl_ms("0"), None); }
#[test]
fn renew_resets_deadline_to_now_plus_ttl_and_preserves_acquired_at() {
let td = TempDir::new().unwrap();
let mut o = opts_in(&td);
o.ttl_ms = Some(120_000);
match acquire("node:x-renew", "target-session:me", o) {
AcquireOutcome::Acquired(_) => {}
other => panic!("{other:?}"),
};
let before = read_claim(&td, "node:x-renew").expires_at.unwrap();
let acquired_at = read_claim(&td, "node:x-renew").acquired_at;
std::thread::sleep(Duration::from_millis(2));
let t0 = now_ms();
assert_eq!(
renew(
"node:x-renew",
"target-session:me",
120_000,
Some(td.path())
),
Ok(true)
);
let after = read_claim(&td, "node:x-renew");
let exp = after.expires_at.unwrap();
assert!(exp > before, "before={before} after={exp}");
assert!(
(exp - (t0 + 120_000)).abs() < 1_000,
"deadline must be ~now+ttl, got {exp} vs {}",
t0 + 120_000
);
assert_eq!(after.acquired_at, acquired_at, "acquired_at preserved");
}
#[test]
fn renew_deadline_does_not_grow_across_repeated_renewals() {
let td = TempDir::new().unwrap();
let mut o = opts_in(&td);
o.ttl_ms = Some(120_000);
let _ = acquire("node:x-grow", "target-session:me", o);
for _ in 0..5 {
std::thread::sleep(Duration::from_millis(2));
assert_eq!(
renew("node:x-grow", "target-session:me", 120_000, Some(td.path())),
Ok(true)
);
}
let exp = read_claim(&td, "node:x-grow").expires_at.unwrap();
assert!(
exp - now_ms() < 121_000,
"deadline grew across renewals: {}ms out",
exp - now_ms()
);
}
#[test]
fn renew_is_noop_for_wrong_holder() {
let td = TempDir::new().unwrap();
let mut o = opts_in(&td);
o.ttl_ms = Some(120_000);
let _ = acquire("node:x-other", "target-session:owner", o);
let before = read_claim(&td, "node:x-other").expires_at.unwrap();
assert_eq!(
renew(
"node:x-other",
"target-session:intruder",
120_000,
Some(td.path())
),
Ok(false)
);
assert_eq!(read_claim(&td, "node:x-other").expires_at.unwrap(), before);
}
#[test]
fn renew_is_noop_for_expired_pid_liveness_and_missing_claim() {
let td = TempDir::new().unwrap();
assert_eq!(
renew("node:x-absent", "h", 120_000, Some(td.path())),
Ok(false)
);
let _ = acquire("session:pidonly", "h", opts_in(&td));
assert!(read_claim(&td, "session:pidonly").expires_at.is_none());
assert_eq!(
renew("session:pidonly", "h", 120_000, Some(td.path())),
Ok(false)
);
let mut o = opts_in(&td);
o.ttl_ms = Some(60_000);
let _ = acquire("node:x-expired", "target-session:me", o);
let mut rec = read_claim(&td, "node:x-expired");
rec.expires_at = Some(now_ms() - 1);
atomic_replace(
&lockfile(&td, "node:x-expired"),
&serialize_claim(&rec).unwrap(),
)
.unwrap();
assert_eq!(
renew(
"node:x-expired",
"target-session:me",
60_000,
Some(td.path())
),
Ok(false)
);
}
#[test]
fn encode_key_matches_python_quote_safe_empty() {
assert_eq!(encode_key("node:ab-1234abcd"), "node%3Aab-1234abcd");
assert_eq!(encode_key("a b/c"), "a%20b%2Fc");
assert_eq!(encode_key("A-Z_a.z~0"), "A-Z_a.z~0");
assert_eq!(encode_key("k:v"), "k%3Av");
assert_eq!(encode_key("é"), "%C3%A9");
assert_eq!(encode_key("èµ°"), "%E8%B5%B0");
}
#[test]
fn set_but_empty_claims_root_is_unset() {
let root = global_claims_root_from(Some(OsString::new()), Some(OsString::from("/home/x")));
assert_eq!(root, Some(PathBuf::from("/home/x")));
let root = global_claims_root_from(
Some(OsString::from("/custom")),
Some(OsString::from("/home/x")),
);
assert_eq!(root, Some(PathBuf::from("/custom")));
assert_eq!(global_claims_root_from(None, None), None);
}
#[test]
fn root_routing_requires_colon_and_known_prefix() {
assert!(claims_dir("node", None).is_err());
assert!(claims_dir("walker:/repo/root", None).is_err());
let dir = claims_dir("walker:/repo/root", Some(Path::new("/tmp/x"))).unwrap();
assert_eq!(dir, PathBuf::from("/tmp/x/.fno/claims"));
}
#[test]
fn validation_rejects_bad_inputs_before_any_write() {
let td = TempDir::new().unwrap();
let o = opts_in(&td);
let err = |k: &str, h: &str, opts: AcquireOpts| match acquire(k, h, opts) {
AcquireOutcome::Error(e) => e,
other => panic!("expected Error, got {other:?}"),
};
assert!(err("", "h", o.clone()).contains("key must be non-empty"));
assert!(err("k", "", o.clone()).contains("holder must be non-empty"));
let long_key = "k".repeat(257);
assert!(err(&long_key, "h", o.clone()).contains("MAX_KEY_LENGTH"));
let expanding = ":".repeat(100);
assert!(err(&expanding, "h", o.clone()).contains("MAX_ENCODED_FILENAME_BYTES"));
let mut ttl_low = o.clone();
ttl_low.ttl_ms = Some(59_999);
assert!(err("k", "h", ttl_low).contains("out of range"));
let mut ttl_high = o.clone();
ttl_high.ttl_ms = Some(86_400_001);
assert!(err("k", "h", ttl_high).contains("out of range"));
assert!(!td.path().join(".fno/claims").exists());
}
#[test]
fn pid_claim_omits_expires_at_entirely() {
let td = TempDir::new().unwrap();
let out = acquire("session:u1", "pty:aa", opts_in(&td));
assert!(matches!(out, AcquireOutcome::Acquired(_)));
let text = std::fs::read_to_string(lockfile(&td, "session:u1")).unwrap();
assert!(
!text.contains("expires_at"),
"PID claim must omit expires_at: {text}"
);
assert!(text.contains("schema_version: 1"));
}
#[test]
fn ttl_claim_serializes_integer_expires_at() {
let td = TempDir::new().unwrap();
let mut o = opts_in(&td);
o.ttl_ms = Some(60_000);
let rec = match acquire("session:u2", "pty:bb", o) {
AcquireOutcome::Acquired(r) => r,
other => panic!("{other:?}"),
};
assert_eq!(rec.expires_at, Some(rec.acquired_at + 60_000));
let text = std::fs::read_to_string(lockfile(&td, "session:u2")).unwrap();
assert!(text.contains(&format!("expires_at: {}", rec.expires_at.unwrap())));
}
#[test]
fn reader_treats_null_and_absent_expires_at_the_same() {
let rec = parse_claim_str(
"schema_version: 1\nkey: k\nholder: h\nacquired_at: 5\npid: 1\nhost: x\nexpires_at: null\n",
)
.unwrap_or_else(|_| panic!("null expires_at must parse"));
assert_eq!(rec.expires_at, None);
}
#[test]
fn reader_ignores_unknown_fields_and_defaults_schema_version() {
let rec = parse_claim_str(
"key: k\nholder: h\nacquired_at: 5\npid: 1\nhost: x\nfuture_field: [1, 2]\n",
)
.expect("unknown fields must be ignored");
assert_eq!(rec.schema_version, 1);
assert!(rec.metadata.is_empty());
}
#[test]
fn reader_rejects_newer_schema_non_dict_and_garbage_as_corrupted() {
for text in [
"schema_version: 2\nkey: k\nholder: h\nacquired_at: 5\npid: 1\nhost: x\n",
"- just\n- a\n- list\n",
"{{{{not yaml",
"key: ''\nholder: h\nacquired_at: 5\npid: 1\nhost: x\n",
] {
assert!(
matches!(parse_claim_str(text), Err(ReadError::Corrupted(_))),
"should be corrupted: {text}"
);
}
}
#[test]
fn metadata_survives_yaml_roundtrip() {
let mut meta = Map::new();
meta.insert("nested".into(), json!({"a": [1, 2], "b": "text"}));
meta.insert("flag".into(), json!(true));
let rec = ClaimRecord {
schema_version: 1,
key: "session:u".into(),
holder: "h".into(),
acquired_at: 42,
pid: 7,
host: "hh".into(),
expires_at: None,
reason: Some("why".into()),
harness: Some("codex".into()),
machine_id: Some("mid".into()),
metadata: meta,
};
let text = serialize_claim(&rec).unwrap();
let back = parse_claim_str(&text).unwrap();
assert_eq!(back, rec);
}
#[test]
fn claim_without_harness_key_reads_none() {
let yaml = "schema_version: 1\nkey: node:x\nholder: h\nacquired_at: 1\npid: 2\nhost: hh\n";
let rec = parse_claim_str(yaml).expect("legacy record must parse");
assert_eq!(rec.harness, None);
}
#[test]
fn claim_with_harness_key_round_trips() {
let yaml = "schema_version: 1\nkey: node:x\nholder: h\nacquired_at: 1\npid: 2\nhost: hh\nharness: codex\n";
let rec = parse_claim_str(yaml).expect("record must parse");
assert_eq!(rec.harness.as_deref(), Some("codex"));
let none = ClaimRecord {
harness: None,
..rec.clone()
};
assert!(!serialize_claim(&none).unwrap().contains("harness"));
}
#[test]
fn resolve_harness_precedence_and_blank_is_unset() {
let both = |k: &str| match k {
"CODEX_THREAD_ID" => Some("cx".to_string()),
"CLAUDE_CODE_SESSION_ID" => Some("cl".to_string()),
_ => None,
};
assert_eq!(resolve_harness_from(both).as_deref(), Some("codex"));
let blank_hi = |k: &str| match k {
"CODEX_THREAD_ID" => Some(" ".to_string()),
"CLAUDE_CODE_SESSION_ID" => Some("cl".to_string()),
_ => None,
};
assert_eq!(resolve_harness_from(blank_hi).as_deref(), Some("claude"));
assert_eq!(
resolve_harness_from(|k| (k == "OPENCODE_SESSION_ID").then(|| "ses_1".to_string()))
.as_deref(),
Some("opencode")
);
assert_eq!(resolve_harness_from(|_| None), None);
}
fn record(pid: i32, acquired_at: i64, expires_at: Option<i64>, host: &str) -> ClaimRecord {
ClaimRecord {
schema_version: 1,
key: "session:x".into(),
holder: "h".into(),
acquired_at,
pid,
host: host.into(),
expires_at,
reason: None,
harness: None,
machine_id: None,
metadata: Map::new(),
}
}
#[test]
fn liveness_matches_python_classify_including_hybrid_arm() {
let me = std::process::id() as i32;
let host = hostname();
let now = now_ms();
assert_eq!(
classify(&record(me, now, None, &host), Some(now)),
ClaimState::Live
);
assert_eq!(
classify(&record(me, 1, None, &host), Some(now)),
ClaimState::Stale
);
assert_eq!(
classify(&record(me, now, None, "elsewhere.example"), Some(now)),
ClaimState::Stale
);
assert_eq!(
classify(&record(me, now, Some(now + 60_000), &host), Some(now)),
ClaimState::Live
);
assert_eq!(
classify(&record(-1, now, Some(now + 60_000), &host), Some(now)),
ClaimState::Suspect
);
assert_eq!(
classify(
&record(me, now, Some(now + 60_000), "elsewhere.example"),
Some(now)
),
ClaimState::Suspect
);
assert_eq!(
classify(&record(me, now, Some(now - 1), &host), Some(now)),
ClaimState::Live
);
assert_eq!(
classify(&record(-1, now, Some(now - 1), &host), Some(now)),
ClaimState::Stale
);
}
#[test]
fn own_process_create_time_is_sane() {
let create = process_create_time_ms(std::process::id() as i32)
.expect("must be able to inspect our own pid");
let now = now_ms();
assert!(create <= now, "create {create} must not postdate now {now}");
assert!(now - create < 86_400_000);
assert_eq!(process_create_time_ms(-1), None);
}
#[test]
fn fresh_acquire_writes_lockfile_and_emits() {
let td = TempDir::new().unwrap();
let mut o = opts_in(&td);
o.reason = Some("testing".into());
let rec = match acquire("session:fresh", "pty:me", o) {
AcquireOutcome::Acquired(r) => r,
other => panic!("{other:?}"),
};
assert_eq!(rec.pid, std::process::id() as i32);
assert!(lockfile(&td, "session:fresh").exists());
let events = read_events(&td);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["type"], "claim_acquired");
assert_eq!(events[0]["source"], "fno-loop");
assert_eq!(events[0]["data"]["holder"], "pty:me");
assert_eq!(events[0]["data"]["reason"], "testing");
assert_eq!(events[0]["data"]["expires_at"], Value::Null);
}
#[test]
fn same_holder_reacquire_is_idempotent_and_refreshes() {
let td = TempDir::new().unwrap();
let first = match acquire("session:idem", "pty:me", opts_in(&td)) {
AcquireOutcome::Acquired(r) => r,
other => panic!("{other:?}"),
};
let mut o = opts_in(&td);
o.pid = Some(4242);
let second = match acquire("session:idem", "pty:me", o) {
AcquireOutcome::Acquired(r) => r,
other => panic!("{other:?}"),
};
assert_eq!(second.pid, 4242);
assert!(second.acquired_at >= first.acquired_at);
let events = read_events(&td);
assert_eq!(events[1]["type"], "claim_idempotent_reacquired");
assert_eq!(events[1]["data"]["previous_acquired_at"], first.acquired_at);
}
#[test]
fn live_other_holder_is_refused_with_identity() {
let td = TempDir::new().unwrap();
assert!(matches!(
acquire("session:held", "pty:owner", opts_in(&td)),
AcquireOutcome::Acquired(_)
));
match acquire("session:held", "pty:intruder", opts_in(&td)) {
AcquireOutcome::HeldByOther { holder, pid, .. } => {
assert_eq!(holder, "pty:owner");
assert_eq!(pid, std::process::id() as i32);
}
other => panic!("{other:?}"),
}
}
#[test]
fn is_same_machine_host_arm() {
assert!(is_same_machine(&hostname(), None));
assert!(!is_same_machine("", None));
assert!(!is_same_machine(
"some-other-host-that-does-not-exist",
None
));
}
#[test]
fn is_same_machine_machine_arm() {
if machine_id().is_empty() {
return;
}
assert!(is_same_machine("anything", Some(&machine_id())));
assert!(!is_same_machine(
&hostname(),
Some("00000000-0000-0000-0000-000000000000")
));
}
#[test]
fn unknown_own_machine_id_is_not_foreign() {
if !machine_id().is_empty() {
return; }
assert!(is_same_machine(
"a-name-it-no-longer-has",
Some("some-machine-id")
));
}
#[test]
fn machine_id_is_stable_across_calls() {
assert_eq!(machine_id(), machine_id());
}
#[test]
fn make_claim_records_machine_id_not_hostname() {
let td = TempDir::new().unwrap();
match acquire("session:mid", "pty:owner", opts_in(&td)) {
AcquireOutcome::Acquired(rec) => {
let expected = machine_id();
if expected.is_empty() {
assert_eq!(rec.machine_id, None);
} else {
assert_eq!(rec.machine_id.as_deref(), Some(expected.as_str()));
}
assert_eq!(
rec.host,
hostname(),
"host stays the hostname a pre-change reader expects"
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn stale_claim_is_reclaimed_archived_and_audited() {
let td = TempDir::new().unwrap();
let mut o = opts_in(&td);
o.pid = Some(std::process::id());
let stale = record(std::process::id() as i32, 1, None, &hostname());
let path = lockfile(&td, "session:x");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, serialize_claim(&stale).unwrap()).unwrap();
let rec = match acquire("session:x", "pty:new", o) {
AcquireOutcome::Acquired(r) => r,
other => panic!("{other:?}"),
};
assert_eq!(rec.holder, "pty:new");
let expired: Vec<_> = std::fs::read_dir(path.parent().unwrap().join(EXPIRED_SUBDIR))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(expired.len(), 1);
assert!(expired[0].starts_with("session%3Ax."));
let events = read_events(&td);
assert_eq!(events.last().unwrap()["type"], "claim_stale_reclaimed");
assert_eq!(events.last().unwrap()["data"]["previous_holder"], "h");
}
#[test]
fn corrupted_file_status_reports_acquire_refuses_release_leaves() {
let td = TempDir::new().unwrap();
let path = lockfile(&td, "session:bad");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{{{{not yaml").unwrap();
let (state, rec) = status("session:bad", Some(td.path()));
assert_eq!(state, ClaimState::Corrupted);
assert!(rec.is_none());
assert!(matches!(
acquire("session:bad", "pty:x", opts_in(&td)),
AcquireOutcome::Error(_)
));
release("session:bad", "pty:x", Some(td.path()), Some(td.path())).unwrap();
assert!(path.exists());
}
#[test]
fn release_semantics_missing_other_holder_and_owned() {
let td = TempDir::new().unwrap();
release("session:gone", "pty:x", Some(td.path()), Some(td.path())).unwrap();
assert!(matches!(
acquire("session:r", "pty:owner", opts_in(&td)),
AcquireOutcome::Acquired(_)
));
release("session:r", "pty:other", Some(td.path()), Some(td.path())).unwrap();
assert!(lockfile(&td, "session:r").exists());
release("session:r", "pty:owner", Some(td.path()), Some(td.path())).unwrap();
assert!(!lockfile(&td, "session:r").exists());
let events = read_events(&td);
let released = events.last().unwrap();
assert_eq!(released["type"], "claim_released");
assert!(released["data"]["duration_held_ms"].as_i64().unwrap() >= 0);
}
#[test]
fn status_reads_free_live_and_full_record() {
let td = TempDir::new().unwrap();
assert_eq!(
status("session:s", Some(td.path())),
(ClaimState::Free, None)
);
let mut o = opts_in(&td);
let mut meta = Map::new();
meta.insert("k".into(), json!("v"));
o.metadata = Some(meta.clone());
acquire("session:s", "pty:me", o);
let (state, rec) = status("session:s", Some(td.path()));
assert_eq!(state, ClaimState::Live);
let rec = rec.unwrap();
assert_eq!(rec.holder, "pty:me");
assert_eq!(rec.metadata, meta);
}
#[test]
fn held_recovery_mutex_is_waited_on_then_recovery_proceeds() {
let td = TempDir::new().unwrap();
let path = lockfile(&td, "session:x");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let stale = record(std::process::id() as i32, 1, None, &hostname());
std::fs::write(&path, serialize_claim(&stale).unwrap()).unwrap();
let mutex = path.with_file_name(format!(
"{}.recovery.d",
path.file_name().unwrap().to_string_lossy()
));
std::fs::create_dir(&mutex).unwrap();
let mutex_clone = mutex.clone();
let releaser = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(120));
std::fs::remove_dir(&mutex_clone).unwrap();
});
let out = acquire("session:x", "pty:waiter", opts_in(&td));
releaser.join().unwrap();
assert!(matches!(out, AcquireOutcome::Acquired(_)), "{out:?}");
}
#[test]
fn deadline_expired_waiter_never_steals_a_fresh_recovery_mutex() {
let td = TempDir::new().unwrap();
let path = lockfile(&td, "session:x");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let stale = record(std::process::id() as i32, 1, None, &hostname());
std::fs::write(&path, serialize_claim(&stale).unwrap()).unwrap();
let mutex = path.with_file_name(format!(
"{}.recovery.d",
path.file_name().unwrap().to_string_lossy()
));
std::fs::create_dir(&mutex).unwrap();
wait_for_recovery_release(&mutex, Duration::from_millis(50)); let out = recover_stale(&path, "session:x", "pty:thief", &opts_in(&td), None);
assert!(matches!(out, RecoverResult::Retry));
assert!(mutex.exists(), "recovery mutex was stolen");
let kept = read_claim_file(&path).ok().unwrap();
assert_eq!(kept.holder, "h");
}
fn age_dir(path: &Path, secs: u64) {
use std::os::unix::ffi::OsStrExt;
let c = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let t = libc::timeval {
tv_sec: now - secs as i64,
tv_usec: 0,
};
let times = [t, t];
assert_eq!(unsafe { libc::lutimes(c.as_ptr(), times.as_ptr()) }, 0);
}
#[test]
fn recovery_mutex_corpse_is_stolen_so_a_claim_cannot_brick() {
let td = TempDir::new().unwrap();
let path = lockfile(&td, "session:x");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let stale = record(999_999, 1, None, &hostname());
std::fs::write(&path, serialize_claim(&stale).unwrap()).unwrap();
let mutex = path.with_file_name(format!(
"{}.recovery.d",
path.file_name().unwrap().to_string_lossy()
));
std::fs::create_dir(&mutex).unwrap();
age_dir(&mutex, STALE_MUTEX_STEAL.as_secs() + 60);
let out = acquire("session:x", "pty:heir", opts_in(&td));
assert!(matches!(out, AcquireOutcome::Acquired(_)), "{out:?}");
assert!(!mutex.exists(), "corpse survived the steal");
}
#[test]
fn events_lock_corpse_is_stolen_within_the_daemon_budget() {
let td = TempDir::new().unwrap();
let events = td.path().join(".fno/events.jsonl");
std::fs::create_dir_all(events.parent().unwrap()).unwrap();
let lock = events.with_file_name("events.jsonl.lock.d");
std::fs::create_dir(&lock).unwrap();
age_dir(&lock, STALE_MUTEX_STEAL.as_secs() + 60);
let started = Instant::now();
let res = append_event_line(
&events,
&json!({"ts": "t", "type": "x"}),
Duration::from_secs(2),
);
assert!(res.is_ok(), "{res:?}");
assert!(started.elapsed() < Duration::from_secs(2));
assert!(!lock.exists());
assert_eq!(std::fs::read_to_string(&events).unwrap().lines().count(), 1);
}
#[test]
fn dangling_symlink_lock_never_spins() {
let td = TempDir::new().unwrap();
let lock = td.path().join("events.jsonl.lock.d");
std::os::unix::fs::symlink(td.path().join("nonexistent"), &lock).unwrap();
assert!(!steal_if_stale(&lock), "fresh dangling link was stolen");
age_dir(&lock, STALE_MUTEX_STEAL.as_secs() + 60);
assert!(steal_if_stale(&lock), "stale dangling link was not stolen");
assert!(std::fs::symlink_metadata(&lock).is_err());
}
#[test]
fn repeated_steals_never_collide_on_the_reap_name() {
let td = TempDir::new().unwrap();
let lock = td.path().join("events.jsonl.lock.d");
for _ in 0..3 {
std::fs::create_dir(&lock).unwrap();
age_dir(&lock, STALE_MUTEX_STEAL.as_secs() + 60);
assert!(steal_if_stale(&lock));
assert!(!lock.exists());
}
}
#[test]
fn events_lock_fresh_contention_still_times_out() {
let td = TempDir::new().unwrap();
let events = td.path().join(".fno/events.jsonl");
std::fs::create_dir_all(events.parent().unwrap()).unwrap();
std::fs::create_dir(events.with_file_name("events.jsonl.lock.d")).unwrap();
let res = append_event_line(
&events,
&json!({"ts": "t", "type": "x"}),
Duration::from_secs(2),
);
assert!(res.is_err(), "fresh lock was stolen");
}
#[test]
fn release_after_steal_leaves_new_holder_intact() {
let td = TempDir::new().unwrap();
let lock = td.path().join("events.jsonl.lock.d");
let victim = acquire_dir_mutex(&lock, Duration::from_secs(5), true).unwrap();
age_dir(&lock, STALE_MUTEX_STEAL.as_secs() + 60);
assert!(steal_if_stale(&lock));
assert!(!lock.exists());
let new_holder = acquire_dir_mutex(&lock, Duration::from_secs(5), true).unwrap();
assert_ne!(new_holder, victim);
release_dir_mutex(&lock, &victim);
assert!(
lock.exists(),
"victim's release deleted the new holder's lock"
);
release_dir_mutex(&lock, &new_holder);
assert!(!lock.exists());
}
#[test]
fn concurrent_stealers_have_exactly_one_rename_winner() {
let td = TempDir::new().unwrap();
let events = td.path().join(".fno/events.jsonl");
std::fs::create_dir_all(events.parent().unwrap()).unwrap();
let lock = events.with_file_name("events.jsonl.lock.d");
std::fs::create_dir(&lock).unwrap();
age_dir(&lock, STALE_MUTEX_STEAL.as_secs() + 60);
let handles: Vec<_> = (0..4)
.map(|i| {
let events = events.clone();
std::thread::spawn(move || {
append_event_line(
&events,
&json!({"ts": "t", "type": "x", "i": i}),
STALE_MUTEX_STEAL * 2,
)
})
})
.collect();
for h in handles {
h.join().unwrap().unwrap();
}
assert_eq!(std::fs::read_to_string(&events).unwrap().lines().count(), 4);
}
#[test]
fn simultaneous_acquire_has_exactly_one_winner() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
let handles: Vec<_> = (0..8)
.map(|i| {
let root = root.clone();
std::thread::spawn(move || {
let o = AcquireOpts {
root: Some(root.clone()),
events_dir: Some(root),
..Default::default()
};
acquire("session:race", &format!("pty:w{i}"), o)
})
})
.collect();
let outcomes: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let winners = outcomes
.iter()
.filter(|o| matches!(o, AcquireOutcome::Acquired(_)))
.count();
assert_eq!(winners, 1, "{outcomes:?}");
assert!(
outcomes
.iter()
.all(|o| !matches!(o, AcquireOutcome::Error(_))),
"{outcomes:?}"
);
assert!(read_claim_file(&lockfile(&td, "session:race")).is_ok());
}
}