Skip to main content

runner_manager_platform/
lock.rs

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