Skip to main content

qframe/storage/
lock.rs

1//! One instance at a time: an advisory lock the operating system holds for the running process.
2//!
3//! A lock made of "the file exists" sets a trap. The counter is running, the power goes out, the
4//! file stays on the disk, and the next start refuses to write — exactly when the recovery that
5//! has to write is the only thing left to do. An advisory lock has no such state: the kernel
6//! holds it for a process, and when the process dies, whatever kills it, the lock is gone.
7
8use std::fs;
9use std::io;
10use std::path::Path;
11
12/// An advisory lock on a file, held by the running process for as long as this value lives.
13///
14/// The lock is the operating system's, not the file's existence: the kernel releases it when the
15/// process ends, so a crash or a power cut never leaves a lock behind. Dropping the value
16/// releases it too.
17///
18/// The file itself holds the process id of the holder as text, and that is only ever material
19/// for a message to the user: a process id is reused, so nothing may be decided from it. The
20/// decision is [`AppLock::acquire`]'s answer and nothing else.
21///
22/// ```no_run
23/// # use qframe::storage::{AppLock, data_dir};
24/// let dir = data_dir("qfocus").expect("a home directory");
25/// std::fs::create_dir_all(&dir)?;
26/// match AppLock::acquire(&dir.join("lock"))? {
27///     Some(_lock) => { /* this instance may write; the lock lives as long as `_lock` */ }
28///     None => { /* another instance is running */ }
29/// }
30/// # Ok::<(), std::io::Error>(())
31/// ```
32#[derive(Debug)]
33pub struct AppLock {
34    /// Holding the open file is the lock; the kernel releases it when this file is closed,
35    /// which is what dropping the lock does. Nothing ever reads the field, and that is the
36    /// point: it exists to be held. Unix only, because only there does this framework have a
37    /// lock to hold.
38    #[cfg(unix)]
39    #[expect(dead_code, reason = "the open file is the lock; closing it releases it")]
40    file: fs::File,
41}
42
43impl AppLock {
44    /// Takes the lock on `path`, creating the file when it is not there, or answers `None`
45    /// because another process holds it.
46    ///
47    /// The parent directory has to exist. On success the process id of this process is written
48    /// into the file, for a message that names the holder; see [`holder_pid`].
49    ///
50    /// On Unix systems this is `flock(LOCK_EX | LOCK_NB)`, which the kernel releases when the
51    /// process dies. Locks of this kind are per open file, not per process, so a second
52    /// `acquire` on the same path inside one process answers `None` as well.
53    ///
54    /// A child process started between the moment the lock is taken and the moment it is released
55    /// keeps it for a few milliseconds longer: between `fork` and `exec` the child holds a copy of
56    /// every open file, this file among them, and the kernel counts the lock as held until that copy
57    /// is closed. So a released lock is free in a moment, not in the same instant, and a program
58    /// that starts children may need to wait briefly before it can take its own lock again.
59    ///
60    /// On every other platform, Windows among them, this framework has no advisory lock yet:
61    /// `acquire` returns an error of kind [`io::ErrorKind::Unsupported`] and never `Ok`, so an
62    /// application is told it has no lock instead of quietly running without one. Windows would
63    /// need `LockFileEx`, which is not reachable without `unsafe`, and this framework forbids
64    /// `unsafe`.
65    ///
66    /// # Errors
67    ///
68    /// Returns the I/O error when the file cannot be opened or written, and
69    /// [`io::ErrorKind::Unsupported`] on a platform without an advisory lock. A lock another
70    /// process holds is `Ok(None)`, not an error.
71    pub fn acquire(path: &Path) -> io::Result<Option<Self>> {
72        acquire(path)
73    }
74}
75
76/// The process id the lock file names, for a message such as "another instance (12345) is
77/// running". `None` when the file is missing, empty or holds anything but a number.
78///
79/// This is diagnostic text and nothing more. The process id may belong to a process that died
80/// long ago and to something else entirely by now, so no decision may rest on it; only
81/// [`AppLock::acquire`] answers whether the lock is free.
82#[must_use]
83pub fn holder_pid(path: &Path) -> Option<u32> {
84    fs::read_to_string(path).ok()?.trim().parse().ok()
85}
86
87/// Takes the lock with `flock`, which the kernel drops when the process dies.
88#[cfg(unix)]
89fn acquire(path: &Path) -> io::Result<Option<AppLock>> {
90    use rustix::fs::{FlockOperation, flock};
91
92    let file = fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?;
93    match flock(&file, FlockOperation::NonBlockingLockExclusive) {
94        Ok(()) => {}
95        Err(errno) => {
96            let error = io::Error::from(errno);
97            // The one error that is an answer rather than a failure: somebody else has it.
98            if error.kind() == io::ErrorKind::WouldBlock {
99                return Ok(None);
100            }
101            return Err(error);
102        }
103    }
104    // The lock is ours from here on; the process id is written for diagnostics only. A failure to
105    // write it is still reported, as `acquire` promises, and returning drops the file, which
106    // releases the lock again: the caller never holds a lock it was told it did not get.
107    let pid = format!("{}\n", std::process::id());
108    file.set_len(0)?;
109    io::Write::write_all(&mut &file, pid.as_bytes())?;
110    Ok(Some(AppLock { file }))
111}
112
113/// Reports that this platform has no advisory lock in this framework.
114#[cfg(not(unix))]
115fn acquire(_path: &Path) -> io::Result<Option<AppLock>> {
116    Err(io::Error::new(
117        io::ErrorKind::Unsupported,
118        "this platform has no advisory lock in this framework; see AppLock::acquire",
119    ))
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::path::PathBuf;
126
127    /// An empty directory of this test's own.
128    fn temp_dir(name: &str) -> PathBuf {
129        let dir = std::env::temp_dir().join(format!("quvyta-lock-{name}-{}", std::process::id()));
130        let _ = fs::remove_dir_all(&dir);
131        fs::create_dir_all(&dir).expect("test directory");
132        dir
133    }
134
135    #[test]
136    fn the_second_attempt_is_told_the_lock_is_taken_and_the_drop_frees_it() {
137        if !cfg!(unix) {
138            return;
139        }
140        let dir = temp_dir("busy");
141        let path = dir.join("lock");
142
143        let held = AppLock::acquire(&path).expect("first attempt").expect("the lock is free");
144        assert_eq!(holder_pid(&path), Some(std::process::id()), "the file names the holder");
145        // A `flock` belongs to an open file, not to a process, so this is the same answer
146        // another process would get.
147        assert!(AppLock::acquire(&path).expect("second attempt").is_none(), "the lock is taken");
148
149        drop(held);
150        // Free in a moment, not in the same instant: another test running beside this one starts
151        // a child process, and between `fork` and `exec` that child holds a copy of this open
152        // file, so the kernel counts the lock as held until the copy is closed. The deadline is
153        // what is asserted, and it is generous on purpose; the usual answer is the first one.
154        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
155        let mut again = AppLock::acquire(&path).expect("third attempt");
156        while again.is_none() && std::time::Instant::now() < deadline {
157            std::thread::sleep(std::time::Duration::from_millis(5));
158            again = AppLock::acquire(&path).expect("another attempt");
159        }
160        assert!(again.is_some(), "dropping the holder released the lock");
161        drop(again);
162        fs::remove_dir_all(&dir).expect("clean");
163    }
164
165    #[test]
166    fn a_lock_file_left_behind_is_not_a_lock() {
167        if !cfg!(unix) {
168            return;
169        }
170        let dir = temp_dir("stale");
171        let path = dir.join("lock");
172        // What a power cut leaves: the file, with a process id in it, and no lock.
173        fs::write(&path, "4242\n").expect("write a stale file");
174        assert_eq!(holder_pid(&path), Some(4242));
175        let lock = AppLock::acquire(&path).expect("attempt").expect("a leftover file holds nothing");
176        assert_eq!(holder_pid(&path), Some(std::process::id()), "the holder is now this process");
177        drop(lock);
178        fs::remove_dir_all(&dir).expect("clean");
179    }
180
181    /// A real second process, so the answer is not only this process talking to itself.
182    /// `flock(1)` exits with the code given to `-E` when the lock is taken.
183    #[test]
184    fn another_process_is_kept_out_and_let_in_again() {
185        if !cfg!(target_os = "linux") {
186            return;
187        }
188        let dir = temp_dir("process");
189        let path = dir.join("lock");
190        let attempt = |path: &Path| {
191            std::process::Command::new("flock")
192                .args(["--nonblock", "--conflict-exit-code", "9"])
193                .arg(path)
194                .args(["--command", "true"])
195                .status()
196        };
197        let held = AppLock::acquire(&path).expect("attempt").expect("the lock is free");
198        let Ok(busy) = attempt(&path) else {
199            // No `flock` command on this machine; the in-process test covers the same call.
200            fs::remove_dir_all(&dir).expect("clean");
201            return;
202        };
203        assert_eq!(busy.code(), Some(9), "the other process was told the lock is taken");
204        drop(held);
205        // Free in a moment, not in the same instant: a child another test starts between `fork`
206        // and `exec` holds a copy of the open file for that long (see `AppLock::acquire`).
207        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
208        let mut code = attempt(&path).expect("run").code();
209        while code != Some(0) && std::time::Instant::now() < deadline {
210            std::thread::sleep(std::time::Duration::from_millis(5));
211            code = attempt(&path).expect("run").code();
212        }
213        assert_eq!(code, Some(0), "with the holder gone the lock is free");
214        fs::remove_dir_all(&dir).expect("clean");
215    }
216
217    #[test]
218    fn a_file_that_cannot_be_opened_is_an_error() {
219        let dir = temp_dir("missing");
220        let error = AppLock::acquire(&dir.join("absent").join("lock")).expect_err("no directory");
221        let expected = if cfg!(unix) { io::ErrorKind::NotFound } else { io::ErrorKind::Unsupported };
222        assert_eq!(error.kind(), expected);
223        fs::remove_dir_all(&dir).expect("clean");
224    }
225
226    #[test]
227    fn a_file_without_a_number_names_nobody() {
228        let dir = temp_dir("pid");
229        let path = dir.join("lock");
230        assert_eq!(holder_pid(&path), None, "a missing file names nobody");
231        fs::write(&path, "").expect("empty");
232        assert_eq!(holder_pid(&path), None);
233        fs::write(&path, "qfocus\n").expect("text");
234        assert_eq!(holder_pid(&path), None);
235        fs::remove_dir_all(&dir).expect("clean");
236    }
237}