Skip to main content

blockworx/store/
lock.rs

1//! The advisory single-writer lock (F1): a `lock` file naming the process
2//! that holds the container open for writing.
3//!
4//! Advisory on purpose. It stops the second *blockworx* from writing a log
5//! the first is also appending to; it does not — and is not meant to —
6//! stop the user's text editor, which is the point of a container made of
7//! plain files.
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use super::record::WallTime;
14
15/// Who holds the lock, as the file records it.
16#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
17pub struct LockHolder {
18    pub pid: u32,
19    pub since: WallTime,
20}
21
22impl std::fmt::Display for LockHolder {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        write!(f, "process {}", self.pid)
25    }
26}
27
28/// Whether the process named in a lock file is still running.
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30pub enum Holder {
31    Alive,
32    Gone,
33}
34
35/// The lock, held. Released by dropping it, which is what makes the lock's
36/// lifetime the writable container's lifetime rather than something a
37/// caller has to remember.
38pub struct LockGuard {
39    path: PathBuf,
40}
41
42impl LockGuard {
43    /// Follow the container to `root` after it has been renamed. The lock
44    /// file moved with the directory it sits in; this is the guard learning
45    /// where it went, so releasing it removes the file that is there rather
46    /// than the name it was taken under.
47    pub fn follow(&mut self, root: &Path) {
48        self.path = root.join(super::container::LOCK);
49    }
50}
51
52impl Drop for LockGuard {
53    fn drop(&mut self) {
54        let _ = std::fs::remove_file(&self.path);
55    }
56}
57
58/// The answer to "may I write this container?".
59pub enum Claim {
60    Taken(LockGuard),
61    Held(LockHolder),
62}
63
64/// Claim the writer's lock on the container at `root`.
65///
66/// A lock file whose process is gone is *stale* — a writer that died
67/// without releasing it — and is broken rather than left to make the
68/// container unopenable forever. So is one that does not parse: it names
69/// nobody, and a container cannot be held hostage by a corrupt byte.
70///
71/// # Errors
72/// Failure to write the lock file.
73pub fn claim(root: &Path, now: WallTime) -> std::io::Result<Claim> {
74    claim_probing(root, now, holder)
75}
76
77/// The seam the liveness probe enters by: a test can state "the holder is
78/// gone" without needing a process that reliably is.
79fn claim_probing(
80    root: &Path,
81    now: WallTime,
82    probe: impl Fn(u32) -> Holder,
83) -> std::io::Result<Claim> {
84    let path = root.join(super::container::LOCK);
85    if let Ok(text) = std::fs::read_to_string(&path) {
86        let held = serde_json::from_str::<LockHolder>(&text)
87            .ok()
88            .filter(|held| probe(held.pid) == Holder::Alive);
89        if let Some(held) = held {
90            return Ok(Claim::Held(held));
91        }
92        tracing::info!(
93            lock = %path.display(),
94            "breaking a stale lock: the process that held it is gone",
95        );
96    }
97
98    let held = LockHolder {
99        pid: std::process::id(),
100        since: now,
101    };
102    let mut line = serde_json::to_vec(&held).map_err(std::io::Error::other)?;
103    line.push(b'\n');
104    crate::atomic::write_atomically(&path, &line)?;
105    Ok(Claim::Taken(LockGuard { path }))
106}
107
108/// Linux answers this from `/proc`, which needs no dependency and no
109/// `unsafe`. Everywhere else the honest answer is that we cannot tell, and
110/// the safe one is to leave the lock alone: a lock wrongly broken means
111/// two writers appending to one log, while a lock wrongly kept means one
112/// read-only session and a file the user can delete.
113#[cfg(target_os = "linux")]
114fn holder(pid: u32) -> Holder {
115    if Path::new(&format!("/proc/{pid}")).exists() {
116        Holder::Alive
117    } else {
118        Holder::Gone
119    }
120}
121
122#[cfg(not(target_os = "linux"))]
123fn holder(_pid: u32) -> Holder {
124    Holder::Alive
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::store::tests::fixture;
131
132    fn read_holder(root: &Path) -> LockHolder {
133        let text = std::fs::read_to_string(root.join(super::super::container::LOCK))
134            .expect("the lock file exists");
135        serde_json::from_str(&text).expect("and parses")
136    }
137
138    #[test]
139    fn a_claim_is_held_until_the_guard_is_dropped() {
140        let dir = fixture::dir("lock-lifetime");
141        let root = dir.join("doc.bwx");
142        std::fs::create_dir_all(&root).expect("the container directory");
143
144        let guard = match claim(&root, WallTime::from_unix_millis(7)).expect("the lock is written")
145        {
146            Claim::Taken(guard) => guard,
147            Claim::Held(held) => panic!("a fresh container cannot be held by {held}"),
148        };
149        assert_eq!(
150            read_holder(&root),
151            LockHolder {
152                pid: std::process::id(),
153                since: WallTime::from_unix_millis(7),
154            },
155        );
156
157        match claim(&root, WallTime::from_unix_millis(8)).expect("the second claim answers") {
158            Claim::Held(held) => assert_eq!(held.pid, std::process::id()),
159            Claim::Taken(_) => panic!("a live holder's lock must not be broken"),
160        }
161
162        drop(guard);
163        assert!(
164            !root.join(super::super::container::LOCK).exists(),
165            "releasing the lock removes the file",
166        );
167    }
168
169    /// The stale case, stated without needing a process that is reliably
170    /// dead: the holder is gone, so the lock is broken and re-taken.
171    #[test]
172    fn a_lock_whose_holder_is_gone_is_broken() {
173        let dir = fixture::dir("lock-stale");
174        let root = dir.join("doc.bwx");
175        std::fs::create_dir_all(&root).expect("the container directory");
176        let stale = LockHolder {
177            pid: 4242,
178            since: WallTime::from_unix_millis(1),
179        };
180        std::fs::write(
181            root.join(super::super::container::LOCK),
182            serde_json::to_vec(&stale).expect("a holder serializes"),
183        )
184        .expect("the stale lock is planted");
185        assert_eq!(read_holder(&root), stale, "precondition: someone holds it");
186
187        let claim = claim_probing(&root, WallTime::from_unix_millis(9), |_| Holder::Gone)
188            .expect("the claim answers");
189        assert!(matches!(claim, Claim::Taken(_)));
190        assert_eq!(read_holder(&root).pid, std::process::id());
191    }
192
193    /// A lock file that names nobody readable is not a lock.
194    #[test]
195    fn an_unreadable_lock_is_broken() {
196        let dir = fixture::dir("lock-garbage");
197        let root = dir.join("doc.bwx");
198        std::fs::create_dir_all(&root).expect("the container directory");
199        std::fs::write(root.join(super::super::container::LOCK), b"not json at all")
200            .expect("the garbage is planted");
201
202        let claim = claim(&root, WallTime::from_unix_millis(9)).expect("the claim answers");
203        assert!(matches!(claim, Claim::Taken(_)));
204    }
205
206    /// The production probe, on the one platform that can answer it.
207    #[cfg(target_os = "linux")]
208    #[test]
209    fn the_probe_tells_this_process_from_one_that_cannot_exist() {
210        assert_eq!(holder(std::process::id()), Holder::Alive);
211        // Above every platform's pid ceiling, so it names no process now
212        // and cannot come to name one.
213        assert_eq!(holder(i32::MAX as u32), Holder::Gone);
214    }
215}