Skip to main content

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}