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}