Skip to main content

blockworx/storage/
lock.rs

1//! One writer per container.
2//!
3//! Two editors on one directory would not merely race — each holds a whole
4//! document in memory and rewrites `root.kdl` from it, so the second to save
5//! silently discards everything the first did. The history would record both
6//! sides of the fight as if they were one person's edits.
7//!
8//! The exclusion is an **advisory lock on the `lock` file**, taken through
9//! `File::try_lock`. That matters more than it sounds: the kernel drops the lock
10//! when the holding process exits, however it exits, so a crashed session cannot
11//! leave a container permanently unopenable. A hand-rolled pid file would need a
12//! liveness check, and would get it wrong the first time a pid was reused.
13//!
14//! The file's *contents* are separate from the exclusion: they name the holder,
15//! so the second session can say who has it rather than only that someone does.
16
17use std::fs::File;
18use std::path::Path;
19
20use serde::{Deserialize, Serialize};
21
22/// The file a container's claim is taken on.
23pub const LOCK: &str = "lock";
24
25/// Who holds a container. Written by the holder, read by whoever is turned away.
26#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
27pub struct Holder {
28    pub pid: u32,
29    /// When the claim was taken, in milliseconds since the epoch.
30    pub since: u64,
31}
32
33/// An exclusive claim on a container, released when this is dropped — or when
34/// the process ends, by the kernel.
35#[derive(Debug)]
36pub struct Lock {
37    // Held solely for its lock; the bytes were written when it was taken.
38    _file: File,
39}
40
41/// The outcome of trying to claim a container.
42#[derive(Debug)]
43pub enum Claim {
44    Held(Lock),
45    /// Someone else has it. The holder is `None` when the `lock` file could not
46    /// be read or made sense of — which still means taken, just anonymously.
47    Taken(Option<Holder>),
48}
49
50/// Claim the container rooted at `dir` for this process.
51///
52/// Creates the directory: taking the claim is what an editing session does when
53/// it decides it owns a container, so there is something to own afterwards.
54pub fn claim(dir: &Path, now: u64) -> std::io::Result<Claim> {
55    std::fs::create_dir_all(dir)?;
56    let path = dir.join(LOCK);
57    let file = File::options()
58        .read(true)
59        .write(true)
60        .create(true)
61        .truncate(false)
62        .open(&path)?;
63
64    match file.try_lock() {
65        Ok(()) => {
66            // Only now, holding the lock, is it safe to replace the previous
67            // holder's identity.
68            let holder = Holder {
69                pid: std::process::id(),
70                since: now,
71            };
72            file.set_len(0)?;
73            serde_json::to_writer(&file, &holder).map_err(std::io::Error::other)?;
74            Ok(Claim::Held(Lock { _file: file }))
75        }
76        // Advisory locks do not block reading, so the holder can be named.
77        Err(std::fs::TryLockError::WouldBlock) => Ok(Claim::Taken(read_holder(&path))),
78        Err(std::fs::TryLockError::Error(e)) => Err(e),
79    }
80}
81
82fn read_holder(path: &Path) -> Option<Holder> {
83    let bytes = std::fs::read(path).ok()?;
84    serde_json::from_slice(&bytes).ok()
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::storage::atomic::tests::TempDir;
91
92    #[test]
93    fn claiming_a_free_container_succeeds_and_names_the_holder() {
94        let dir = TempDir::new("lock-free");
95        let root = dir.join("d.bwx");
96        let Claim::Held(_held) = claim(&root, 1234).unwrap() else {
97            panic!("a container nobody holds should be claimable");
98        };
99        assert_eq!(
100            read_holder(&root.join(LOCK)),
101            Some(Holder {
102                pid: std::process::id(),
103                since: 1234,
104            })
105        );
106    }
107
108    /// The whole point: a second session is turned away rather than allowed to
109    /// overwrite the first one's document on its next save.
110    #[test]
111    fn a_second_claim_is_refused_and_can_say_who_holds_it() {
112        let dir = TempDir::new("lock-contended");
113        let root = dir.join("d.bwx");
114        let Claim::Held(_first) = claim(&root, 1234).unwrap() else {
115            panic!("the first claim should succeed");
116        };
117
118        let Claim::Taken(holder) = claim(&root, 5678).unwrap() else {
119            panic!("the second claim should be refused");
120        };
121        assert_eq!(
122            holder,
123            Some(Holder {
124                pid: std::process::id(),
125                since: 1234,
126            }),
127            "the refusal should name who has it"
128        );
129    }
130
131    /// Releasing has to actually release, or closing one editor would leave the
132    /// container unopenable until the machine was rebooted.
133    #[test]
134    fn releasing_the_claim_frees_the_container() {
135        let dir = TempDir::new("lock-release");
136        let root = dir.join("d.bwx");
137
138        let first = claim(&root, 1).unwrap();
139        assert!(matches!(first, Claim::Held(_)));
140        assert!(matches!(claim(&root, 2).unwrap(), Claim::Taken(_)));
141
142        drop(first);
143        let Claim::Held(_) = claim(&root, 3).unwrap() else {
144            panic!("the claim was not released");
145        };
146    }
147
148    /// A `lock` file left holding nonsense — hand-edited, or from a build that
149    /// wrote something else — must not make a free container unopenable.
150    #[test]
151    fn an_unreadable_lock_file_does_not_block_a_claim() {
152        let dir = TempDir::new("lock-garbage");
153        let root = dir.join("d.bwx");
154        std::fs::create_dir_all(&root).unwrap();
155        std::fs::write(root.join(LOCK), b"not json at all").unwrap();
156
157        let Claim::Held(_held) = claim(&root, 9).unwrap() else {
158            panic!("nobody holds this, whatever the file says");
159        };
160        // ...and the nonsense is replaced with a real holder.
161        assert_eq!(read_holder(&root.join(LOCK)).map(|h| h.since), Some(9));
162    }
163
164    /// The identity is only replaced by whoever actually took the lock, so a
165    /// refused session cannot overwrite the holder's name.
166    #[test]
167    fn a_refused_claim_leaves_the_holders_identity_alone() {
168        let dir = TempDir::new("lock-identity");
169        let root = dir.join("d.bwx");
170        let Claim::Held(_first) = claim(&root, 111).unwrap() else {
171            panic!("the first claim should succeed");
172        };
173        let _ = claim(&root, 222).unwrap();
174        assert_eq!(read_holder(&root.join(LOCK)).map(|h| h.since), Some(111));
175    }
176}