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