use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use kimetsu_core::KimetsuResult;
use kimetsu_core::ids::RunId;
use kimetsu_core::paths::ProjectPaths;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(15);
const POLL_INTERVAL: Duration = Duration::from_millis(150);
const STALE_SHORT_OP_AGE: Duration = Duration::from_secs(120);
#[derive(Debug)]
pub struct ProjectLock {
path: PathBuf,
active: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct LockPayload {
pid: u32,
command: String,
run_id: Option<String>,
#[serde(with = "time::serde::rfc3339")]
started_at: OffsetDateTime,
}
impl ProjectLock {
pub fn acquire(
paths: &ProjectPaths,
command: impl Into<String>,
run_id: Option<RunId>,
) -> KimetsuResult<Self> {
acquire_with_timeout(paths, command, run_id, ACQUIRE_TIMEOUT)
}
pub fn release(mut self) -> KimetsuResult<()> {
self.active = false;
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
}
impl Drop for ProjectLock {
fn drop(&mut self) {
if self.active {
let _ = fs::remove_file(&self.path);
}
}
}
pub(crate) fn acquire_with_timeout(
paths: &ProjectPaths,
command: impl Into<String>,
run_id: Option<RunId>,
timeout: Duration,
) -> KimetsuResult<ProjectLock> {
fs::create_dir_all(&paths.kimetsu_dir)?;
let command: String = command.into();
let payload = LockPayload {
pid: std::process::id(),
command: command.clone(),
run_id: run_id.map(|id| id.to_string()),
started_at: OffsetDateTime::now_utc(),
};
let serialized = serde_json::to_string_pretty(&payload)?;
let deadline = Instant::now() + timeout;
loop {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&paths.lock_file)
{
Ok(mut file) => {
file.write_all(serialized.as_bytes())?;
file.sync_all()?;
return Ok(ProjectLock {
path: paths.lock_file.clone(),
active: true,
});
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if lock_is_stale(&paths.lock_file) {
let _ = fs::remove_file(&paths.lock_file);
continue;
}
if Instant::now() >= deadline {
let existing = fs::read_to_string(&paths.lock_file).unwrap_or_default();
return Err(format!(
"project writer lock held (timed out after {}s); \
if the holder crashed, run `kimetsu lock clear`.\n{existing}",
timeout.as_secs()
)
.into());
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => return Err(e.into()),
}
}
}
fn lock_is_stale(lock_file: &Path) -> bool {
let content = match fs::read_to_string(lock_file) {
Ok(s) => s,
Err(_) => return true, };
let payload: LockPayload = match serde_json::from_str(&content) {
Ok(p) => p,
Err(_) => return true, };
match process_alive(payload.pid) {
ProcessLiveness::Dead => true,
ProcessLiveness::Alive => false,
ProcessLiveness::Indeterminate => {
is_short_op_too_old(&payload)
}
}
}
fn is_short_op_too_old(payload: &LockPayload) -> bool {
let age_secs = (OffsetDateTime::now_utc() - payload.started_at).whole_seconds();
if age_secs < 0 {
return false; }
let age = Duration::from_secs(age_secs as u64);
if age < STALE_SHORT_OP_AGE {
return false;
}
let cmd = payload.command.to_ascii_lowercase();
let is_long_op = cmd.contains("run") || cmd.contains("record") || cmd.contains("ingest");
!is_long_op
}
#[derive(Debug, PartialEq, Eq)]
enum ProcessLiveness {
Alive,
Dead,
#[cfg_attr(unix, allow(dead_code))]
Indeterminate,
}
fn process_alive(pid: u32) -> ProcessLiveness {
#[cfg(unix)]
{
process_alive_unix(pid)
}
#[cfg(windows)]
{
process_alive_windows(pid)
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
ProcessLiveness::Indeterminate
}
}
#[cfg(unix)]
fn process_alive_unix(pid: u32) -> ProcessLiveness {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
unsafe {
let rc = kill(pid as i32, 0);
if rc == 0 {
return ProcessLiveness::Alive;
}
let errno = *libc_errno();
if errno == 3 {
ProcessLiveness::Dead
} else {
ProcessLiveness::Alive }
}
}
#[cfg(unix)]
unsafe fn libc_errno() -> *mut i32 {
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn __error() -> *mut i32;
}
#[cfg(target_os = "macos")]
return unsafe { __error() };
#[cfg(not(target_os = "macos"))]
unsafe extern "C" {
fn __errno_location() -> *mut i32;
}
#[cfg(not(target_os = "macos"))]
return unsafe { __errno_location() };
}
#[cfg(windows)]
fn process_alive_windows(pid: u32) -> ProcessLiveness {
unsafe extern "system" {
fn OpenProcess(desired_access: u32, inherit_handle: i32, pid: u32) -> isize;
fn CloseHandle(handle: isize) -> i32;
fn GetLastError() -> u32;
fn WaitForSingleObject(handle: isize, milliseconds: u32) -> u32;
}
const SYNCHRONIZE: u32 = 0x0010_0000;
const ERROR_INVALID_PARAMETER: u32 = 87;
const ERROR_ACCESS_DENIED: u32 = 5;
const WAIT_OBJECT_0: u32 = 0;
const WAIT_TIMEOUT: u32 = 258;
unsafe {
let handle = OpenProcess(SYNCHRONIZE, 0, pid);
if handle == 0 {
let err = GetLastError();
return match err {
ERROR_INVALID_PARAMETER => ProcessLiveness::Dead,
ERROR_ACCESS_DENIED => ProcessLiveness::Alive,
_ => ProcessLiveness::Indeterminate,
};
}
let wait_result = WaitForSingleObject(handle, 0);
CloseHandle(handle);
match wait_result {
WAIT_OBJECT_0 => ProcessLiveness::Dead,
WAIT_TIMEOUT => ProcessLiveness::Alive,
_ => ProcessLiveness::Indeterminate,
}
}
}
pub fn clear_force(paths: &ProjectPaths) -> KimetsuResult<bool> {
match fs::remove_file(&paths.lock_file) {
Ok(()) => Ok(true),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kimetsu_core::paths::ProjectPaths;
use std::sync::{Arc, Barrier};
use std::time::Instant;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let dir = std::env::temp_dir().join(format!("kimetsu-lock-test-{pid}-{n}"));
fs::create_dir_all(&dir).expect("create temp dir");
TempDir(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn make_paths(dir: &TempDir) -> ProjectPaths {
ProjectPaths::at_root(dir.path())
}
#[test]
fn concurrent_acquire_serializes() {
let dir = TempDir::new();
let paths = make_paths(&dir);
fs::create_dir_all(&paths.kimetsu_dir).unwrap();
let paths = Arc::new(paths);
let barrier = Arc::new(Barrier::new(2));
let errors = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let mut handles = Vec::new();
for i in 0..2 {
let p = Arc::clone(&paths);
let b = Arc::clone(&barrier);
let errs = Arc::clone(&errors);
let h = std::thread::spawn(move || {
b.wait(); match acquire_with_timeout(
&p,
format!("test-thread-{i}"),
None,
Duration::from_secs(10),
) {
Ok(lock) => {
std::thread::sleep(Duration::from_millis(30));
lock.release().unwrap();
}
Err(e) => {
errs.lock().unwrap().push(e.to_string());
}
}
});
handles.push(h);
}
for h in handles {
h.join().unwrap();
}
let errs = errors.lock().unwrap();
assert!(
errs.is_empty(),
"expected both threads to succeed; errors: {errs:?}"
);
}
#[test]
fn stale_lock_dead_pid_is_reclaimed() {
let dir = TempDir::new();
let paths = make_paths(&dir);
fs::create_dir_all(&paths.kimetsu_dir).unwrap();
let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
.args(if cfg!(windows) {
&["/c", "exit", "0"][..]
} else {
&[][..]
})
.spawn()
.expect("spawn child");
let dead_pid = child.id();
child.wait().expect("wait for child to exit");
std::thread::sleep(Duration::from_millis(200));
let stale_payload = serde_json::json!({
"pid": dead_pid,
"command": "memory add",
"run_id": null,
"started_at": "2000-01-01T00:00:00Z"
});
fs::write(&paths.lock_file, stale_payload.to_string()).unwrap();
let lock = acquire_with_timeout(&paths, "test", None, Duration::from_secs(5))
.expect("should reclaim stale lock and succeed");
lock.release().unwrap();
}
#[test]
fn corrupt_lock_is_reclaimed() {
let dir = TempDir::new();
let paths = make_paths(&dir);
fs::create_dir_all(&paths.kimetsu_dir).unwrap();
fs::write(&paths.lock_file, b"not json at all!!!\x00\x01\x02").unwrap();
let lock = acquire_with_timeout(&paths, "test", None, Duration::from_secs(5))
.expect("should reclaim corrupt lock and succeed");
lock.release().unwrap();
}
#[test]
fn live_held_lock_times_out() {
let dir = TempDir::new();
let paths = make_paths(&dir);
fs::create_dir_all(&paths.kimetsu_dir).unwrap();
let paths = Arc::new(paths);
let barrier = Arc::new(Barrier::new(2));
let paths2 = Arc::clone(&paths);
let b2 = Arc::clone(&barrier);
let holder = std::thread::spawn(move || {
let lock = acquire_with_timeout(&paths2, "holder", None, Duration::from_secs(5))
.expect("holder should acquire");
b2.wait(); std::thread::sleep(Duration::from_secs(3));
lock.release().unwrap();
});
barrier.wait();
let short_timeout = Duration::from_millis(350);
let t0 = Instant::now();
let result = acquire_with_timeout(&paths, "waiter", None, short_timeout);
let elapsed = t0.elapsed();
assert!(result.is_err(), "expected Err, got Ok");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("timed out"),
"error message should mention 'timed out', got: {msg}"
);
assert!(
elapsed >= short_timeout.saturating_sub(Duration::from_millis(50)),
"waiter returned too quickly (elapsed {elapsed:?}, expected ~{short_timeout:?})"
);
holder.join().unwrap();
}
#[test]
fn process_alive_current_is_alive() {
let my_pid = std::process::id();
assert_eq!(
process_alive(my_pid),
ProcessLiveness::Alive,
"current process should be Alive"
);
}
#[test]
fn process_alive_dead_pid_is_dead() {
let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
.args(if cfg!(windows) {
&["/c", "exit", "0"][..]
} else {
&[][..]
})
.spawn()
.expect("spawn child");
let pid = child.id();
child.wait().expect("wait for child");
std::thread::sleep(Duration::from_millis(100));
let liveness = process_alive(pid);
assert!(
matches!(
liveness,
ProcessLiveness::Dead | ProcessLiveness::Indeterminate
),
"dead child PID should be Dead or Indeterminate, got {liveness:?}"
);
}
}