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