Skip to main content

qframe/storage/
instance_lock.rs

1//! Many instances at once, and something that waits for the last of them to close.
2//!
3//! [`AppLock`](super::AppLock) answers "is another instance running?". A background service asks
4//! the other question: "tell me when none is". Every instance holds a shared lock on one file; the
5//! service asks for the exclusive lock on the same file and the kernel puts it to sleep until the
6//! last shared holder is gone. There is no polling and no count to keep right: a crashed instance
7//! holds nothing, because the kernel releases a lock when the process dies.
8
9#[cfg(unix)]
10use std::fs;
11use std::io;
12use std::path::Path;
13
14/// A shared or exclusive advisory lock on a file, held for as long as this value lives.
15///
16/// Every running instance of an application holds a [shared](Self::shared) lock; any number of
17/// them can at once. Something that has to act once they are all gone, such as a service that
18/// cleans up after the last window closes, [waits for the exclusive lock](Self::wait_exclusive)
19/// on the same file, and wakes when the last shared lock is released, however that happens: a
20/// normal exit, a crash or a `kill -9`.
21///
22/// ```no_run
23/// # use qframe::storage::{InstanceLock, data_dir};
24/// # let path = data_dir("qcode").expect("a home directory").join("instances");
25/// // In every instance, for as long as it runs:
26/// let _instance = InstanceLock::shared(&path)?;
27///
28/// // In the service, on a thread of its own:
29/// let last_closed = InstanceLock::wait_exclusive(&path)?;
30/// // ... clean up after the instances ...
31/// drop(last_closed); // let a new instance start
32/// # Ok::<(), std::io::Error>(())
33/// ```
34///
35/// These locks are `flock` locks, which belong to an open file rather than to a process: two
36/// locks taken on one path inside one process meet each other exactly as two processes would. A
37/// shared lock and an [`AppLock`](super::AppLock) are two different questions, so keep them on
38/// two different files.
39///
40/// On every platform other than Unix this framework has no advisory lock, so each call returns an
41/// error of kind [`io::ErrorKind::Unsupported`] and never `Ok`, as
42/// [`AppLock::acquire`](super::AppLock::acquire) does.
43#[derive(Debug)]
44pub struct InstanceLock {
45    /// Holding the open file is the lock; closing it, which dropping the value does, releases
46    /// it. Nothing reads the field: it exists to be held. Unix only, as for `AppLock`.
47    #[cfg(unix)]
48    #[expect(dead_code, reason = "the open file is the lock; closing it releases it")]
49    file: fs::File,
50}
51
52impl InstanceLock {
53    /// Takes a shared lock on `path`, creating the file when it is not there. Any number of
54    /// shared locks can be held at once, so this does not wait for other instances.
55    ///
56    /// It waits only while an exclusive lock is held: a service that is cleaning up after the
57    /// last instance holds that lock until it is done, and an instance starting meanwhile waits
58    /// for the cleanup instead of running into it. The parent directory has to exist.
59    ///
60    /// # Errors
61    ///
62    /// Returns the I/O error when the file cannot be opened or locked, and
63    /// [`io::ErrorKind::Unsupported`] on a platform without an advisory lock.
64    pub fn shared(path: &Path) -> io::Result<Self> {
65        shared(path)
66    }
67
68    /// Takes the exclusive lock on `path` when nobody holds a lock on it, or answers `None` at
69    /// once because somebody does. The file is created when it is not there; the parent
70    /// directory has to exist.
71    ///
72    /// # Errors
73    ///
74    /// Returns the I/O error when the file cannot be opened or locked, and
75    /// [`io::ErrorKind::Unsupported`] on a platform without an advisory lock. A lock somebody
76    /// else holds is `Ok(None)`, not an error.
77    pub fn try_exclusive(path: &Path) -> io::Result<Option<Self>> {
78        try_exclusive(path)
79    }
80
81    /// Waits until nobody holds a lock on `path`, then takes the exclusive lock and returns it.
82    /// The file is created when it is not there; the parent directory has to exist.
83    ///
84    /// The thread sleeps in the kernel while it waits and costs no processor time. It wakes when
85    /// the last holder's lock is released, which the kernel also does when a holder's process
86    /// dies. The wait cannot be cancelled, so give it a thread of its own, and hold the lock only
87    /// as long as the work that needs it: every [`shared`](Self::shared) call waits meanwhile.
88    ///
89    /// When nobody holds the lock this returns at once. A service that should wait again for the
90    /// next group of instances therefore has to learn in some other way that one has started;
91    /// calling this again in a loop with nobody running would spin.
92    ///
93    /// A released lock is free in a moment rather than in the same instant: a child process
94    /// started between `fork` and `exec` holds a copy of every open file for those few
95    /// milliseconds (see [`AppLock::acquire`](super::AppLock::acquire)).
96    ///
97    /// # Errors
98    ///
99    /// Returns the I/O error when the file cannot be opened or locked, and
100    /// [`io::ErrorKind::Unsupported`] on a platform without an advisory lock.
101    pub fn wait_exclusive(path: &Path) -> io::Result<Self> {
102        wait_exclusive(path)
103    }
104}
105
106/// Opens `path`, creating it when needed, and locks it with `operation`.
107#[cfg(unix)]
108fn open_and_lock(path: &Path, operation: rustix::fs::FlockOperation) -> io::Result<InstanceLock> {
109    // The standard library opens files close-on-exec, so a program this process starts never
110    // keeps the lock past its own `exec`.
111    let file = fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?;
112    loop {
113        match rustix::fs::flock(&file, operation) {
114            Ok(()) => return Ok(InstanceLock { file }),
115            // A signal handled on this thread interrupts a wait; the wait goes on.
116            Err(rustix::io::Errno::INTR) => {}
117            Err(errno) => return Err(errno.into()),
118        }
119    }
120}
121
122#[cfg(unix)]
123fn shared(path: &Path) -> io::Result<InstanceLock> {
124    open_and_lock(path, rustix::fs::FlockOperation::LockShared)
125}
126
127#[cfg(unix)]
128fn try_exclusive(path: &Path) -> io::Result<Option<InstanceLock>> {
129    match open_and_lock(path, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
130        Ok(lock) => Ok(Some(lock)),
131        // The one error that is an answer rather than a failure: somebody holds a lock.
132        Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None),
133        Err(error) => Err(error),
134    }
135}
136
137#[cfg(unix)]
138fn wait_exclusive(path: &Path) -> io::Result<InstanceLock> {
139    open_and_lock(path, rustix::fs::FlockOperation::LockExclusive)
140}
141
142/// Reports that this platform has no advisory lock in this framework.
143#[cfg(not(unix))]
144fn unsupported() -> io::Error {
145    io::Error::new(io::ErrorKind::Unsupported, "this platform has no advisory lock in this framework; see InstanceLock")
146}
147
148#[cfg(not(unix))]
149fn shared(_path: &Path) -> io::Result<InstanceLock> {
150    Err(unsupported())
151}
152
153#[cfg(not(unix))]
154fn try_exclusive(_path: &Path) -> io::Result<Option<InstanceLock>> {
155    Err(unsupported())
156}
157
158#[cfg(not(unix))]
159fn wait_exclusive(_path: &Path) -> io::Result<InstanceLock> {
160    Err(unsupported())
161}
162
163#[cfg(test)]
164#[path = "instance_lock_tests.rs"]
165mod tests;