1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6use kimetsu_core::KimetsuResult;
7use kimetsu_core::ids::RunId;
8use kimetsu_core::paths::ProjectPaths;
9use serde::{Deserialize, Serialize};
10use time::OffsetDateTime;
11
12const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(15);
14
15const POLL_INTERVAL: Duration = Duration::from_millis(150);
17
18const STALE_SHORT_OP_AGE: Duration = Duration::from_secs(120);
21
22#[derive(Debug)]
23pub struct ProjectLock {
24 path: PathBuf,
25 active: bool,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29struct LockPayload {
30 pid: u32,
31 command: String,
32 run_id: Option<String>,
33 #[serde(with = "time::serde::rfc3339")]
34 started_at: OffsetDateTime,
35}
36
37impl ProjectLock {
38 pub fn acquire(
42 paths: &ProjectPaths,
43 command: impl Into<String>,
44 run_id: Option<RunId>,
45 ) -> KimetsuResult<Self> {
46 acquire_with_timeout(paths, command, run_id, ACQUIRE_TIMEOUT)
47 }
48
49 pub fn release(mut self) -> KimetsuResult<()> {
50 self.active = false;
51 match fs::remove_file(&self.path) {
52 Ok(()) => Ok(()),
53 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
54 Err(err) => Err(err.into()),
55 }
56 }
57}
58
59impl Drop for ProjectLock {
60 fn drop(&mut self) {
61 if self.active {
62 let _ = fs::remove_file(&self.path);
63 }
64 }
65}
66
67pub(crate) fn acquire_with_timeout(
70 paths: &ProjectPaths,
71 command: impl Into<String>,
72 run_id: Option<RunId>,
73 timeout: Duration,
74) -> KimetsuResult<ProjectLock> {
75 fs::create_dir_all(&paths.kimetsu_dir)?;
76 let command: String = command.into();
77 let payload = LockPayload {
78 pid: std::process::id(),
79 command: command.clone(),
80 run_id: run_id.map(|id| id.to_string()),
81 started_at: OffsetDateTime::now_utc(),
82 };
83 let serialized = serde_json::to_string_pretty(&payload)?;
84 let deadline = Instant::now() + timeout;
85
86 loop {
87 match OpenOptions::new()
88 .write(true)
89 .create_new(true)
90 .open(&paths.lock_file)
91 {
92 Ok(mut file) => {
93 file.write_all(serialized.as_bytes())?;
94 file.sync_all()?;
95 return Ok(ProjectLock {
96 path: paths.lock_file.clone(),
97 active: true,
98 });
99 }
100 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
101 if lock_is_stale(&paths.lock_file) {
103 let _ = fs::remove_file(&paths.lock_file);
106 continue;
107 }
108
109 if Instant::now() >= deadline {
110 let existing = fs::read_to_string(&paths.lock_file).unwrap_or_default();
111 return Err(format!(
112 "project writer lock held (timed out after {}s); \
113 if the holder crashed, run `kimetsu lock clear`.\n{existing}",
114 timeout.as_secs()
115 )
116 .into());
117 }
118
119 std::thread::sleep(POLL_INTERVAL);
120 }
121 Err(e) => return Err(e.into()),
122 }
123 }
124}
125
126fn lock_is_stale(lock_file: &Path) -> bool {
134 let content = match fs::read_to_string(lock_file) {
135 Ok(s) => s,
136 Err(_) => return true, };
138
139 let payload: LockPayload = match serde_json::from_str(&content) {
140 Ok(p) => p,
141 Err(_) => return true, };
143
144 match process_alive(payload.pid) {
145 ProcessLiveness::Dead => true,
146 ProcessLiveness::Alive => false,
147 ProcessLiveness::Indeterminate => {
148 is_short_op_too_old(&payload)
150 }
151 }
152}
153
154fn is_short_op_too_old(payload: &LockPayload) -> bool {
157 let age_secs = (OffsetDateTime::now_utc() - payload.started_at).whole_seconds();
158 if age_secs < 0 {
159 return false; }
161 let age = Duration::from_secs(age_secs as u64);
162 if age < STALE_SHORT_OP_AGE {
163 return false;
164 }
165 let cmd = payload.command.to_ascii_lowercase();
169 let is_long_op = cmd.contains("run") || cmd.contains("record") || cmd.contains("ingest");
170 !is_long_op
171}
172
173#[derive(Debug, PartialEq, Eq)]
174enum ProcessLiveness {
175 Alive,
176 Dead,
177 #[cfg_attr(unix, allow(dead_code))]
182 Indeterminate,
183}
184
185fn process_alive(pid: u32) -> ProcessLiveness {
190 #[cfg(unix)]
191 {
192 process_alive_unix(pid)
193 }
194 #[cfg(windows)]
195 {
196 process_alive_windows(pid)
197 }
198 #[cfg(not(any(unix, windows)))]
199 {
200 let _ = pid;
201 ProcessLiveness::Indeterminate
202 }
203}
204
205#[cfg(unix)]
206fn process_alive_unix(pid: u32) -> ProcessLiveness {
207 unsafe extern "C" {
213 fn kill(pid: i32, sig: i32) -> i32;
214 }
215 unsafe {
216 let rc = kill(pid as i32, 0);
217 if rc == 0 {
218 return ProcessLiveness::Alive;
219 }
220 let errno = *libc_errno();
222 if errno == 3 {
223 ProcessLiveness::Dead
225 } else {
226 ProcessLiveness::Alive }
228 }
229}
230
231#[cfg(unix)]
233unsafe fn libc_errno() -> *mut i32 {
234 #[cfg(target_os = "macos")]
237 unsafe extern "C" {
238 fn __error() -> *mut i32;
239 }
240 #[cfg(target_os = "macos")]
241 return unsafe { __error() };
242
243 #[cfg(not(target_os = "macos"))]
244 unsafe extern "C" {
245 fn __errno_location() -> *mut i32;
246 }
247 #[cfg(not(target_os = "macos"))]
248 return unsafe { __errno_location() };
249}
250
251#[cfg(windows)]
252fn process_alive_windows(pid: u32) -> ProcessLiveness {
253 unsafe extern "system" {
266 fn OpenProcess(desired_access: u32, inherit_handle: i32, pid: u32) -> isize;
267 fn CloseHandle(handle: isize) -> i32;
268 fn GetLastError() -> u32;
269 fn WaitForSingleObject(handle: isize, milliseconds: u32) -> u32;
270 }
271
272 const SYNCHRONIZE: u32 = 0x0010_0000;
273 const ERROR_INVALID_PARAMETER: u32 = 87;
274 const ERROR_ACCESS_DENIED: u32 = 5;
275 const WAIT_OBJECT_0: u32 = 0;
276 const WAIT_TIMEOUT: u32 = 258;
277
278 unsafe {
279 let handle = OpenProcess(SYNCHRONIZE, 0, pid);
280 if handle == 0 {
281 let err = GetLastError();
282 return match err {
283 ERROR_INVALID_PARAMETER => ProcessLiveness::Dead,
284 ERROR_ACCESS_DENIED => ProcessLiveness::Alive,
285 _ => ProcessLiveness::Indeterminate,
286 };
287 }
288 let wait_result = WaitForSingleObject(handle, 0);
292 CloseHandle(handle);
293 match wait_result {
294 WAIT_OBJECT_0 => ProcessLiveness::Dead,
295 WAIT_TIMEOUT => ProcessLiveness::Alive,
296 _ => ProcessLiveness::Indeterminate,
297 }
298 }
299}
300
301pub fn clear_force(paths: &ProjectPaths) -> KimetsuResult<bool> {
302 match fs::remove_file(&paths.lock_file) {
303 Ok(()) => Ok(true),
304 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
305 Err(err) => Err(err.into()),
306 }
307}
308
309#[cfg(test)]
314mod tests {
315 use super::*;
316 use kimetsu_core::paths::ProjectPaths;
317 use std::sync::{Arc, Barrier};
318 use std::time::Instant;
319
320 struct TempDir(PathBuf);
322
323 impl TempDir {
324 fn new() -> Self {
325 use std::sync::atomic::{AtomicU64, Ordering};
326 static CTR: AtomicU64 = AtomicU64::new(0);
327 let n = CTR.fetch_add(1, Ordering::Relaxed);
328 let pid = std::process::id();
329 let dir = std::env::temp_dir().join(format!("kimetsu-lock-test-{pid}-{n}"));
330 fs::create_dir_all(&dir).expect("create temp dir");
331 TempDir(dir)
332 }
333
334 fn path(&self) -> &Path {
335 &self.0
336 }
337 }
338
339 impl Drop for TempDir {
340 fn drop(&mut self) {
341 let _ = fs::remove_dir_all(&self.0);
342 }
343 }
344
345 fn make_paths(dir: &TempDir) -> ProjectPaths {
347 ProjectPaths::at_root(dir.path())
348 }
349
350 #[test]
354 fn concurrent_acquire_serializes() {
355 let dir = TempDir::new();
356 let paths = make_paths(&dir);
357 fs::create_dir_all(&paths.kimetsu_dir).unwrap();
358
359 let paths = Arc::new(paths);
360 let barrier = Arc::new(Barrier::new(2));
362 let errors = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
363
364 let mut handles = Vec::new();
365 for i in 0..2 {
366 let p = Arc::clone(&paths);
367 let b = Arc::clone(&barrier);
368 let errs = Arc::clone(&errors);
369 let h = std::thread::spawn(move || {
370 b.wait(); match acquire_with_timeout(
372 &p,
373 format!("test-thread-{i}"),
374 None,
375 Duration::from_secs(10),
376 ) {
377 Ok(lock) => {
378 std::thread::sleep(Duration::from_millis(30));
379 lock.release().unwrap();
380 }
381 Err(e) => {
382 errs.lock().unwrap().push(e.to_string());
383 }
384 }
385 });
386 handles.push(h);
387 }
388
389 for h in handles {
390 h.join().unwrap();
391 }
392
393 let errs = errors.lock().unwrap();
394 assert!(
395 errs.is_empty(),
396 "expected both threads to succeed; errors: {errs:?}"
397 );
398 }
399
400 #[test]
404 fn stale_lock_dead_pid_is_reclaimed() {
405 let dir = TempDir::new();
406 let paths = make_paths(&dir);
407 fs::create_dir_all(&paths.kimetsu_dir).unwrap();
408
409 let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
411 .args(if cfg!(windows) {
412 &["/c", "exit", "0"][..]
413 } else {
414 &[][..]
415 })
416 .spawn()
417 .expect("spawn child");
418 let dead_pid = child.id();
419 child.wait().expect("wait for child to exit");
420 std::thread::sleep(Duration::from_millis(200));
422
423 let stale_payload = serde_json::json!({
425 "pid": dead_pid,
426 "command": "memory add",
427 "run_id": null,
428 "started_at": "2000-01-01T00:00:00Z"
429 });
430 fs::write(&paths.lock_file, stale_payload.to_string()).unwrap();
431
432 let lock = acquire_with_timeout(&paths, "test", None, Duration::from_secs(5))
434 .expect("should reclaim stale lock and succeed");
435 lock.release().unwrap();
436 }
437
438 #[test]
442 fn corrupt_lock_is_reclaimed() {
443 let dir = TempDir::new();
444 let paths = make_paths(&dir);
445 fs::create_dir_all(&paths.kimetsu_dir).unwrap();
446
447 fs::write(&paths.lock_file, b"not json at all!!!\x00\x01\x02").unwrap();
449
450 let lock = acquire_with_timeout(&paths, "test", None, Duration::from_secs(5))
451 .expect("should reclaim corrupt lock and succeed");
452 lock.release().unwrap();
453 }
454
455 #[test]
459 fn live_held_lock_times_out() {
460 let dir = TempDir::new();
461 let paths = make_paths(&dir);
462 fs::create_dir_all(&paths.kimetsu_dir).unwrap();
463
464 let paths = Arc::new(paths);
465
466 let barrier = Arc::new(Barrier::new(2));
469 let paths2 = Arc::clone(&paths);
470 let b2 = Arc::clone(&barrier);
471 let holder = std::thread::spawn(move || {
472 let lock = acquire_with_timeout(&paths2, "holder", None, Duration::from_secs(5))
473 .expect("holder should acquire");
474 b2.wait(); std::thread::sleep(Duration::from_secs(3));
477 lock.release().unwrap();
478 });
479
480 barrier.wait(); let short_timeout = Duration::from_millis(350);
483 let t0 = Instant::now();
484 let result = acquire_with_timeout(&paths, "waiter", None, short_timeout);
485 let elapsed = t0.elapsed();
486
487 assert!(result.is_err(), "expected Err, got Ok");
489 let msg = result.unwrap_err().to_string();
490 assert!(
491 msg.contains("timed out"),
492 "error message should mention 'timed out', got: {msg}"
493 );
494
495 assert!(
498 elapsed >= short_timeout.saturating_sub(Duration::from_millis(50)),
499 "waiter returned too quickly (elapsed {elapsed:?}, expected ~{short_timeout:?})"
500 );
501
502 holder.join().unwrap();
503 }
504
505 #[test]
509 fn process_alive_current_is_alive() {
510 let my_pid = std::process::id();
511 assert_eq!(
512 process_alive(my_pid),
513 ProcessLiveness::Alive,
514 "current process should be Alive"
515 );
516 }
517
518 #[test]
519 fn process_alive_dead_pid_is_dead() {
520 let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
522 .args(if cfg!(windows) {
523 &["/c", "exit", "0"][..]
524 } else {
525 &[][..]
526 })
527 .spawn()
528 .expect("spawn child");
529 let pid = child.id();
530 child.wait().expect("wait for child");
531
532 std::thread::sleep(Duration::from_millis(100));
534
535 let liveness = process_alive(pid);
536 assert!(
539 matches!(
540 liveness,
541 ProcessLiveness::Dead | ProcessLiveness::Indeterminate
542 ),
543 "dead child PID should be Dead or Indeterminate, got {liveness:?}"
544 );
545 }
546}