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}