1use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14const STALE_SLACK_MS: i64 = 30_000;
17
18pub fn stale_ms() -> i64 {
21 i64::try_from(crate::predict::timeout().as_millis()).unwrap_or(i64::MAX).saturating_mul(2).saturating_add(STALE_SLACK_MS)
22}
23
24pub fn lock_file(ctx: &Path) -> PathBuf {
27 ctx.with_extension("live.lock")
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Lock {
32 Free,
33 Held,
34 Stale,
36}
37
38pub fn alive(pid: i32) -> bool {
40 pid > 0 && (unsafe { libc::kill(pid, 0) } == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM))
42}
43
44fn state_of(text: &str, file_ms: i64, now: i64, stale_after: i64, alive: impl Fn(i32) -> bool) -> Lock {
47 let mut it = text.split_whitespace();
48 match (it.next().and_then(|p| p.parse::<i32>().ok()), it.next().and_then(|m| m.parse::<i64>().ok())) {
49 (Some(pid), Some(ms)) if alive(pid) && now - ms <= stale_after => Lock::Held,
50 (Some(_), Some(_)) => Lock::Stale,
51 _ if now - file_ms > stale_after => Lock::Stale,
52 _ => Lock::Held,
53 }
54}
55
56fn modified_ms(path: &Path) -> i64 {
57 std::fs::metadata(path).and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_millis() as i64)
58}
59
60pub fn lock_state(ctx: &Path, now: i64, stale_after: i64, alive: impl Fn(i32) -> bool) -> Lock {
62 let path = lock_file(ctx);
63 let Ok(t) = std::fs::read_to_string(&path) else { return Lock::Free };
64 state_of(&t, modified_ms(&path), now, stale_after, alive)
65}
66
67#[derive(Debug)]
69pub struct Guard {
70 path: PathBuf,
71 text: String,
72}
73
74impl Guard {
75 pub fn renew(&mut self, now: i64) -> Renewal {
81 match std::fs::read_to_string(&self.path) {
82 Ok(t) if t == self.text => {}
83 Ok(_) => return Renewal::Lost,
84 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Renewal::Lost,
85 Err(_) => return Renewal::Unwritten,
86 }
87 let pid = std::process::id();
88 let text = format!("{pid} {now}\n");
89 let tmp = self.path.with_extension(format!("lock.{pid}.renew"));
90 if std::fs::write(&tmp, &text).is_err() || std::fs::rename(&tmp, &self.path).is_err() {
91 let _ = std::fs::remove_file(&tmp);
92 return Renewal::Unwritten;
93 }
94 self.text = text;
95 Renewal::Renewed
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum Renewal {
102 Renewed,
104 Lost,
106 Unwritten,
108}
109
110impl Drop for Guard {
111 fn drop(&mut self) {
112 if std::fs::read_to_string(&self.path).is_ok_and(|t| t == self.text) {
113 let _ = std::fs::remove_file(&self.path);
114 }
115 }
116}
117
118pub fn acquire(ctx: &Path, now: i64) -> Option<Guard> {
124 let path = lock_file(ctx);
125 let pid = std::process::id();
126 let text = format!("{pid} {now}\n");
127 let tmp = path.with_extension(format!("lock.{pid}"));
128 std::fs::write(&tmp, &text).ok()?;
129 let mut got = None;
130 for _ in 0..2 {
131 if std::fs::hard_link(&tmp, &path).is_ok() {
132 got = Some(Guard { path: path.clone(), text: text.clone() });
133 break;
134 }
135 let Ok(seen) = std::fs::read_to_string(&path) else { continue };
136 if state_of(&seen, modified_ms(&path), now, stale_ms(), alive) != Lock::Stale {
137 break;
138 }
139 let aside = path.with_extension(format!("lock.stale.{pid}"));
140 if std::fs::rename(&path, &aside).is_err() {
141 continue;
142 }
143 let moved = std::fs::read_to_string(&aside).unwrap_or_default();
144 if moved != seen {
145 let _ = std::fs::hard_link(&aside, &path);
146 let _ = std::fs::remove_file(&aside);
147 break;
148 }
149 let _ = std::fs::remove_file(&aside);
150 }
151 let _ = std::fs::remove_file(&tmp);
152 got
153}
154
155pub fn locked<T>(file: &Path, f: impl FnOnce() -> T) -> T {
159 let held = lock_of(file).ok();
160 let out = f();
161 if let Some(l) = held {
162 let _ = l.unlock();
163 }
164 out
165}
166
167pub fn hold(file: &Path) -> Option<std::fs::File> {
170 lock_of(file).ok()
171}
172
173fn lock_of(file: &Path) -> std::io::Result<std::fs::File> {
175 let lock = std::fs::OpenOptions::new()
176 .create(true)
177 .truncate(false)
178 .write(true)
179 .open(file.with_extension(format!("{}.lock", file.extension().and_then(|e| e.to_str()).unwrap_or_default())))?;
180 lock.lock()?;
181 Ok(lock)
182}
183
184const WAIT_POLL: Duration = Duration::from_millis(200);
186
187pub fn wait_free(ctx: &Path, max_ms: u64) -> Lock {
189 let until = std::time::Instant::now() + std::time::Duration::from_millis(max_ms);
190 loop {
191 let l = lock_state(ctx, crate::learn::now_ms(), stale_ms(), alive);
192 if l != Lock::Held || std::time::Instant::now() >= until {
193 return l;
194 }
195 std::thread::sleep(WAIT_POLL);
196 }
197}
198
199pub const AFTER_CALL: Duration = Duration::from_secs(3);
202
203pub fn call_limit(budget: Option<Duration>, spent: Duration) -> Duration {
207 let Some(b) = budget else { return crate::predict::timeout() };
208 crate::predict::timeout().min(b.saturating_sub(spent).saturating_sub(AFTER_CALL))
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use std::io::Write;
215
216 fn tmp(tag: &str) -> PathBuf {
217 let d = std::env::temp_dir().join(format!("borg-live-{tag}-{}", std::process::id()));
218 let _ = std::fs::remove_dir_all(&d);
219 std::fs::create_dir_all(&d).unwrap();
220 d.join("s1.ctx")
221 }
222
223 #[test]
224 fn the_lock_is_held_while_its_process_lives_and_stale_when_gone_or_too_old() {
225 let ctx = tmp("lock");
226 assert_eq!(lock_state(&ctx, 1000, 120_000, |_| true), Lock::Free);
227 let g = acquire(&ctx, 1000).expect("free lock taken");
228 assert!(acquire(&ctx, 1001).is_none(), "a second holder does not get it");
229 assert_eq!(lock_state(&ctx, 2000, 120_000, |_| true), Lock::Held);
230 assert_eq!(lock_state(&ctx, 2000, 120_000, |_| false), Lock::Stale, "its process is gone");
231 assert_eq!(lock_state(&ctx, 200_000, 120_000, |_| true), Lock::Stale, "older than twice the timeout");
232 drop(g);
233 assert_eq!(lock_state(&ctx, 2000, 120_000, |_| true), Lock::Free, "the guard removes it");
234 std::fs::write(lock_file(&ctx), format!("{} 1000\n", i32::MAX)).unwrap();
236 assert!(!alive(i32::MAX));
237 let g = acquire(&ctx, 2000).expect("stale lock taken over");
238 std::fs::write(lock_file(&ctx), "1 2000\n").unwrap();
240 drop(g);
241 assert!(lock_file(&ctx).exists(), "not this guard's lock any more");
242 }
243
244 #[test]
247 fn a_renewed_lock_stays_held_and_a_lost_one_is_left_alone() {
248 let ctx = tmp("renew");
249 let mut g = acquire(&ctx, 1000).expect("free lock taken");
250 assert_eq!(lock_state(&ctx, 200_000, 120_000, |_| true), Lock::Stale, "unrenewed: stale after the limit");
251 assert_eq!(g.renew(150_000), Renewal::Renewed);
252 assert_eq!(lock_state(&ctx, 200_000, 120_000, |_| true), Lock::Held, "renewed: held");
253 assert!(acquire(&ctx, 200_000).is_none());
254 let tmp = lock_file(&ctx).with_extension(format!("lock.{}.renew", std::process::id()));
256 std::fs::create_dir_all(&tmp).unwrap();
257 assert_eq!(g.renew(160_000), Renewal::Unwritten, "an I/O failure is no takeover");
258 std::fs::remove_dir_all(&tmp).unwrap();
259 assert_eq!(g.renew(170_000), Renewal::Renewed, "still this guard's");
260 std::fs::write(lock_file(&ctx), "1 2000\n").unwrap();
261 assert_eq!(g.renew(210_000), Renewal::Lost, "taken over: not renewed");
262 assert_eq!(std::fs::read_to_string(lock_file(&ctx)).unwrap(), "1 2000\n");
263 drop(g);
264 assert!(lock_file(&ctx).exists(), "not this guard's lock any more");
265 }
266
267 #[test]
268 fn a_half_written_lock_is_held_until_its_file_is_old() {
269 assert_eq!(state_of("", 1000, 2000, 120_000, |_| false), Lock::Held, "being written: never taken over");
270 assert_eq!(state_of("12", 1000, 2000, 120_000, |_| false), Lock::Held);
271 assert_eq!(state_of("", 1000, 200_000, 120_000, |_| false), Lock::Stale, "left empty long ago");
272 assert_eq!(state_of("7 1000", 0, 2000, 120_000, |_| true), Lock::Held);
273 let ctx = tmp("half");
274 std::fs::write(lock_file(&ctx), "").unwrap();
275 assert!(acquire(&ctx, crate::learn::now_ms()).is_none(), "an empty, fresh lock is someone's");
276 }
277
278 #[test]
279 fn the_stop_fits_its_model_call_into_the_harness_budget() {
280 let s = Duration::from_secs;
281 assert_eq!(call_limit(None, s(30)), crate::predict::timeout(), "Claude Code: the model's own limit");
282 assert_eq!(call_limit(Some(s(90)), s(15)), crate::predict::timeout().min(s(72)), "opencode");
283 assert_eq!(call_limit(Some(s(28)), s(0)), crate::predict::timeout().min(s(25)), "omp");
284 assert_eq!(call_limit(Some(s(28)), s(11)), crate::predict::timeout().min(s(14)), "time spent before the call comes off it");
285 assert_eq!(call_limit(Some(s(28)), s(27)), Duration::ZERO);
286 }
287
288 #[test]
289 fn a_lock_that_cannot_be_taken_still_runs_f_unlocked() {
290 let d = tmp("try-locked");
292 let file = d.join("tasks.jsonl");
293 std::fs::create_dir_all(d.join("tasks.jsonl.lock")).unwrap();
294 assert!(locked(&file, || true));
295 let _ = std::fs::remove_dir_all(d);
296 }
297
298 #[test]
299 fn appends_under_the_queue_lock_never_land_in_a_taken_file() {
300 let ctx = tmp("queue");
301 let q = ctx.with_extension("queued");
302 let writers: Vec<_> = (0..8)
303 .map(|i| {
304 let q = q.clone();
305 std::thread::spawn(move || {
306 for j in 0..50 {
307 locked(&q, || std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| writeln!(f, "{i}-{j}")).unwrap());
308 }
309 })
310 })
311 .collect();
312 let mut got = 0;
313 let taken = q.with_extension("queued.t");
314 while writers.iter().any(|w| !w.is_finished()) {
315 if locked(&q, || std::fs::rename(&q, &taken).is_ok()) {
316 got += std::fs::read_to_string(&taken).unwrap().lines().count();
317 }
318 }
319 for w in writers {
320 w.join().unwrap();
321 }
322 if std::fs::rename(&q, &taken).is_ok() {
323 got += std::fs::read_to_string(&taken).unwrap().lines().count();
324 }
325 assert_eq!(got, 400, "every line taken once");
326 }
327}