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}