Skip to main content

majordomus_cli/
lease.rs

1//! One shared server per checkout: the lease that decides who it is. The first
2//! `majordomus mcp` (or `serve`) to create `state/mcp/server.json` under the checkout's
3//! local half owns the server and publishes its URL there; every later process reads the
4//! file, checks that the server answers for this root, and attaches to it. A lease whose
5//! server does not answer is stale, and the next process takes it over; so is a file that
6//! is not a lease document, an empty one, or one whose owner never published a URL. A
7//! server that is slow to answer while its process is alive is busy, not stale: the
8//! election waits for it for [`BUSY_GRACE`] before it takes anything over. The file lives
9//! under `.ai/local/` (never tracked, by the layer's contract), and it is removed when the
10//! server stops — including when `SIGTERM`, `SIGINT` or `SIGHUP` asks it to — but only by
11//! the process that still holds it: every removal and every rewrite re-reads the file under
12//! the lock beside it (`server.lock`) and acts only on a lease that carries the actor's own
13//! token (`take_over_with` says why it is a lock).
14//!
15//! Per *checkout*, and this file said "per repository" until ADR 0044: a linked worktree
16//! is a checkout, so it has a manifest, a root, a lease and a server of its own, and on
17//! 2026-09-11 this repository had seven of them at once. What is repository-wide is the
18//! board, which `peers.list` gathers over these very leases; the election is unchanged.
19
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Arc, Mutex, OnceLock};
25use std::time::{Duration, Instant, SystemTime};
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use crate::error::{Error, Result};
32use crate::mcp::bridge;
33use crate::repository::Repository;
34
35/// The lease file, relative to the checkout-local half (`.ai/local/`).
36pub const LEASE_PATH: &str = "state/mcp/server.json";
37
38/// The lease file's `schema`.
39pub const SCHEMA: &str = "majordomus-mcp-lease/v1";
40
41/// How long a lease without a URL may be (the owner is still binding) before it counts as
42/// abandoned.
43pub const BIND_GRACE: Duration = Duration::from_secs(15);
44
45/// How long the probe of a published URL waits.
46pub const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
47
48/// How long a live server that does not answer its probe is waited for before its lease is
49/// taken over.
50///
51/// A probe that times out is not a dead server. On 2026-09-15 ten MCP clients attached at
52/// once to a server a shell entry had started; nine were bridged to it, and the tenth
53/// probed while the server was answering the other nine, heard nothing within
54/// [`PROBE_TIMEOUT`], took the lease over and started a second server for one checkout
55/// (test/cases/192). So a silent probe of a lease whose process is still alive is waited
56/// on, and asked again; only a server that stays silent for this long, or whose process is
57/// gone, loses its lease. The bound keeps the recovery from a hung server, and it sits
58/// well inside [`JOIN_TIMEOUT`] so that the take-over still happens within one election.
59pub const BUSY_GRACE: Duration = Duration::from_secs(10);
60
61/// How long a process keeps trying to acquire or join the lease before it gives up and
62/// says so: the bind grace with a margin for the probes. The caller then serves its
63/// client alone.
64pub const JOIN_TIMEOUT: Duration = Duration::from_secs(20);
65
66/// How long the shared server lets a connection go without a request read in full on it
67/// before it closes the connection: waiting for a request head, kept alive between requests,
68/// or reading a body. A client that sends one byte a second would otherwise hold a request
69/// thread forever, and enough of them would leave the probe above nobody to answer it (I2156).
70/// Generous, because a person's client on a slow link is a client too; far below anything
71/// that would let a handful of slow sockets outlast a [`BUSY_GRACE`] contest many times over.
72pub const READ_DEADLINE: Duration = Duration::from_secs(30);
73
74/// How long a server started for a checkout no client owns — by `serve ensure` or by a shell
75/// entering the checkout — outlives its last peer before it stops by itself:
76/// [`crate::cli::DEFAULT_IDLE_SECONDS`]. `serve --idle` on a command line overrides whatever
77/// is declared (I2701).
78pub const IDLE: Duration = Duration::from_secs(crate::cli::DEFAULT_IDLE_SECONDS);
79
80/// The timings above are the defaults. What a process actually judges a lease contest by is
81/// declared in `.ai/repo/policy.yaml`'s `server:` block and read once, here.
82///
83/// They were compiled constants until 2026-09-15: unchangeable without a rebuild, stated
84/// nowhere a reader would look, and invisible to every projection — while `probe_timeout` is
85/// the number that decides whether a live but slow owner keeps its lease or is taken over
86/// while it is still serving. That is a decision about how this repository is supervised, not
87/// an implementation detail, so it belongs in the model.
88///
89/// A `OnceLock`, because a process reads one policy: the repository is opened once and the
90/// answer must not change underneath a contest that is already being judged. Absent keys keep
91/// the constants, so a policy that cannot be read does not silently change behaviour.
92///
93/// ```
94/// use majordomus_cli::lease::{
95///     Timings, BIND_GRACE, BUSY_GRACE, IDLE, JOIN_TIMEOUT, PROBE_TIMEOUT, READ_DEADLINE,
96/// };
97/// // the defaults are the compiled constants, one for one
98/// let t = Timings::default();
99/// assert_eq!(t.bind_grace, BIND_GRACE);
100/// assert_eq!(t.probe_timeout, PROBE_TIMEOUT);
101/// assert_eq!(t.join_timeout, JOIN_TIMEOUT);
102/// assert_eq!(t.busy_grace, BUSY_GRACE);
103/// assert_eq!(t.read_deadline, READ_DEADLINE);
104/// assert_eq!(t.idle, IDLE);
105/// // a busy owner is given up on before the election that waits on it gives up itself
106/// assert!(t.busy_grace < t.join_timeout);
107/// ```
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct Timings {
110    /// `server.bind_grace_seconds:`
111    pub bind_grace: Duration,
112    /// `server.probe_timeout_seconds:`
113    pub probe_timeout: Duration,
114    /// `server.join_timeout_seconds:`
115    pub join_timeout: Duration,
116    /// `server.busy_grace_seconds:`
117    pub busy_grace: Duration,
118    /// `server.read_deadline_seconds:`
119    pub read_deadline: Duration,
120    /// `server.idle_seconds:`
121    pub idle: Duration,
122}
123
124impl Default for Timings {
125    fn default() -> Self {
126        Self {
127            bind_grace: BIND_GRACE,
128            probe_timeout: PROBE_TIMEOUT,
129            join_timeout: JOIN_TIMEOUT,
130            busy_grace: BUSY_GRACE,
131            read_deadline: READ_DEADLINE,
132            idle: IDLE,
133        }
134    }
135}
136
137static TIMINGS: OnceLock<Timings> = OnceLock::new();
138
139/// What this process judges a lease contest by. The declaration when one was read, the
140/// constants otherwise.
141///
142/// ```
143/// use majordomus_cli::lease::{timings, Timings};
144/// // this example's process declared nothing: it judges by the constants, and asking again
145/// // answers the same, because the first answer is the one the process keeps
146/// assert_eq!(timings(), Timings::default());
147/// assert_eq!(timings(), timings());
148/// ```
149pub fn timings() -> Timings {
150    *TIMINGS.get_or_init(Timings::default)
151}
152
153impl Timings {
154    /// What a declaration means, as a value rather than as a side effect.
155    ///
156    /// The mapping is the part that can be wrong; the `OnceLock` below is plumbing. Kept
157    /// separate so it can be tested directly — a test that went through the lock would
158    /// depend on which test ran first, since a process reads one policy by construction.
159    ///
160    /// ```
161    /// use majordomus_cli::lease::Timings;
162    /// use majordomus_cli::policy::ServerPolicy;
163    /// use std::time::Duration;
164    ///
165    /// // an empty declaration keeps every default
166    /// assert_eq!(Timings::from_policy(&ServerPolicy::default()), Timings::default());
167    ///
168    /// // and a declared value is the one used, rather than the constant
169    /// let declared = ServerPolicy {
170    ///     probe_timeout_seconds: Some(9),
171    ///     ..ServerPolicy::default()
172    /// };
173    /// let t = Timings::from_policy(&declared);
174    /// assert_eq!(t.probe_timeout, Duration::from_secs(9));
175    /// assert_ne!(t.probe_timeout, Timings::default().probe_timeout);
176    /// // the keys it says nothing about are untouched
177    /// assert_eq!(t.bind_grace, Timings::default().bind_grace);
178    ///
179    /// // the idle timeout is declared beside the others (I2701)
180    /// let declared = ServerPolicy { idle_seconds: Some(3), ..ServerPolicy::default() };
181    /// assert_eq!(Timings::from_policy(&declared).idle, Duration::from_secs(3));
182    /// ```
183    pub fn from_policy(policy: &crate::policy::ServerPolicy) -> Self {
184        let d = Self::default();
185        Self {
186            bind_grace: policy
187                .bind_grace_seconds
188                .map(Duration::from_secs)
189                .unwrap_or(d.bind_grace),
190            probe_timeout: policy
191                .probe_timeout_seconds
192                .map(Duration::from_secs)
193                .unwrap_or(d.probe_timeout),
194            join_timeout: policy
195                .join_timeout_seconds
196                .map(Duration::from_secs)
197                .unwrap_or(d.join_timeout),
198            busy_grace: policy
199                .busy_grace_seconds
200                .map(Duration::from_secs)
201                .unwrap_or(d.busy_grace),
202            read_deadline: policy
203                .read_deadline_seconds
204                .map(Duration::from_secs)
205                .unwrap_or(d.read_deadline),
206            idle: policy
207                .idle_seconds
208                .map(Duration::from_secs)
209                .unwrap_or(d.idle),
210        }
211    }
212}
213
214/// Declare the timings for this process from a repository's policy. The first call decides;
215/// later ones are ignored, which is what makes [`timings`] answer the same thing all the way
216/// through one contest.
217///
218/// ```
219/// use majordomus_cli::lease::{declare_timings, timings};
220/// use majordomus_cli::policy::ServerPolicy;
221/// use std::time::Duration;
222/// // the first declaration is the one in force
223/// declare_timings(&ServerPolicy { busy_grace_seconds: Some(4), ..ServerPolicy::default() });
224/// assert_eq!(timings().busy_grace, Duration::from_secs(4));
225/// // a later one cannot move a judgement already being made
226/// declare_timings(&ServerPolicy { busy_grace_seconds: Some(9), ..ServerPolicy::default() });
227/// assert_eq!(timings().busy_grace, Duration::from_secs(4));
228/// ```
229pub fn declare_timings(policy: &crate::policy::ServerPolicy) {
230    let _ = TIMINGS.set(Timings::from_policy(policy));
231}
232
233/// The environment variable a server is started with when the process that started it had
234/// already waited on the lease it found and judged it stale; its value is that lease's
235/// token. The new server's election then takes exactly that lease over without spending the
236/// same patience on it again. Any other lease, including one that changed in between, still
237/// gets [`probe_patiently`]'s wait.
238pub const JUDGED_STALE_ENV: &str = "MAJORDOMUS_LEASE_JUDGED_STALE";
239
240/// The identity of the file an executable was started from: where it is, and the mtime
241/// and size of the file at that path. A server outlives its own binary — a rebuild replaces
242/// the file under a process that keeps serving the code it loaded hours ago — and nothing
243/// about the process itself says so. This is what makes that visible.
244///
245/// ```
246/// use majordomus_cli::lease::ExecutableIdentity;
247/// let recorded = ExecutableIdentity { path: "/opt/majordomus".into(), mtime: 1_700_000_000, size: 42 };
248/// let text = serde_json::to_string(&recorded).unwrap();
249/// assert_eq!(serde_json::from_str::<ExecutableIdentity>(&text).unwrap(), recorded);
250/// ```
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
252pub struct ExecutableIdentity {
253    /// The path the process was started from.
254    pub path: PathBuf,
255    /// The file's modification time, seconds since the epoch.
256    pub mtime: u64,
257    /// The file's size in bytes.
258    pub size: u64,
259}
260
261impl ExecutableIdentity {
262    /// Has the file at the recorded path been replaced or removed since it was recorded?
263    /// The reason, for a reader; `None` when the same file is still there.
264    ///
265    /// ```
266    /// use majordomus_cli::lease::ExecutableIdentity;
267    /// let gone = ExecutableIdentity { path: "/nowhere/majordomus".into(), mtime: 1, size: 1 };
268    /// assert!(gone.replaced().unwrap().contains("no longer on disk"));
269    /// ```
270    pub fn replaced(&self) -> Option<String> {
271        let Ok(meta) = fs::metadata(&self.path) else {
272            return Some(format!(
273                "the executable it was started from, {}, is no longer on disk",
274                self.path.display()
275            ));
276        };
277        let mtime = meta
278            .modified()
279            .ok()
280            .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok())
281            .map(|d| d.as_secs())
282            .unwrap_or(0);
283        if mtime == self.mtime && meta.len() == self.size {
284            return None;
285        }
286        Some(format!(
287            "the executable it was started from, {}, has been replaced since (it is serving \
288             code that is no longer on disk)",
289            self.path.display()
290        ))
291    }
292}
293
294/// The lease file, typed: what the process serving a checkout wrote down about itself.
295/// Every reader of the file — the election, a command asking which server is running, the
296/// environment snapshot, the server's own status — reads it through [`LeaseFile::read`],
297/// so that a field added here is added once and a file that is not a lease is refused in
298/// one place. A key this version does not know is ignored, so a lease written by a newer
299/// executable still reads; a key it needs that an older lease lacks takes its default.
300///
301/// ```
302/// use majordomus_cli::lease::{LeaseDocument, SCHEMA};
303/// // a lease an older server wrote: no pid, no version, a key this version has never heard of
304/// let older = format!(r#"{{"schema":"{SCHEMA}","token":"t","root":"/r","url":"http://127.0.0.1:1","later":1}}"#);
305/// let doc: LeaseDocument = serde_json::from_str(&older).unwrap();
306/// assert_eq!(doc.pid, 0);
307/// assert!(doc.version.is_none(), "older than any executable that reads this");
308/// assert_eq!(doc.url.as_deref(), Some("http://127.0.0.1:1"));
309/// // and what this version writes reads back as itself
310/// let text = serde_json::to_string(&doc).unwrap();
311/// assert_eq!(serde_json::from_str::<LeaseDocument>(&text).unwrap(), doc);
312/// ```
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
314pub struct LeaseDocument {
315    /// [`SCHEMA`].
316    pub schema: String,
317    /// The process id of the server. A live pid is not a live server: only an answering
318    /// probe says that. What a live pid does decide is patience — a probe that goes
319    /// unanswered by a live owner is asked again within [`BUSY_GRACE`] before the lease
320    /// counts as stale (the election's busy wait, [`probe_patiently`]); a dead or zero pid
321    /// gets no patience at all.
322    #[serde(default)]
323    pub pid: u32,
324    /// What makes the file this process's: only the process holding this token removes it.
325    #[serde(default)]
326    pub token: String,
327    /// The checkout root the server serves.
328    #[serde(default)]
329    pub root: PathBuf,
330    /// The address, once bound; `null` while the owner is still binding.
331    pub url: Option<String>,
332    /// When the server took the lease, RFC 3339.
333    #[serde(default)]
334    pub started_at: String,
335    /// The executable the server was started from, when it could be located.
336    #[serde(default)]
337    pub executable: Option<ExecutableIdentity>,
338    /// The executable's version. Absent in a lease written before this field existed,
339    /// which is itself a fact: that server is older than any executable that reads this.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub version: Option<String>,
342}
343
344/// What a lease file holds, read once. Every state of the file is a variant, so that a
345/// reader decides what to do about each rather than learning it from an error.
346///
347/// ```
348/// use majordomus_cli::lease::LeaseFile;
349/// let dir = tempfile::tempdir().unwrap();
350/// let path = dir.path().join("server.json");
351/// assert_eq!(LeaseFile::read(&path), LeaseFile::Absent);
352/// assert!(LeaseFile::read(&path).document().is_none(), "no document to hand back");
353/// ```
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum LeaseFile {
356    /// There is no file.
357    Absent,
358    /// A file with nothing in it: its owner created it and has not written it yet.
359    Empty,
360    /// A file that is not a lease document; the reason, for the log.
361    Corrupt(String),
362    /// A lease document.
363    Document(LeaseDocument),
364}
365
366impl LeaseFile {
367    /// Read and type the file at `path`. Never fails: every state of the file is one of the
368    /// variants, and what to do about each is the reader's decision.
369    ///
370    /// ```
371    /// use majordomus_cli::lease::{LeaseFile, SCHEMA};
372    /// let dir = tempfile::tempdir().unwrap();
373    /// let path = dir.path().join("server.json");
374    /// assert_eq!(LeaseFile::read(&path), LeaseFile::Absent);
375    /// std::fs::write(&path, "").unwrap();
376    /// assert_eq!(LeaseFile::read(&path), LeaseFile::Empty);
377    /// std::fs::write(&path, "{not json").unwrap();
378    /// assert!(matches!(LeaseFile::read(&path), LeaseFile::Corrupt(r) if r.starts_with("not JSON")));
379    /// std::fs::write(&path, r#"{"schema":"other/v1"}"#).unwrap();
380    /// assert!(matches!(LeaseFile::read(&path), LeaseFile::Corrupt(r) if r.contains(SCHEMA)));
381    /// std::fs::write(&path, format!(r#"{{"schema":"{SCHEMA}","token":"x","root":"/r","url":null}}"#)).unwrap();
382    /// match LeaseFile::read(&path) {
383    ///     LeaseFile::Document(d) => { assert_eq!(d.token, "x"); assert!(d.url.is_none()); assert!(d.version.is_none()); }
384    ///     other => panic!("{other:?}"),
385    /// }
386    /// ```
387    pub fn read(path: &Path) -> LeaseFile {
388        let text = match fs::read_to_string(path) {
389            Ok(text) => text,
390            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return LeaseFile::Absent,
391            Err(e) => return LeaseFile::Corrupt(format!("cannot be read ({e})")),
392        };
393        if text.trim().is_empty() {
394            return LeaseFile::Empty;
395        }
396        let value: Value = match serde_json::from_str(&text) {
397            Ok(value) => value,
398            Err(e) => return LeaseFile::Corrupt(format!("not JSON ({e})")),
399        };
400        if !value.is_object() || value["schema"] != SCHEMA {
401            return LeaseFile::Corrupt(format!("not a {SCHEMA} document"));
402        }
403        match serde_json::from_value::<LeaseDocument>(value) {
404            Ok(document) => LeaseFile::Document(document),
405            Err(e) => LeaseFile::Corrupt(format!("not a {SCHEMA} document ({e})")),
406        }
407    }
408
409    /// The document, when the file holds one; `None` for an absent, empty or corrupt file,
410    /// for a reader that has no different answer for those three.
411    ///
412    /// ```
413    /// use majordomus_cli::lease::{LeaseDocument, LeaseFile, SCHEMA};
414    /// let doc = LeaseDocument {
415    ///     schema: SCHEMA.into(), pid: 7, token: "t".into(), root: "/r".into(), url: None,
416    ///     started_at: "2026-09-10T00:00:00Z".into(), executable: None, version: None,
417    /// };
418    /// assert_eq!(LeaseFile::Document(doc.clone()).document(), Some(&doc));
419    /// assert_eq!(LeaseFile::Corrupt("not JSON".into()).document(), None);
420    /// ```
421    pub fn document(&self) -> Option<&LeaseDocument> {
422        match self {
423            LeaseFile::Document(d) => Some(d),
424            _ => None,
425        }
426    }
427}
428
429/// The lease this process holds and has published, when it is a server. Set by
430/// [`Lease::publish`], cleared when the lease is released, so that the server can say what
431/// it wrote down about itself without reading its own file back.
432static PUBLISHED: OnceLock<Mutex<Option<LeaseDocument>>> = OnceLock::new();
433
434fn published() -> &'static Mutex<Option<LeaseDocument>> {
435    PUBLISHED.get_or_init(|| Mutex::new(None))
436}
437
438/// The lease this process holds and has published: `None` in a process that serves nothing.
439///
440/// ```
441/// // a process that has published no lease holds none
442/// assert!(majordomus_cli::lease::held().is_none());
443/// ```
444pub fn held() -> Option<LeaseDocument> {
445    published().lock().ok()?.clone()
446}
447
448/// Is `doc` the lease this very process holds, published and not lost?
449///
450/// A server asked where its own checkout stands must answer from memory. Probing the
451/// address its lease names is a request to itself, served by the same small pool of HTTP
452/// workers the question arrived on: with every worker answering such a question at once,
453/// none is left to answer the probes, each waits out [`PROBE_TIMEOUT`], the server calls
454/// itself stale — and a client electing in that window times out on the same queue and
455/// takes a live lease over. Measured on 2026-09-15: ten MCP clients calling
456/// `majordomus_peers` at once, and one of them started a second server of the checkout.
457///
458/// ```
459/// use majordomus_cli::lease::{is_own, LeaseDocument};
460/// let doc: LeaseDocument = serde_json::from_str(
461///     r#"{"schema":"lease/v1","pid":1,"token":"t","url":"http://127.0.0.1:1"}"#,
462/// )
463/// .unwrap();
464/// // a process that holds no lease owns no document
465/// assert!(!is_own(&doc));
466/// ```
467pub fn is_own(doc: &LeaseDocument) -> bool {
468    !was_lost()
469        && held().is_some_and(|h| h.token == doc.token && h.pid == doc.pid && h.url == doc.url)
470}
471
472/// The lease this process holds. Dropping it removes the file (when the file is still
473/// this process's), so a failed start never leaves a stale lease behind.
474#[derive(Debug)]
475pub struct Lease {
476    path: PathBuf,
477    token: String,
478    root: PathBuf,
479    released: bool,
480    /// When this process took the lease, RFC 3339. Fixed here rather than at publish time
481    /// because it is the identity of this server's generation.
482    started_at: String,
483    /// `true` from the election until [`Lease::publish`]: the window in which
484    /// [`Lease::keep_alive`] rewrites the file so that it never looks abandoned, and the
485    /// lock under which it and `publish` write, so that a touch can never land on top of
486    /// the URL.
487    binding: Arc<Mutex<bool>>,
488}
489
490/// What the election decided for this process.
491#[derive(Debug)]
492pub enum Role {
493    /// Nobody serves this repository: this process does, holding the lease.
494    Server(Lease),
495    /// A server answers at this URL: attach to it.
496    Peer {
497        /// `http://host:port` of the running server.
498        url: String,
499    },
500}
501
502/// Where the lease of a repository lives.
503pub fn lease_path(repo: &Repository) -> PathBuf {
504    lease_file(repo.root(), &repo.local_path())
505}
506
507/// Where the lease of the checkout at `root` lives, given the repository-relative path of
508/// its local half (`.ai/local`). The one composition of that path; every reader of a lease
509/// that is not this process's own repository — another checkout's, in the server's status
510/// or the environment snapshot — goes through here.
511///
512/// ```
513/// use majordomus_cli::lease::lease_file;
514/// use std::path::Path;
515/// assert_eq!(
516///     lease_file(Path::new("/r"), ".ai/local"),
517///     Path::new("/r/.ai/local/state/mcp/server.json")
518/// );
519/// ```
520pub fn lease_file(root: &Path, local_half: &str) -> PathBuf {
521    root.join(local_half).join(LEASE_PATH)
522}
523
524/// Which server is serving this repository right now, if any.
525///
526/// Read-only, unlike [`elect`]: it takes no lease, creates no file and waits for nobody, so
527/// a command that only wants to *ask* the running server something cannot accidentally
528/// become it. `None` means no lease, no URL in it, or a lease whose server does not answer
529/// for this root.
530pub fn serving(repo: &Repository) -> Option<String> {
531    let url = LeaseFile::read(&lease_path(repo)).document()?.url.clone()?;
532    probe(&url, repo.root()).then_some(url)
533}
534
535/// Decide whether this process serves the repository or attaches to the process that does.
536///
537/// An existing file is read on every attempt and classified: the lease of a live server
538/// (it answers for this root) is attached to; a stale one (its server does not answer),
539/// a corrupt one (not a lease document), an empty one, or an abandoned one (no URL after
540/// [`BIND_GRACE`]) is taken over; a fresh lease without a URL means its owner is still
541/// binding, and this process waits for it. Nothing a client leaves behind can lock the
542/// others out; when the file can neither be created nor removed within [`JOIN_TIMEOUT`],
543/// the error names the path and the caller serves its client alone.
544pub fn elect(repo: &Repository) -> Result<Role> {
545    let path = lease_path(repo);
546    let root = repo.root().to_path_buf();
547    if let Some(dir) = path.parent() {
548        fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
549    }
550    let waited_since = Instant::now();
551    // when a live server first failed to answer this election; cleared whenever it answers,
552    // or the file stops naming a busy server
553    let mut busy_since: Option<Instant> = None;
554    loop {
555        match fs::OpenOptions::new()
556            .write(true)
557            .create_new(true)
558            .open(&path)
559        {
560            Ok(mut file) => {
561                let token = format!(
562                    "{}-{:x}",
563                    std::process::id(),
564                    SystemTime::now()
565                        .duration_since(SystemTime::UNIX_EPOCH)
566                        .map(|d| d.as_nanos())
567                        .unwrap_or(0)
568                );
569                let lease = Lease {
570                    path: path.clone(),
571                    token,
572                    root,
573                    released: false,
574                    started_at: crate::peers::rfc3339(SystemTime::now()),
575                    binding: Arc::new(Mutex::new(true)),
576                };
577                let text =
578                    serde_json::to_string(&lease.document(None)).map_err(|e| Error::Lease {
579                        reason: format!("cannot render the lease: {e}"),
580                    })?;
581                file.write_all(text.as_bytes())
582                    .map_err(|e| Error::io(&path, e))?;
583                hold(&path, &lease.token);
584                return Ok(Role::Server(lease));
585            }
586            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
587                let (found, seen) = inspect(&path, &root);
588                if !matches!(found, Found::Busy(_)) {
589                    busy_since = None;
590                }
591                match found {
592                    Found::Live(url) => return Ok(Role::Peer { url }),
593                    Found::Busy(url) => {
594                        let since = *busy_since.get_or_insert_with(Instant::now);
595                        if since.elapsed() < timings().busy_grace {
596                            std::thread::sleep(Duration::from_millis(200));
597                        } else {
598                            tracing::warn!(
599                                lease = %path.display(),
600                                "the server at {url} is alive and has not answered for {} seconds; taking it over",
601                                timings().busy_grace.as_secs()
602                            );
603                            take_over(&path, &seen)?;
604                            busy_since = None;
605                        }
606                    }
607                    Found::Stale(reason) => {
608                        tracing::warn!(lease = %path.display(), "{reason}; taking it over");
609                        take_over(&path, &seen)?;
610                    }
611                    // an owner that is still binding keeps its file young (`keep_alive`), so
612                    // a file that is old enough to be abandoned is classified so by `inspect`
613                    // and this arm only ever waits; the whole attempt is bounded below
614                    Found::Binding => std::thread::sleep(Duration::from_millis(100)),
615                }
616            }
617            Err(e) => return Err(Error::io(&path, e)),
618        }
619        if waited_since.elapsed() > timings().join_timeout {
620            return Err(Error::Lease {
621                reason: format!(
622                    "could not acquire or join the lease at {} within {} seconds",
623                    path.display(),
624                    timings().join_timeout.as_secs()
625                ),
626            });
627        }
628    }
629}
630
631/// What a lease file that already exists says.
632enum Found {
633    /// A server answers at this URL for this root.
634    Live(String),
635    /// The server at this URL did not answer in time, and the process that holds the lease
636    /// is alive: busy rather than gone, and waited on for [`BUSY_GRACE`].
637    Busy(String),
638    /// The file is not a usable lease; the reason says why, for the log.
639    Stale(String),
640    /// A lease without a URL, young enough that its owner may still be binding.
641    Binding,
642}
643
644/// What the running executable is, for the lease to record: the path it was started from
645/// and the identity of the file at that path. A server outlives its own binary — a rebuild
646/// replaces the file under a process that keeps serving the code it loaded hours ago — and
647/// nothing about the process itself says so. This is what makes that visible.
648///
649/// `None` when the executable cannot be located or stat'ed; the lease then carries no
650/// claim, and the election's `superseded` check makes none either.
651///
652/// ```
653/// use majordomus_cli::lease::executable_identity;
654/// // the process running this example can locate itself, and the file it names is there
655/// let mine = executable_identity().expect("a test binary knows where it is");
656/// assert!(mine.path.is_file());
657/// assert!(mine.replaced().is_none(), "the file that is running has not been replaced");
658/// ```
659pub fn executable_identity() -> Option<ExecutableIdentity> {
660    let path = std::env::current_exe().ok()?;
661    let meta = fs::metadata(&path).ok()?;
662    let mtime = meta
663        .modified()
664        .ok()?
665        .duration_since(SystemTime::UNIX_EPOCH)
666        .ok()?
667        .as_secs();
668    Some(ExecutableIdentity {
669        path,
670        mtime,
671        size: meta.len(),
672    })
673}
674
675/// The identity [`started_as`] pins: set once, by the first caller, and never again.
676static STARTED_AS: OnceLock<Option<ExecutableIdentity>> = OnceLock::new();
677
678/// The executable this process was started from, as it stood on disk when first asked.
679///
680/// `main` asks before anything else, so the answer is the file the process was loaded from,
681/// not whatever sits at that path later. A server that has been running for a day still
682/// knows which bytes it is: the comparison [`serving_replaced_code`] makes is against this,
683/// never against a second executable a client would have to hold (I1502).
684///
685/// ```
686/// use majordomus_cli::lease::started_as;
687/// let first = started_as().cloned();
688/// assert_eq!(started_as().cloned(), first, "pinned: the second answer is the first");
689/// ```
690pub fn started_as() -> Option<&'static ExecutableIdentity> {
691    STARTED_AS.get_or_init(executable_identity).as_ref()
692}
693
694/// Why this process is serving code that is no longer on disk, or `None` while the file it
695/// was started from is still the file at that path. The same judgement the lease reader
696/// makes from outside ([`ExecutableIdentity::replaced`]), made by the process about itself,
697/// so that the endpoint a client asks for readiness can say so.
698///
699/// ```
700/// use majordomus_cli::lease::serving_replaced_code;
701/// assert!(serving_replaced_code().is_none(), "a test binary has not been replaced under it");
702/// ```
703pub fn serving_replaced_code() -> Option<String> {
704    started_as().and_then(stale_reason)
705}
706
707/// The judgement [`serving_replaced_code`] makes, over an identity it is handed rather than
708/// the one this process pinned, so that each of its answers — unchanged, replaced, removed —
709/// can be shown against a file a test controls.
710///
711/// The reason is served to whoever can reach the socket, so it says what happened and not
712/// where the file is: the host's paths are of no use to a client and of some use to others.
713fn stale_reason(me: &ExecutableIdentity) -> Option<String> {
714    me.replaced()?;
715    Some(if me.path.exists() {
716        "the executable this process was started from has been replaced since it started: \
717         it is serving code that is no longer on disk"
718            .to_string()
719    } else {
720        "the executable this process was started from has been removed since it started: \
721         it is serving code that is no longer on disk"
722            .to_string()
723    })
724}
725
726/// Has the executable behind a lease been replaced since that server started?
727///
728/// Only a lease naming *this process's own* executable path can answer: same path, a file
729/// that is now a different file, and the server on the other end is provably running code
730/// that no longer exists on disk. A lease naming some other path — a release install next
731/// to a debug build — makes no claim either way and is left alone, so two legitimate
732/// binaries never fight over the lease.
733fn superseded(doc: &LeaseDocument) -> Option<String> {
734    let recorded = doc.executable.as_ref()?;
735    let mine = executable_identity()?;
736    if recorded.path != mine.path {
737        return None;
738    }
739    if recorded.mtime == mine.mtime && recorded.size == mine.size {
740        return None;
741    }
742    Some(format!(
743        "superseded lease: the server was started from {} and that file has been replaced since \
744         (it is serving code that is no longer on disk)",
745        mine.path.display()
746    ))
747}
748
749/// How the version a server published in its lease stands to this executable's.
750///
751/// One verdict, read by the election, by `serve ensure` and by `server.status`, so that the
752/// three never disagree about whether a server is older (I2163). The installer puts each
753/// release in a directory of its own, so an upgrade changes the executable's path and
754/// `superseded` — same path, different file — never fires on it: before this verdict a
755/// new client attached to the old server and was served yesterday's code until it idled out.
756///
757/// ```
758/// use majordomus_cli::lease::{version_verdict, VersionVerdict};
759/// // only an older release is replaced; every other verdict attaches
760/// let replaces = |served| version_verdict(served, "0.20.0") == VersionVerdict::ServerOlder;
761/// assert!(replaces(Some("0.19.1")));
762/// assert!(!replaces(Some("0.20.0")) && !replaces(Some("0.21.0")) && !replaces(None));
763/// ```
764#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765pub enum VersionVerdict {
766    /// The version this executable is.
767    Same,
768    /// An older release than this executable: a client of this executable replaces it.
769    ServerOlder,
770    /// A newer release: a client of this executable attaches to it and says it is older.
771    ServerNewer,
772    /// No version published: a server from before leases carried one. Reported as outdated,
773    /// never taken over on that ground alone.
774    Unpublished,
775    /// Two versions that do not order as releases (a pre-release, a local build tag):
776    /// reported as different, never acted on.
777    Unordered,
778}
779
780/// Decide how `served`, the version in a lease, stands to `mine`.
781///
782/// ```
783/// use majordomus_cli::lease::{version_verdict, VersionVerdict};
784/// assert_eq!(version_verdict(Some("0.19.1"), "0.19.1"), VersionVerdict::Same);
785/// assert_eq!(version_verdict(Some("0.19.1"), "0.20.0"), VersionVerdict::ServerOlder);
786/// assert_eq!(version_verdict(Some("0.9.10"), "0.10.0"), VersionVerdict::ServerOlder);
787/// assert_eq!(version_verdict(Some("1.0.0"), "0.20.0"), VersionVerdict::ServerNewer);
788/// assert_eq!(version_verdict(None, "0.20.0"), VersionVerdict::Unpublished);
789/// // a pre-release is not ordered against its release: different, and left alone
790/// assert_eq!(version_verdict(Some("0.20.0-rc.1"), "0.20.0"), VersionVerdict::Unordered);
791/// ```
792pub fn version_verdict(served: Option<&str>, mine: &str) -> VersionVerdict {
793    fn release(v: &str) -> Option<(u64, u64, u64)> {
794        let (major, rest) = v.trim_start_matches('v').split_once('.')?;
795        let (minor, patch) = rest.split_once('.')?;
796        Some((
797            major.parse().ok()?,
798            minor.parse().ok()?,
799            patch.parse().ok()?,
800        ))
801    }
802    let Some(served) = served else {
803        return VersionVerdict::Unpublished;
804    };
805    if served == mine {
806        return VersionVerdict::Same;
807    }
808    match (release(served), release(mine)) {
809        (Some(theirs), Some(ours)) if theirs < ours => VersionVerdict::ServerOlder,
810        (Some(theirs), Some(ours)) if theirs > ours => VersionVerdict::ServerNewer,
811        _ => VersionVerdict::Unordered,
812    }
813}
814
815/// Read and classify an existing lease file. What was read comes back beside the verdict,
816/// so that a take-over can insist on removing the file it judged and not one that arrived
817/// in the meantime.
818fn inspect(path: &Path, root: &Path) -> (Found, LeaseFile) {
819    let age = file_age(path);
820    let seen = LeaseFile::read(path);
821    let doc = match &seen {
822        // gone between the failed create and this read: the next attempt creates it
823        LeaseFile::Absent => return (Found::Binding, seen),
824        LeaseFile::Empty if age > timings().bind_grace => {
825            return (
826                Found::Stale("empty lease: its owner never wrote it".into()),
827                seen,
828            )
829        }
830        LeaseFile::Empty => return (Found::Binding, seen),
831        LeaseFile::Corrupt(reason) => {
832            return (Found::Stale(format!("corrupt lease: {reason}")), seen)
833        }
834        LeaseFile::Document(doc) => doc.clone(),
835    };
836    // before asking whether it answers: a server that answers from a binary that has been
837    // replaced answers with yesterday's code, which is the harder failure to see
838    if let Some(reason) = superseded(&doc) {
839        return (Found::Stale(reason), seen);
840    }
841    let found = match doc.url.as_deref() {
842        Some(url) => match ask(url, root, timings().probe_timeout) {
843            // an older release answering is replaced, not joined: its sessions move on to the
844            // new server once it sees its lease taken (I2127), and nobody is served
845            // yesterday's code because the installer put today's at another path (I2163)
846            Answer::Ours(_)
847                if version_verdict(doc.version.as_deref(), crate::VERSION)
848                    == VersionVerdict::ServerOlder =>
849            {
850                Found::Stale(format!(
851                    "superseded lease: the server at {url} runs version {}, older than this executable's {}",
852                    doc.version.as_deref().unwrap_or_default(),
853                    crate::VERSION
854                ))
855            }
856            Answer::Ours(_) => Found::Live(url.to_string()),
857            // a live owner that is silent is busy and waited on — unless the process that
858            // started this one already waited out that patience on this very lease
859            Answer::Silent if alive(doc.pid) && !judged_stale(&doc) => Found::Busy(url.to_string()),
860            Answer::Silent | Answer::NotOurs => Found::Stale(format!(
861                "stale lease: the server it names at {url} does not answer for this repository"
862            )),
863        },
864        None if age > timings().bind_grace => {
865            Found::Stale("abandoned lease: its owner never published a URL".into())
866        }
867        None => Found::Binding,
868    };
869    (found, seen)
870}
871
872/// How long ago the file was last written; zero when that cannot be read, so that a file
873/// whose age is unknown is never taken for an old one.
874///
875/// ```
876/// use majordomus_cli::lease::file_age;
877/// use std::time::Duration;
878/// let dir = tempfile::tempdir().unwrap();
879/// let path = dir.path().join("server.json");
880/// assert_eq!(file_age(&path), Duration::ZERO, "no file: no age");
881/// std::fs::write(&path, "").unwrap();
882/// assert!(file_age(&path) < Duration::from_secs(60), "just written");
883/// ```
884pub fn file_age(path: &Path) -> Duration {
885    fs::metadata(path)
886        .and_then(|m| m.modified())
887        .ok()
888        .and_then(|m| m.elapsed().ok())
889        .unwrap_or(Duration::ZERO)
890}
891
892/// Remove a lease that cannot be used, so that the next attempt creates a fresh one — but
893/// only the file that was judged: when another process has replaced it since, the file on
894/// disk is somebody's fresh lease, and the next round of the election reads that one.
895fn take_over(path: &Path, seen: &LeaseFile) -> Result<()> {
896    take_over_with(path, seen, LOCK_PATIENCE, || {})
897}
898
899/// [`take_over`], with the patience for the [change lock](lock_for_change) stated and a seam
900/// between the comparison and the removal, where the unit tests put a second elector.
901///
902/// # Why a lock, and not a rename aside
903///
904/// Until I2128 this was three steps — read, compare with what was judged, remove — and two
905/// electors that had both judged one lease stale could interleave: A removes the stale lease
906/// and creates its own, and B, which had already compared, removes A's. A then serves with no
907/// lease on disk, and the next client starts a second server for the checkout.
908///
909/// The textbook repair for a compare-and-delete on a file is to rename it aside under a name
910/// of one's own and look at what was moved: the rename is atomic, so whatever was moved is
911/// the mover's to judge. It was considered and not taken, because it cannot be used for the
912/// other half of the problem. A lease is also *rewritten* — published, kept young while the
913/// layer loads — and a rewrite that moved the file aside to check it would leave the path
914/// empty for a moment on every rewrite, during which any elector's `create_new` succeeds:
915/// the owner would lose its lease to its own bookkeeping. A moved-aside lease that turned out
916/// to be somebody else's has the same hole on the way back.
917///
918/// So every change to the file — this removal, [`Lease::publish`], the keep-alive rewrite,
919/// the release — is made under one advisory lock beside the lease (`server.lock`), and each
920/// re-reads the file under it before acting. Creating the lease needs no lock: `create_new`
921/// only succeeds on an empty path, and a path holding a lease can only be emptied under the
922/// lock, by a holder that has just read it. Two electors that judged one lease stale then end
923/// with exactly one lease: whichever takes the lock first removes the stale file, the other
924/// finds a file that is not the one it judged and leaves it, and `create_new` lets exactly
925/// one of them create the next.
926///
927/// What it does not cover is an executable older than this one, which takes no lock; two
928/// builds side by side keep the old window between them until the older one is gone.
929fn take_over_with(
930    path: &Path,
931    seen: &LeaseFile,
932    patience: Duration,
933    between: impl FnOnce(),
934) -> Result<()> {
935    let Some(_lock) = lock_for_change(path, patience) else {
936        tracing::debug!(lease = %path.display(), "another process is changing the lease; reading it again");
937        return Ok(());
938    };
939    if LeaseFile::read(path) != *seen {
940        tracing::debug!(lease = %path.display(), "the lease changed under the take-over; reading it again");
941        return Ok(());
942    }
943    between();
944    match fs::remove_file(path) {
945        Ok(()) => Ok(()),
946        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
947        Err(e) => Err(Error::Lease {
948            reason: format!(
949                "cannot remove the unusable lease at {}: {e}",
950                path.display()
951            ),
952        }),
953    }
954}
955
956/// How long a change to the lease waits for another process's change to finish. A change
957/// holds the lock for one small read and one rename or removal, so anything near this bound
958/// is a holder that has been stopped (`SIGSTOP`, a debugger), and the change is then not
959/// made: a lease left in place is recovered by the next election, a lease removed or
960/// overwritten in error is not.
961const LOCK_PATIENCE: Duration = Duration::from_secs(2);
962
963/// The advisory lock every change to a lease file is made under: an exclusive `flock(2)` on
964/// `server.lock` beside it, held for as long as the value lives. The kernel releases it when
965/// the descriptor closes, so a holder that is killed cannot keep it.
966///
967/// Where the lock file cannot be opened at all (a directory that refuses writes) or the
968/// filesystem refuses `flock`, the change goes ahead without it: the check that the file is
969/// the actor's own is still made, as it was before the lock existed.
970struct ChangeLock {
971    #[cfg(unix)]
972    _file: Option<fs::File>,
973}
974
975/// The lock file beside a lease.
976fn lock_path(lease: &Path) -> PathBuf {
977    lease.with_extension("lock")
978}
979
980/// Take the [`ChangeLock`] of the lease at `lease`, waiting up to `patience` for another
981/// holder; `None` when it is still held after that. A `patience` of zero is one attempt.
982fn lock_for_change(lease: &Path, patience: Duration) -> Option<ChangeLock> {
983    #[cfg(unix)]
984    {
985        use std::os::unix::io::AsRawFd;
986        lock_for_change_with(lease, patience, |file| {
987            // SAFETY: flock(2) on a descriptor the caller owns; no memory is touched.
988            if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
989                Ok(())
990            } else {
991                Err(std::io::Error::last_os_error())
992            }
993        })
994    }
995    #[cfg(not(unix))]
996    {
997        let _ = (lease, patience);
998        Some(ChangeLock {})
999    }
1000}
1001
1002/// [`lock_for_change`], taking the lock with `try_lock`: `flock(2)` in earnest, a test's own
1003/// refusals in a test, since a file just opened is one the system never refuses for anything
1004/// but contention.
1005#[cfg(unix)]
1006fn lock_for_change_with(
1007    lease: &Path,
1008    patience: Duration,
1009    try_lock: impl Fn(&fs::File) -> std::io::Result<()>,
1010) -> Option<ChangeLock> {
1011    let Ok(file) = fs::OpenOptions::new()
1012        .create(true)
1013        .truncate(false)
1014        .write(true)
1015        .open(lock_path(lease))
1016    else {
1017        return Some(ChangeLock { _file: None });
1018    };
1019    let deadline = Instant::now() + patience;
1020    loop {
1021        let Err(error) = try_lock(&file) else {
1022            return Some(ChangeLock { _file: Some(file) });
1023        };
1024        let contended = error.raw_os_error() == Some(libc::EWOULDBLOCK)
1025            || error.kind() == std::io::ErrorKind::Interrupted;
1026        if !contended {
1027            return Some(ChangeLock { _file: None });
1028        }
1029        if Instant::now() >= deadline {
1030            return None;
1031        }
1032        std::thread::sleep(Duration::from_millis(2));
1033    }
1034}
1035
1036/// Does the file at `path` hold the lease named by `token`? The token is minted once per
1037/// election, from the pid and the clock, so it names one process's one generation of the
1038/// lease: a process that took the lease, lost it and took it again holds a different token.
1039fn holds(path: &Path, token: &str) -> bool {
1040    LeaseFile::read(path)
1041        .document()
1042        .is_some_and(|d| d.token == token)
1043}
1044
1045/// Remove the lease at `path` if, and only if, it is still the one `token` names — under the
1046/// [`ChangeLock`], so that nobody can replace the file between the check and the removal.
1047/// Whether it was removed.
1048fn remove_if_held(path: &Path, token: &str) -> bool {
1049    let Some(_lock) = lock_for_change(path, LOCK_PATIENCE) else {
1050        tracing::warn!(lease = %path.display(), "the lease's lock stayed held; leaving the lease for the next election to judge");
1051        return false;
1052    };
1053    holds(path, token) && fs::remove_file(path).is_ok()
1054}
1055
1056/// A temporary file of one writer's own beside the lease: named for the token, so that two
1057/// writers never write into, or rename, each other's half-written file.
1058fn tmp_path(path: &Path, token: &str) -> PathBuf {
1059    let name = path
1060        .file_name()
1061        .map(|n| n.to_string_lossy().into_owned())
1062        .unwrap_or_else(|| "server.json".into());
1063    path.with_file_name(format!("{name}.{token}.tmp"))
1064}
1065
1066/// Write `text` to `tmp` and sync it to the disk, so that the rename that follows publishes
1067/// a whole file even across a crash of the machine.
1068fn write_synced(tmp: &Path, text: &str) -> std::io::Result<()> {
1069    let mut file = fs::File::create(tmp)?;
1070    file.write_all(text.as_bytes())
1071        .and_then(|()| file.sync_all())
1072}
1073
1074/// Sync the directory a rename happened in, so that the rename itself is durable. Best
1075/// effort: a platform that cannot open a directory for syncing loses only the durability.
1076fn sync_dir(path: &Path) {
1077    if let Some(dir) = path.parent() {
1078        if let Ok(dir) = fs::File::open(dir) {
1079            let _ = dir.sync_all();
1080        }
1081    }
1082}
1083
1084/// The lease this process holds, by path and token, from the election until it is released
1085/// or lost: what [`release_held`] removes when the process is asked to stop by a signal and
1086/// the [`Lease`] itself belongs to a thread that will not get to drop it.
1087static HOLDING: Mutex<Option<(PathBuf, String)>> = Mutex::new(None);
1088
1089/// This process now holds the lease at `path` under `token`; install the signal handlers the
1090/// first time.
1091fn hold(path: &Path, token: &str) {
1092    if let Ok(mut holding) = HOLDING.lock() {
1093        *holding = Some((path.to_path_buf(), token.to_string()));
1094    }
1095    signals::install();
1096}
1097
1098/// This process no longer holds the lease `token` names (or, with `None`, any lease).
1099fn forget(token: Option<&str>) {
1100    if let Ok(mut holding) = HOLDING.lock() {
1101        if token.is_none_or(|t| holding.as_ref().is_some_and(|(_, h)| h == t)) {
1102            *holding = None;
1103        }
1104    }
1105}
1106
1107/// Release the lease this process holds, if the file is still its own: the release a
1108/// process makes when it is stopped by a signal, from a thread that does not own the
1109/// [`Lease`]. A lease taken over since is somebody else's and stays where it is.
1110pub(crate) fn release_held() {
1111    let taken = HOLDING.lock().ok().and_then(|mut h| h.take());
1112    release_record(taken);
1113}
1114
1115/// [`release_held`] over a record it is handed rather than the process-wide one, so that a
1116/// test can show it without touching what a concurrent test's election recorded.
1117fn release_record(record: Option<(PathBuf, String)>) {
1118    if let Some((path, token)) = record {
1119        remove_if_held(&path, &token);
1120        if let Ok(mut slot) = published().lock() {
1121            if slot.as_ref().is_some_and(|d| d.token == token) {
1122                *slot = None;
1123            }
1124        }
1125    }
1126}
1127
1128/// How long a server asked to stop by a signal is given to stop in order before the process
1129/// is ended anyway. It sits below `serve stop`'s default wait of ten seconds, so that a stop
1130/// that hangs — a mesh link that will not drain — still ends within what `serve stop` waits.
1131pub(crate) const STOP_BOUND: Duration = Duration::from_secs(8);
1132
1133/// The signal that asked this process to stop, once one has: `SIGTERM` from `serve stop`,
1134/// `SIGINT` from Ctrl-C, `SIGHUP` from a closing terminal. A loop that stops the server in
1135/// order polls this.
1136pub(crate) fn stop_requested() -> Option<i32> {
1137    signals::requested()
1138}
1139
1140/// Declare that this process answers a stop request in order: the first signal is then left
1141/// to it for [`STOP_BOUND`], instead of ending the process at once.
1142pub(crate) fn answer_stop_requests() {
1143    signals::answer();
1144}
1145
1146/// End the process the way the signal would have: release the lease if it is still this
1147/// process's, restore the signal's default disposition and send it again, so that the exit
1148/// status still names the signal.
1149pub(crate) fn end_by_signal(signal: i32) -> ! {
1150    release_held();
1151    #[cfg(unix)]
1152    // SAFETY: restoring a default disposition and signalling this process; no memory is
1153    // touched.
1154    unsafe {
1155        libc::signal(signal, libc::SIG_DFL);
1156        libc::kill(libc::getpid(), signal);
1157    }
1158    // the default disposition of every signal the handlers take ends the process; this is
1159    // the moment between the sending and the delivery
1160    std::thread::sleep(Duration::from_secs(1));
1161    std::process::exit(128 + signal)
1162}
1163
1164/// What the signal watcher does about the signals received so far.
1165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1166enum Watch {
1167    /// Nothing to do yet.
1168    Wait,
1169    /// End the process now.
1170    End,
1171}
1172
1173/// The watcher's decision, as a value: no signal is nothing to do; the first, in a process
1174/// that answers stop requests, is left to it for [`STOP_BOUND`]; a first one nobody answers,
1175/// a second one, or the bound passing ends the process.
1176fn watch_verdict(received: usize, answered: bool, since_first: Duration) -> Watch {
1177    match received {
1178        0 => Watch::Wait,
1179        1 if answered && since_first < STOP_BOUND => Watch::Wait,
1180        _ => Watch::End,
1181    }
1182}
1183
1184/// Set by [`lost`], never cleared: a lease this process was given and no longer has.
1185///
1186/// Distinct from `held().is_none()`, which is also true of a process that never took a
1187/// lease at all — a test, a one-off command, a bridged client. Only a process that *had*
1188/// the lease and lost it must change how it behaves, so only that one is recorded here.
1189static LOST: AtomicBool = AtomicBool::new(false);
1190
1191/// The key the index answers [`was_lost`]'s converse under: whether the server answering
1192/// still holds the lease of the checkout it serves. Named once, read by [`probe`] and
1193/// written by the index route, so the two cannot drift apart.
1194pub const LEASEHOLDER_KEY: &str = "leaseholder";
1195
1196/// This process's lease was taken over by another process: from now on nothing this process
1197/// does on its way out touches the file, which is somebody else's, and it stops claiming to be the
1198/// checkout's server — [`held`] answers `None` and [`was_lost`] answers `true` from here
1199/// on. It serves the peers it has and ends with them; what it must not do is take on new
1200/// ones, or answer a stranger's probe as though it were still the one.
1201///
1202/// ```
1203/// // a process that holds no lease has nothing to lose; saying so twice changes nothing
1204/// majordomus_cli::lease::lost();
1205/// majordomus_cli::lease::lost();
1206/// assert!(majordomus_cli::lease::held().is_none());
1207/// assert!(majordomus_cli::lease::was_lost(), "it was told the lease is gone");
1208/// ```
1209pub fn lost() {
1210    let _ = LOST_AT.set(Instant::now());
1211    LOST.store(true, Ordering::SeqCst);
1212    if let Ok(mut published) = published().lock() {
1213        *published = None;
1214    }
1215    forget(None);
1216}
1217
1218/// Did this process hold the checkout's lease and lose it?
1219///
1220/// The one question a server asks before taking on work that outlives the request. A
1221/// process that never held a lease answers `false` and goes on serving whoever asked it —
1222/// a test, a bridged client, a one-off command. One that was superseded answers `true`,
1223/// and from then on refuses to become anybody's server again. It never resets: a lease
1224/// that was taken over is not given back.
1225///
1226/// ```
1227/// // nothing has taken a lease away from the process running this example; `lost()` in
1228/// // the doctest above runs in a process of its own
1229/// assert!(!majordomus_cli::lease::was_lost());
1230/// ```
1231pub fn was_lost() -> bool {
1232    LOST.load(Ordering::SeqCst)
1233}
1234
1235static LOST_AT: OnceLock<Instant> = OnceLock::new();
1236
1237/// How long ago this process lost the checkout's lease, when it did.
1238///
1239/// A superseded server keeps answering its sessions for the declared busy grace, so that a
1240/// request in flight and the few that follow it finish where they started; after that every
1241/// session is handed on (I2127).
1242///
1243/// ```
1244/// // a process that never lost a lease has no such age
1245/// assert!(majordomus_cli::lease::lost_for().is_none() || majordomus_cli::lease::was_lost());
1246/// ```
1247pub fn lost_for() -> Option<Duration> {
1248    LOST_AT.get().map(Instant::elapsed)
1249}
1250
1251/// Does a Majordomus server answer at `url` for the repository at `root`, *as* that
1252/// repository's server?
1253///
1254/// Three questions, and the third is the one a remembered address needs: it is a
1255/// Majordomus server, it serves this root, and it still holds this checkout's lease.
1256/// Without the third, a process whose lease was taken over answers every probe exactly as
1257/// the current server does — same name, same `repository_id`, its own peer board, its own
1258/// generation of the layer — and a client holding an address from before the takeover is
1259/// served plausible answers by a server nobody else is talking to. That was measured on
1260/// 2026-09-10: two servers of one checkout, one lease, and the older one indistinguishable
1261/// from the current one on this endpoint.
1262///
1263/// A server too old to answer the question is accepted. It cannot be told from a current
1264/// one here, and refusing it would be the worse failure: a live server taken for dead is
1265/// taken over, which is how one checkout comes to have two.
1266pub fn probe(url: &str, root: &Path) -> bool {
1267    matches!(ask(url, root, timings().probe_timeout), Answer::Ours(_))
1268}
1269
1270/// [`probe`], keeping what the server answered: its index document when it answers as the
1271/// leaseholder of this checkout, `None` otherwise. One decision, so a caller that also wants
1272/// the surfaces the server lists makes neither a second request nor a second judgement.
1273///
1274/// ```
1275/// use majordomus_cli::lease::probe_reply;
1276/// use std::time::Duration;
1277/// // nothing listens on port 1 of the loopback address
1278/// let root = std::path::Path::new("/r");
1279/// assert!(probe_reply("http://127.0.0.1:1", root, Duration::from_millis(50)).is_none());
1280/// ```
1281pub fn probe_reply(url: &str, root: &Path, timeout: Duration) -> Option<Value> {
1282    match ask(url, root, timeout) {
1283        Answer::Ours(index) => Some(index),
1284        Answer::NotOurs | Answer::Silent => None,
1285    }
1286}
1287
1288/// What a probe of a published URL heard.
1289enum Answer {
1290    /// A Majordomus server that serves this root and holds its lease, with the index
1291    /// document it answered.
1292    Ours(Value),
1293    /// Something answered that is not this checkout's server, or nothing is listening.
1294    NotOurs,
1295    /// The connection was made and no answer came within the timeout: a server that is
1296    /// busy looks exactly like this, and so does a hung one.
1297    Silent,
1298}
1299
1300/// Ask a published URL the probe's three questions within `timeout`, telling a server that
1301/// said no apart from one that said nothing in time.
1302fn ask(url: &str, root: &Path, timeout: Duration) -> Answer {
1303    match bridge::request(url, "GET", "/", &[], None, timeout) {
1304        Ok(reply) if reply.status == 200 => {
1305            let v: Value = serde_json::from_str(&reply.body).unwrap_or(Value::Null);
1306            // the identity and not the path: the index names the repository it serves
1307            // without telling every caller where the checkout sits
1308            let ours = v["name"] == "majordomus"
1309                && v["repository_id"].as_str() == Some(crate::repository::identity(root).as_str())
1310                && v[LEASEHOLDER_KEY] != Value::Bool(false);
1311            if ours {
1312                Answer::Ours(v)
1313            } else {
1314                Answer::NotOurs
1315            }
1316        }
1317        Ok(_) => Answer::NotOurs,
1318        Err(e)
1319            if matches!(
1320                e.kind(),
1321                std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
1322            ) =>
1323        {
1324            Answer::Silent
1325        }
1326        Err(_) => Answer::NotOurs,
1327    }
1328}
1329
1330/// Is the process with this id alive on this machine? A lease is a file of this checkout,
1331/// so its pid is a pid of this host. `kill(pid, 0)` delivers nothing and asks only whether
1332/// the process exists; a process owned by somebody else answers `EPERM`, which is alive.
1333/// Whether a process with this pid exists (signal 0: checked, nothing sent).
1334pub(crate) fn alive(pid: u32) -> bool {
1335    if pid == 0 {
1336        return false;
1337    }
1338    let Ok(pid) = libc::pid_t::try_from(pid) else {
1339        return false;
1340    };
1341    // SAFETY: signal 0 performs the existence and permission check and sends nothing.
1342    let rc = unsafe { libc::kill(pid, 0) };
1343    rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1344}
1345
1346/// [`probe`], with patience for an owner that is alive but slow.
1347///
1348/// A probe that times out says nothing about whether the server is gone: a server loading
1349/// a large layer, or one on a machine under load (an instrumented test run, a loaded CI
1350/// runner), answers late. Judging it gone from one silent probe makes `serve ensure` start
1351/// a second server beside it and report the old one's address as though it had started it.
1352/// So when the probe fails and the lease's `pid` is a live process on this machine, the
1353/// probe is repeated every second for up to `patience` — the caller's share of
1354/// [`Timings::busy_grace`], the same wait the election gives a busy owner. The answer is
1355/// `false` only when every attempt fails.
1356///
1357/// The patience is bounded on purpose. A live pid can be a wedged server that never answers
1358/// again, and a probe that waited on it forever would make every client hang on a ghost. A
1359/// dead pid, or pid 0, gets no patience at all, so a killed server is recovered at once,
1360/// and a `patience` of zero is a single probe.
1361///
1362/// ```
1363/// use majordomus_cli::lease::probe_patiently;
1364/// use std::path::Path;
1365/// use std::time::{Duration, Instant};
1366/// // nothing listens on port 1 and pid 0 names no process: one refused probe, no patience
1367/// let t0 = Instant::now();
1368/// let patience = Duration::from_secs(10);
1369/// assert!(!probe_patiently("http://127.0.0.1:1", Path::new("/nowhere"), 0, patience));
1370/// assert!(t0.elapsed() < Duration::from_secs(5));
1371/// ```
1372pub fn probe_patiently(url: &str, root: &Path, pid: u32, patience: Duration) -> bool {
1373    if probe(url, root) {
1374        return true;
1375    }
1376    let deadline = Instant::now() + patience;
1377    while alive(pid) && Instant::now() < deadline {
1378        std::thread::sleep(Duration::from_secs(1));
1379        if probe(url, root) {
1380            return true;
1381        }
1382    }
1383    false
1384}
1385
1386/// Was this lease already waited on, and judged stale, by the process that started this
1387/// one? Its token is then in [`JUDGED_STALE_ENV`], and the election takes exactly that
1388/// lease over at once instead of spending the same patience on it a second time: waiting
1389/// twice would only double the delay before a wedged owner is replaced. Any other lease —
1390/// a new owner, or one that changed in between — still gets the full wait.
1391fn judged_stale(doc: &LeaseDocument) -> bool {
1392    !doc.token.is_empty() && std::env::var(JUDGED_STALE_ENV).is_ok_and(|token| token == doc.token)
1393}
1394
1395impl Lease {
1396    /// The file.
1397    pub fn path(&self) -> &Path {
1398        &self.path
1399    }
1400
1401    /// The repository root the lease is for.
1402    pub fn root(&self) -> &Path {
1403        &self.root
1404    }
1405
1406    /// When this process took the lease, RFC 3339: the identity of this server's
1407    /// generation, the same value the lease file carries as `started_at`.
1408    pub fn started_at(&self) -> &str {
1409        &self.started_at
1410    }
1411
1412    /// What makes the file this process's. For the server's own reader, which checks from
1413    /// another thread whether the lease is still its own.
1414    pub(crate) fn token(&self) -> &str {
1415        &self.token
1416    }
1417
1418    /// Keep the lease young while the layer loads. A peer that finds a lease without a URL
1419    /// waits for it only as long as the file is younger than [`BIND_GRACE`]; a cold start
1420    /// that takes longer than that — a large layer, a machine under load — would otherwise
1421    /// be taken for an abandoned one and taken over while its owner is still binding. So
1422    /// until [`Lease::publish`], a thread rewrites the same document every third of the
1423    /// grace, under the lock `publish` also takes, so that a touch can never land after the
1424    /// URL. It writes only while the file is still this process's: a lease that was taken
1425    /// over anyway is somebody else's to write.
1426    ///
1427    /// ```
1428    /// use majordomus_cli::lease::{elect, held, LeaseFile, Role};
1429    /// use majordomus_cli::Repository;
1430    /// let dir = tempfile::tempdir().unwrap();
1431    /// std::fs::create_dir_all(dir.path().join(".ai/repo")).unwrap();
1432    /// std::fs::write(dir.path().join(".ai/manifest.yaml"),
1433    ///     "schema: ai-repository/v1\nrepo:\n  path: repo\nlocal:\n  path: local\n  tracked: false\n  implicit_context: false\nsections:\n  policy: repo/policy.yaml\n").unwrap();
1434    /// let repo = Repository::discover(dir.path()).unwrap();
1435    /// let Role::Server(lease) = elect(&repo).unwrap() else { panic!("nobody else serves a fresh directory") };
1436    /// lease.keep_alive();                         // returns at once; the touching runs beside the load
1437    /// assert!(held().is_none(), "nothing is published yet");
1438    /// lease.publish("http://127.0.0.1:1").unwrap();  // stops the touching, under the same lock
1439    /// assert_eq!(held().unwrap().url.as_deref(), Some("http://127.0.0.1:1"));
1440    /// assert!(matches!(LeaseFile::read(lease.path()), LeaseFile::Document(d) if d.url.is_some()));
1441    /// lease.release();
1442    /// assert_eq!(LeaseFile::read(&dir.path().join(".ai/local/state/mcp/server.json")), LeaseFile::Absent);
1443    /// ```
1444    pub fn keep_alive(&self) {
1445        let path = self.path.clone();
1446        let token = self.token.clone();
1447        let binding = Arc::clone(&self.binding);
1448        let document = serde_json::to_string(&self.document(None)).unwrap_or_default();
1449        let tick = timings().bind_grace / 3;
1450        let _ = std::thread::Builder::new()
1451            .name("majordomus-lease-keep-alive".into())
1452            .spawn(move || loop {
1453                std::thread::sleep(tick);
1454                let Ok(guard) = binding.lock() else { return };
1455                if !*guard {
1456                    return;
1457                }
1458                if !holds(&path, &token) {
1459                    return;
1460                }
1461                // the same discipline as `publish`: a file of its own, synced, renamed only
1462                // while the lease is still this process's, under the lock a take-over takes;
1463                // a lock held by somebody else skips this touch rather than wait on it
1464                let tmp = tmp_path(&path, &token);
1465                if write_synced(&tmp, &document).is_err() {
1466                    let _ = fs::remove_file(&tmp);
1467                    continue;
1468                }
1469                let renamed = lock_for_change(&path, Duration::from_millis(200))
1470                    .filter(|_| holds(&path, &token))
1471                    .is_some_and(|_lock| fs::rename(&tmp, &path).is_ok());
1472                if !renamed {
1473                    let _ = fs::remove_file(&tmp);
1474                }
1475            });
1476    }
1477
1478    fn document(&self, url: Option<&str>) -> LeaseDocument {
1479        LeaseDocument {
1480            schema: SCHEMA.into(),
1481            pid: std::process::id(),
1482            token: self.token.clone(),
1483            root: self.root.clone(),
1484            url: url.map(str::to_string),
1485            started_at: self.started_at.clone(),
1486            executable: executable_identity(),
1487            version: Some(crate::VERSION.into()),
1488        }
1489    }
1490
1491    /// Record the URL the server is listening on, atomically, so that a peer reading the
1492    /// file sees either no URL or the whole one. Refused when the file is no longer this
1493    /// process's: a lease taken over while its owner was binding belongs to whoever took it,
1494    /// and writing over it would leave two servers claiming one address.
1495    ///
1496    /// The document is written to a temporary file named for this lease's token and synced
1497    /// before anything else happens; then, under the lock every change to the lease takes
1498    /// (`take_over_with` says why there is one), the file is checked to be still this
1499    /// process's and the temporary file is renamed over it. No take-over can land between
1500    /// the check and the rename, and no other writer shares the temporary file.
1501    pub fn publish(&self, url: &str) -> Result<()> {
1502        self.publish_with(url, || {})
1503    }
1504
1505    /// [`Lease::publish`], with a seam between the check and the rename, where the unit
1506    /// tests put an elector taking the lease over.
1507    fn publish_with(&self, url: &str, between: impl FnOnce()) -> Result<()> {
1508        let mut binding = self.binding.lock().map_err(|_| Error::Lease {
1509            reason: "the lease's binding lock is poisoned".into(),
1510        })?;
1511        *binding = false;
1512        let document = self.document(Some(url));
1513        let text = serde_json::to_string(&document).map_err(|e| Error::Lease {
1514            reason: format!("cannot render the lease: {e}"),
1515        })?;
1516        let tmp = tmp_path(&self.path, &self.token);
1517        let renamed = write_synced(&tmp, &text)
1518            .map_err(|e| Error::io(&tmp, e))
1519            .and_then(|()| self.replace_with(&tmp, between));
1520        if renamed.is_err() {
1521            let _ = fs::remove_file(&tmp);
1522        }
1523        renamed?;
1524        sync_dir(&self.path);
1525        if let Ok(mut slot) = published().lock() {
1526            *slot = Some(document);
1527        }
1528        Ok(())
1529    }
1530
1531    /// Rename `tmp` over the lease, under the change lock, only while the lease is still this
1532    /// process's.
1533    fn replace_with(&self, tmp: &Path, between: impl FnOnce()) -> Result<()> {
1534        let Some(_lock) = lock_for_change(&self.path, LOCK_PATIENCE) else {
1535            return Err(Error::Lease {
1536                reason: format!(
1537                    "the lock beside the lease at {} stayed held for {} seconds; not publishing over a lease another process is changing",
1538                    self.path.display(),
1539                    LOCK_PATIENCE.as_secs()
1540                ),
1541            });
1542        };
1543        if !self.is_mine() {
1544            return Err(Error::Lease {
1545                reason: format!(
1546                    "the lease at {} was taken over while this server was starting; another process serves this checkout",
1547                    self.path.display()
1548                ),
1549            });
1550        }
1551        between();
1552        fs::rename(tmp, &self.path).map_err(|e| Error::io(&self.path, e))
1553    }
1554
1555    /// Is the file on disk still this process's lease?
1556    fn is_mine(&self) -> bool {
1557        holds(&self.path, &self.token)
1558    }
1559
1560    /// Remove the lease: the server has stopped. Only while the file is still this process's,
1561    /// checked under the lock a take-over takes, so that a lease taken over since is never
1562    /// removed by the process it was taken from.
1563    pub fn release(mut self) {
1564        self.release_now();
1565    }
1566
1567    fn release_now(&mut self) {
1568        if let Ok(mut binding) = self.binding.lock() {
1569            *binding = false;
1570        }
1571        if !self.released {
1572            remove_if_held(&self.path, &self.token);
1573        }
1574        self.released = true;
1575        if let Ok(mut slot) = published().lock() {
1576            if slot.as_ref().is_some_and(|d| d.token == self.token) {
1577                *slot = None;
1578            }
1579        }
1580        forget(Some(&self.token));
1581    }
1582}
1583
1584impl Drop for Lease {
1585    fn drop(&mut self) {
1586        self.release_now();
1587    }
1588}
1589
1590/// `SIGTERM`, `SIGINT` and `SIGHUP` — `serve stop`, Ctrl-C, a closing terminal — ask a
1591/// process that holds a lease to stop. The handler records the request and nothing else.
1592///
1593/// It used to unlink the lease's path and die of the signal, whenever the process had held a
1594/// lease. That was async-signal-safe and wrong: a lease taken over a moment earlier is the
1595/// successor's, and the old owner's reader notices the takeover only at its next tick, so a
1596/// signal in that window removed the *new* owner's lease (I2128). Checking the token first in
1597/// the handler would only shrink the window, not close it — a check and an unlink are two
1598/// system calls and a takeover fits between them — and reading and parsing a file is not
1599/// work a handler may do. So the handler counts, and normal code acts:
1600///
1601/// * the server's loop (`serve`) polls [`stop_requested`] and stops the server in order —
1602///   episodes closed, listeners closed, the lease released through the check under the
1603///   lock, the mesh drained — and the process returns from `main`;
1604/// * a server whose loop does not poll (`majordomus mcp`, whose main thread is its client's
1605///   stdio session) is stopped in the same order by its own reader thread, which then ends
1606///   the process with the signal it was sent;
1607/// * a watcher thread ends the process when nobody answers the request, when a second signal
1608///   arrives, or when the ordered stop takes longer than [`STOP_BOUND`] — releasing the
1609///   lease through the same check first. A third signal is the kernel's: the handler restores
1610///   the default disposition on the second.
1611///
1612/// A `kill -9` cannot be caught: the next process finds the stale lease and takes it over.
1613#[cfg(unix)]
1614mod signals {
1615    use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
1616    use std::sync::Once;
1617    use std::time::{Duration, Instant};
1618
1619    static RECEIVED: AtomicUsize = AtomicUsize::new(0);
1620    static SIGNAL: AtomicI32 = AtomicI32::new(0);
1621    static ANSWERED: AtomicBool = AtomicBool::new(false);
1622    static INSTALL: Once = Once::new();
1623
1624    /// Install the handlers and start the watcher, once per process.
1625    pub fn install() {
1626        INSTALL.call_once(|| {
1627            let handler = on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
1628            for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] {
1629                // SAFETY: installing a handler that only touches lock-free atomics and calls
1630                // signal(2), both async-signal-safe
1631                unsafe { libc::signal(signal, handler) };
1632            }
1633            let _ = std::thread::Builder::new()
1634                .name("majordomus-signal-watcher".into())
1635                .spawn(watch);
1636        });
1637    }
1638
1639    pub fn requested() -> Option<i32> {
1640        (RECEIVED.load(Ordering::SeqCst) > 0).then(|| SIGNAL.load(Ordering::SeqCst))
1641    }
1642
1643    pub fn answer() {
1644        ANSWERED.store(true, Ordering::SeqCst);
1645    }
1646
1647    fn watch() {
1648        let mut first: Option<Instant> = None;
1649        loop {
1650            std::thread::sleep(Duration::from_millis(50));
1651            let received = RECEIVED.load(Ordering::SeqCst);
1652            if received == 0 {
1653                continue;
1654            }
1655            let since = first.get_or_insert_with(Instant::now).elapsed();
1656            let answered = ANSWERED.load(Ordering::SeqCst);
1657            if super::watch_verdict(received, answered, since) == super::Watch::End {
1658                if received == 1 && answered {
1659                    tracing::warn!(
1660                        "the ordered stop did not finish within {} seconds; ending the process",
1661                        super::STOP_BOUND.as_secs()
1662                    );
1663                }
1664                super::end_by_signal(SIGNAL.load(Ordering::SeqCst));
1665            }
1666        }
1667    }
1668
1669    extern "C" fn on_signal(signal: libc::c_int) {
1670        SIGNAL.store(signal, Ordering::SeqCst);
1671        if RECEIVED.fetch_add(1, Ordering::SeqCst) >= 1 {
1672            // SAFETY: restoring the default disposition is async-signal-safe; the next one
1673            // of this signal ends the process whatever the watcher is doing
1674            unsafe { libc::signal(signal, libc::SIG_DFL) };
1675        }
1676    }
1677}
1678
1679#[cfg(not(unix))]
1680mod signals {
1681    pub fn install() {}
1682
1683    pub fn requested() -> Option<i32> {
1684        None
1685    }
1686
1687    pub fn answer() {}
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692    use super::*;
1693
1694    /// The identity of `path` as it stands now, the way [`executable_identity`] records the
1695    /// running executable.
1696    fn identity_of(path: &Path) -> ExecutableIdentity {
1697        let meta = fs::metadata(path).unwrap();
1698        let mtime = meta
1699            .modified()
1700            .unwrap()
1701            .duration_since(SystemTime::UNIX_EPOCH)
1702            .unwrap()
1703            .as_secs();
1704        ExecutableIdentity {
1705            path: path.to_path_buf(),
1706            mtime,
1707            size: meta.len(),
1708        }
1709    }
1710
1711    #[test]
1712    fn an_unchanged_executable_is_not_stale() {
1713        let dir = tempfile::tempdir().unwrap();
1714        let exe = dir.path().join("majordomus");
1715        fs::write(&exe, b"the code that was loaded").unwrap();
1716        assert_eq!(stale_reason(&identity_of(&exe)), None);
1717    }
1718
1719    #[test]
1720    fn a_replaced_executable_is_stale_and_says_replaced() {
1721        let dir = tempfile::tempdir().unwrap();
1722        let exe = dir.path().join("majordomus");
1723        fs::write(&exe, b"the code that was loaded").unwrap();
1724        let pinned = identity_of(&exe);
1725        // a rebuild writes a different file over the same path; the size alone differs,
1726        // whatever the clock's resolution makes of the mtime
1727        fs::write(&exe, b"the code a rebuild put there afterwards").unwrap();
1728        let reason = stale_reason(&pinned).expect("a replaced executable is stale");
1729        assert!(reason.contains("has been replaced"), "{reason}");
1730        assert!(reason.contains("no longer on disk"), "{reason}");
1731        assert!(
1732            !reason.contains(&exe.display().to_string()),
1733            "the reason names no path: {reason}"
1734        );
1735    }
1736
1737    #[test]
1738    fn a_removed_executable_is_stale_and_says_removed() {
1739        let dir = tempfile::tempdir().unwrap();
1740        let exe = dir.path().join("majordomus");
1741        fs::write(&exe, b"the code that was loaded").unwrap();
1742        let pinned = identity_of(&exe);
1743        fs::remove_file(&exe).unwrap();
1744        let reason = stale_reason(&pinned).expect("a removed executable is stale");
1745        assert!(reason.contains("has been removed"), "{reason}");
1746        assert!(
1747            !reason.contains(&exe.display().to_string()),
1748            "the reason names no path: {reason}"
1749        );
1750    }
1751
1752    #[test]
1753    fn this_process_is_not_serving_replaced_code() {
1754        assert!(started_as().is_some(), "a test binary knows where it is");
1755        assert_eq!(serving_replaced_code(), None);
1756    }
1757
1758    /// A lease document as another process would write it.
1759    fn lease_text(token: &str, url: Option<&str>) -> String {
1760        serde_json::to_string(&LeaseDocument {
1761            schema: SCHEMA.into(),
1762            pid: std::process::id(),
1763            token: token.into(),
1764            root: "/r".into(),
1765            url: url.map(str::to_string),
1766            started_at: "2026-10-09T00:00:00Z".into(),
1767            executable: None,
1768            version: None,
1769        })
1770        .unwrap()
1771    }
1772
1773    /// What an elector does once the path is free: create the file exclusively and write its
1774    /// lease into it. `true` when this elector is the one that created it.
1775    fn create(path: &Path, token: &str) -> bool {
1776        match fs::OpenOptions::new()
1777            .write(true)
1778            .create_new(true)
1779            .open(path)
1780        {
1781            Ok(mut file) => {
1782                file.write_all(lease_text(token, None).as_bytes()).unwrap();
1783                true
1784            }
1785            Err(_) => false,
1786        }
1787    }
1788
1789    fn token_on_disk(path: &Path) -> Option<String> {
1790        LeaseFile::read(path).document().map(|d| d.token.clone())
1791    }
1792
1793    fn binding_lease(path: &Path) -> Lease {
1794        Lease {
1795            path: path.to_path_buf(),
1796            token: "owner".into(),
1797            root: "/r".into(),
1798            released: false,
1799            started_at: "2026-10-09T00:00:00Z".into(),
1800            binding: Arc::new(Mutex::new(true)),
1801        }
1802    }
1803
1804    /// Two electors judged the same lease stale. Elector B has read it and compared it with
1805    /// what it judged; elector A makes the same judgement at that very moment, takes the lease
1806    /// over and creates its own. When B then removes "the stale lease", it must not be A's.
1807    #[test]
1808    fn two_electors_that_judged_one_lease_stale_end_with_exactly_one_lease() {
1809        let dir = tempfile::tempdir().unwrap();
1810        let path = dir.path().join("server.json");
1811        fs::write(&path, lease_text("stale", Some("http://127.0.0.1:1"))).unwrap();
1812        let seen = LeaseFile::read(&path);
1813
1814        let mut a_created = false;
1815        take_over_with(&path, &seen, LOCK_PATIENCE, || {
1816            // A, at B's worst moment, without waiting for anybody
1817            let _ = take_over_with(&path, &seen, Duration::ZERO, || {});
1818            a_created = create(&path, "a");
1819        })
1820        .unwrap();
1821        // B's next round of the election
1822        let b_created = create(&path, "b");
1823
1824        let on_disk = token_on_disk(&path);
1825        assert!(
1826            a_created != b_created,
1827            "exactly one elector believes it created the lease (A {a_created}, B {b_created}); \
1828             on disk: {on_disk:?}"
1829        );
1830        let creator = if a_created { "a" } else { "b" };
1831        assert_eq!(
1832            on_disk.as_deref(),
1833            Some(creator),
1834            "the lease names the elector that created it"
1835        );
1836    }
1837
1838    /// The lease of a process still binding, judged abandoned by an elector between the
1839    /// owner's check that the lease is its own and the rename that publishes the URL.
1840    #[test]
1841    fn a_takeover_between_the_check_and_the_rename_of_publish_is_not_overwritten() {
1842        let dir = tempfile::tempdir().unwrap();
1843        let path = dir.path().join("server.json");
1844        fs::write(&path, lease_text("owner", None)).unwrap();
1845        let lease = binding_lease(&path);
1846        let seen = LeaseFile::read(&path);
1847        let mut taker_created = false;
1848        let published = lease.publish_with("http://127.0.0.1:2", || {
1849            let _ = take_over_with(&path, &seen, Duration::ZERO, || {});
1850            taker_created = create(&path, "taker");
1851        });
1852        let doc = LeaseFile::read(&path)
1853            .document()
1854            .expect("a lease on disk")
1855            .clone();
1856        if taker_created {
1857            assert_eq!(doc.token, "taker", "the taker's lease was written over");
1858            assert!(
1859                published.is_err(),
1860                "publish reported success over a takeover"
1861            );
1862        } else {
1863            assert_eq!(doc.token, "owner");
1864            assert_eq!(doc.url.as_deref(), Some("http://127.0.0.1:2"));
1865            assert!(published.is_ok(), "{published:?}");
1866        }
1867        lease.release();
1868    }
1869
1870    /// The release a stopping process makes from a thread that does not own the [`Lease`]:
1871    /// its own lease is removed, a successor's is not.
1872    #[test]
1873    fn a_release_on_the_way_out_spares_a_successor() {
1874        let dir = tempfile::tempdir().unwrap();
1875        let path = dir.path().join("server.json");
1876        fs::write(&path, lease_text("successor", Some("http://127.0.0.1:4"))).unwrap();
1877        assert!(!remove_if_held(&path, "the-old-owner"));
1878        assert_eq!(token_on_disk(&path).as_deref(), Some("successor"));
1879        assert!(
1880            remove_if_held(&path, "successor"),
1881            "the holder removes its own"
1882        );
1883        assert_eq!(LeaseFile::read(&path), LeaseFile::Absent);
1884    }
1885
1886    /// A change waits for another holder of the lock only as long as it was told to, and
1887    /// makes no change when that runs out.
1888    #[test]
1889    fn a_held_change_lock_refuses_a_second_change() {
1890        let dir = tempfile::tempdir().unwrap();
1891        let path = dir.path().join("server.json");
1892        fs::write(&path, lease_text("owner", None)).unwrap();
1893        let held = lock_for_change(&path, Duration::ZERO).expect("a free lock is taken");
1894        assert!(lock_for_change(&path, Duration::from_millis(20)).is_none());
1895        // under a subscriber, so the warning that names the lease is written, not skipped
1896        let subscriber = tracing_subscriber::fmt()
1897            .with_max_level(tracing::Level::WARN)
1898            .with_writer(std::io::sink)
1899            .finish();
1900        tracing::subscriber::with_default(subscriber, || {
1901            assert!(
1902                !remove_if_held(&path, "owner"),
1903                "no removal without the lock"
1904            );
1905        });
1906        drop(held);
1907        assert!(remove_if_held(&path, "owner"));
1908    }
1909
1910    #[test]
1911    fn the_watcher_leaves_the_first_signal_to_a_server_that_answers_it_for_the_bound() {
1912        assert_eq!(watch_verdict(0, true, Duration::ZERO), Watch::Wait);
1913        assert_eq!(watch_verdict(1, true, Duration::ZERO), Watch::Wait);
1914        assert_eq!(
1915            watch_verdict(1, true, STOP_BOUND),
1916            Watch::End,
1917            "the bound passed"
1918        );
1919        assert_eq!(
1920            watch_verdict(1, false, Duration::ZERO),
1921            Watch::End,
1922            "nobody answers"
1923        );
1924        assert_eq!(
1925            watch_verdict(2, true, Duration::ZERO),
1926            Watch::End,
1927            "a second signal"
1928        );
1929    }
1930
1931    /// The release [`release_held`] makes from the record of the election: the lease it
1932    /// names goes only while the file is still that lease.
1933    #[test]
1934    fn the_release_on_a_signal_removes_only_the_lease_the_record_names() {
1935        let dir = tempfile::tempdir().unwrap();
1936        let path = dir.path().join("server.json");
1937        let record = || Some((path.clone(), "held-by-this-test".to_string()));
1938        fs::write(&path, lease_text("successor", None)).unwrap();
1939        release_record(record());
1940        assert_eq!(token_on_disk(&path).as_deref(), Some("successor"));
1941        fs::write(&path, lease_text("held-by-this-test", None)).unwrap();
1942        release_record(record());
1943        assert_eq!(LeaseFile::read(&path), LeaseFile::Absent);
1944        release_record(None);
1945    }
1946
1947    /// A temporary file is per writer: another writer's temporary file (here, something at
1948    /// the old fixed name that is not even a file) neither blocks this one nor is replaced.
1949    #[test]
1950    fn publish_writes_through_a_temporary_file_of_its_own() {
1951        let dir = tempfile::tempdir().unwrap();
1952        let path = dir.path().join("server.json");
1953        fs::write(&path, lease_text("owner", None)).unwrap();
1954        fs::create_dir(dir.path().join("server.json.tmp")).unwrap();
1955        let lease = binding_lease(&path);
1956        lease
1957            .publish("http://127.0.0.1:3")
1958            .expect("publish does not share a temporary name with anybody");
1959        assert_eq!(
1960            LeaseFile::read(&path)
1961                .document()
1962                .and_then(|d| d.url.clone()),
1963            Some("http://127.0.0.1:3".into())
1964        );
1965        lease.release();
1966        assert_eq!(LeaseFile::read(&path), LeaseFile::Absent);
1967    }
1968
1969    /// Only three dot-separated numbers are a release: anything shorter, longer or not
1970    /// numeric on either side is different from the other version and not ordered against it.
1971    #[cfg(unix)]
1972    #[test]
1973    fn a_lock_refused_for_anything_but_contention_lets_the_change_go_ahead_without_it() {
1974        let dir = std::env::temp_dir().join(format!("mj-lease-refused-{}", std::process::id()));
1975        std::fs::create_dir_all(&dir).unwrap();
1976        let lease = dir.join("server.json");
1977        let unsupported = lock_for_change_with(&lease, Duration::from_secs(5), |_| {
1978            Err(std::io::Error::from_raw_os_error(libc::ENOLCK))
1979        });
1980        assert!(
1981            unsupported.is_some_and(|lock| lock._file.is_none()),
1982            "a filesystem that refuses flock is changed without the lock"
1983        );
1984        let tries = std::cell::Cell::new(0);
1985        let taken = lock_for_change_with(&lease, Duration::from_secs(5), |_| {
1986            tries.set(tries.get() + 1);
1987            if tries.get() == 1 {
1988                Err(std::io::Error::from(std::io::ErrorKind::Interrupted))
1989            } else {
1990                Ok(())
1991            }
1992        });
1993        assert!(taken.is_some_and(|lock| lock._file.is_some()));
1994        assert_eq!(tries.get(), 2, "an interrupted attempt is made again");
1995        let _ = std::fs::remove_dir_all(&dir);
1996    }
1997
1998    #[test]
1999    fn a_version_that_is_not_three_numbers_is_not_ordered() {
2000        for served in ["1", "1.2", "x.2.3", "1.x.3", "1.2.x", "1.2.3.4"] {
2001            assert_eq!(
2002                version_verdict(Some(served), "0.20.0"),
2003                VersionVerdict::Unordered,
2004                "served {served}"
2005            );
2006        }
2007        assert_eq!(
2008            version_verdict(Some("0.20.0"), "dev"),
2009            VersionVerdict::Unordered,
2010            "a client of no release orders nothing"
2011        );
2012    }
2013
2014    /// A take-over that finds the lock held by another change makes no change: the lease is
2015    /// left for the next reading, which judges whatever the other change left there.
2016    /// Every version that is not exactly three numbers is unordered against a release, and
2017    /// three numbers are ordered as numbers.
2018    #[test]
2019    fn a_version_is_ordered_only_when_it_is_three_numbers() {
2020        for served in ["1", "1.2", "x.1.2", "1.x.2", "1.2.x", "1.2.3.4", "1..3"] {
2021            assert_eq!(
2022                version_verdict(Some(served), "0.20.0"),
2023                VersionVerdict::Unordered,
2024                "{served}"
2025            );
2026        }
2027        assert_eq!(
2028            version_verdict(Some("v0.19.1"), "0.20.0"),
2029            VersionVerdict::ServerOlder
2030        );
2031        assert_eq!(
2032            version_verdict(Some("0.21.0"), "0.20.0"),
2033            VersionVerdict::ServerNewer
2034        );
2035    }
2036
2037    /// A take-over that cannot take the lock says so in the log, naming the lease.
2038    #[test]
2039    fn a_take_over_that_waits_on_the_lock_names_the_lease_in_the_log() {
2040        let dir = tempfile::tempdir().unwrap();
2041        let path = dir.path().join("server.json");
2042        fs::write(&path, lease_text("stale", None)).unwrap();
2043        let seen = LeaseFile::read(&path);
2044        let held = lock_for_change(&path, Duration::ZERO).expect("a free lock is taken");
2045        let subscriber = tracing_subscriber::fmt()
2046            .with_max_level(tracing::Level::DEBUG)
2047            .with_writer(std::io::sink)
2048            .finish();
2049        let mut ran = false;
2050        tracing::subscriber::with_default(subscriber, || {
2051            take_over_with(&path, &seen, Duration::ZERO, || ran = true).unwrap();
2052        });
2053        assert!(!ran, "nothing past the lock ran");
2054        drop(held);
2055        assert_eq!(token_on_disk(&path).as_deref(), Some("stale"));
2056    }
2057
2058    #[test]
2059    fn a_take_over_that_cannot_take_the_lock_leaves_the_lease_for_the_next_reading() {
2060        let dir = tempfile::tempdir().unwrap();
2061        let path = dir.path().join("server.json");
2062        fs::write(&path, lease_text("stale", None)).unwrap();
2063        let seen = LeaseFile::read(&path);
2064        let held = lock_for_change(&path, Duration::ZERO).expect("a free lock is taken");
2065        let mut ran = false;
2066        take_over_with(&path, &seen, Duration::ZERO, || ran = true)
2067            .expect("a held lock is not an error");
2068        assert!(!ran, "nothing past the lock ran");
2069        assert_eq!(token_on_disk(&path).as_deref(), Some("stale"));
2070        drop(held);
2071        // Not a single attempt: closing `held` releases the flock only once no process holds
2072        // its open file description, and a sibling test that forks (`pre_exec`,
2073        // `process_group`) gives its child a copy of every descriptor until that child execs.
2074        // A zero patience then met the lock still held and left the lease, under a parallel
2075        // run alone. The wait ends as soon as the copy is gone; the bound is only a backstop.
2076        take_over_with(&path, &seen, Duration::from_secs(30), || {}).unwrap();
2077        assert_eq!(
2078            LeaseFile::read(&path),
2079            LeaseFile::Absent,
2080            "with the lock it goes"
2081        );
2082    }
2083
2084    /// A lease path with no file name still gets a temporary file of the writer's own, under
2085    /// the default name.
2086    #[test]
2087    fn a_temporary_name_for_a_path_without_a_file_name_uses_the_default_one() {
2088        let tmp = tmp_path(Path::new("/"), "t1");
2089        assert_eq!(
2090            tmp.file_name().and_then(|n| n.to_str()),
2091            Some("server.json.t1.tmp"),
2092            "{tmp:?}"
2093        );
2094    }
2095
2096    /// A temporary file that cannot be created is an error, and nothing is written.
2097    #[test]
2098    fn a_temporary_file_that_cannot_be_created_is_an_error() {
2099        let dir = tempfile::tempdir().unwrap();
2100        let tmp = dir.path().join("missing").join("server.json.t.tmp");
2101        let e = write_synced(&tmp, "{}").expect_err("no directory to create it in");
2102        assert_eq!(e.kind(), std::io::ErrorKind::NotFound, "{e}");
2103        assert!(!dir.path().join("missing").exists(), "nothing was created");
2104    }
2105
2106    /// A temporary file that cannot be written whole is an error: a lease is never published
2107    /// from a file the disk did not take.
2108    #[cfg(target_os = "linux")]
2109    #[test]
2110    fn a_temporary_file_on_a_full_disk_is_an_error() {
2111        let dir = tempfile::tempdir().unwrap();
2112        let tmp = dir.path().join("server.json.t.tmp");
2113        std::os::unix::fs::symlink("/dev/full", &tmp).unwrap();
2114        let e = write_synced(&tmp, "{}").expect_err("a full device takes nothing");
2115        assert_eq!(e.raw_os_error(), Some(libc::ENOSPC), "{e}");
2116    }
2117
2118    /// Syncing the directory of a rename is best effort: a path with no parent, or a parent
2119    /// that cannot be opened, loses only the durability, and nothing is created for it.
2120    #[test]
2121    fn syncing_the_directory_of_a_rename_is_best_effort() {
2122        sync_dir(Path::new("/"));
2123        let dir = tempfile::tempdir().unwrap();
2124        let gone = dir.path().join("gone");
2125        sync_dir(&gone.join("server.json"));
2126        assert!(!gone.exists(), "the missing directory is not created");
2127        sync_dir(&dir.path().join("server.json"));
2128        assert!(dir.path().is_dir());
2129    }
2130
2131    /// How long a keep-alive touch takes to come round, with room for a loaded machine.
2132    fn keep_alive_tick() -> Duration {
2133        timings().bind_grace / 3
2134    }
2135
2136    /// Wait up to `bound` for `ok`, polling.
2137    fn eventually(bound: Duration, mut ok: impl FnMut() -> bool) -> bool {
2138        let deadline = Instant::now() + bound;
2139        while Instant::now() < deadline {
2140            if ok() {
2141                return true;
2142            }
2143            std::thread::sleep(Duration::from_millis(50));
2144        }
2145        ok()
2146    }
2147
2148    /// The version a touch writes: what tells a touched lease from the one written by hand.
2149    fn touched(path: &Path) -> bool {
2150        LeaseFile::read(path)
2151            .document()
2152            .is_some_and(|d| d.version.as_deref() == Some(crate::VERSION))
2153    }
2154
2155    /// A touch whose temporary file cannot be written leaves the lease as it is and tries
2156    /// again at the next tick, rather than giving the lease up.
2157    #[test]
2158    fn a_keep_alive_touch_that_cannot_write_its_temporary_file_tries_again() {
2159        let dir = tempfile::tempdir().unwrap();
2160        let path = dir.path().join("server.json");
2161        fs::write(&path, lease_text("owner", None)).unwrap();
2162        // a directory where the touch's own temporary file goes: creating the file fails
2163        let blocked = tmp_path(&path, "owner");
2164        fs::create_dir(&blocked).unwrap();
2165        let lease = binding_lease(&path);
2166        lease.keep_alive();
2167        std::thread::sleep(keep_alive_tick() + keep_alive_tick() / 2);
2168        assert!(!touched(&path), "no touch landed while it could not write");
2169        assert!(
2170            blocked.is_dir(),
2171            "a directory it did not create is left alone"
2172        );
2173        fs::remove_dir(&blocked).unwrap();
2174        assert!(
2175            eventually(keep_alive_tick() * 6, || touched(&path)),
2176            "the next tick touched the lease"
2177        );
2178        assert_eq!(token_on_disk(&path).as_deref(), Some("owner"));
2179        lease.release();
2180        assert_eq!(LeaseFile::read(&path), LeaseFile::Absent);
2181    }
2182
2183    /// A touch that finds the lock held by another change skips that touch, removes its
2184    /// temporary file and waits for the next tick: it never renames without the lock.
2185    #[test]
2186    fn a_keep_alive_touch_skips_a_lease_whose_lock_another_change_holds() {
2187        let dir = tempfile::tempdir().unwrap();
2188        let path = dir.path().join("server.json");
2189        fs::write(&path, lease_text("owner", None)).unwrap();
2190        let held = lock_for_change(&path, Duration::ZERO).expect("a free lock is taken");
2191        let lease = binding_lease(&path);
2192        lease.keep_alive();
2193        std::thread::sleep(keep_alive_tick() + keep_alive_tick() / 2);
2194        assert!(!touched(&path), "no touch without the lock");
2195        assert!(
2196            !tmp_path(&path, "owner").exists(),
2197            "the skipped touch removed its temporary file"
2198        );
2199        drop(held);
2200        assert!(
2201            eventually(keep_alive_tick() * 6, || touched(&path)),
2202            "the touch after the lock was freed landed"
2203        );
2204        lease.release();
2205        assert_eq!(LeaseFile::read(&path), LeaseFile::Absent);
2206    }
2207
2208    /// A publish whose temporary file cannot be written fails with the path it could not
2209    /// write, and the lease is left as it was.
2210    #[test]
2211    fn a_publish_whose_temporary_file_cannot_be_written_leaves_the_lease() {
2212        let dir = tempfile::tempdir().unwrap();
2213        let path = dir.path().join("server.json");
2214        fs::write(&path, lease_text("owner", None)).unwrap();
2215        let blocked = tmp_path(&path, "owner");
2216        fs::create_dir(&blocked).unwrap();
2217        let lease = binding_lease(&path);
2218        match lease.publish("http://127.0.0.1:5") {
2219            Err(Error::Io { path: p, .. }) => assert_eq!(p, blocked),
2220            other => panic!("expected the temporary file's I/O error, got {other:?}"),
2221        }
2222        assert_eq!(
2223            LeaseFile::read(&path).document().map(|d| d.url.clone()),
2224            Some(None),
2225            "the lease still names no URL"
2226        );
2227        assert!(blocked.is_dir());
2228    }
2229
2230    /// A publish waits for a change another process holds the lock for only as long as
2231    /// [`LOCK_PATIENCE`], then refuses and removes its temporary file.
2232    #[test]
2233    fn a_publish_refuses_when_the_change_lock_stays_held() {
2234        let dir = tempfile::tempdir().unwrap();
2235        let path = dir.path().join("server.json");
2236        fs::write(&path, lease_text("owner", None)).unwrap();
2237        let held = lock_for_change(&path, Duration::ZERO).expect("a free lock is taken");
2238        let lease = binding_lease(&path);
2239        match lease.publish("http://127.0.0.1:6") {
2240            Err(Error::Lease { reason }) => assert!(
2241                reason.contains("stayed held for 2 seconds")
2242                    && reason.contains("another process is changing"),
2243                "{reason}"
2244            ),
2245            other => panic!("expected the lock refusal, got {other:?}"),
2246        }
2247        assert!(
2248            !tmp_path(&path, "owner").exists(),
2249            "the temporary file is gone"
2250        );
2251        assert_eq!(token_on_disk(&path).as_deref(), Some("owner"));
2252        drop(held);
2253        lease.release();
2254    }
2255
2256    /// A rename that fails is an error naming the lease, and the temporary file is removed.
2257    #[test]
2258    fn a_publish_whose_rename_fails_names_the_lease_and_removes_its_temporary_file() {
2259        let dir = tempfile::tempdir().unwrap();
2260        let path = dir.path().join("server.json");
2261        fs::write(&path, lease_text("owner", None)).unwrap();
2262        let lease = binding_lease(&path);
2263        let result = lease.publish_with("http://127.0.0.1:7", || {
2264            // between the check and the rename, the lease becomes a directory that is not
2265            // empty: no file can be renamed over it
2266            fs::remove_file(&path).unwrap();
2267            fs::create_dir(&path).unwrap();
2268            fs::write(path.join("inside"), "x").unwrap();
2269        });
2270        match result {
2271            Err(Error::Io { path: p, .. }) => assert_eq!(p, path),
2272            other => panic!("expected the rename's I/O error, got {other:?}"),
2273        }
2274        assert!(
2275            !tmp_path(&path, "owner").exists(),
2276            "the temporary file is gone"
2277        );
2278        assert!(path.join("inside").is_file(), "what stands there is left");
2279    }
2280
2281    /// The variable that makes a run of this test binary one of the child processes below.
2282    #[cfg(unix)]
2283    const CHILD: &str = "MAJORDOMUS_LEASE_TEST_CHILD";
2284
2285    /// Run `test` of this binary in a process of its own, with `CHILD` naming `dir`. With
2286    /// `block_sighup`, the process starts with `SIGHUP` blocked in every thread: a signal the
2287    /// test raises on its own thread, with the block lifted there, is still handled, but the
2288    /// signal [`end_by_signal`] sends again is never delivered, so the process ends by the
2289    /// exit that follows it, and the exit status is still `128 + signal`.
2290    #[cfg(unix)]
2291    fn in_a_process_of_its_own(test: &str, dir: &Path, block_sighup: bool) -> std::process::Output {
2292        use std::os::unix::process::CommandExt;
2293        let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
2294        cmd.args([
2295            "--exact",
2296            &format!("lease::tests::{test}"),
2297            "--nocapture",
2298            "--test-threads",
2299            "1",
2300        ])
2301        .env(CHILD, dir);
2302        if block_sighup {
2303            // SAFETY: runs in the forked child before exec; sigemptyset, sigaddset and
2304            // pthread_sigmask are async-signal-safe and touch only this closure's own set.
2305            unsafe {
2306                cmd.pre_exec(|| {
2307                    sighup_mask(libc::SIG_BLOCK);
2308                    Ok(())
2309                });
2310            }
2311        }
2312        cmd.output().expect("the child process runs")
2313    }
2314
2315    /// Block or unblock `SIGHUP` in the calling thread.
2316    #[cfg(unix)]
2317    fn sighup_mask(how: libc::c_int) {
2318        // SAFETY: a signal set on this stack, initialised before use; pthread_sigmask only
2319        // changes the calling thread's mask.
2320        unsafe {
2321            let mut set: libc::sigset_t = std::mem::zeroed();
2322            libc::sigemptyset(&mut set);
2323            libc::sigaddset(&mut set, libc::SIGHUP);
2324            libc::pthread_sigmask(how, &set, std::ptr::null_mut());
2325        }
2326    }
2327
2328    /// Poison `lock` the way a holder that panicked would.
2329    #[cfg(unix)]
2330    fn poison<T: Send + 'static>(lock: &'static Mutex<T>) {
2331        let _ = std::thread::spawn(move || {
2332            let _held = lock.lock();
2333            panic!("a holder of the lock panicked (on purpose)");
2334        })
2335        .join();
2336        assert!(lock.is_poisoned());
2337    }
2338
2339    /// The process-wide records stay usable when a holder panicked: recording, forgetting and
2340    /// releasing through a poisoned lock change nothing and do not panic, and the release
2341    /// still removes the lease the record names. In a process of its own, because the
2342    /// records are the process's.
2343    #[cfg(unix)]
2344    #[test]
2345    fn the_process_wide_records_tolerate_a_poisoned_lock() {
2346        if let Some(dir) = std::env::var_os(CHILD) {
2347            let path = Path::new(&dir).join("server.json");
2348            fs::write(&path, lease_text("owner", None)).unwrap();
2349            hold(&path, "owner");
2350            poison(&HOLDING);
2351            hold(&path, "successor");
2352            forget(None);
2353            HOLDING.clear_poison();
2354            assert_eq!(
2355                *HOLDING.lock().unwrap(),
2356                Some((path.clone(), "owner".to_string())),
2357                "a poisoned record is neither overwritten nor cleared"
2358            );
2359            poison(published());
2360            release_record(Some((path.clone(), "owner".into())));
2361            assert_eq!(LeaseFile::read(&path), LeaseFile::Absent);
2362            published().clear_poison();
2363            return;
2364        }
2365        let dir = tempfile::tempdir().unwrap();
2366        let out = in_a_process_of_its_own(
2367            "the_process_wide_records_tolerate_a_poisoned_lock",
2368            dir.path(),
2369            false,
2370        );
2371        let stdout = String::from_utf8_lossy(&out.stdout);
2372        assert!(
2373            out.status.success() && stdout.contains("1 passed"),
2374            "{:?}\n{stdout}\n{}",
2375            out.status,
2376            String::from_utf8_lossy(&out.stderr)
2377        );
2378    }
2379
2380    /// The child half of the two tests below: take a lease, publish it, declare that this
2381    /// process answers stop requests, receive `signals` `SIGHUP`s, record when, and wait for
2382    /// the watcher to end the process.
2383    #[cfg(unix)]
2384    fn receive_stop_signals(dir: &Path, signals: usize) {
2385        let path = dir.join("server.json");
2386        fs::write(&path, lease_text("owner", None)).unwrap();
2387        let lease = binding_lease(&path);
2388        lease.publish("http://127.0.0.1:8").unwrap();
2389        hold(&path, "owner");
2390        answer_stop_requests();
2391        sighup_mask(libc::SIG_UNBLOCK);
2392        for _ in 0..signals {
2393            // SAFETY: raising a signal whose handler only touches atomics
2394            unsafe { libc::raise(libc::SIGHUP) };
2395        }
2396        sighup_mask(libc::SIG_BLOCK);
2397        assert_eq!(stop_requested(), Some(libc::SIGHUP));
2398        let at = SystemTime::now()
2399            .duration_since(SystemTime::UNIX_EPOCH)
2400            .unwrap()
2401            .as_millis();
2402        fs::write(dir.join("signalled"), at.to_string()).unwrap();
2403        std::thread::sleep(Duration::from_secs(60));
2404        drop(lease);
2405    }
2406
2407    /// How long after the child recorded its signals it ended.
2408    #[cfg(unix)]
2409    fn ended_after_the_signals(dir: &Path) -> Duration {
2410        let at: u128 = fs::read_to_string(dir.join("signalled"))
2411            .expect("the child recorded its signals")
2412            .parse()
2413            .unwrap();
2414        let now = SystemTime::now()
2415            .duration_since(SystemTime::UNIX_EPOCH)
2416            .unwrap()
2417            .as_millis();
2418        Duration::from_millis(now.saturating_sub(at) as u64)
2419    }
2420
2421    /// A second stop signal ends the process at once, without waiting out the bound the
2422    /// first one gave the ordered stop; on the way out the lease this process holds is
2423    /// released, and the exit status names the signal.
2424    #[cfg(unix)]
2425    #[test]
2426    fn a_second_stop_signal_ends_the_process_at_once_and_releases_its_lease() {
2427        if let Some(dir) = std::env::var_os(CHILD) {
2428            receive_stop_signals(Path::new(&dir), 2);
2429            return;
2430        }
2431        let dir = tempfile::tempdir().unwrap();
2432        let out = in_a_process_of_its_own(
2433            "a_second_stop_signal_ends_the_process_at_once_and_releases_its_lease",
2434            dir.path(),
2435            true,
2436        );
2437        assert_eq!(
2438            out.status.code(),
2439            Some(128 + libc::SIGHUP),
2440            "{:?}\n{}\n{}",
2441            out.status,
2442            String::from_utf8_lossy(&out.stdout),
2443            String::from_utf8_lossy(&out.stderr)
2444        );
2445        let after = ended_after_the_signals(dir.path());
2446        assert!(after < STOP_BOUND, "ended {after:?} after the signals");
2447        assert_eq!(
2448            LeaseFile::read(&dir.path().join("server.json")),
2449            LeaseFile::Absent,
2450            "the lease was released on the way out"
2451        );
2452    }
2453
2454    /// A first stop signal is left to the process that answers it, but only for
2455    /// [`STOP_BOUND`]: an ordered stop that never finishes is ended then, and the lease is
2456    /// released on the way out.
2457    #[cfg(unix)]
2458    #[test]
2459    fn an_ordered_stop_that_overruns_the_bound_is_ended_and_releases_its_lease() {
2460        if let Some(dir) = std::env::var_os(CHILD) {
2461            // the watcher's warning is written, so that what it says is evaluated too
2462            let _ = tracing::subscriber::set_global_default(
2463                tracing_subscriber::fmt()
2464                    .with_writer(std::io::sink)
2465                    .finish(),
2466            );
2467            receive_stop_signals(Path::new(&dir), 1);
2468            return;
2469        }
2470        let dir = tempfile::tempdir().unwrap();
2471        let out = in_a_process_of_its_own(
2472            "an_ordered_stop_that_overruns_the_bound_is_ended_and_releases_its_lease",
2473            dir.path(),
2474            true,
2475        );
2476        assert_eq!(
2477            out.status.code(),
2478            Some(128 + libc::SIGHUP),
2479            "{:?}\n{}\n{}",
2480            out.status,
2481            String::from_utf8_lossy(&out.stdout),
2482            String::from_utf8_lossy(&out.stderr)
2483        );
2484        let after = ended_after_the_signals(dir.path());
2485        assert!(after >= STOP_BOUND, "ended {after:?} after the signal");
2486        assert_eq!(
2487            LeaseFile::read(&dir.path().join("server.json")),
2488            LeaseFile::Absent,
2489            "the lease was released on the way out"
2490        );
2491    }
2492}