use crate::events;
use crate::paths::Paths;
use crate::session;
use crate::store::{Collection, FileStore, Key, StateStore};
use crate::util;
use anyhow::{Result, bail};
use std::process::ExitCode;
fn safe_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name.starts_with('.')
|| name == ".."
{
bail!("invalid claim name {name:?}");
}
Ok(())
}
fn claim_holder(store: &impl StateStore, key: &Key) -> String {
store
.read(key)
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("session").and_then(|x| x.as_str()).map(str::to_owned))
.unwrap_or_default()
}
fn claim_session(args: &[String]) -> String {
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--session" {
return it.next().cloned().unwrap_or_default();
}
}
std::env::var("LOOOP_SESSION_ID").unwrap_or_default()
}
fn claim_positional(args: &[String]) -> String {
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--session" {
it.next(); continue;
}
if !a.starts_with("--") {
return a.clone();
}
}
String::new()
}
pub fn cmd_claim(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let name = claim_positional(args);
safe_name(&name)?;
let session = claim_session(args);
let store = FileStore::new(paths);
let key = Key::Claim(name.clone());
let body = serde_json::json!({ "session": session, "name": name }).to_string();
for _ in 0..8 {
if store.create_exclusive(&key, &body)? {
println!("claimed {name}");
return Ok(ExitCode::SUCCESS);
}
let holder = claim_holder(&store, &key);
if !holder.is_empty() && holder == session {
return Ok(ExitCode::SUCCESS); }
if !holder.is_empty() && session::is_alive(paths, &holder) {
eprintln!("claim {name}: held by live session '{holder}'");
return Ok(ExitCode::from(1));
}
let _ = store.remove(&key);
}
bail!("claim {name}: contention reclaiming a stale lease");
}
pub fn cmd_unclaim(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let name = claim_positional(args);
safe_name(&name)?;
let session = claim_session(args);
let store = FileStore::new(paths);
let key = Key::Claim(name.clone());
if !store.exists(&key) {
return Ok(ExitCode::SUCCESS); }
let holder = claim_holder(&store, &key);
if holder.is_empty() || holder == session || !session::is_alive(paths, &holder) {
store.remove(&key)?;
return Ok(ExitCode::SUCCESS);
}
eprintln!("unclaim {name}: held by another live session '{holder}'");
Ok(ExitCode::from(1))
}
pub fn reap_stale_claims(paths: &Paths) {
let store = FileStore::new(paths);
let alive: Vec<String> = session::list(paths)
.into_iter()
.filter(|s| s.alive)
.map(|s| s.id)
.collect();
for name in store.list(&Collection::Claims) {
let key = Key::Claim(name.clone());
let sess = claim_holder(&store, &key);
if sess.is_empty() || !alive.iter().any(|a| a == &sess) {
let _ = store.remove(&key);
util::event(
util::Level::Info,
"claim.reaped",
&format!(
"reaped stale claim {name} (session '{}' not alive)",
if sess.is_empty() { "?" } else { &sess }
),
&[
("claim", serde_json::json!(name)),
("session", serde_json::json!(sess)),
],
);
events::emit(
paths,
"claim_reaped",
serde_json::json!({ "claim": name, "session": sess }),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn args(name: &str, sess: &str) -> Vec<String> {
vec![name.into(), "--session".into(), sess.into()]
}
#[test]
fn claim_creates_lease_and_is_idempotent_for_owner() {
let p = Paths::temp();
assert_eq!(
cmd_claim(&p, &args("repo-x", "w1")).unwrap(),
ExitCode::SUCCESS
);
let path = p.claims_dir().join("repo-x.json");
assert!(path.is_file());
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["session"], "w1");
assert_eq!(v["name"], "repo-x");
assert_eq!(
cmd_claim(&p, &args("repo-x", "w1")).unwrap(),
ExitCode::SUCCESS
);
}
#[test]
fn claim_reclaims_a_stale_lease_from_a_dead_holder() {
let p = Paths::temp();
fs::create_dir_all(p.claims_dir()).unwrap();
fs::write(
p.claims_dir().join("repo-y.json"),
br#"{"session":"dead","name":"repo-y"}"#,
)
.unwrap();
assert_eq!(
cmd_claim(&p, &args("repo-y", "w2")).unwrap(),
ExitCode::SUCCESS
);
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(p.claims_dir().join("repo-y.json")).unwrap())
.unwrap();
assert_eq!(v["session"], "w2", "a dead holder's lease is reclaimed");
}
#[test]
fn unclaim_removes_owned_and_is_idempotent() {
let p = Paths::temp();
cmd_claim(&p, &args("repo-z", "w1")).unwrap();
assert_eq!(
cmd_unclaim(&p, &args("repo-z", "w1")).unwrap(),
ExitCode::SUCCESS
);
assert!(!p.claims_dir().join("repo-z.json").exists());
assert_eq!(
cmd_unclaim(&p, &args("repo-z", "w1")).unwrap(),
ExitCode::SUCCESS
);
}
#[test]
fn claim_name_after_session_flag_is_not_the_flag_value() {
let p = Paths::temp();
let a = vec!["--session".into(), "w1".into(), "repo-q".into()];
assert_eq!(cmd_claim(&p, &a).unwrap(), ExitCode::SUCCESS);
assert!(p.claims_dir().join("repo-q.json").is_file());
assert!(!p.claims_dir().join("w1.json").exists());
}
#[test]
fn claim_rejects_unsafe_names() {
let p = Paths::temp();
for bad in ["", "..", "a/b", ".hidden"] {
assert!(
cmd_claim(&p, &args(bad, "w1")).is_err(),
"should reject {bad:?}"
);
}
}
}