use anyhow::Context as _;
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
pub(crate) const LOCK_SUBPATH: &str = ".doctrine/state/dispatch/lock";
#[derive(Debug)]
pub(crate) struct ClaimLock {
file: File,
}
impl Drop for ClaimLock {
fn drop(&mut self) {
if rustix::fs::flock(&self.file, rustix::fs::FlockOperation::Unlock).is_err() {
}
}
}
fn lock_path(coord: &Path, name: &str) -> PathBuf {
coord.join(LOCK_SUBPATH).join(name)
}
fn open_lock_file(coord: &Path, name: &str) -> anyhow::Result<File> {
let path = lock_path(coord, name);
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("claim lock path {} has no parent", path.display()))?;
std::fs::create_dir_all(parent)
.with_context(|| format!("create claim lock dir {}", parent.display()))?;
OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("open claim lock {}", path.display()))
}
pub(crate) fn acquire(coord: &Path, name: &str) -> anyhow::Result<ClaimLock> {
let file = open_lock_file(coord, name)?;
rustix::fs::flock(&file, rustix::fs::FlockOperation::LockExclusive)
.with_context(|| format!("acquire claim lock for {name}"))?;
Ok(ClaimLock { file })
}
pub(crate) fn try_acquire(coord: &Path, name: &str) -> anyhow::Result<Option<ClaimLock>> {
let file = open_lock_file(coord, name)?;
match rustix::fs::flock(&file, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
Ok(()) => Ok(Some(ClaimLock { file })),
Err(rustix::io::Errno::WOULDBLOCK) => Ok(None),
Err(e) => Err(anyhow::Error::new(e).context(format!("try claim lock for {name}"))),
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests: fail-fast unwrap on fixture setup is idiomatic"
)]
mod tests {
use super::*;
#[test]
fn a_held_lock_is_busy_to_a_non_blocking_acquirer_and_free_after_drop() {
let tmp = tempfile::tempdir().unwrap();
let coord = tmp.path();
let held = acquire(coord, "agent-abc").unwrap();
assert!(
try_acquire(coord, "agent-abc").unwrap().is_none(),
"a held claim is BUSY to the non-blocking sweep"
);
assert!(
try_acquire(coord, "agent-other").unwrap().is_some(),
"the claim lock is per-name: a sibling name is free"
);
drop(held);
assert!(
try_acquire(coord, "agent-abc").unwrap().is_some(),
"the name is free once the claimant's lock drops"
);
}
#[test]
fn the_lock_file_lands_in_the_runtime_tier_beside_its_siblings() {
let tmp = tempfile::tempdir().unwrap();
let coord = tmp.path();
let held = acquire(coord, "agent-abc").unwrap();
assert!(
coord.join(LOCK_SUBPATH).join("agent-abc").exists(),
"the lock file lands under the runtime-tier lock subpath"
);
drop(held);
}
const HOLDER_ENV: &str = "DOCTRINE_TEST_CLAIM_LOCK_HOLDER";
const HOLDER_READY: &str = "claim-held";
#[test]
fn lock_holder_child() {
let Ok(spec) = std::env::var(HOLDER_ENV) else {
return;
};
let (coord, name) = spec
.split_once('\n')
.expect("holder spec is `<coord>\\n<name>`");
let held = acquire(Path::new(coord), name).expect("the child takes the claim");
println!("{HOLDER_READY}");
std::io::Write::flush(&mut std::io::stdout()).unwrap();
std::thread::sleep(std::time::Duration::from_secs(120));
drop(held);
}
#[test]
fn a_crashed_claimants_lock_is_released_by_the_kernel() {
let tmp = tempfile::tempdir().unwrap();
let coord = std::fs::canonicalize(tmp.path()).unwrap();
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"worktree::claim_lock::tests::lock_holder_child",
"--nocapture",
])
.env(HOLDER_ENV, format!("{}\nagent-crash", coord.display()))
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn a real lock-holding child process");
let out = child.stdout.take().expect("child stdout");
let ready = std::io::BufRead::lines(std::io::BufReader::new(out))
.map_while(Result::ok)
.any(|line| line.trim() == HOLDER_READY);
assert!(ready, "the child took the claim");
assert!(
try_acquire(&coord, "agent-crash").unwrap().is_none(),
"the live claimant's lock is BUSY to the sweep"
);
child.kill().expect("kill the holder");
child.wait().expect("reap the holder");
assert!(
try_acquire(&coord, "agent-crash").unwrap().is_some(),
"a crashed claimant's lock is kernel-released — the residue sweeps freely"
);
}
}