Skip to main content

borg/
live.rs

1//! The session lock, and the file locks every store append takes (`locked`).
2//!
3//! Live mode, which checked the work during the turn from here, was removed in 0.7.6
4//! (docs/delivery.md, "Live mode (removed in 0.7.6)"); the module kept its name and its lock file's.
5//!
6//! The session lock: `<S>.live.lock` beside the session's state file, `<pid> <started_ms>`, held by a
7//! stop for its whole check and by the session-end judge (hook.rs `judge_session`), so two judge calls
8//! never judge the same deliveries at once. Linked into place whole; stale when its process is gone or
9//! it is older than `stale_ms`.
10
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14/// Guard: what a lock may outlive its holder's longest model call by (its own reading and writing),
15/// before it is taken for a crashed holder's.
16const STALE_SLACK_MS: i64 = 30_000;
17
18/// A lock older than this belongs to a holder that cannot still be waiting for its model (or to a pid
19/// that was reused): derived, twice the predict timeout plus [`STALE_SLACK_MS`].
20pub 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
24/// The session lock's file beside the state file `ctx`. Its name is live mode's: a lock an older
25/// binary holds across an upgrade still counts.
26pub 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    /// The file is there, but its holder is gone (or far too old): it may be taken over.
35    Stale,
36}
37
38/// Whether process `pid` still exists (signal 0 checks without sending).
39pub fn alive(pid: i32) -> bool {
40    // SAFETY: kill with signal 0 only checks that the process exists and may be signalled.
41    pid > 0 && (unsafe { libc::kill(pid, 0) } == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM))
42}
43
44/// The state of a lock whose file reads `text` at `now`. A lock is written whole (`acquire`), so one
45/// that does not parse is someone's half-written file: held, until its file is older than `stale_after`.
46fn 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
60/// The lock's state at `now`; `alive` tells whether a pid still runs.
61pub 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/// A held lock; dropping it removes the file, if it is still this one's.
68#[derive(Debug)]
69pub struct Guard {
70    path: PathBuf,
71    text: String,
72}
73
74impl Guard {
75    /// Writes the lock anew at `now`, if it is still this one's: a holder whose work outlasts one model
76    /// call (the session-end judge's batches, hook.rs `judge_session`) renews it before each call, so it
77    /// never reads stale while its holder still works. Whole, by a rename: no one reads it half-written.
78    /// `Lost` when another holds it now (or it is gone), and nothing is written; `Unwritten` when it is
79    /// still this one's but could not be written (it stays as it was, and may read stale later).
80    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/// What renewing a held lock did (`Guard::renew`).
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum Renewal {
102    /// Written anew: held for another `stale_ms`.
103    Renewed,
104    /// Another process holds it now (it read stale and was taken over), or it is gone.
105    Lost,
106    /// Still this one's, but not written (an I/O failure): held as before.
107    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
118/// Takes the session's lock for this process, or None when another holds it. The lock is written
119/// to a file of its own first and hard-linked into place, which fails when a lock is there: no one ever
120/// reads a half-written lock. A stale lock is moved aside (a rename: only one process gets it) and taken
121/// once more; when what was moved aside is no longer the stale lock seen, someone took it over first,
122/// and it goes back.
123pub 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
155/// Runs `f` while holding an exclusive lock on `<file>.lock` (kept, never removed), so appends to `file`
156/// and taking it whole (a rename) never interleave: an append never lands in a file already taken.
157/// When the lock cannot be taken `f` runs unlocked: for a plain append no worse than without it.
158pub 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
167/// The lock of `file` (`<file>.lock`), held until the returned file is dropped; None when it cannot be
168/// taken (`locked` runs unlocked then too). For holding several at once (moves.rs `with_locks`).
169pub fn hold(file: &Path) -> Option<std::fs::File> {
170    lock_of(file).ok()
171}
172
173/// `<file>.lock`, opened and locked exclusively.
174fn 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
184/// Method: how often a holder waiting for the lock (hook.rs `judge_session`) looks at it.
185const WAIT_POLL: Duration = Duration::from_millis(200);
186
187/// Waits up to `max_ms` for the lock's holder to finish; the lock's state after.
188pub 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
199/// Guard: what the stop's model call leaves of the budget for the rest of the hook (its records and its
200/// answer), so the answer is out before the harness kills the hook.
201pub const AFTER_CALL: Duration = Duration::from_secs(3);
202
203/// The stop's model call's limit, `spent` into the hook: what the harness's budget leaves but
204/// [`AFTER_CALL`] for the rest of the hook, never over the model's own limit. omp's 28 s gives at most
205/// 25 s.
206pub 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        // A lock left by a process that is gone is taken over.
235        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        // A guard whose lock was taken over leaves the new holder's file alone.
239        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    /// A holder that works past one call renews its lock, so it stays held; a guard whose lock was taken
245    /// over renews nothing and leaves the new holder's file alone.
246    #[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        // Not written (here: its temp file's place is taken), but still this guard's: not lost.
255        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        // The lock file cannot be opened (a directory in its place): `locked` runs `f` unlocked.
291        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}