Skip to main content

runner_manager_platform/
lock.rs

1// owner: d1-platform-core
2
3//! The two host locks: one that keeps a second agent from reconciling the same
4//! policies, and one that serialises runtime creation.
5//!
6//! `03-control-flows.md`, flow 3.1: *"A single-instance lock prevents two
7//! agents on one host from reconciling the same policy."* Flow 2.4: the agent
8//! *"takes the host-wide allocation lock before creating each local runtime"*.
9//! `07-security.md`'s threat table names the single-instance lock as one of the
10//! four controls on *"API replay or a duplicate agent creates too many
11//! runners"*.
12//!
13//! # Why an operating-system file lock, and not a PID file
14//!
15//! The requirement that decides the mechanism is *"released on crash rather
16//! than leaking"*. A PID file cannot do that: a process that is `SIGKILL`ed, or
17//! whose machine loses power, leaves the file behind, and every recovery
18//! strategy built on top — is that PID still alive? was it reused? — is
19//! guesswork that fails exactly when it matters. An operating-system file lock
20//! is released by the kernel when the holding process ends, for *any* reason,
21//! with no cooperation from the process and nothing left to clean up.
22//!
23//! Two mechanisms, one behaviour:
24//!
25//! - **Windows** opens the file for read and write while sharing only read
26//!   access. A second acquirer asks for write access as well, which the
27//!   holder's share mode denies, and gets `ERROR_SHARING_VIOLATION`. A
28//!   *reader* asks only for read access, which the share mode permits — which
29//!   is what lets the loser find out who beat it.
30//! - **Unix** takes `flock(LOCK_EX | LOCK_NB)`. `flock` is advisory and does
31//!   not stand in the way of an ordinary `open` for reading, so the loser can
32//!   read the holder record there too. Locks are held per open file
33//!   description, so a second acquisition from the *same* process is refused as
34//!   firmly as one from another process.
35//!
36//! # The lock file is never deleted
37//!
38//! Not on release, not on a clean shutdown, not by `Drop`. On Unix a lock is a
39//! property of the inode, so a holder that unlinks the file lets the next
40//! acquirer create and lock a *different* inode — after which two processes
41//! each hold "the lock" and neither can see the other. Leaving a zero-cost
42//! empty file behind is the whole price of not having that bug.
43//!
44//! # What "host-wide" means, precisely
45//!
46//! The lock is a file under [`crate::paths::AppPaths::state_dir`], as
47//! `05-infrastructure.md` specifies (*"state/ agent lock"*). Two agents
48//! contend if and only if they resolve the same state directory — which is what
49//! makes the lock host-wide for every configuration this product supports, and
50//! is worth stating rather than assuming: the platform-standard state directory
51//! is per-account on all three operating systems, so a daemon running as a
52//! service account and an interactive `daemon run` by a logged-in operator
53//! resolve *different* paths and would not contend. That is why
54//! `05-infrastructure.md` requires `service install` to record its resolved
55//! configuration and why `service status` reports it. A future host-wide
56//! machine lock (`%ProgramData%`, `/var/lock`) would need the installer to
57//! create it with the right ownership, which is `d3`'s territory, not this
58//! module's.
59
60use std::fmt;
61use std::fs::{File, OpenOptions};
62use std::io::{Read, Seek, SeekFrom, Write};
63use std::path::{Path, PathBuf};
64use std::time::{Duration, Instant};
65
66use chrono::{DateTime, Utc};
67use serde::{Deserialize, Serialize};
68
69use crate::paths::AppPaths;
70use crate::process::{Adoption, ProcessIdentity};
71
72/// Which of the two host locks.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum LockKind {
76    /// Held for the whole life of the agent process. One holder per host means
77    /// one reconciler per host.
78    SingleInstance,
79    /// Held only while one runtime is being created, so that two concurrent
80    /// allocations cannot both read the same headroom and both use it.
81    Allocation,
82}
83
84impl LockKind {
85    /// The file this lock lives in, inside `state/`.
86    #[must_use]
87    pub const fn file_name(self) -> &'static str {
88        match self {
89            Self::SingleInstance => "agent.lock",
90            Self::Allocation => "allocation.lock",
91        }
92    }
93
94    /// How to name this lock to an operator.
95    #[must_use]
96    pub const fn description(self) -> &'static str {
97        match self {
98            Self::SingleInstance => "the single-instance agent lock",
99            Self::Allocation => "the runtime allocation lock",
100        }
101    }
102
103    /// What an operator who lost the race should do about it. Present because
104    /// "the lock is held" is a statement and not yet an instruction.
105    #[must_use]
106    pub const fn advice(self) -> &'static str {
107        match self {
108            Self::SingleInstance => {
109                "Only one agent may reconcile policies on a host. Stop the other agent, or wait \
110                 for it to exit; the operating system releases this lock when that process ends, \
111                 including after a crash, so there is never anything to clean up by hand."
112            }
113            Self::Allocation => {
114                "This lock is held only for as long as it takes to create one runtime. Retry \
115                 shortly."
116            }
117        }
118    }
119}
120
121impl fmt::Display for LockKind {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.write_str(self.description())
124    }
125}
126
127/// Who holds a lock, as recorded by the holder itself.
128///
129/// Written into the lock file after the lock is taken, so that the process
130/// that loses the race can say something useful instead of "somebody else".
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct LockHolder {
133    /// The holder's process identity — a PID alone would not survive being
134    /// read back by a process that started after a reboot.
135    pub identity: ProcessIdentity,
136    /// The holder's executable, when it could be resolved. `05-infrastructure.md`
137    /// requires `service status` to report a stale or moved binary path, and
138    /// an operator looking at a contended lock has the same question.
139    pub executable: Option<PathBuf>,
140    /// When the lock was taken.
141    pub acquired_at: DateTime<Utc>,
142    /// Which lock the record belongs to. Recorded so that a file opened by
143    /// mistake is recognised rather than misreported.
144    pub lock: LockKind,
145}
146
147impl fmt::Display for LockHolder {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(f, "process {}", self.identity.pid())?;
150        if let Some(executable) = &self.executable {
151            write!(f, " ({})", executable.display())?;
152        }
153        write!(f, ", holding since {}", self.acquired_at.to_rfc3339())
154    }
155}
156
157/// Something went wrong taking or inspecting a lock.
158#[derive(Debug, thiserror::Error)]
159pub enum LockError {
160    /// Somebody else has it.
161    #[error(
162        "{kind} ({}) is already held on this host by {}. {} [refused with {}]",
163        path.display(),
164        describe(holder.as_deref()),
165        kind.advice(),
166        describe_refusal(*refused_with)
167    )]
168    Held {
169        /// Which lock.
170        kind: LockKind,
171        /// Where the lock file is, so the message is actionable on a host with
172        /// a non-default layout.
173        path: PathBuf,
174        /// Who holds it, when the record could be read. `None` is not the same
175        /// as "nobody": it means the holder had not finished identifying
176        /// itself, which is a race of microseconds and not a reason to proceed.
177        ///
178        /// Boxed because this variant travels inside a `Result` that `e1`
179        /// carries across a `spawn_blocking` boundary, and an inline
180        /// `LockHolder` makes that `Err` large enough for clippy's
181        /// `result_large_err` to refuse the build. The indirection costs one
182        /// allocation on a path that has already lost a lock race.
183        holder: Option<Box<LockHolder>>,
184        /// The operating system code the refusal actually carried.
185        ///
186        /// # Why an errno is in a user-facing message
187        ///
188        /// This variant has two causes that look identical once it is
189        /// constructed: the lock is genuinely somebody else's, or the operating
190        /// system said "would block" for a reason this code does not model. The
191        /// second is not hypothetical — a refusal that no reading of this
192        /// module explains has been seen in CI on both Unix platforms, for a
193        /// lock the same process had just released, and there was nothing in
194        /// the message to tell the two apart.
195        ///
196        /// Carrying the raw code costs a few characters and turns the next
197        /// occurrence from a mystery into a report. `None` where the platform
198        /// excluded at the open rather than at the lock call.
199        refused_with: Option<i32>,
200    },
201
202    /// The lock file could not be opened, read, or written.
203    #[error("cannot use the lock file {}: {source}", path.display())]
204    Io {
205        /// The lock file.
206        path: PathBuf,
207        /// The underlying error.
208        #[source]
209        source: std::io::Error,
210    },
211
212    /// The holder's own identity could not be read, so it could not record who
213    /// it is.
214    #[error("cannot record this process as the holder of {kind}: {source}")]
215    Identity {
216        /// Which lock.
217        kind: LockKind,
218        /// The underlying error.
219        #[source]
220        source: crate::process::ProcessError,
221    },
222}
223
224/// Renders the holder half of a [`LockError::Held`] message.
225fn describe(holder: Option<&LockHolder>) -> String {
226    match holder {
227        Some(holder) => holder.to_string(),
228        None => "a process that has not finished identifying itself".to_string(),
229    }
230}
231
232/// A held lock. Releasing it is dropping it.
233///
234/// There is no `release()` returning a `Result`, deliberately: releasing is
235/// closing a file descriptor, the kernel does it whether this program asks or
236/// not, and an API that suggested release could fail would invite a caller to
237/// handle a failure that does not exist.
238#[derive(Debug)]
239pub struct HostLock {
240    /// Held open for the lock's whole life. Closing it *is* the release, which
241    /// is why this field exists even though nothing reads it after
242    /// acquisition.
243    file: File,
244    path: PathBuf,
245    kind: LockKind,
246}
247
248impl HostLock {
249    /// Takes the lock, or reports who has it, without waiting.
250    ///
251    /// # Errors
252    ///
253    /// [`LockError::Held`] when another process has it, [`LockError::Io`] when
254    /// the lock file cannot be opened, and [`LockError::Identity`] when this
255    /// process cannot describe itself.
256    pub fn try_acquire(paths: &AppPaths, kind: LockKind) -> Result<Self, LockError> {
257        Self::try_acquire_at(&paths.state_dir().join(kind.file_name()), kind)
258    }
259
260    /// Takes the lock, retrying until `wait` elapses.
261    ///
262    /// `e1` takes the allocation lock before each runtime it creates, and brief
263    /// contention there is expected rather than exceptional, so waiting a
264    /// little is the right default for that caller. The single-instance lock
265    /// should normally use [`HostLock::try_acquire`]: a second agent is a
266    /// configuration problem, and waiting for it makes the problem quieter
267    /// rather than fixing it.
268    ///
269    /// # This blocks the calling thread
270    ///
271    /// The retry loop is `std::thread::sleep`, not a timer an executor can
272    /// park. `e1` takes the allocation lock from inside async reconciliation,
273    /// and calling this directly from a `tokio` task blocks a worker thread for
274    /// up to `wait` — starving every other task scheduled on it, and with a
275    /// current-thread runtime deadlocking against the very task that would
276    /// release the lock. **Async callers must wrap it in
277    /// [`tokio::task::spawn_blocking`]**, which is also where the returned
278    /// [`HostLock`] should then live, since dropping it is the release.
279    ///
280    /// [`HostLock::try_acquire`] does not block and is safe to call inline.
281    ///
282    /// # Errors
283    ///
284    /// As [`HostLock::try_acquire`], reporting the last holder seen.
285    pub fn acquire(paths: &AppPaths, kind: LockKind, wait: Duration) -> Result<Self, LockError> {
286        Self::acquire_at(&paths.state_dir().join(kind.file_name()), kind, wait)
287    }
288
289    /// [`HostLock::try_acquire`] against an explicit path.
290    ///
291    /// # Errors
292    ///
293    /// As [`HostLock::try_acquire`].
294    pub fn try_acquire_at(path: &Path, kind: LockKind) -> Result<Self, LockError> {
295        if let Some(parent) = path.parent() {
296            std::fs::create_dir_all(parent).map_err(|source| LockError::Io {
297                path: path.to_path_buf(),
298                source,
299            })?;
300        }
301
302        // Best effort in both contention branches below: an unreadable or
303        // half-written record makes the message vaguer, and is never a reason
304        // to behave as if the lock were free.
305        let held = |path: &Path, refused_with: Option<i32>| LockError::Held {
306            kind,
307            path: path.to_path_buf(),
308            holder: read_holder(path).ok().flatten().map(Box::new),
309            refused_with,
310        };
311
312        let file = match open_for_locking(path) {
313            Ok(file) => file,
314            // Windows excludes at the open itself, through the share mode, so
315            // this is where contention surfaces there. On Unix nothing fails
316            // the open for contention and this arm never fires.
317            Err(source) if sys::is_contention(&source) => {
318                return Err(held(path, source.raw_os_error()));
319            }
320            Err(source) => return Err(io_error(path, source)),
321        };
322
323        match sys::try_lock(&file).map_err(|source| io_error(path, source))? {
324            Acquired::Yes => {}
325            Acquired::No { refused_with } => return Err(held(path, refused_with)),
326        }
327
328        let lock = Self {
329            file,
330            path: path.to_path_buf(),
331            kind,
332        };
333        lock.record_holder()?;
334        Ok(lock)
335    }
336
337    /// [`HostLock::acquire`] against an explicit path.
338    ///
339    /// # Errors
340    ///
341    /// As [`HostLock::try_acquire`].
342    pub fn acquire_at(path: &Path, kind: LockKind, wait: Duration) -> Result<Self, LockError> {
343        let deadline = Instant::now() + wait;
344        loop {
345            match Self::try_acquire_at(path, kind) {
346                Ok(lock) => return Ok(lock),
347                Err(error @ LockError::Held { .. }) => {
348                    if Instant::now() >= deadline {
349                        return Err(error);
350                    }
351                    std::thread::sleep(RETRY_INTERVAL);
352                }
353                Err(other) => return Err(other),
354            }
355        }
356    }
357
358    /// Reads the holder record of a lock without trying to take it.
359    ///
360    /// Answers `Ok(None)` when the file does not exist or carries no readable
361    /// record. It deliberately says nothing about whether the lock is *held*:
362    /// the record outlives its writer by design, and the only authority on
363    /// whether a lock is free is trying to take it.
364    ///
365    /// # Errors
366    ///
367    /// [`LockError::Io`] when the file exists but cannot be read.
368    pub fn holder_of(path: &Path) -> Result<Option<LockHolder>, LockError> {
369        read_holder(path)
370    }
371
372    /// Where the lock file is.
373    #[must_use]
374    pub fn path(&self) -> &Path {
375        &self.path
376    }
377
378    /// Which lock this is.
379    #[must_use]
380    pub const fn kind(&self) -> LockKind {
381        self.kind
382    }
383
384    /// Whether the recorded holder of `path` is still running.
385    ///
386    /// For diagnostics — `host show` reporting a lock whose record names a
387    /// process that no longer exists tells an operator something a bare
388    /// "locked/unlocked" does not.
389    ///
390    /// # Errors
391    ///
392    /// [`LockError::Io`] when the record cannot be read.
393    pub fn recorded_holder_is_live(path: &Path) -> Result<bool, LockError> {
394        let Some(holder) = read_holder(path)? else {
395            return Ok(false);
396        };
397        Ok(matches!(holder.identity.recheck(), Ok(Adoption::Live)))
398    }
399
400    /// Writes this process's identity into the lock file.
401    ///
402    /// Runs *after* the lock is taken, which leaves a window of a few
403    /// microseconds in which a loser sees the previous holder's record or none
404    /// at all. That is why [`LockError::Held`] carries an `Option` and why the
405    /// message for `None` says the holder has not identified itself yet, rather
406    /// than implying the lock might be free.
407    fn record_holder(&self) -> Result<(), LockError> {
408        let holder = LockHolder {
409            identity: ProcessIdentity::of_current_process().map_err(|source| {
410                LockError::Identity {
411                    kind: self.kind,
412                    source,
413                }
414            })?,
415            executable: std::env::current_exe().ok(),
416            acquired_at: Utc::now(),
417            lock: self.kind,
418        };
419
420        let encoded = serde_json::to_vec_pretty(&holder).map_err(|source| {
421            io_error(
422                &self.path,
423                std::io::Error::new(std::io::ErrorKind::InvalidData, source),
424            )
425        })?;
426
427        let mut file = &self.file;
428        file.seek(SeekFrom::Start(0))
429            .map_err(|source| io_error(&self.path, source))?;
430        // The previous holder's record is longer or shorter than this one; a
431        // write without a truncate would leave its tail behind and produce
432        // unparseable JSON for the next reader.
433        file.set_len(0)
434            .map_err(|source| io_error(&self.path, source))?;
435        file.write_all(&encoded)
436            .map_err(|source| io_error(&self.path, source))?;
437        file.flush()
438            .map_err(|source| io_error(&self.path, source))?;
439        // Durable before the caller does anything with the lock: a record that
440        // is only in the page cache is not there for the operator diagnosing
441        // the machine that just stopped responding.
442        file.sync_all()
443            .map_err(|source| io_error(&self.path, source))
444    }
445}
446
447fn io_error(path: &Path, source: std::io::Error) -> LockError {
448    LockError::Io {
449        path: path.to_path_buf(),
450        source,
451    }
452}
453
454/// How often [`HostLock::acquire_at`] retries. Short enough that an allocation
455/// waiting on the lock is not noticeably delayed.
456const RETRY_INTERVAL: Duration = Duration::from_millis(25);
457
458/// What a non-blocking lock attempt answered, and — when it refused — why.
459///
460/// A bare `bool` threw away the one fact that would explain a refusal nobody
461/// can account for. See [`LockError::Held::refused_with`].
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463// Windows excludes at the open, so its `try_lock` can only ever answer `Yes`
464// and the refusal arm is genuinely unreachable there. It is not dead code — it
465// is the whole answer on the two platforms that lock after opening.
466#[cfg_attr(windows, allow(dead_code))]
467pub(crate) enum Acquired {
468    Yes,
469    No { refused_with: Option<i32> },
470}
471
472/// Renders the refusal code for an operator, without pretending to interpret it.
473fn describe_refusal(code: Option<i32>) -> String {
474    match code {
475        Some(code) => format!("os error {code}"),
476        None => "the open itself, which is how this platform excludes".to_string(),
477    }
478}
479
480fn open_for_locking(path: &Path) -> std::io::Result<File> {
481    let mut options = OpenOptions::new();
482    // `truncate(false)` is explicit rather than implied: the previous holder's
483    // record must survive the open, because it is what a *loser* reads, and the
484    // loser's open is this same call.
485    options.read(true).write(true).create(true).truncate(false);
486    sys::prepare_for_locking(&mut options);
487    options.open(path)
488}
489
490fn read_holder(path: &Path) -> Result<Option<LockHolder>, LockError> {
491    let mut options = OpenOptions::new();
492    options.read(true);
493    sys::prepare_for_reading(&mut options);
494
495    let mut file = match options.open(path) {
496        Ok(file) => file,
497        Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
498        Err(source) => return Err(io_error(path, source)),
499    };
500
501    let mut contents = String::new();
502    file.read_to_string(&mut contents)
503        .map_err(|source| io_error(path, source))?;
504
505    // An empty or half-written file is the acquisition race described on
506    // `HostLock::record_holder`, not a corrupt installation. Say "no record"
507    // and let the caller's message be vaguer.
508    Ok(serde_json::from_str(&contents).ok())
509}
510
511// ---------------------------------------------------------------------------
512// Platform implementations
513// ---------------------------------------------------------------------------
514
515#[cfg(windows)]
516mod sys {
517    use std::fs::{File, OpenOptions};
518    use std::io;
519    use std::os::windows::fs::OpenOptionsExt;
520
521    use windows::Win32::Storage::FileSystem::{
522        FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
523    };
524
525    /// The acquisition open. Sharing *read* only: a second acquirer also asks
526    /// for write access, which this share mode denies, so `CreateFile` fails
527    /// with `ERROR_SHARING_VIOLATION` before any lock call is needed. The
528    /// exclusion is the open itself, which is why `try_lock` below has nothing
529    /// left to do.
530    pub(super) fn prepare_for_locking(options: &mut OpenOptions) {
531        options.share_mode(FILE_SHARE_READ.0);
532    }
533
534    /// The diagnostic open. Read access only, and permissive sharing, so that
535    /// it is compatible with the holder's open in both directions: the holder
536    /// permits readers, and this permits the holder's read/write access.
537    pub(super) fn prepare_for_reading(options: &mut OpenOptions) {
538        options.share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0);
539    }
540
541    /// Always `true` on Windows: if the open in `open_for_locking` succeeded,
542    /// this process is the exclusive writer, and when the process ends — for
543    /// any reason, including a crash — the kernel closes the handle and the
544    /// next acquirer's open succeeds.
545    pub(super) fn try_lock(_file: &File) -> io::Result<super::Acquired> {
546        Ok(super::Acquired::Yes)
547    }
548
549    /// Windows reports a share-mode conflict as `ERROR_SHARING_VIOLATION`, and
550    /// `std` maps it to a generic error, so the raw code is what identifies it.
551    pub(super) const SHARING_VIOLATION: i32 =
552        windows::Win32::Foundation::ERROR_SHARING_VIOLATION.0 as i32;
553
554    /// Whether an open failure means "somebody else holds it" rather than
555    /// something an operator should investigate.
556    pub(super) fn is_contention(error: &io::Error) -> bool {
557        error.raw_os_error() == Some(SHARING_VIOLATION)
558    }
559}
560
561#[cfg(unix)]
562mod sys {
563    use std::fs::{File, OpenOptions};
564    use std::io;
565    use std::os::unix::io::AsRawFd;
566
567    /// Nothing to prepare: on Unix the open is ordinary and the exclusion comes
568    /// from `flock` below.
569    pub(super) fn prepare_for_locking(_options: &mut OpenOptions) {}
570
571    pub(super) fn prepare_for_reading(_options: &mut OpenOptions) {}
572
573    /// `flock(LOCK_EX | LOCK_NB)`.
574    ///
575    /// Chosen over `fcntl` record locks for one reason that matters here:
576    /// `fcntl` locks are dropped when *any* file descriptor for the file is
577    /// closed by the process, so an unrelated `read_holder` in the same process
578    /// would silently release the agent's lock. `flock` locks belong to the
579    /// open file description and are immune to that.
580    pub(super) fn try_lock(file: &File) -> io::Result<super::Acquired> {
581        // SAFETY: `flock` takes a file descriptor and a flag word and touches
582        // no memory this program owns. The descriptor is valid for the life of
583        // `file`, which outlives the call.
584        let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
585        if result == 0 {
586            return Ok(super::Acquired::Yes);
587        }
588        let error = io::Error::last_os_error();
589        if is_contention(&error) {
590            return Ok(super::Acquired::No {
591                refused_with: error.raw_os_error(),
592            });
593        }
594        Err(error)
595    }
596
597    /// `EWOULDBLOCK` — and `EAGAIN`, which is the same number on Linux and
598    /// macOS but is written both ways in the documentation.
599    pub(super) fn is_contention(error: &io::Error) -> bool {
600        matches!(
601            error.raw_os_error(),
602            Some(code) if code == libc::EWOULDBLOCK || code == libc::EAGAIN
603        )
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    use crate::process::{OutputMode, SpawnSpec};
612
613    /// The environment variable that turns `lock_holder_helper` below from a
614    /// no-op into a process that takes a lock and holds it until it is killed.
615    const HELPER_PATH: &str = "RUNNER_MANAGER_LOCK_HELPER_PATH";
616    /// What the helper prints once it holds the lock.
617    ///
618    /// Searched for *within* a line rather than at the start of one, because
619    /// libtest writes `test <name> ... ` with no trailing newline before the
620    /// test body runs — so the helper's first line of output is that prefix
621    /// followed by this marker, not the marker alone.
622    const HELPER_READY: &str = "@@LOCK-HELD@@";
623
624    fn lock_path(directory: &tempfile::TempDir, kind: LockKind) -> PathBuf {
625        directory.path().join(kind.file_name())
626    }
627
628    /// Takes the lock twice with `acquire` and reports whether exactly one
629    /// acquisition won.
630    ///
631    /// A helper returning `Result` rather than inline assertions, so that
632    /// `the_contention_check_catches_a_lock_that_never_excludes` can point it
633    /// at a lock that excludes nothing. A mutual-exclusion test that has only
634    /// ever been run against a working lock cannot distinguish "the lock works"
635    /// from "the test asserts nothing".
636    fn check_mutual_exclusion<G>(acquire: impl Fn() -> Result<G, LockError>) -> Result<(), String> {
637        let first = acquire().map_err(|error| format!("the first acquisition failed: {error}"))?;
638
639        let outcome = match acquire() {
640            Ok(_) => Err("both acquisitions succeeded; nothing is being excluded".to_string()),
641            Err(LockError::Held { .. }) => Ok(()),
642            Err(other) => Err(format!(
643                "the second acquisition failed, but not because the lock was held: {other}"
644            )),
645        };
646
647        drop(first);
648        outcome
649    }
650
651    #[test]
652    fn two_contenders_produce_exactly_one_holder() {
653        let directory = tempfile::tempdir().expect("a temporary directory");
654        let path = lock_path(&directory, LockKind::SingleInstance);
655
656        check_mutual_exclusion(|| HostLock::try_acquire_at(&path, LockKind::SingleInstance))
657            .expect("the single-instance lock must admit exactly one holder");
658    }
659
660    #[test]
661    fn the_contention_check_catches_a_lock_that_never_excludes() {
662        let complaint = check_mutual_exclusion(|| Ok::<(), LockError>(()))
663            .expect_err("a lock that excludes nothing must be caught");
664        assert!(
665            complaint.contains("nothing is being excluded"),
666            "the complaint must name the failure mode, got: {complaint}"
667        );
668    }
669
670    #[test]
671    fn releasing_the_lock_lets_the_next_contender_take_it() {
672        let directory = tempfile::tempdir().expect("a temporary directory");
673        let path = lock_path(&directory, LockKind::SingleInstance);
674
675        let first = HostLock::try_acquire_at(&path, LockKind::SingleInstance).expect("acquired");
676        assert!(HostLock::try_acquire_at(&path, LockKind::SingleInstance).is_err());
677        drop(first);
678
679        let second = HostLock::try_acquire_at(&path, LockKind::SingleInstance)
680            .expect("the lock must be free once the holder drops it");
681        assert_eq!(second.path(), path);
682        assert_eq!(second.kind(), LockKind::SingleInstance);
683    }
684
685    #[test]
686    fn the_two_locks_do_not_contend_with_each_other() {
687        // `e1` takes the allocation lock while the agent already holds the
688        // single-instance lock. If those two shared a file, the agent would
689        // deadlock against itself on the first runtime it tried to create.
690        let directory = tempfile::tempdir().expect("a temporary directory");
691
692        let instance = HostLock::try_acquire_at(
693            &lock_path(&directory, LockKind::SingleInstance),
694            LockKind::SingleInstance,
695        )
696        .expect("the instance lock is free");
697
698        let allocation = HostLock::try_acquire_at(
699            &lock_path(&directory, LockKind::Allocation),
700            LockKind::Allocation,
701        )
702        .expect("the allocation lock is a different lock and must be free");
703
704        assert_ne!(instance.path(), allocation.path());
705        assert_ne!(
706            LockKind::SingleInstance.file_name(),
707            LockKind::Allocation.file_name()
708        );
709    }
710
711    #[test]
712    fn the_loser_gets_a_message_naming_the_holder_and_saying_what_to_do() {
713        let directory = tempfile::tempdir().expect("a temporary directory");
714        let path = lock_path(&directory, LockKind::SingleInstance);
715
716        let _held = HostLock::try_acquire_at(&path, LockKind::SingleInstance).expect("acquired");
717        let error = HostLock::try_acquire_at(&path, LockKind::SingleInstance)
718            .expect_err("the second acquisition must fail");
719
720        let LockError::Held { holder, kind, .. } = &error else {
721            panic!("expected a contention error, got {error}");
722        };
723        assert_eq!(*kind, LockKind::SingleInstance);
724
725        let holder = holder.as_ref().expect("the holder recorded itself");
726        assert_eq!(holder.identity.pid(), std::process::id());
727        assert_eq!(holder.lock, LockKind::SingleInstance);
728        assert_eq!(
729            holder.executable.as_deref(),
730            std::env::current_exe().ok().as_deref()
731        );
732
733        let message = error.to_string();
734        assert!(
735            message.contains(&std::process::id().to_string()),
736            "the message must name the holding process: {message}"
737        );
738        assert!(
739            message.contains("Stop the other agent"),
740            "the message must say what to do, not only what happened: {message}"
741        );
742    }
743
744    #[test]
745    fn the_recorded_holder_survives_a_read_by_a_second_process_shape() {
746        // Reading the record must work while the lock is held — on Windows that
747        // is a share-mode question and it is easy to get wrong in a way that
748        // only shows up when it matters, because the only caller is the loser.
749        let directory = tempfile::tempdir().expect("a temporary directory");
750        let path = lock_path(&directory, LockKind::SingleInstance);
751
752        let _held = HostLock::try_acquire_at(&path, LockKind::SingleInstance).expect("acquired");
753
754        let holder = HostLock::holder_of(&path)
755            .expect("the record must be readable while the lock is held")
756            .expect("a record must be there");
757        assert_eq!(holder.identity.pid(), std::process::id());
758        assert!(HostLock::recorded_holder_is_live(&path).expect("readable"));
759    }
760
761    #[test]
762    fn a_stale_record_is_reported_as_not_live() {
763        let directory = tempfile::tempdir().expect("a temporary directory");
764        let path = lock_path(&directory, LockKind::SingleInstance);
765
766        // A record naming a process that has come and gone: exactly what a
767        // crashed holder leaves behind.
768        //
769        // THE CHILD HAS TO OUTLIVE ITS OWN IDENTITY LOOKUP.
770        //
771        // `SpawnSpec::spawn` reads `ProcessIdentity::of_child` immediately after
772        // starting the process, because the start token is what stops a reused
773        // pid from being mistaken for the original. A child that exits INSTANTLY
774        // -- `true` did -- can be gone before that read, and the spawn then
775        // fails with `NoSuchProcess` rather than yielding the identity this test
776        // needs. It blocked release 0.1.5 on the macOS leg, where a loaded
777        // runner made the race easy to lose.
778        //
779        // So the child sleeps briefly: long enough to be observed, short enough
780        // that waiting for it costs nothing. What is being tested is unchanged
781        // -- the record is stale by the time it is read, because `wait` below
782        // returns only after the process is gone.
783        let mut child = SpawnSpec::new(if cfg!(windows) { "cmd" } else { "sh" })
784            .args(if cfg!(windows) {
785                vec!["/C", "exit", "0"]
786            } else {
787                vec!["-c", "sleep 0.3"]
788            })
789            .spawn()
790            .expect("the child starts");
791        let identity = child.identity().clone();
792        child.wait().expect("the child exits");
793
794        let stale = LockHolder {
795            identity,
796            executable: None,
797            acquired_at: Utc::now(),
798            lock: LockKind::SingleInstance,
799        };
800        std::fs::write(&path, serde_json::to_vec(&stale).expect("serialisable")).expect("writable");
801
802        assert!(
803            !HostLock::recorded_holder_is_live(&path).expect("readable"),
804            "a record naming a dead process must not be reported as live"
805        );
806        // And the lock itself is free, because nothing holds the file.
807        HostLock::try_acquire_at(&path, LockKind::SingleInstance)
808            .expect("a stale record must not keep the lock held");
809    }
810
811    #[test]
812    fn an_unidentified_holder_still_reads_as_a_holder() {
813        // The microsecond between taking the lock and writing the record. The
814        // message gets vaguer; the exclusion does not, and the wording must not
815        // leave a reader thinking the lock might be free.
816        let no_record = describe(None);
817        assert!(
818            no_record.contains("not finished identifying itself"),
819            "an unidentified holder must still read as a holder: {no_record}"
820        );
821
822        let error = LockError::Held {
823            kind: LockKind::SingleInstance,
824            path: PathBuf::from("/var/lib/runner-manager/state/agent.lock"),
825            holder: None,
826            refused_with: Some(35),
827        };
828        let message = error.to_string();
829        assert!(message.contains("already held"), "{message}");
830        assert!(message.contains("agent.lock"), "{message}");
831        assert!(message.contains("Stop the other agent"), "{message}");
832        // The refusal code is in the message because this variant has two
833        // causes that are otherwise indistinguishable once constructed: a lock
834        // that really is somebody else's, and a refusal this module does not
835        // model. Without it, the second reads exactly like the first.
836        assert!(message.contains("os error 35"), "{message}");
837
838        let at_open = LockError::Held {
839            kind: LockKind::SingleInstance,
840            path: PathBuf::from("/var/lib/runner-manager/state/agent.lock"),
841            holder: None,
842            refused_with: None,
843        };
844        assert!(
845            at_open.to_string().contains("the open itself"),
846            "a platform that excludes at the open says so rather than showing a bare `None`: {at_open}"
847        );
848    }
849
850    #[test]
851    fn acquire_waits_and_then_reports_the_holder() {
852        let directory = tempfile::tempdir().expect("a temporary directory");
853        let path = lock_path(&directory, LockKind::Allocation);
854
855        let _held = HostLock::try_acquire_at(&path, LockKind::Allocation).expect("acquired");
856
857        let wait = Duration::from_millis(200);
858        let started = Instant::now();
859        let error = HostLock::acquire_at(&path, LockKind::Allocation, wait)
860            .expect_err("the lock is held for the whole window");
861        let elapsed = started.elapsed();
862
863        assert!(matches!(error, LockError::Held { .. }), "{error}");
864        assert!(
865            elapsed >= wait,
866            "acquire must actually wait; it returned after {elapsed:?} of a {wait:?} window"
867        );
868    }
869
870    #[test]
871    fn acquire_returns_immediately_when_the_lock_is_free() {
872        let directory = tempfile::tempdir().expect("a temporary directory");
873        let path = lock_path(&directory, LockKind::Allocation);
874
875        let started = Instant::now();
876        let lock = HostLock::acquire_at(&path, LockKind::Allocation, Duration::from_secs(30))
877            .expect("the lock is free");
878        assert!(
879            started.elapsed() < Duration::from_secs(5),
880            "a free lock must not be waited for"
881        );
882        drop(lock);
883    }
884
885    #[test]
886    fn try_acquire_uses_the_state_directory() {
887        let root = tempfile::tempdir().expect("a temporary directory");
888        let paths = AppPaths::rooted_at(root.path());
889
890        let lock = HostLock::try_acquire(&paths, LockKind::SingleInstance).expect("acquired");
891        assert_eq!(lock.path(), paths.state_dir().join("agent.lock"));
892        assert!(
893            lock.path().exists(),
894            "the lock file must have been created under state/, as 05-infrastructure.md says"
895        );
896    }
897
898    // -----------------------------------------------------------------------
899    // The cross-process half of the Definition of Done
900    // -----------------------------------------------------------------------
901
902    /// Runs as an ordinary no-op test, unless `RUNNER_MANAGER_LOCK_HELPER_PATH`
903    /// is set — in which case this process *is* the second contender: it takes
904    /// the lock named by that variable, announces that it has it, and then
905    /// waits to be killed.
906    ///
907    /// Re-executing the test binary is what makes a genuinely separate process
908    /// available without shipping a second binary. The two tests below drive
909    /// it.
910    #[test]
911    fn lock_holder_helper() {
912        let Some(path) = std::env::var_os(HELPER_PATH) else {
913            return;
914        };
915
916        let _lock = HostLock::try_acquire_at(Path::new(&path), LockKind::SingleInstance)
917            .expect("the helper must be able to take the lock");
918
919        println!("{HELPER_READY} {}", std::process::id());
920        let _ = std::io::stdout().flush();
921
922        // Long enough that the parent always kills it first, short enough that
923        // a parent which somehow died leaves nothing behind for long.
924        std::thread::sleep(Duration::from_secs(120));
925    }
926
927    /// Starts the helper and waits until it reports that it holds the lock.
928    fn start_helper(path: &Path) -> (crate::process::ChildProcess, u32) {
929        let executable = std::env::current_exe().expect("the test binary's own path");
930
931        let mut child = SpawnSpec::new(executable)
932            .args([
933                "--exact",
934                "lock::tests::lock_holder_helper",
935                // Without this, libtest swallows the helper's announcement and
936                // the parent waits forever for a line that was captured.
937                "--nocapture",
938                "--test-threads=1",
939            ])
940            .env(HELPER_PATH, path)
941            .output(OutputMode::Capture)
942            .spawn()
943            .expect("the helper process starts");
944
945        let stdout = child.take_stdout().expect("captured");
946        let (sender, receiver) = std::sync::mpsc::channel();
947        std::thread::spawn(move || {
948            use std::io::BufRead as _;
949            for line in std::io::BufReader::new(stdout).lines() {
950                let Ok(line) = line else { break };
951                if let Some((_, rest)) = line.split_once(HELPER_READY) {
952                    let _ = sender.send(rest.trim().to_string());
953                    return;
954                }
955            }
956            let _ = sender.send(String::new());
957        });
958
959        let announced = receiver
960            .recv_timeout(Duration::from_secs(60))
961            .expect("the helper must announce that it holds the lock");
962        let pid: u32 = announced
963            .parse()
964            .unwrap_or_else(|_| panic!("the helper announced {announced:?} instead of a PID"));
965
966        (child, pid)
967    }
968
969    #[test]
970    fn two_processes_contending_produce_exactly_one_holder() {
971        let directory = tempfile::tempdir().expect("a temporary directory");
972        let path = lock_path(&directory, LockKind::SingleInstance);
973
974        let (mut helper, helper_pid) = start_helper(&path);
975        assert_ne!(helper_pid, std::process::id());
976
977        let error = HostLock::try_acquire_at(&path, LockKind::SingleInstance)
978            .expect_err("a second agent must not get the lock");
979
980        let LockError::Held { holder, .. } = &error else {
981            panic!("expected a contention error, got {error}");
982        };
983        let holder = holder.as_ref().expect("the helper recorded itself");
984        assert_eq!(
985            holder.identity.pid(),
986            helper_pid,
987            "the record must name the process that actually holds it"
988        );
989        assert!(
990            error.to_string().contains(&helper_pid.to_string()),
991            "the loser's message must name the holder: {error}"
992        );
993
994        helper.stop(Duration::ZERO).expect("cleanup");
995    }
996
997    #[test]
998    fn killing_the_holder_releases_the_lock_with_no_manual_cleanup() {
999        let directory = tempfile::tempdir().expect("a temporary directory");
1000        let path = lock_path(&directory, LockKind::SingleInstance);
1001
1002        let (mut helper, helper_pid) = start_helper(&path);
1003        assert!(
1004            HostLock::try_acquire_at(&path, LockKind::SingleInstance).is_err(),
1005            "the helper holds it"
1006        );
1007
1008        // `Duration::ZERO` leaves the helper no grace period. On Windows that
1009        // is literally straight to `TerminateProcess`, because there is no
1010        // signal to send; on Unix a SIGTERM is still sent first, but the grace
1011        // period it is given is zero, so SIGKILL follows before the helper can
1012        // act on it. Either way no destructor and no cleanup code runs, which
1013        // is the point: this is a crash, not a shutdown.
1014        helper.stop(Duration::ZERO).expect("the helper is killed");
1015        assert!(!helper.is_running().expect("observable"));
1016
1017        // Nothing is deleted, nothing is reset, no PID file is reaped: the next
1018        // acquisition simply succeeds.
1019        let recovered = HostLock::try_acquire_at(&path, LockKind::SingleInstance)
1020            .unwrap_or_else(|error| panic!("the lock leaked after its holder was killed: {error}"));
1021
1022        assert!(
1023            path.exists(),
1024            "the lock file itself must survive; deleting it is how two processes end up \
1025             locking different inodes"
1026        );
1027
1028        let holder = HostLock::holder_of(&path)
1029            .expect("readable")
1030            .expect("the new holder recorded itself");
1031        assert_eq!(holder.identity.pid(), std::process::id());
1032        assert_ne!(
1033            holder.identity.pid(),
1034            helper_pid,
1035            "the record must have been replaced, not inherited"
1036        );
1037        drop(recovered);
1038    }
1039}