Skip to main content

agentmux_srv/registry/
leases.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cross-process session-ownership leases — at most one live process
5//! may drive turns for a given agent `instance_id` at a time.
6//!
7//! Sibling of [`super::store::Registry`] but deliberately a separate
8//! file tree (`<registry_root>/leases/<instance_id>.{lease.json,lock,epoch}`),
9//! not new fields on [`super::schema::NamedAgentRecordV1`]:
10//! `Registry::upsert` is a blind read-modify-write (cross-process
11//! safety comes only from atomic rename, no compare-and-swap — see
12//! that module's doc comment), which is fine for the registry's own
13//! last-writer-wins semantics but wrong for a lock, where "I thought
14//! nobody held it" must be an atomic check.
15//!
16//! `claim`/`renew`/`release`/`verify` each hold a real, blocking,
17//! per-`instance_id` OS advisory lock (`flock` on Unix, `LockFileEx` on
18//! Windows — same primitive family already used for single-instance
19//! enforcement in `backend::base::MuxLock` and
20//! `agentmux-launcher::second_instance`, just scoped per-lease instead of
21//! globally) for the short duration of the call, not across calls. An
22//! earlier draft tried to get away with pure atomic-rename/create-if-not-
23//! exists tricks and no real lock; it had two real, independently-
24//! discovered races (a create-then-write window a concurrent claimer could
25//! observe as "corrupt" and evict, and a check-then-write TOCTOU in renew
26//! that could clobber a new owner) — a real critical section is simpler
27//! AND correct, where the lock-free version was neither.
28//!
29//! Since `SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24.md` Phase 1 the key is
30//! the agent UID and interactive (persistent) panes hold a lease for as long
31//! as their CLI process lives, not just the subprocess controller per turn.
32//! That spec's review added three rules this module enforces:
33//!
34//! - **Fail closed on an unreadable lease.** A lease file that doesn't parse
35//!   proves nothing about its holder. It stays held until nobody has
36//!   rewritten it for a full TTL (a live holder renews — rewrites — it every
37//!   [`RENEW_INTERVAL_MS`]), rather than being evicted on sight.
38//! - **Epochs survive release.** Every change of owner takes the next value
39//!   of a per-key counter (`<instance_id>.epoch`) that, like the lock file,
40//!   is never deleted — so a release followed by a new claim can never
41//!   reuse an older holder's epoch.
42//! - **A dead holder is reclaimed at once.** A lease records its holder's
43//!   host, pid and process start time; on the same host a pid that is gone,
44//!   or now belongs to a different process, frees the lease immediately
45//!   instead of after the TTL.
46//!
47//! Root cause + design context:
48//! `docs/retro/RETRO_DEV_BUILD_SHARED_AGENT_SESSION_COLLISION_2026_07_29.md`,
49//! `docs/analysis/ANALYSIS_MULTI_AGENT_SESSION_AND_WORKDIR_ISOLATION_2026-07-29.md`,
50//! `docs/specs/SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24.md`.
51
52use std::path::{Path, PathBuf};
53use std::sync::Arc;
54
55use serde::{Deserialize, Serialize};
56use thiserror::Error;
57
58use super::atomic::write_atomic;
59
60/// How often a held lease must be renewed. Drives its own dedicated renewal
61/// task (`subprocess/host_spawn.rs` per turn; `backend::agent_admission` for
62/// a persistent pane's process) on this interval — not piggybacked on
63/// anything else.
64pub const RENEW_INTERVAL_MS: u64 = 5_000;
65
66/// 3x the renew interval — tolerates two missed ticks (disk hiccup, GC
67/// pause) before a lease is considered abandoned and reclaimable.
68pub const LEASE_TTL_MS: u64 = RENEW_INTERVAL_MS * 3;
69
70#[derive(Debug, Error)]
71pub enum LeaseError {
72    #[error("session '{instance_id}' is already owned by another AgentMux process (boot {owner_boot_id}, renewed {age_ms}ms ago)")]
73    HeldByOther {
74        instance_id: String,
75        owner_boot_id: String,
76        age_ms: u64,
77        /// Who holds it, when the lease file could be read — what a
78        /// refusal message names. `None` for an unreadable or vanished file.
79        holder: Option<LeaseHolder>,
80    },
81    #[error("io: {0}")]
82    Io(#[from] std::io::Error),
83}
84
85/// Who holds a lease, as recorded in its file. Fields added by the
86/// single-live-instance spec are empty/zero on a lease written by an older
87/// build; readers must treat those as "unknown", never as a value.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct LeaseHolder {
90    pub owner_boot_id: String,
91    pub block_id: String,
92    pub pid: u32,
93    pub channel: String,
94    pub version: String,
95    pub hostname: String,
96    pub epoch: u64,
97    pub acquired_at_ms: i64,
98    pub renewed_at_ms: i64,
99}
100
101/// What a claimant records about itself beyond its boot id and block —
102/// shown to whoever it later refuses. Empty strings are allowed (tests,
103/// callers that don't know); they render as "unknown".
104#[derive(Debug, Clone, Default)]
105pub struct ClaimantInfo {
106    pub channel: String,
107    pub version: String,
108}
109
110#[derive(Debug, Serialize, Deserialize)]
111struct LeaseFile {
112    instance_id: String,
113    owner_boot_id: String,
114    block_id: String,
115    /// Informational only — NOT the lease key (see module doc comment
116    /// and the analysis doc's Open Question this resolves: a
117    /// session_id can be `None` on an agent's first-ever turn, which
118    /// would leave that exact case unprotected if it were the key).
119    session_id: Option<String>,
120    pid: u32,
121    acquired_at_ms: i64,
122    renewed_at_ms: i64,
123    // ── Added by SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 Phase 1. ──
124    // `serde(default)` both ways: an older build's lease file still parses
125    // here (so version skew never looks "corrupt"), and an older build
126    // reading one of ours ignores unknown fields.
127    #[serde(default)]
128    epoch: u64,
129    #[serde(default)]
130    hostname: String,
131    /// Holder process start time, seconds since the Unix epoch (sysinfo).
132    /// 0 = unknown. Distinguishes a reused pid from the original holder.
133    #[serde(default)]
134    pid_start_s: u64,
135    #[serde(default)]
136    channel: String,
137    #[serde(default)]
138    version: String,
139    /// Fresh for every successful `claim`, including a same-block re-claim.
140    /// Only the handle carrying the current value may renew, verify or
141    /// release: a controller replaced for the same block (a forced restart)
142    /// re-claims before the old process's exit runs, and that exit must not
143    /// delete — or be fenced by — the lease the new process now holds.
144    /// Empty on a lease written by an older build (matches any handle).
145    #[serde(default)]
146    claim_id: String,
147}
148
149impl LeaseFile {
150    fn holder(&self) -> LeaseHolder {
151        LeaseHolder {
152            owner_boot_id: self.owner_boot_id.clone(),
153            block_id: self.block_id.clone(),
154            pid: self.pid,
155            channel: self.channel.clone(),
156            version: self.version.clone(),
157            hostname: self.hostname.clone(),
158            epoch: self.epoch,
159            acquired_at_ms: self.acquired_at_ms,
160            renewed_at_ms: self.renewed_at_ms,
161        }
162    }
163}
164
165/// A held lease. Cheap to `Clone` (a few `String`s + a `PathBuf`) — the
166/// renew task, the pre-turn fence check and the eventual release each need
167/// their own owned copy.
168#[derive(Debug, Clone)]
169pub struct Lease {
170    instance_id: String,
171    owner_boot_id: Arc<str>,
172    block_id: String,
173    claim_id: String,
174    epoch: u64,
175    path: PathBuf,
176}
177
178impl Lease {
179    pub fn instance_id(&self) -> &str {
180        &self.instance_id
181    }
182
183    /// The ownership generation this lease was granted under. Strictly
184    /// greater than every earlier holder's for the same key.
185    pub fn epoch(&self) -> u64 {
186        self.epoch
187    }
188}
189
190use agentmux_common::time::now_ms;
191
192/// The lease file as found on disk.
193enum OnDisk {
194    Missing,
195    /// Present but unparseable; `age_ms` since it was last written.
196    Unreadable { age_ms: u64 },
197    Valid(LeaseFile),
198}
199
200pub struct LeaseStore {
201    root: PathBuf,
202    ttl_ms: u64,
203}
204
205impl LeaseStore {
206    /// `<registry_root>/leases/`. Creates the directory if missing.
207    pub fn open(registry_root: &Path) -> std::io::Result<Self> {
208        let root = registry_root.join("leases");
209        std::fs::create_dir_all(&root)?;
210        Ok(Self {
211            root,
212            ttl_ms: LEASE_TTL_MS,
213        })
214    }
215
216    /// Test-only constructor with a small TTL so expiry tests don't
217    /// need real multi-second sleeps.
218    #[cfg(test)]
219    pub(crate) fn open_with_ttl(registry_root: &Path, ttl_ms: u64) -> std::io::Result<Self> {
220        let mut s = Self::open(registry_root)?;
221        s.ttl_ms = ttl_ms;
222        Ok(s)
223    }
224
225    fn content_path(&self, instance_id: &str) -> PathBuf {
226        self.root.join(format!("{instance_id}.lease.json"))
227    }
228
229    fn lock_path(&self, instance_id: &str) -> PathBuf {
230        self.root.join(format!("{instance_id}.lock"))
231    }
232
233    fn epoch_path(&self, instance_id: &str) -> PathBuf {
234        self.root.join(format!("{instance_id}.epoch"))
235    }
236
237    fn read(path: &Path) -> std::io::Result<OnDisk> {
238        let bytes = match std::fs::read(path) {
239            Ok(b) => b,
240            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(OnDisk::Missing),
241            Err(e) => return Err(e),
242        };
243        match serde_json::from_slice::<LeaseFile>(&bytes) {
244            Ok(file) => Ok(OnDisk::Valid(file)),
245            Err(_) => {
246                let age_ms = std::fs::metadata(path)
247                    .and_then(|m| m.modified())
248                    .ok()
249                    .and_then(|t| t.elapsed().ok())
250                    .map(|d| d.as_millis() as u64)
251                    // An mtime we can't read proves nothing either way;
252                    // treat the file as fresh (held) — fail closed.
253                    .unwrap_or(0);
254                Ok(OnDisk::Unreadable { age_ms })
255            }
256        }
257    }
258
259    /// Next ownership epoch for `instance_id`: one past the larger of the
260    /// persisted counter and `floor` (the current file's epoch, which a lease
261    /// written before the counter existed may carry alone). Must be called
262    /// inside the key's critical section.
263    fn next_epoch(&self, instance_id: &str, floor: u64) -> std::io::Result<u64> {
264        let path = self.epoch_path(instance_id);
265        let stored = std::fs::read_to_string(&path)
266            .ok()
267            .and_then(|s| s.trim().parse::<u64>().ok())
268            .unwrap_or(0);
269        let next = stored.max(floor) + 1;
270        write_atomic(&path, format!("{next}\n").as_bytes())?;
271        Ok(next)
272    }
273
274    /// Attempt to claim the lease for `instance_id`. Kept for the
275    /// subprocess controller's per-turn claim; see [`Self::claim_as`].
276    pub fn claim(
277        &self,
278        instance_id: &str,
279        boot_id: &Arc<str>,
280        block_id: &str,
281        session_id_hint: Option<&str>,
282    ) -> Result<Lease, LeaseError> {
283        self.claim_as(instance_id, boot_id, block_id, session_id_hint, &ClaimantInfo::default())
284    }
285
286    /// Attempt to claim the lease for `instance_id`.
287    ///
288    /// - Already held by `boot_id` (this process) for the same block →
289    ///   idempotent re-claim, timestamp refreshed, epoch kept.
290    /// - Held, not yet expired, by **another block of this same process**
291    ///   → `Err(HeldByOther)`: one driver per agent holds inside a process
292    ///   too (ReAgent P1 on #3738), and the newcomer is the one refused.
293    /// - Held by another owner, not yet expired, holder not provably
294    ///   dead → `Err(HeldByOther)`.
295    /// - Unreadable and rewritten within the TTL → `Err(HeldByOther)`
296    ///   (fail closed — see module doc).
297    /// - Free, expired, unreadable-and-stale, or held by a dead process
298    ///   → claimed under a new epoch.
299    ///
300    /// The whole read-decide-write sequence runs inside a per-
301    /// `instance_id` exclusive lock (see module doc comment), so
302    /// there's no window for a concurrent claimer to observe an
303    /// inconsistent state.
304    pub fn claim_as(
305        &self,
306        instance_id: &str,
307        boot_id: &Arc<str>,
308        block_id: &str,
309        session_id_hint: Option<&str>,
310        info: &ClaimantInfo,
311    ) -> Result<Lease, LeaseError> {
312        let _cs = CriticalSection::enter(&self.lock_path(instance_id))?;
313        let path = self.content_path(instance_id);
314
315        let (kept_epoch, floor) = match Self::read(&path)? {
316            OnDisk::Missing => (None, 0),
317            OnDisk::Unreadable { age_ms } => {
318                if age_ms <= self.ttl_ms {
319                    return Err(LeaseError::HeldByOther {
320                        instance_id: instance_id.to_string(),
321                        owner_boot_id: "<unreadable lease file>".to_string(),
322                        age_ms,
323                        holder: None,
324                    });
325                }
326                tracing::warn!(
327                    instance_id,
328                    age_ms,
329                    "lease: unreadable lease file not rewritten for a full TTL — reclaiming"
330                );
331                (None, 0)
332            }
333            OnDisk::Valid(existing) => {
334                let age_ms = (now_ms() - existing.renewed_at_ms).max(0) as u64;
335                if existing.owner_boot_id == boot_id.as_ref() && same_block(&existing, block_id) {
336                    // Same owner re-claiming (e.g. controller recreated
337                    // on a resync with no process restart): ownership
338                    // didn't change, so neither does the epoch.
339                    (Some(existing.epoch), existing.epoch)
340                } else if existing.owner_boot_id == boot_id.as_ref() {
341                    // Another block of this process holds it.
342                    if age_ms <= self.ttl_ms {
343                        return Err(LeaseError::HeldByOther {
344                            instance_id: instance_id.to_string(),
345                            owner_boot_id: existing.owner_boot_id.clone(),
346                            age_ms,
347                            holder: Some(existing.holder()),
348                        });
349                    }
350                    (None, existing.epoch)
351                } else {
352                    if age_ms <= self.ttl_ms && !holder_is_dead(&existing) {
353                        return Err(LeaseError::HeldByOther {
354                            instance_id: instance_id.to_string(),
355                            owner_boot_id: existing.owner_boot_id.clone(),
356                            age_ms,
357                            holder: Some(existing.holder()),
358                        });
359                    }
360                    // Expired, or its process is gone — take it over.
361                    (None, existing.epoch)
362                }
363            }
364        };
365
366        let epoch = match kept_epoch {
367            Some(e) if e > 0 => e,
368            _ => self.next_epoch(instance_id, floor)?,
369        };
370        let claim_id = uuid::Uuid::new_v4().to_string();
371        let bytes = lease_bytes(instance_id, boot_id, block_id, session_id_hint, epoch, &claim_id, info)?;
372        write_atomic(&path, &bytes)?;
373        Ok(Lease {
374            instance_id: instance_id.to_string(),
375            owner_boot_id: Arc::clone(boot_id),
376            block_id: block_id.to_string(),
377            claim_id,
378            epoch,
379            path,
380        })
381    }
382
383    /// Extend a held lease's TTL. Runs inside the same per-`instance_id`
384    /// critical section as `claim`, so the ownership check and the
385    /// write are atomic with respect to any concurrent `claim`/`renew`/
386    /// `release` for this instance — a caller that lost the lease to a
387    /// TTL reclaim between its last renew and this one gets a real
388    /// `HeldByOther`, never a silent clobber of the new owner's file.
389    pub fn renew(&self, lease: &Lease) -> Result<(), LeaseError> {
390        let _cs = CriticalSection::enter(&self.lock_path(&lease.instance_id))?;
391        let existing = self.owned_file(lease)?;
392        let renewed = LeaseFile {
393            renewed_at_ms: now_ms(),
394            ..existing
395        };
396        let mut bytes = serde_json::to_vec_pretty(&renewed).map_err(std::io::Error::from)?;
397        bytes.push(b'\n');
398        write_atomic(&lease.path, &bytes)?;
399        Ok(())
400    }
401
402    /// Is `lease` still ours? The pre-turn fence check: a holder that was
403    /// suspended past the TTL and reclaimed must find out before it starts
404    /// another turn, not only at its next renew tick. Does not write.
405    pub fn verify(&self, lease: &Lease) -> Result<(), LeaseError> {
406        let _cs = CriticalSection::enter(&self.lock_path(&lease.instance_id))?;
407        self.owned_file(lease).map(|_| ())
408    }
409
410    /// Read the lease file and confirm `lease`'s owner still holds it.
411    /// Caller holds the critical section.
412    fn owned_file(&self, lease: &Lease) -> Result<LeaseFile, LeaseError> {
413        let existing = match Self::read(&lease.path)? {
414            OnDisk::Valid(f) => f,
415            // Gone entirely (reclaimed and later released, or evicted), or
416            // unreadable — either way not provably ours.
417            OnDisk::Missing | OnDisk::Unreadable { .. } => {
418                return Err(LeaseError::HeldByOther {
419                    instance_id: lease.instance_id.clone(),
420                    owner_boot_id: "<unknown — lease file missing or unreadable>".to_string(),
421                    age_ms: 0,
422                    holder: None,
423                });
424            }
425        };
426        // Owner, block AND claim: a lease another block of this process took
427        // over (after this one's expired), or this block re-claimed through a
428        // newer handle (a replaced controller), is not this handle's any more.
429        if !is_this_claim(&existing, lease) {
430            let age_ms = (now_ms() - existing.renewed_at_ms).max(0) as u64;
431            return Err(LeaseError::HeldByOther {
432                instance_id: lease.instance_id.clone(),
433                owner_boot_id: existing.owner_boot_id.clone(),
434                age_ms,
435                holder: Some(existing.holder()),
436            });
437        }
438        Ok(existing)
439    }
440
441    /// Release a held lease. If it's no longer ours — already
442    /// TTL-reclaimed by another process, or re-claimed by another block of
443    /// this same process — this is a safe no-op: deleting would destroy the
444    /// current holder's lease, not ours. Idempotent: releasing an
445    /// already-missing lease is `Ok(())`. The epoch counter is never
446    /// removed (see module doc).
447    pub fn release(&self, lease: &Lease) -> Result<(), LeaseError> {
448        let _cs = CriticalSection::enter(&self.lock_path(&lease.instance_id))?;
449        let existing = match Self::read(&lease.path)? {
450            OnDisk::Valid(f) => f,
451            OnDisk::Missing | OnDisk::Unreadable { .. } => return Ok(()),
452        };
453        if !is_this_claim(&existing, lease) {
454            return Ok(());
455        }
456        match std::fs::remove_file(&lease.path) {
457            Ok(()) => Ok(()),
458            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
459            Err(e) => Err(e.into()),
460        }
461    }
462
463    /// The current holder of `instance_id`'s lease if it is **live** —
464    /// unexpired and not provably dead — and is not `own_boot_id`. The
465    /// read-only early check callers run before doing anything shared on
466    /// the agent's behalf (spec I9); the binding decision is still `claim`.
467    /// An unreadable, still-fresh file is reported as held with an empty
468    /// holder (fail closed).
469    pub fn live_holder_other_than(
470        &self,
471        instance_id: &str,
472        own_boot_id: &str,
473    ) -> std::io::Result<Option<LeaseHolder>> {
474        let _cs = CriticalSection::enter(&self.lock_path(instance_id))?;
475        Ok(match Self::read(&self.content_path(instance_id))? {
476            OnDisk::Missing => None,
477            OnDisk::Unreadable { age_ms } => (age_ms <= self.ttl_ms).then(|| LeaseHolder {
478                owner_boot_id: "<unreadable lease file>".to_string(),
479                block_id: String::new(),
480                pid: 0,
481                channel: String::new(),
482                version: String::new(),
483                hostname: String::new(),
484                epoch: 0,
485                acquired_at_ms: 0,
486                renewed_at_ms: 0,
487            }),
488            OnDisk::Valid(f) => {
489                let age_ms = (now_ms() - f.renewed_at_ms).max(0) as u64;
490                (f.owner_boot_id != own_boot_id && age_ms <= self.ttl_ms && !holder_is_dead(&f))
491                    .then(|| f.holder())
492            }
493        })
494    }
495}
496
497/// Does `file` belong to `block_id`? A lease written before block ids were
498/// compared (empty) matches any block of its owner.
499fn same_block(file: &LeaseFile, block_id: &str) -> bool {
500    file.block_id.is_empty() || file.block_id == block_id
501}
502
503/// Is `file` the lease `lease` was granted — same owner, same block, same
504/// claim? An empty `claim_id` (older build) matches.
505fn is_this_claim(file: &LeaseFile, lease: &Lease) -> bool {
506    file.owner_boot_id == lease.owner_boot_id.as_ref()
507        && same_block(file, &lease.block_id)
508        && (file.claim_id.is_empty() || file.claim_id == lease.claim_id)
509}
510
511/// This host's name as recorded in a lease — lowercased and trimmed so two
512/// processes on one host always agree.
513fn this_host() -> String {
514    whoami::fallible::hostname()
515        .unwrap_or_default()
516        .trim()
517        .to_lowercase()
518}
519
520/// Start time (seconds since the Unix epoch) of process `pid` on this host,
521/// `None` if no such process is running.
522fn process_start_s(pid: u32) -> Option<u64> {
523    let target = sysinfo::Pid::from(pid as usize);
524    let mut sys = sysinfo::System::new();
525    sys.refresh_processes_specifics(
526        sysinfo::ProcessesToUpdate::Some(&[target]),
527        true,
528        sysinfo::ProcessRefreshKind::nothing(),
529    );
530    sys.process(target).map(|p| p.start_time())
531}
532
533/// True only when the holder is **provably** gone: it recorded this host,
534/// and its pid is not running or now belongs to a process that started at a
535/// different time. Anything we can't prove (a lease from another host — the
536/// shared root on a network drive — or one written before hosts were
537/// recorded) is treated as alive.
538fn holder_is_dead(file: &LeaseFile) -> bool {
539    if file.hostname.is_empty() || file.pid == 0 || file.hostname != this_host() {
540        return false;
541    }
542    match process_start_s(file.pid) {
543        None => true,
544        Some(start) => file.pid_start_s != 0 && start != file.pid_start_s,
545    }
546}
547
548fn lease_bytes(
549    instance_id: &str,
550    boot_id: &Arc<str>,
551    block_id: &str,
552    session_id_hint: Option<&str>,
553    epoch: u64,
554    claim_id: &str,
555    info: &ClaimantInfo,
556) -> Result<Vec<u8>, std::io::Error> {
557    let now = now_ms();
558    let pid = std::process::id();
559    let file = LeaseFile {
560        instance_id: instance_id.to_string(),
561        owner_boot_id: boot_id.to_string(),
562        block_id: block_id.to_string(),
563        session_id: session_id_hint.map(str::to_string),
564        pid,
565        acquired_at_ms: now,
566        renewed_at_ms: now,
567        epoch,
568        hostname: this_host(),
569        pid_start_s: process_start_s(pid).unwrap_or(0),
570        channel: info.channel.clone(),
571        version: info.version.clone(),
572        claim_id: claim_id.to_string(),
573    };
574    let mut bytes = serde_json::to_vec_pretty(&file).map_err(std::io::Error::from)?;
575    bytes.push(b'\n');
576    Ok(bytes)
577}
578
579/// A held per-`instance_id` OS advisory lock. Blocking (waits rather
580/// than failing immediately) — critical sections here are a handful of
581/// small file ops, so worst-case wait time is negligible. Released
582/// automatically on drop: closing the underlying handle releases both
583/// `flock` (Unix) and `LockFileEx` (Windows) advisory locks.
584struct CriticalSection {
585    _file: std::fs::File,
586}
587
588impl CriticalSection {
589    /// Deliberately never removes `lock_path` — one empty lock file
590    /// persists per `instance_id` that has ever called `claim`, same
591    /// bounded/accepted cost as `backend::base::MuxLock`'s single
592    /// global lock file. This is NOT an oversight: deleting a flock/
593    /// LockFileEx-locked file out from under a waiter is the classic
594    /// "unlink race" footgun — a process blocked in `open()` (before
595    /// it gets to actually lock) can end up holding a handle to a
596    /// *different* underlying file than a fresh claimant that
597    /// recreated the path after deletion, so both would believe they
598    /// hold "the" lock while actually holding two distinct advisory
599    /// locks on two distinct inodes. Leaving the lock file in place
600    /// forever avoids that hazard entirely at the cost of a handful of
601    /// permanently-empty files.
602    fn enter(lock_path: &Path) -> std::io::Result<Self> {
603        let file = std::fs::OpenOptions::new()
604            .create(true)
605            .write(true)
606            .open(lock_path)?;
607        lock_exclusive(&file)?;
608        Ok(Self { _file: file })
609    }
610}
611
612#[cfg(unix)]
613pub(crate) fn lock_exclusive(file: &std::fs::File) -> std::io::Result<()> {
614    use std::os::unix::io::AsRawFd;
615    // Blocking (no LOCK_NB) — we want to wait for the holder, not fail.
616    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
617        return Err(std::io::Error::last_os_error());
618    }
619    Ok(())
620}
621
622#[cfg(windows)]
623pub(crate) fn lock_exclusive(file: &std::fs::File) -> std::io::Result<()> {
624    use std::os::windows::io::AsRawHandle;
625    use windows_sys::Win32::Storage::FileSystem::{LockFileEx, LOCKFILE_EXCLUSIVE_LOCK};
626    use windows_sys::Win32::System::IO::OVERLAPPED;
627
628    let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
629    // Zeroed OVERLAPPED with offset 0 + max length locks the whole
630    // file. LockFileEx requires an OVERLAPPED struct even for a
631    // synchronous, blocking call (offset fields only matter for
632    // partial-file locks, which we don't use).
633    let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
634    // No LOCKFILE_FAIL_IMMEDIATELY — blocks until the lock is free.
635    let ok = unsafe {
636        LockFileEx(handle, LOCKFILE_EXCLUSIVE_LOCK, 0, u32::MAX, u32::MAX, &mut overlapped)
637    };
638    if ok == 0 {
639        return Err(std::io::Error::last_os_error());
640    }
641    Ok(())
642}
643
644/// Non-blocking [`lock_exclusive`]: `Ok(true)` when the lock was taken,
645/// `Ok(false)` when another holder has it. For waiters that must stay
646/// cancellable (poll this instead of blocking in `lock_exclusive`).
647#[cfg(unix)]
648pub(crate) fn try_lock_exclusive(file: &std::fs::File) -> std::io::Result<bool> {
649    use std::os::unix::io::AsRawFd;
650    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
651        return Ok(true);
652    }
653    let err = std::io::Error::last_os_error();
654    if err.kind() == std::io::ErrorKind::WouldBlock {
655        Ok(false)
656    } else {
657        Err(err)
658    }
659}
660
661#[cfg(windows)]
662pub(crate) fn try_lock_exclusive(file: &std::fs::File) -> std::io::Result<bool> {
663    use std::os::windows::io::AsRawHandle;
664    use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
665    use windows_sys::Win32::Storage::FileSystem::{LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY};
666    use windows_sys::Win32::System::IO::OVERLAPPED;
667
668    let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
669    let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
670    let ok = unsafe {
671        LockFileEx(
672            handle,
673            LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
674            0,
675            u32::MAX,
676            u32::MAX,
677            &mut overlapped,
678        )
679    };
680    if ok != 0 {
681        return Ok(true);
682    }
683    let err = std::io::Error::last_os_error();
684    if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) {
685        Ok(false)
686    } else {
687        Err(err)
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    fn store(ttl_ms: u64) -> (tempfile::TempDir, LeaseStore) {
696        let tmp = tempfile::tempdir().unwrap();
697        let s = LeaseStore::open_with_ttl(tmp.path(), ttl_ms).unwrap();
698        (tmp, s)
699    }
700
701    fn boot(id: &str) -> Arc<str> {
702        Arc::from(id)
703    }
704
705    fn lease_path(tmp: &tempfile::TempDir, id: &str) -> PathBuf {
706        tmp.path().join("leases").join(format!("{id}.lease.json"))
707    }
708
709    fn on_disk(tmp: &tempfile::TempDir, id: &str) -> LeaseFile {
710        serde_json::from_slice(&std::fs::read(lease_path(tmp, id)).unwrap()).unwrap()
711    }
712
713    /// Rewrite the lease file so it names `pid` (and `start_s`) on this
714    /// host — simulates a holder that is some other process.
715    fn set_holder_process(tmp: &tempfile::TempDir, id: &str, pid: u32, start_s: u64) {
716        let mut f = on_disk(tmp, id);
717        f.pid = pid;
718        f.pid_start_s = start_s;
719        std::fs::write(lease_path(tmp, id), serde_json::to_vec(&f).unwrap()).unwrap();
720    }
721
722    /// A pid no process has: above Linux's `pid_max` ceiling (2^22) and far
723    /// above any pid Windows hands out. (Not a spawned-and-reaped child —
724    /// the srv's spawn surface is pinned by `pane_env`'s inventory test.)
725    fn exited_pid() -> u32 {
726        const NO_SUCH_PID: u32 = 4_000_000_000;
727        assert!(process_start_s(NO_SUCH_PID).is_none(), "pid {NO_SUCH_PID} unexpectedly running");
728        NO_SUCH_PID
729    }
730
731    #[test]
732    fn claim_succeeds_when_no_existing_lease() {
733        let (_tmp, s) = store(60_000);
734        let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
735        assert_eq!(lease.instance_id(), "agent-1");
736    }
737
738    #[test]
739    fn claim_fails_when_held_by_other_and_fresh() {
740        let (_tmp, s) = store(60_000);
741        s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
742        let err = s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap_err();
743        match err {
744            LeaseError::HeldByOther { owner_boot_id, holder, .. } => {
745                assert_eq!(owner_boot_id, "boot-a");
746                let holder = holder.expect("a readable lease names its holder");
747                assert_eq!(holder.block_id, "block-1");
748                assert_eq!(holder.pid, std::process::id());
749            }
750            other => panic!("expected HeldByOther, got {other:?}"),
751        }
752    }
753
754    #[test]
755    fn claim_is_idempotent_for_same_owner() {
756        let (_tmp, s) = store(60_000);
757        let first = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
758        let second = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
759        assert_eq!(first.epoch(), second.epoch(), "a same-owner re-claim is not an ownership change");
760    }
761
762    #[test]
763    fn claim_reclaims_after_ttl_expiry() {
764        // Wide margin (20x) — under a full parallel `cargo test` run,
765        // thread::sleep can overshoot by tens of ms under scheduler
766        // contention; a tight TTL/sleep ratio here was observed flaky.
767        let (_tmp, s) = store(50);
768        s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
769        std::thread::sleep(std::time::Duration::from_millis(1_000));
770        let reclaimed = s.claim("agent-1", &boot("boot-b"), "block-2", None);
771        assert!(reclaimed.is_ok(), "expected expired lease to be reclaimable, got {reclaimed:?}");
772    }
773
774    /// Codex P1 on #3730: a parse failure does not prove the holder dead.
775    #[test]
776    fn a_fresh_unreadable_lease_is_held_not_evicted() {
777        let (tmp, s) = store(60_000);
778        let path = lease_path(&tmp, "agent-1");
779        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
780        std::fs::write(&path, b"not json").unwrap();
781        let err = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap_err();
782        assert!(matches!(err, LeaseError::HeldByOther { holder: None, .. }), "got {err:?}");
783        assert!(s.live_holder_other_than("agent-1", "boot-a").unwrap().is_some());
784    }
785
786    #[test]
787    fn an_unreadable_lease_nobody_rewrote_for_a_ttl_is_reclaimed() {
788        let (tmp, s) = store(50);
789        let path = lease_path(&tmp, "agent-1");
790        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
791        std::fs::write(&path, b"not json").unwrap();
792        std::thread::sleep(std::time::Duration::from_millis(1_000));
793        let claimed = s.claim("agent-1", &boot("boot-a"), "block-1", None);
794        assert!(claimed.is_ok(), "expected a stale unreadable lease to be reclaimable, got {claimed:?}");
795    }
796
797    /// Version skew: a lease written by a build that predates the added
798    /// fields must parse (and so be honoured), not read as corrupt.
799    #[test]
800    fn a_lease_in_the_old_format_is_still_honoured() {
801        let (tmp, s) = store(60_000);
802        let path = lease_path(&tmp, "agent-1");
803        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
804        let old = serde_json::json!({
805            "instance_id": "agent-1", "owner_boot_id": "boot-old", "block_id": "b",
806            "session_id": null, "pid": 1, "acquired_at_ms": now_ms(), "renewed_at_ms": now_ms(),
807        });
808        std::fs::write(&path, serde_json::to_vec(&old).unwrap()).unwrap();
809        let err = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap_err();
810        match err {
811            LeaseError::HeldByOther { owner_boot_id, .. } => assert_eq!(owner_boot_id, "boot-old"),
812            other => panic!("expected HeldByOther, got {other:?}"),
813        }
814    }
815
816    /// Codex P2 on #3730: release deletes the lease file, so the epoch
817    /// must live somewhere that survives it.
818    #[test]
819    fn epochs_strictly_increase_across_release_and_reclaim() {
820        let (_tmp, s) = store(60_000);
821        let a = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
822        s.release(&a).unwrap();
823        let b = s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
824        s.release(&b).unwrap();
825        let c = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
826        assert!(a.epoch() >= 1);
827        assert!(b.epoch() > a.epoch(), "{} !> {}", b.epoch(), a.epoch());
828        assert!(c.epoch() > b.epoch(), "{} !> {}", c.epoch(), b.epoch());
829    }
830
831    #[test]
832    fn epochs_increase_across_ttl_reclaim_too() {
833        let (_tmp, s) = store(50);
834        let a = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
835        std::thread::sleep(std::time::Duration::from_millis(1_000));
836        let b = s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
837        assert!(b.epoch() > a.epoch());
838    }
839
840    #[test]
841    fn a_holder_whose_process_is_gone_is_reclaimed_immediately() {
842        let (tmp, s) = store(60_000);
843        s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
844        set_holder_process(&tmp, "agent-1", exited_pid(), 0);
845        let reclaimed = s.claim("agent-1", &boot("boot-b"), "block-2", None);
846        assert!(reclaimed.is_ok(), "a dead holder must not wait out the TTL, got {reclaimed:?}");
847        assert!(s.live_holder_other_than("agent-1", "boot-c").unwrap().is_some());
848    }
849
850    /// PID reuse: the pid is running, but it is not the process that
851    /// took the lease (different start time).
852    #[test]
853    fn a_reused_pid_does_not_keep_a_lease_alive() {
854        let (tmp, s) = store(60_000);
855        s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
856        let me = std::process::id();
857        let my_start = process_start_s(me).expect("this test process is running");
858        set_holder_process(&tmp, "agent-1", me, my_start.saturating_sub(3_600));
859        assert!(s.claim("agent-1", &boot("boot-b"), "block-2", None).is_ok());
860    }
861
862    #[test]
863    fn a_holder_on_another_host_is_never_presumed_dead() {
864        let (tmp, s) = store(60_000);
865        s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
866        let mut f = on_disk(&tmp, "agent-1");
867        f.hostname = "some-other-host".to_string();
868        f.pid = exited_pid();
869        std::fs::write(lease_path(&tmp, "agent-1"), serde_json::to_vec(&f).unwrap()).unwrap();
870        assert!(s.claim("agent-1", &boot("boot-b"), "block-2", None).is_err());
871    }
872
873    #[test]
874    fn renew_extends_ttl_and_blocks_reclaim_by_other() {
875        // Large TTL relative to the sleeps (100x+ margin) — this test
876        // asserts the lease is still considered FRESH, so it needs the
877        // opposite safety direction from the expiry tests: elapsed
878        // time must stay comfortably under the TTL even with generous
879        // scheduler jitter on a busy `cargo test` run.
880        let (_tmp, s) = store(5_000);
881        let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
882        std::thread::sleep(std::time::Duration::from_millis(20));
883        s.renew(&lease).unwrap();
884        std::thread::sleep(std::time::Duration::from_millis(20));
885        // ~40ms elapsed since claim, ~20ms since the renew — still fresh.
886        let err = s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap_err();
887        assert!(matches!(err, LeaseError::HeldByOther { .. }));
888    }
889
890    #[test]
891    fn renew_fails_when_already_reclaimed_by_other() {
892        let (tmp, s) = store(50);
893        let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
894        std::thread::sleep(std::time::Duration::from_millis(1_000));
895        s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
896
897        let err = s.renew(&lease).unwrap_err();
898        assert!(matches!(err, LeaseError::HeldByOther { .. }));
899
900        // Must NOT have clobbered boot-b's lease.
901        assert_eq!(on_disk(&tmp, "agent-1").owner_boot_id, "boot-b");
902    }
903
904    /// The fence check a holder runs before each turn.
905    #[test]
906    fn verify_detects_a_lost_lease_without_writing() {
907        let (tmp, s) = store(50);
908        let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
909        assert!(s.verify(&lease).is_ok());
910        std::thread::sleep(std::time::Duration::from_millis(1_000));
911        // Expired but not reclaimed: still ours.
912        assert!(s.verify(&lease).is_ok());
913        s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
914        assert!(matches!(s.verify(&lease), Err(LeaseError::HeldByOther { .. })));
915        assert_eq!(on_disk(&tmp, "agent-1").owner_boot_id, "boot-b");
916    }
917
918    #[test]
919    fn release_removes_the_lease_file() {
920        let (tmp, s) = store(60_000);
921        let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
922        s.release(&lease).unwrap();
923        assert!(!lease_path(&tmp, "agent-1").exists());
924    }
925
926    #[test]
927    fn release_is_a_noop_when_already_reclaimed_by_other() {
928        let (tmp, s) = store(50);
929        let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
930        std::thread::sleep(std::time::Duration::from_millis(1_000));
931        s.claim("agent-1", &boot("boot-b"), "block-2", None).unwrap();
932
933        s.release(&lease).unwrap();
934
935        assert!(lease_path(&tmp, "agent-1").exists(), "release must not delete another owner's lease");
936        assert_eq!(on_disk(&tmp, "agent-1").owner_boot_id, "boot-b");
937    }
938
939    /// ReAgent P1 on #3738: one process, two blocks of the same agent (two
940    /// panes, or a subprocess turn beside a persistent pane) — one driver
941    /// holds inside a process too, and the newcomer is the one refused.
942    #[test]
943    fn a_second_block_of_the_same_process_is_refused_while_the_first_holds_it() {
944        let (_tmp, s) = store(60_000);
945        let first = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
946        let err = s.claim("agent-1", &boot("boot-a"), "block-2", None).unwrap_err();
947        match err {
948            LeaseError::HeldByOther { holder, .. } => assert_eq!(holder.unwrap().block_id, "block-1"),
949            other => panic!("expected HeldByOther, got {other:?}"),
950        }
951        assert!(s.verify(&first).is_ok(), "the holder keeps the agent");
952        s.release(&first).unwrap();
953        assert!(s.claim("agent-1", &boot("boot-a"), "block-2", None).is_ok());
954    }
955
956    /// A controller replaced for the same block (a forced restart) re-claims
957    /// before the old process's exit runs. That exit's release must not
958    /// delete the new claim, and the old handle is the one fenced — not the
959    /// new process.
960    #[test]
961    fn a_same_block_reclaim_supersedes_the_older_handle() {
962        let (tmp, s) = store(60_000);
963        let old = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
964        let new = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
965        assert_eq!(old.epoch(), new.epoch(), "same owner and block: not an ownership change");
966        s.release(&old).unwrap();
967        assert!(lease_path(&tmp, "agent-1").exists(), "the old handle's release must not free the new claim");
968        assert!(s.verify(&new).is_ok());
969        assert!(s.renew(&new).is_ok());
970        assert!(matches!(s.verify(&old), Err(LeaseError::HeldByOther { .. })));
971        assert!(matches!(s.renew(&old), Err(LeaseError::HeldByOther { .. })));
972        s.release(&new).unwrap();
973        assert!(!lease_path(&tmp, "agent-1").exists());
974    }
975
976    /// If a same-process block does take over — after the first block's
977    /// lease expired — the first block's `verify`/`renew` fail and its
978    /// release leaves the new holder alone.
979    #[test]
980    fn a_block_superseded_after_expiry_is_fenced_and_cannot_release() {
981        let (tmp, s) = store(50);
982        let first = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
983        std::thread::sleep(std::time::Duration::from_millis(1_000));
984        let second = s.claim("agent-1", &boot("boot-a"), "block-2", None).unwrap();
985        assert!(second.epoch() > first.epoch(), "a block change is an ownership change");
986        assert!(matches!(s.verify(&first), Err(LeaseError::HeldByOther { .. })));
987        assert!(matches!(s.renew(&first), Err(LeaseError::HeldByOther { .. })));
988        s.release(&first).unwrap();
989        assert_eq!(on_disk(&tmp, "agent-1").block_id, "block-2");
990    }
991
992    #[test]
993    fn release_is_idempotent_on_missing_file() {
994        let (_tmp, s) = store(60_000);
995        let lease = s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
996        s.release(&lease).unwrap();
997        assert!(s.release(&lease).is_ok());
998    }
999
1000    #[test]
1001    fn live_holder_ignores_self_and_names_the_holder() {
1002        // A TTL well above the sysinfo process lookup a liveness read does
1003        // (tens of ms on Windows) — this test asserts the lease is FRESH.
1004        let (_tmp, s) = store(60_000);
1005        s.claim_as(
1006            "agent-1",
1007            &boot("boot-a"),
1008            "block-1",
1009            None,
1010            &ClaimantInfo { channel: "stable".into(), version: "0.57.4".into() },
1011        )
1012        .unwrap();
1013        assert!(s.live_holder_other_than("agent-1", "boot-a").unwrap().is_none());
1014        let other = s.live_holder_other_than("agent-1", "boot-b").unwrap().unwrap();
1015        assert_eq!((other.channel.as_str(), other.version.as_str()), ("stable", "0.57.4"));
1016    }
1017
1018    #[test]
1019    fn live_holder_ignores_an_expired_lease() {
1020        let (_tmp, s) = store(50);
1021        s.claim("agent-1", &boot("boot-a"), "block-1", None).unwrap();
1022        std::thread::sleep(std::time::Duration::from_millis(1_000));
1023        assert!(s.live_holder_other_than("agent-1", "boot-b").unwrap().is_none());
1024    }
1025
1026    #[test]
1027    fn concurrent_claim_race_has_exactly_one_winner() {
1028        let tmp = tempfile::tempdir().unwrap();
1029        let root = tmp.path().to_path_buf();
1030        let n = 8;
1031        let handles: Vec<_> = (0..n)
1032            .map(|i| {
1033                let root = root.clone();
1034                std::thread::spawn(move || {
1035                    let s = LeaseStore::open(&root).unwrap();
1036                    s.claim("agent-1", &boot(&format!("boot-{i}")), "block", None)
1037                        .is_ok()
1038                })
1039            })
1040            .collect();
1041        let wins: usize = handles.into_iter().map(|h| h.join().unwrap()).filter(|ok| *ok).count();
1042        assert_eq!(wins, 1, "exactly one claimant should win the race");
1043    }
1044}