sqry_daemon/lifecycle/pidfile.rs
1//! Pidfile and exclusive-lock management for `sqryd`.
2//!
3//! ## Overview
4//!
5//! Every running `sqryd` process holds an **OFD-level exclusive flock** on
6//! `sqryd.lock` for its entire lifetime. Because the flock is tied to the
7//! *open file description* (OFD) rather than the process, it survives
8//! `fork`+`exec` when the FD is inherited with `FD_CLOEXEC` cleared — this
9//! is the mechanism used in the `--detach` double-fork path (see §C.3.2 in
10//! the design doc).
11//!
12//! ## Key types
13//!
14//! - [`PidfileOwnership`] — state machine governing which process is
15//! responsible for unlinking `sqryd.pid` on drop.
16//! - [`PidfileLock`] — RAII handle that holds the exclusive flock and the
17//! pidfile. Drop behaviour depends on the ownership state.
18//! - [`acquire_pidfile_lock`] — the normal entry point; fails with
19//! [`DaemonError::AlreadyRunning`] if the lock is contended.
20//!
21//! ## Ownership model (design §D.2 / iter-2 M6 fix)
22//!
23//! ```text
24//! WriteOwner ──hand_off_to_adopter()──► Handoff
25//! │ │
26//! │ drop │ drop
27//! ▼ ▼
28//! unlink pidfile + unlock close FD only (no LOCK_UN)
29//!
30//! Adopted ──────────────────────────────────►
31//! │ drop
32//! ▼
33//! unlink pidfile + unlock
34//! ```
35//!
36//! In the detach path the parent starts as `WriteOwner`, the grandchild wraps
37//! the inherited FD via [`PidfileLock::adopt`] (starting as `Adopted`), and
38//! the parent transitions to `Handoff` once the grandchild signals ready.
39//! Exactly one party — the grandchild — unlinks the pidfile at graceful
40//! shutdown.
41//!
42//! **OFD flock invariant (M-2):** `Handoff` drop must NOT call `flock(LOCK_UN)`.
43//! OFD-level locks are released when ALL FDs sharing the OFD are closed or
44//! when an explicit `LOCK_UN` is issued on any of them. The grandchild holds
45//! the inherited FD; if the parent calls `LOCK_UN`, the grandchild's lock is
46//! released too. The parent therefore only closes its FD (decrementing the
47//! OFD reference count) — the lock remains held by the grandchild until it
48//! drops its `Adopted` [`PidfileLock`].
49//!
50//! ## Stale-pidfile recovery (design §D.3)
51//!
52//! On **abnormal** process termination (SIGKILL, panic=abort, segfault, OOM)
53//! the `Drop` impl does **not** run. However, the kernel releases OFD-level
54//! flocks unconditionally when the process's FD table is torn down by
55//! `do_exit`, so the lock is released even without Rust `Drop` executing.
56//! The next `sqryd start` calls `try_lock_exclusive` on the same lockfile
57//! inode; success proves there is no live owner, and the fresh daemon
58//! overwrites the stale `sqryd.pid` atomically.
59//!
60//! ## Lockfile unlink policy (design §D.4)
61//!
62//! The lockfile (`sqryd.lock`) is **never** unlinked. Inode stability is the
63//! linchpin of the stale-recovery correctness argument — all contenders must
64//! flock the same inode.
65//!
66//! ## Design reference
67//!
68//! `docs/reviews/sqryd-daemon/2026-04-19/task-9-design_iter3_request.md`
69//! §D (pidfile locking), §C.3.2 (detach path), §C.3.3 (adopted-FD API).
70
71use std::{
72 fs::{self, File, OpenOptions},
73 io::{self, Write as _},
74 path::PathBuf,
75};
76
77use fs2::FileExt as _;
78use tracing::{debug, warn};
79
80use crate::{
81 config::DaemonConfig,
82 error::{DaemonError, DaemonResult},
83};
84
85// ---------------------------------------------------------------------------
86// Ownership state machine
87// ---------------------------------------------------------------------------
88
89/// Tracks which entity is responsible for unlinking the pidfile on [`Drop`].
90///
91/// Transitions:
92/// - [`WriteOwner`] is the initial state after [`acquire_pidfile_lock`].
93/// - [`Handoff`] is set by the parent process in the detach path after the
94/// grandchild signals ready (via [`PidfileLock::hand_off_to_adopter`]).
95/// - [`Adopted`] is the initial state of a [`PidfileLock`] constructed with
96/// [`PidfileLock::adopt`].
97///
98/// [`WriteOwner`]: PidfileOwnership::WriteOwner
99/// [`Handoff`]: PidfileOwnership::Handoff
100/// [`Adopted`]: PidfileOwnership::Adopted
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum PidfileOwnership {
103 /// This handle wrote the pidfile. Drop must unlink it.
104 WriteOwner,
105 /// This handle transferred pidfile lifecycle to an adopter. Drop must
106 /// **not** unlink the pidfile; only the flock is released.
107 Handoff,
108 /// This handle adopted a pre-locked FD. Drop must unlink the pidfile
109 /// (matching the behaviour of [`WriteOwner`]).
110 Adopted,
111}
112
113// ---------------------------------------------------------------------------
114// PidfileLock
115// ---------------------------------------------------------------------------
116
117/// RAII handle holding an exclusive OFD-level flock on `sqryd.lock`.
118///
119/// Construct via [`acquire_pidfile_lock`] (normal start) or
120/// [`PidfileLock::adopt`] (grandchild after `--detach` FD inheritance).
121///
122/// # Drop behaviour
123///
124/// | [`PidfileOwnership`] | Unlinks pidfile? | Calls `unlock()`? |
125/// |-------------------------|------------------|-------------------|
126/// | [`WriteOwner`] | yes | yes |
127/// | [`Handoff`] | no | **no** (M-2 fix) |
128/// | [`Adopted`] | yes | yes |
129///
130/// `Handoff` closes the parent's `File` handle (decrementing the OFD
131/// reference count) WITHOUT calling `flock(LOCK_UN)`. This is intentional:
132/// `LOCK_UN` on a shared OFD releases the lock for ALL processes sharing that
133/// OFD — including the grandchild — which would break the singleton guarantee.
134///
135/// Drop MUST NOT panic — all cleanup uses `let _ = ...`.
136///
137/// [`WriteOwner`]: PidfileOwnership::WriteOwner
138/// [`Handoff`]: PidfileOwnership::Handoff
139/// [`Adopted`]: PidfileOwnership::Adopted
140pub struct PidfileLock {
141 /// Open file handle for `sqryd.lock`; the exclusive flock is attached to
142 /// this handle's OFD.
143 lock: File,
144 /// Path to `sqryd.pid`.
145 pidfile: PathBuf,
146 /// Path to `sqryd.lock` (never unlinked; kept for diagnostics and adopt).
147 lockfile: PathBuf,
148 /// Who is responsible for unlinking the pidfile on drop.
149 ownership: PidfileOwnership,
150}
151
152impl std::fmt::Debug for PidfileLock {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.debug_struct("PidfileLock")
155 .field("pidfile", &self.pidfile)
156 .field("lockfile", &self.lockfile)
157 .field("ownership", &self.ownership)
158 .finish_non_exhaustive()
159 }
160}
161
162impl PidfileLock {
163 /// Returns the path to the pidfile (`sqryd.pid`).
164 #[must_use]
165 pub fn pidfile_path(&self) -> &PathBuf {
166 &self.pidfile
167 }
168
169 /// Returns the path to the lockfile (`sqryd.lock`).
170 #[must_use]
171 pub fn lockfile_path(&self) -> &PathBuf {
172 &self.lockfile
173 }
174
175 /// Returns the current ownership state.
176 #[must_use]
177 pub fn ownership(&self) -> PidfileOwnership {
178 self.ownership
179 }
180
181 /// Returns the raw OS file descriptor for the underlying lock file.
182 ///
183 /// This is used by the `--detach` parent path in
184 /// `sqry_daemon::entrypoint` to obtain the FD that must be passed to
185 /// the grandchild process via `SQRYD_LOCK_FD` so the grandchild can call
186 /// [`PidfileLock::adopt`].
187 ///
188 /// The returned FD is owned by this `PidfileLock`; the caller MUST NOT
189 /// close it and MUST NOT use it after this lock is dropped.
190 #[cfg(unix)]
191 #[must_use]
192 pub fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
193 use std::os::unix::io::AsRawFd as _;
194 self.lock.as_raw_fd()
195 }
196
197 /// Transition from [`WriteOwner`] to [`Handoff`].
198 ///
199 /// Called by the parent process in the `--detach` path after the grandchild
200 /// has signalled ready via the self-pipe. After this call the parent's
201 /// `Drop` will release the flock but **not** unlink the pidfile — the
202 /// grandchild's [`Adopted`] drop owns that responsibility.
203 ///
204 /// Panics if the current state is not [`WriteOwner`] — calling this on a
205 /// [`Handoff`] or [`Adopted`] handle is always a programming error and
206 /// must be caught in both debug and release builds to prevent incorrect
207 /// drop behaviour (skipping required pidfile unlink).
208 ///
209 /// [`WriteOwner`]: PidfileOwnership::WriteOwner
210 /// [`Handoff`]: PidfileOwnership::Handoff
211 /// [`Adopted`]: PidfileOwnership::Adopted
212 pub fn hand_off_to_adopter(&mut self) {
213 assert_eq!(
214 self.ownership,
215 PidfileOwnership::WriteOwner,
216 "hand_off_to_adopter called on non-WriteOwner PidfileLock (ownership={:?})",
217 self.ownership,
218 );
219 self.ownership = PidfileOwnership::Handoff;
220 }
221
222 /// Write the current process's PID (decimal + newline) to the pidfile
223 /// atomically (tmp + rename).
224 ///
225 /// This is called by:
226 /// - The foreground / `run_start` path immediately after
227 /// [`acquire_pidfile_lock`] succeeds.
228 /// - The grandchild in the `--detach` path after
229 /// [`PidfileLock::adopt`] to overwrite the parent's PID with the
230 /// grandchild's PID.
231 ///
232 /// The pidfile is created with mode `0644` (world-readable so that
233 /// diagnostic tools can read it without elevated privileges).
234 pub fn write_pid(&self, pid: u32) -> DaemonResult<()> {
235 write_pidfile_atomic(&self.pidfile, pid)
236 }
237
238 /// Wrap an already-locked FD inherited across `fork`+`exec` into a
239 /// `PidfileLock` with `ownership = Adopted`.
240 ///
241 /// # Safety
242 ///
243 /// The caller MUST ensure:
244 ///
245 /// 1. `fd` is a valid, open file descriptor in the current process.
246 /// 2. `fd` is the target of an active OFD-level exclusive flock (typically
247 /// inherited across `fork`+`exec` with `FD_CLOEXEC` cleared).
248 /// 3. No other [`PidfileLock`] in this process owns `fd`.
249 /// 4. The caller MUST NOT close `fd` separately — dropping the returned
250 /// [`PidfileLock`] will close it (and release the flock) via the
251 /// [`File`] destructor.
252 ///
253 /// Violating any of these invariants is undefined behaviour (double-free,
254 /// double-close, or stale-lock ABA).
255 #[cfg(unix)]
256 pub unsafe fn adopt(fd: std::os::fd::RawFd, pidfile: PathBuf, lockfile: PathBuf) -> Self {
257 use std::os::unix::io::FromRawFd as _;
258 // SAFETY: caller guarantees fd is valid and exclusively owned.
259 let lock = unsafe { File::from_raw_fd(fd) };
260 Self {
261 lock,
262 pidfile,
263 lockfile,
264 ownership: PidfileOwnership::Adopted,
265 }
266 }
267}
268
269impl Drop for PidfileLock {
270 /// Release the flock and, when appropriate, unlink the pidfile.
271 ///
272 /// Drop MUST NOT panic — every operation is wrapped in `let _ = ...`.
273 ///
274 /// # Handoff and OFD-level flock invariant (M-2 fix)
275 ///
276 /// In the `--detach` double-fork path the parent transitions to `Handoff`
277 /// after the grandchild signals ready. The grandchild inherits the same
278 /// OFD (open file description) via FD inheritance across `fork`+`exec`.
279 ///
280 /// `flock(LOCK_UN)` operates at the *OFD* level: it releases the lock for
281 /// every FD in every process that shares the same OFD. Calling `unlock()`
282 /// in the `Handoff` drop would therefore release the grandchild's lock too,
283 /// breaking the singleton guarantee.
284 ///
285 /// The correct behaviour is to **close** the parent's FD without explicitly
286 /// unlocking it. Closing a single FD that shares an OFD with another
287 /// process's FD is safe: the kernel decrements the OFD reference count but
288 /// keeps the lock alive as long as any process still holds an open FD on
289 /// that OFD. The lock remains held until the grandchild closes its
290 /// inherited FD (which happens in `Adopted` drop).
291 fn drop(&mut self) {
292 // Unlink the pidfile for WriteOwner and Adopted; skip for Handoff.
293 match self.ownership {
294 PidfileOwnership::WriteOwner | PidfileOwnership::Adopted => {
295 let result = fs::remove_file(&self.pidfile);
296 match result {
297 Ok(()) => {
298 debug!(path = %self.pidfile.display(), "pidfile removed");
299 }
300 Err(e) if e.kind() == io::ErrorKind::NotFound => {
301 // Already gone — benign.
302 debug!(path = %self.pidfile.display(), "pidfile already absent on drop");
303 }
304 Err(e) => {
305 warn!(
306 path = %self.pidfile.display(),
307 err = %e,
308 "failed to remove pidfile on drop"
309 );
310 }
311 }
312 }
313 PidfileOwnership::Handoff => {
314 debug!(
315 path = %self.pidfile.display(),
316 "pidfile handoff — skipping unlink on parent drop"
317 );
318 }
319 }
320
321 // Release the flock for WriteOwner and Adopted.
322 //
323 // For Handoff: do NOT call unlock() — see the doc comment above.
324 // Closing the parent's File handle decrements the OFD reference count
325 // without calling LOCK_UN, which preserves the grandchild's lock.
326 // The File destructor closes the FD automatically, so no explicit
327 // action is needed here; we just skip the `unlock()` call.
328 match self.ownership {
329 PidfileOwnership::WriteOwner | PidfileOwnership::Adopted => {
330 // fs2's `unlock` calls flock(fd, LOCK_UN) on Unix and
331 // UnlockFile on Windows. We log but do not propagate errors
332 // because (a) the kernel releases OFD locks on process exit
333 // anyway, and (b) Drop must not panic.
334 let result = self.lock.unlock();
335 match result {
336 Ok(()) => {
337 debug!(lockfile = %self.lockfile.display(), "flock released");
338 }
339 Err(e) => {
340 warn!(
341 lockfile = %self.lockfile.display(),
342 err = %e,
343 "flock release failed on drop (kernel will clean up on exit)"
344 );
345 }
346 }
347 }
348 PidfileOwnership::Handoff => {
349 // The File handle is dropped here by RAII (closing the FD),
350 // but we do NOT call unlock() — see the doc comment.
351 debug!(
352 lockfile = %self.lockfile.display(),
353 "pidfile handoff — closing parent FD without LOCK_UN (grandchild retains lock)"
354 );
355 }
356 }
357 }
358}
359
360// ---------------------------------------------------------------------------
361// Acquire
362// ---------------------------------------------------------------------------
363
364/// Attempt to acquire the exclusive pidfile lock for this daemon instance.
365///
366/// ## Algorithm
367///
368/// 1. Ensure the runtime directory exists with mode `0700` (Unix).
369/// 2. Open-or-create `sqryd.lock` for read+write.
370/// 3. Set the lockfile permissions to `0600` (Unix).
371/// 4. Call [`fs2::FileExt::try_lock_exclusive`]:
372/// - `WouldBlock` → read the pidfile for the owner PID (best-effort) and
373/// return [`DaemonError::AlreadyRunning`].
374/// - Other I/O error → propagate as [`DaemonError::Io`].
375/// 5. Write the current process's PID to `sqryd.pid` atomically
376/// (tmp-file + rename) with mode `0644`.
377/// 6. Return a [`PidfileLock`] with `ownership = WriteOwner`.
378///
379/// ## Stale-pidfile recovery (§D.3)
380///
381/// Step 4 succeeding on the same lockfile inode proves there is no live OFD
382/// owner — the kernel releases flocks on process death via any mechanism
383/// (SIGKILL, segfault, panic=abort, OOM kill) because the reference count on
384/// the OFD drops to zero when the process's FD table is torn down. Step 5
385/// then overwrites any stale `sqryd.pid` atomically.
386///
387/// ## NFS warning
388///
389/// If the runtime directory resides on an NFS mount, `flock(2)` semantics are
390/// not guaranteed. A [`tracing::warn`] is emitted if the mount type is
391/// detectable; NFS operators are out of scope.
392pub fn acquire_pidfile_lock(cfg: &DaemonConfig) -> DaemonResult<PidfileLock> {
393 let lockfile = cfg.lock_path();
394 let pidfile = cfg.pid_path();
395
396 // 1. Ensure runtime dir (and its parent) with secure permissions.
397 ensure_runtime_dir(&lockfile)?;
398
399 // 2. Open-or-create the lockfile with mode 0600 atomically on Unix to
400 // avoid a creation-time window where the file is visible with the
401 // process umask permissions. On Windows, ACLs govern access; the
402 // extra `set_permissions` call is a no-op but kept for portability.
403 #[cfg(unix)]
404 let lock_file = {
405 use std::os::unix::fs::OpenOptionsExt as _;
406 OpenOptions::new()
407 .read(true)
408 .write(true)
409 .create(true)
410 .truncate(false)
411 .mode(0o600)
412 .open(&lockfile)?
413 };
414 #[cfg(not(unix))]
415 let lock_file = OpenOptions::new()
416 .read(true)
417 .write(true)
418 .create(true)
419 .truncate(false)
420 .open(&lockfile)?;
421
422 // 3. Ensure lockfile permissions are 0600. On Unix this is a no-op for
423 // newly-created files (mode was set above), but also correctly
424 // normalises pre-existing lockfiles left behind by a previous sqryd
425 // build that did not use OpenOptionsExt::mode.
426 #[cfg(unix)]
427 set_permissions_0600(&lockfile)?;
428
429 // Warn if the runtime dir appears to be on NFS (best-effort detection).
430 #[cfg(unix)]
431 warn_if_nfs(lockfile.parent().unwrap_or(&lockfile));
432
433 // 4. Try to acquire the exclusive flock.
434 match lock_file.try_lock_exclusive() {
435 Ok(()) => {
436 debug!(lockfile = %lockfile.display(), "exclusive flock acquired");
437 }
438 Err(e) if is_would_block(&e) => {
439 // Another daemon holds the lock. Read the pidfile for diagnostics.
440 let owner_pid = read_pid(&pidfile);
441 debug!(
442 lockfile = %lockfile.display(),
443 owner_pid = ?owner_pid,
444 "flock contended — daemon already running"
445 );
446 return Err(DaemonError::AlreadyRunning {
447 owner_pid,
448 socket: cfg.socket_path(),
449 lock: lockfile,
450 });
451 }
452 Err(e) => {
453 return Err(DaemonError::Io(e));
454 }
455 }
456
457 // 5. Write the current PID atomically.
458 let pid = std::process::id();
459 write_pidfile_atomic(&pidfile, pid)?;
460
461 Ok(PidfileLock {
462 lock: lock_file,
463 pidfile,
464 lockfile,
465 ownership: PidfileOwnership::WriteOwner,
466 })
467}
468
469// ---------------------------------------------------------------------------
470// Internal helpers
471// ---------------------------------------------------------------------------
472
473/// Ensure the directory containing `lockfile` exists with mode `0700` (Unix).
474fn ensure_runtime_dir(lockfile: &std::path::Path) -> DaemonResult<()> {
475 let dir = lockfile.parent().ok_or_else(|| {
476 DaemonError::Io(io::Error::new(
477 io::ErrorKind::InvalidInput,
478 "lockfile path has no parent directory",
479 ))
480 })?;
481
482 fs::create_dir_all(dir)?;
483
484 #[cfg(unix)]
485 {
486 use std::os::unix::fs::PermissionsExt as _;
487 let perms = fs::Permissions::from_mode(0o700);
488 // Propagate permission errors: if we created this directory we must be
489 // able to chmod it. If we are merely re-using a pre-existing directory
490 // owned by the same UID the chmod should succeed; if the directory is
491 // owned by a different user (e.g. a shared XDG_RUNTIME_DIR) the error
492 // is surfaced to the operator rather than silently running with an
493 // insecure mode.
494 fs::set_permissions(dir, perms)?;
495 }
496
497 Ok(())
498}
499
500/// Set Unix permissions on `path` to `0600`.
501#[cfg(unix)]
502fn set_permissions_0600(path: &std::path::Path) -> DaemonResult<()> {
503 use std::os::unix::fs::PermissionsExt as _;
504 let perms = fs::Permissions::from_mode(0o600);
505 fs::set_permissions(path, perms)?;
506 Ok(())
507}
508
509/// Emit a warning if the parent directory of `path` appears to be on NFS.
510///
511/// Detection is best-effort: on Linux we check the filesystem type via
512/// `statfs(2)`. On macOS we check `f_fstypename`. On other platforms we
513/// do nothing. Failure to detect does not affect correctness.
514#[cfg(unix)]
515fn warn_if_nfs(dir: &std::path::Path) {
516 if is_nfs(dir) {
517 warn!(
518 dir = %dir.display(),
519 "sqryd runtime directory appears to be on NFS; flock(2) semantics \
520 are not guaranteed on NFS mounts — consider using a local \
521 filesystem for SQRY_DAEMON_SOCKET / XDG_RUNTIME_DIR"
522 );
523 }
524}
525
526/// Returns `true` if `dir` is on an NFS filesystem (best-effort).
527#[cfg(all(unix, target_os = "linux"))]
528fn is_nfs(dir: &std::path::Path) -> bool {
529 use std::ffi::CString;
530 use std::os::unix::ffi::OsStrExt as _;
531
532 let c_path = match CString::new(dir.as_os_str().as_bytes()) {
533 Ok(p) => p,
534 Err(_) => return false,
535 };
536 // SAFETY: c_path is a valid null-terminated string.
537 let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
538 let rc = unsafe { libc::statfs(c_path.as_ptr(), &mut buf) };
539 if rc != 0 {
540 return false;
541 }
542 // NFS magic number on Linux: 0x6969
543 const NFS_SUPER_MAGIC: libc::c_long = 0x6969;
544 buf.f_type == NFS_SUPER_MAGIC
545}
546
547#[cfg(all(unix, target_os = "macos"))]
548fn is_nfs(dir: &std::path::Path) -> bool {
549 use std::ffi::CString;
550 use std::os::unix::ffi::OsStrExt as _;
551
552 let c_path = match CString::new(dir.as_os_str().as_bytes()) {
553 Ok(p) => p,
554 Err(_) => return false,
555 };
556 // SAFETY: c_path is a valid null-terminated string.
557 let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
558 let rc = unsafe { libc::statfs(c_path.as_ptr(), &mut buf) };
559 if rc != 0 {
560 return false;
561 }
562 // On macOS the filesystem type name is a C string in f_fstypename.
563 // SAFETY: f_fstypename is a NUL-terminated array in the statfs struct.
564 let ftype = unsafe { std::ffi::CStr::from_ptr(buf.f_fstypename.as_ptr()) };
565 ftype.to_bytes() == b"nfs"
566}
567
568#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
569fn is_nfs(_dir: &std::path::Path) -> bool {
570 false
571}
572
573/// Write `pid` (decimal + newline) to `pidfile` atomically via a tmp file +
574/// rename. The file is created with mode `0644`.
575fn write_pidfile_atomic(pidfile: &std::path::Path, pid: u32) -> DaemonResult<()> {
576 let dir = pidfile.parent().ok_or_else(|| {
577 DaemonError::Io(io::Error::new(
578 io::ErrorKind::InvalidInput,
579 "pidfile path has no parent directory",
580 ))
581 })?;
582
583 // Write to a sibling tmp file so the rename is atomic within the same dir.
584 let tmp_path = dir.join(format!(".sqryd.pid.tmp.{}", std::process::id()));
585
586 {
587 let mut tmp = OpenOptions::new()
588 .write(true)
589 .create(true)
590 .truncate(true)
591 .open(&tmp_path)?;
592
593 // Set 0644 before writing so the content is never visible with wrong perms.
594 #[cfg(unix)]
595 {
596 use std::os::unix::fs::PermissionsExt as _;
597 let perms = fs::Permissions::from_mode(0o644);
598 tmp.set_permissions(perms)?;
599 }
600
601 writeln!(tmp, "{pid}")?;
602 tmp.flush()?;
603 // tmp dropped here — file is closed before rename.
604 }
605
606 // Atomic replace.
607 fs::rename(&tmp_path, pidfile)?;
608 debug!(path = %pidfile.display(), pid, "pidfile written");
609 Ok(())
610}
611
612/// Read the PID from `pidfile` (best-effort; returns `None` on any failure).
613pub fn read_pid(pidfile: &std::path::Path) -> Option<u32> {
614 let text = fs::read_to_string(pidfile).ok()?;
615 text.trim().parse::<u32>().ok()
616}
617
618/// Returns `true` if `e` indicates a "would block" / "resource busy" locking
619/// failure — i.e. the lock is held by another process.
620fn is_would_block(e: &io::Error) -> bool {
621 e.kind() == io::ErrorKind::WouldBlock
622 // fs2 on Linux returns EWOULDBLOCK / EAGAIN; on Windows it returns a
623 // raw OS error that maps to `WouldBlock`. Some older kernels may also
624 // return `ResourceBusy` which is represented differently.
625 || e.raw_os_error()
626 .is_some_and(|c| {
627 #[cfg(unix)]
628 {
629 c == libc::EWOULDBLOCK || c == libc::EAGAIN
630 }
631 #[cfg(not(unix))]
632 {
633 // Windows: ERROR_LOCK_VIOLATION (33)
634 c == 33
635 }
636 })
637}
638
639// ---------------------------------------------------------------------------
640// Tests
641// ---------------------------------------------------------------------------
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 use tempfile::TempDir;
648
649 // -----------------------------------------------------------------------
650 // Use the crate-wide TEST_ENV_LOCK to serialise XDG_RUNTIME_DIR
651 // mutations across ALL test modules in the same binary (pidfile,
652 // detach, config). A per-module mutex is insufficient because cargo
653 // test runs all #[test] fns as threads in one process.
654 // -----------------------------------------------------------------------
655 use crate::TEST_ENV_LOCK as ENV_LOCK;
656
657 // -----------------------------------------------------------------------
658 // Helper: build a minimal DaemonConfig pointing at a temp directory.
659 //
660 // The fixture holds both the `TempDir` (to keep it alive) and a mutex
661 // guard that prevents other env-manipulating tests from running
662 // concurrently. On drop, `XDG_RUNTIME_DIR` is restored to whatever it
663 // was before the fixture was created (typically the real XDG runtime dir).
664 // -----------------------------------------------------------------------
665
666 struct TestCfg {
667 _tmp: TempDir,
668 cfg: DaemonConfig,
669 _guard: std::sync::MutexGuard<'static, ()>,
670 prior_xdg: Option<String>,
671 }
672
673 impl TestCfg {
674 /// Creates a temp dir, sets `XDG_RUNTIME_DIR` to it (under an
675 /// exclusive mutex), and returns a `DaemonConfig` whose `lock_path()`
676 /// and `pid_path()` resolve inside the temp dir.
677 fn new() -> Self {
678 // Acquire the serialisation lock BEFORE reading the env var so
679 // we see the canonical pre-test state.
680 let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
681
682 let tmp = TempDir::new().expect("TempDir::new");
683
684 // Override XDG_RUNTIME_DIR so `runtime_dir()` / `lock_path()` /
685 // `pid_path()` resolve inside our temp dir.
686 let prior_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
687 // SAFETY: single-threaded by virtue of the mutex above.
688 #[allow(unsafe_code)]
689 unsafe {
690 std::env::set_var("XDG_RUNTIME_DIR", tmp.path());
691 }
692
693 let mut cfg = DaemonConfig::default();
694 cfg.socket.path = Some(tmp.path().join("sqry").join("sqryd.sock"));
695
696 Self {
697 _tmp: tmp,
698 cfg,
699 _guard: guard,
700 prior_xdg,
701 }
702 }
703
704 fn cfg(&self) -> &DaemonConfig {
705 &self.cfg
706 }
707 }
708
709 impl Drop for TestCfg {
710 fn drop(&mut self) {
711 // Restore the original XDG_RUNTIME_DIR.
712 #[allow(unsafe_code)]
713 unsafe {
714 match self.prior_xdg.take() {
715 Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
716 None => std::env::remove_var("XDG_RUNTIME_DIR"),
717 }
718 }
719 }
720 }
721
722 // -----------------------------------------------------------------------
723 // T1: acquire_succeeds_on_clean_dir
724 // -----------------------------------------------------------------------
725
726 #[test]
727 fn acquire_succeeds_on_clean_dir() {
728 let fix = TestCfg::new();
729 let lock = acquire_pidfile_lock(fix.cfg()).expect("acquire should succeed on clean dir");
730 assert_eq!(lock.ownership(), PidfileOwnership::WriteOwner);
731 assert!(fix.cfg().pid_path().exists(), "pidfile must be created");
732 drop(lock);
733 }
734
735 // -----------------------------------------------------------------------
736 // T2: acquire_rejects_already_held
737 // -----------------------------------------------------------------------
738
739 #[test]
740 fn acquire_rejects_already_held() {
741 let fix = TestCfg::new();
742 let _lock1 = acquire_pidfile_lock(fix.cfg()).expect("first acquire should succeed");
743 let err = acquire_pidfile_lock(fix.cfg()).expect_err("second acquire must fail");
744 match err {
745 DaemonError::AlreadyRunning { owner_pid, .. } => {
746 // Owner PID must be readable (this process wrote the pidfile).
747 assert_eq!(
748 owner_pid,
749 Some(std::process::id()),
750 "owner_pid must match current process"
751 );
752 }
753 other => panic!("expected AlreadyRunning, got {other:?}"),
754 }
755 }
756
757 // -----------------------------------------------------------------------
758 // T3: drop_removes_pidfile_but_not_lockfile
759 // -----------------------------------------------------------------------
760
761 #[test]
762 fn drop_removes_pidfile_but_not_lockfile() {
763 let fix = TestCfg::new();
764 let lock = acquire_pidfile_lock(fix.cfg()).expect("acquire");
765 let pidfile = fix.cfg().pid_path();
766 let lockfile = fix.cfg().lock_path();
767
768 assert!(pidfile.exists(), "pidfile must exist before drop");
769 assert!(lockfile.exists(), "lockfile must exist before drop");
770
771 drop(lock);
772
773 assert!(!pidfile.exists(), "pidfile must be removed after drop");
774 assert!(
775 lockfile.exists(),
776 "lockfile must NOT be removed after drop (§D.4)"
777 );
778 }
779
780 // -----------------------------------------------------------------------
781 // T4: acquire_reclaims_stale_pidfile
782 //
783 // Simulates §D.3: a previous process crashed leaving a stale pidfile and
784 // lockfile on disk. The lockfile must NOT be locked (simulating kernel
785 // flock release on process death). The new acquire must succeed and
786 // overwrite the stale pidfile.
787 // -----------------------------------------------------------------------
788
789 #[test]
790 fn acquire_reclaims_stale_pidfile() {
791 let fix = TestCfg::new();
792
793 // Simulate stale state: write a lockfile and pidfile but hold no lock.
794 let lockfile = fix.cfg().lock_path();
795 let pidfile = fix.cfg().pid_path();
796 fs::create_dir_all(lockfile.parent().unwrap()).unwrap();
797 fs::write(&lockfile, b"").unwrap();
798 fs::write(&pidfile, b"99999\n").unwrap(); // stale PID
799
800 // Acquire must succeed because nobody holds the flock.
801 let lock = acquire_pidfile_lock(fix.cfg()).expect("stale recovery must succeed");
802 assert_eq!(lock.ownership(), PidfileOwnership::WriteOwner);
803
804 // The pidfile must now contain the current PID, not the stale one.
805 let new_pid = read_pid(&pidfile).expect("pidfile must be legible");
806 assert_eq!(
807 new_pid,
808 std::process::id(),
809 "pidfile must be overwritten with current PID"
810 );
811
812 drop(lock);
813 }
814
815 // -----------------------------------------------------------------------
816 // T5: pidfile_is_0644_lockfile_is_0600_dir_is_0700 (Unix only)
817 // -----------------------------------------------------------------------
818
819 #[cfg(unix)]
820 #[test]
821 fn pidfile_is_0644_lockfile_is_0600_dir_is_0700() {
822 use std::os::unix::fs::MetadataExt as _;
823
824 let fix = TestCfg::new();
825 let lock = acquire_pidfile_lock(fix.cfg()).expect("acquire");
826
827 let dir = fix.cfg().lock_path();
828 let dir = dir.parent().expect("lock_path has parent");
829
830 let dir_mode = fs::metadata(dir).unwrap().mode() & 0o777;
831 assert_eq!(dir_mode, 0o700, "runtime dir must be 0700");
832
833 let lockfile_mode = fs::metadata(fix.cfg().lock_path()).unwrap().mode() & 0o777;
834 assert_eq!(lockfile_mode, 0o600, "lockfile must be 0600");
835
836 let pidfile_mode = fs::metadata(fix.cfg().pid_path()).unwrap().mode() & 0o777;
837 assert_eq!(pidfile_mode, 0o644, "pidfile must be 0644");
838
839 drop(lock);
840 }
841
842 // -----------------------------------------------------------------------
843 // T6: hand_off_to_adopter_transitions_writeowner_to_handoff_skips_unlink
844 // -----------------------------------------------------------------------
845
846 #[test]
847 fn hand_off_to_adopter_transitions_writeowner_to_handoff_skips_unlink() {
848 let fix = TestCfg::new();
849 let mut lock = acquire_pidfile_lock(fix.cfg()).expect("acquire");
850 assert_eq!(lock.ownership(), PidfileOwnership::WriteOwner);
851
852 lock.hand_off_to_adopter();
853 assert_eq!(lock.ownership(), PidfileOwnership::Handoff);
854
855 let pidfile = fix.cfg().pid_path();
856 drop(lock);
857
858 // Handoff drop must NOT unlink the pidfile.
859 assert!(
860 pidfile.exists(),
861 "pidfile must NOT be unlinked by Handoff drop"
862 );
863 }
864
865 // -----------------------------------------------------------------------
866 // T7: adopt_preserves_ofd_lock_via_duped_fd_flock_contention (Unix only)
867 //
868 // Design doc iter-2 m7 fix: validates adoption by dup-ing the locked FD
869 // and asserting that the adopted lock still holds the OFD-level lock via
870 // try_lock_shared WouldBlock from a second thread.
871 // -----------------------------------------------------------------------
872
873 #[cfg(unix)]
874 #[test]
875 fn adopt_preserves_ofd_lock_via_duped_fd_flock_contention() {
876 use std::os::unix::io::{AsRawFd as _, RawFd};
877
878 let tmp = TempDir::new().unwrap();
879 let lockfile = tmp.path().join("test.lock");
880 let pidfile = tmp.path().join("test.pid");
881
882 // (a) Open and lock the file exclusively.
883 let original = OpenOptions::new()
884 .read(true)
885 .write(true)
886 .create(true)
887 .truncate(true)
888 .open(&lockfile)
889 .unwrap();
890 original.lock_exclusive().expect("lock_exclusive");
891
892 // (b) Dup the FD.
893 let raw_fd: RawFd = original.as_raw_fd();
894 // SAFETY: raw_fd is a valid open FD in this process.
895 let duped_fd: RawFd = unsafe { libc::dup(raw_fd) };
896 assert!(duped_fd >= 0, "libc::dup failed");
897
898 // (c) Pass the duped FD to PidfileLock::adopt.
899 // SAFETY: duped_fd is valid, locked, and exclusively owned for this test.
900 let adopted = unsafe { PidfileLock::adopt(duped_fd, pidfile.clone(), lockfile.clone()) };
901 assert_eq!(adopted.ownership(), PidfileOwnership::Adopted);
902
903 // (d) Assert the OFD-level lock is still held: a second thread
904 // attempting try_lock_shared must get WouldBlock.
905 // Use fully-qualified fs2::FileExt::try_lock_shared to avoid
906 // Rust 1.80+ std::fs::File::try_lock_shared() ambiguity (which
907 // returns Result<(), std::fs::TryLockError>, not io::Error).
908 let lockfile_clone = lockfile.clone();
909 let contention_blocked = std::thread::spawn(move || {
910 let f = OpenOptions::new()
911 .read(true)
912 .write(true)
913 .open(&lockfile_clone)
914 .unwrap();
915 // Explicitly use fs2's trait to get Result<(), io::Error>.
916 let result = fs2::FileExt::try_lock_shared(&f);
917 match result {
918 Err(ref e) if is_would_block_err(e) => true,
919 Ok(()) => {
920 // Unexpected: lock was granted — release and report failure.
921 let _ = fs2::FileExt::unlock(&f);
922 false
923 }
924 Err(_) => false,
925 }
926 })
927 .join()
928 .unwrap();
929
930 assert!(
931 contention_blocked,
932 "try_lock_shared from a second thread must return WouldBlock while adopted lock is held"
933 );
934
935 // (e) Drop the original + adopted lock; assert release.
936 drop(original);
937 drop(adopted);
938
939 // (f) After both drops, try_lock_exclusive on a fresh FD must succeed.
940 let fresh = OpenOptions::new()
941 .read(true)
942 .write(true)
943 .open(&lockfile)
944 .unwrap();
945 fs2::FileExt::try_lock_exclusive(&fresh)
946 .expect("try_lock_exclusive must succeed after release");
947 }
948
949 #[cfg(unix)]
950 fn is_would_block_err(e: &io::Error) -> bool {
951 e.kind() == io::ErrorKind::WouldBlock
952 || e.raw_os_error()
953 .is_some_and(|c| c == libc::EWOULDBLOCK || c == libc::EAGAIN)
954 }
955
956 // -----------------------------------------------------------------------
957 // T8: drop_of_adopted_unlinks_pidfile (design M6 fix)
958 //
959 // Verifies that Adopted state's Drop DOES unlink the pidfile (this was
960 // the M6 finding: previously adopted was specified to skip unlink, but the
961 // correct ownership model requires Adopted to unlink).
962 // -----------------------------------------------------------------------
963
964 #[cfg(unix)]
965 #[test]
966 fn drop_of_adopted_unlinks_pidfile() {
967 use std::os::unix::io::{AsRawFd as _, RawFd};
968
969 let tmp = TempDir::new().unwrap();
970 let lockfile = tmp.path().join("test.lock");
971 let pidfile = tmp.path().join("test.pid");
972
973 // Create a lockfile and pidfile.
974 let original = OpenOptions::new()
975 .read(true)
976 .write(true)
977 .create(true)
978 .truncate(true)
979 .open(&lockfile)
980 .unwrap();
981 original.lock_exclusive().unwrap();
982 fs::write(&pidfile, b"12345\n").unwrap();
983
984 // Dup and adopt.
985 let raw_fd: RawFd = original.as_raw_fd();
986 let duped_fd: RawFd = unsafe { libc::dup(raw_fd) };
987 assert!(duped_fd >= 0);
988 // SAFETY: duped_fd is valid and owned for this test.
989 let adopted = unsafe { PidfileLock::adopt(duped_fd, pidfile.clone(), lockfile.clone()) };
990
991 assert!(pidfile.exists(), "pidfile must exist before drop");
992
993 drop(original); // Release the original lock handle first.
994 drop(adopted); // Adopted drop must unlink the pidfile.
995
996 assert!(
997 !pidfile.exists(),
998 "Adopted drop must unlink the pidfile (design M6 fix)"
999 );
1000 }
1001
1002 // -----------------------------------------------------------------------
1003 // T9: write_pid writes and overwrites the pidfile correctly
1004 // -----------------------------------------------------------------------
1005
1006 #[test]
1007 fn write_pid_overwrites_correctly() {
1008 let fix = TestCfg::new();
1009 let lock = acquire_pidfile_lock(fix.cfg()).expect("acquire");
1010
1011 // Overwrite with an explicit PID (simulates the grandchild writing its
1012 // own PID into the pidfile after adoption).
1013 lock.write_pid(42_000).expect("write_pid");
1014 let written = read_pid(&fix.cfg().pid_path()).expect("read_pid");
1015 assert_eq!(written, 42_000);
1016
1017 drop(lock);
1018 }
1019
1020 // -----------------------------------------------------------------------
1021 // T10: is_would_block covers all variants
1022 // -----------------------------------------------------------------------
1023
1024 #[test]
1025 fn is_would_block_covers_would_block_kind() {
1026 let e = io::Error::new(io::ErrorKind::WouldBlock, "would block");
1027 assert!(is_would_block(&e));
1028 }
1029
1030 #[test]
1031 fn is_would_block_returns_false_for_other_errors() {
1032 let e = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
1033 assert!(!is_would_block(&e));
1034 }
1035
1036 // -----------------------------------------------------------------------
1037 // T11: handoff_drop_does_not_release_adopted_ofd_lock (Unix only) — M-2 fix
1038 //
1039 // Simulates the detach path: a `WriteOwner` lock transitions to `Handoff`
1040 // (parent) while a duped `Adopted` lock represents the grandchild's
1041 // inherited FD sharing the same OFD. Dropping the `Handoff` parent must
1042 // NOT release the lock (no LOCK_UN) — a third opener must still see
1043 // WouldBlock after the parent drops. Only when the `Adopted` child drops
1044 // does the lock become acquirable.
1045 //
1046 // This test does NOT use TestCfg / ENV_LOCK because it operates on its
1047 // own isolated TempDir paths and never reads/writes XDG_RUNTIME_DIR —
1048 // avoiding spurious cross-test interference with tests that change the
1049 // XDG_RUNTIME_DIR env var concurrently (like the detach fast-path test).
1050 // -----------------------------------------------------------------------
1051
1052 #[cfg(unix)]
1053 #[test]
1054 fn handoff_drop_does_not_release_adopted_ofd_lock() {
1055 use std::os::unix::io::{AsRawFd as _, RawFd};
1056
1057 // Use isolated temp paths — no TestCfg / ENV_LOCK needed.
1058 let tmp = TempDir::new().unwrap();
1059 let lockfile = tmp.path().join("test.lock");
1060 let pidfile = tmp.path().join("test.pid");
1061
1062 // Open and exclusively lock the file (simulates WriteOwner's acquired lock).
1063 let original = OpenOptions::new()
1064 .read(true)
1065 .write(true)
1066 .create(true)
1067 .truncate(true)
1068 .open(&lockfile)
1069 .unwrap();
1070 original.lock_exclusive().expect("lock_exclusive");
1071 fs::write(&pidfile, b"42\n").unwrap();
1072
1073 // Dup the lock FD — simulates the grandchild inheriting the FD across
1074 // fork+exec (the inherited FD shares the same OFD as `original`).
1075 let raw_fd: RawFd = original.as_raw_fd();
1076 // SAFETY: raw_fd is valid and open for the duration of this test.
1077 let parent_fd: RawFd = unsafe { libc::dup(raw_fd) };
1078 assert!(parent_fd >= 0, "libc::dup(parent) failed");
1079 let child_fd: RawFd = unsafe { libc::dup(raw_fd) };
1080 assert!(child_fd >= 0, "libc::dup(child) failed");
1081
1082 // Wrap parent_fd as Adopted and transition to WriteOwner+Handoff manually
1083 // by building the parent PidfileLock via adopt and calling hand_off.
1084 // Note: adopt() creates Adopted state; we need WriteOwner to call
1085 // hand_off_to_adopter. We simulate the parent by using `original`
1086 // directly (keep it in scope) and adopting parent_fd separately to hold
1087 // the "parent simulation" handle. The parent handle transitions to Handoff
1088 // using the adopt + custom flow below.
1089 //
1090 // Simpler approach: the parent just needs to hold a PidfileLock with
1091 // WriteOwner state. We build this from parent_fd via adopt(), then use
1092 // an unsafe transmute of the ownership field. To avoid unsafe hacks,
1093 // we instead construct the test around the known behaviour:
1094 //
1095 // - Keep `original` as the "parent" File that holds the lock.
1096 // - Build the child_lock (Adopted) from child_fd.
1097 // - Simulate Handoff drop by dropping `original` (which closes the parent
1098 // FD WITHOUT calling LOCK_UN — exactly what our Handoff drop does).
1099 // - Assert the lock is still held by child_lock after original drops.
1100 //
1101 // Then drop child_lock (Adopted) and assert lock is released.
1102
1103 // Build the Adopted lock (grandchild simulation) from child_fd.
1104 // SAFETY: child_fd is valid, locked via the original OFD, and exclusively
1105 // owned by this test for the duration of the `Adopted` lock.
1106 let child_lock = unsafe { PidfileLock::adopt(child_fd, pidfile.clone(), lockfile.clone()) };
1107 assert_eq!(child_lock.ownership(), PidfileOwnership::Adopted);
1108
1109 // Close parent_fd (simulates Handoff drop: close FD without LOCK_UN).
1110 // SAFETY: parent_fd is a valid open FD owned by this test.
1111 unsafe { libc::close(parent_fd) };
1112 // Also drop `original` — this closes the original FD. The OFD still has
1113 // one reference: child_fd (inside child_lock).
1114 drop(original);
1115
1116 // After closing all "parent" FDs, the child_lock must still hold the
1117 // OFD-level flock — a third opener must see WouldBlock.
1118 let lockfile_clone = lockfile.clone();
1119 let still_locked = std::thread::spawn(move || {
1120 let f = OpenOptions::new()
1121 .read(true)
1122 .write(true)
1123 .open(&lockfile_clone)
1124 .unwrap();
1125 let result = fs2::FileExt::try_lock_exclusive(&f);
1126 match result {
1127 Err(ref e) if is_would_block_err(e) => true, // correct: still locked
1128 Ok(()) => {
1129 // Wrong: lock was released when parent FDs closed (LOCK_UN bug).
1130 let _ = fs2::FileExt::unlock(&f);
1131 false
1132 }
1133 Err(_) => false,
1134 }
1135 })
1136 .join()
1137 .unwrap();
1138
1139 assert!(
1140 still_locked,
1141 "Handoff drop (close FD without LOCK_UN) must NOT release the OFD-level lock \
1142 (M-2 fix): the adopted grandchild lock must still hold after all parent FDs close"
1143 );
1144
1145 // Drop the child lock (Adopted); this calls unlock() + remove_file.
1146 drop(child_lock);
1147
1148 // After Adopted drop, the lock must be released.
1149 let fresh = OpenOptions::new()
1150 .read(true)
1151 .write(true)
1152 .open(&lockfile)
1153 .unwrap();
1154 fs2::FileExt::try_lock_exclusive(&fresh)
1155 .expect("try_lock_exclusive must succeed after Adopted child drop");
1156 }
1157}