Skip to main content

mbr_server/
file_write.rs

1//! Writing the user's files in place: atomic replacement and per-file locks.
2//!
3//! Every server endpoint that rewrites an existing note — the editor, task
4//! toggles, flashcard reviews, moves and the link rewriting a move triggers —
5//! goes through here, so they agree on two things:
6//!
7//! * **Atomic replacement** ([`atomic_write`], implemented by
8//!   [`mbr_core::vault::LocalVault`] and re-exported here): a temp file with a
9//!   name of its own, beside the target, renamed over it, carrying the
10//!   target's permissions.
11//! * **Serialization** ([`FileWriteLocks`]): a read-patch-write cycle on one
12//!   file excludes every other one on the same file, so no write is silently
13//!   overwritten by a second writer that read the older text.
14//!
15//! Knows nothing about HTTP; callers map `std::io::Error` themselves.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Weak};
20
21use tokio::sync::{Mutex, OwnedMutexGuard};
22
23/// One async mutex per file, for code that reads a file, patches it and
24/// writes it back.
25///
26/// Without it two writes to the same note — a task toggle and a flashcard
27/// review a moment apart, two windows, or a move rewriting a link in a note
28/// being toggled — both read the old text and the second rename silently
29/// drops the first one's change.
30///
31/// Entries are `Weak`: a file's mutex lives exactly as long as someone holds
32/// or awaits it, and dead entries are swept on every acquisition, so the map
33/// only ever holds the files being written right now.
34///
35/// Keys are compared as given, so callers must pass **canonical** paths (two
36/// spellings of one file would otherwise get two locks). Not reentrant: never
37/// lock a path the current task already holds.
38#[derive(Default)]
39pub struct FileWriteLocks {
40    locks: parking_lot::Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>,
41}
42
43impl FileWriteLocks {
44    /// The mutex for `path`, created on first use.
45    fn mutex_for(&self, path: &Path) -> Arc<Mutex<()>> {
46        let mut locks = self.locks.lock();
47        locks.retain(|_, weak| weak.strong_count() > 0);
48        if let Some(mutex) = locks.get(path).and_then(Weak::upgrade) {
49            return mutex;
50        }
51        let mutex = Arc::new(Mutex::new(()));
52        locks.insert(path.to_path_buf(), Arc::downgrade(&mutex));
53        mutex
54    }
55
56    /// Waits for exclusive write access to `path`.
57    pub async fn lock(&self, path: &Path) -> OwnedMutexGuard<()> {
58        self.mutex_for(path).lock_owned().await
59    }
60
61    /// [`Self::lock`] for blocking code (`spawn_blocking`, plain threads).
62    ///
63    /// # Panics
64    ///
65    /// When called from within an async execution context, like tokio's own
66    /// `blocking_lock`.
67    pub fn lock_blocking(&self, path: &Path) -> OwnedMutexGuard<()> {
68        self.mutex_for(path).blocking_lock_owned()
69    }
70
71    /// Locks every path in `paths`, in sorted order and once each, so two
72    /// callers locking overlapping sets can never deadlock. Blocking.
73    pub fn lock_all_blocking(&self, paths: &[&Path]) -> Vec<OwnedMutexGuard<()>> {
74        let mut ordered: Vec<&Path> = paths.to_vec();
75        ordered.sort_unstable();
76        ordered.dedup();
77        ordered
78            .into_iter()
79            .map(|path| self.lock_blocking(path))
80            .collect()
81    }
82
83    /// Files with a live lock (held or awaited). For tests.
84    #[cfg(test)]
85    fn live(&self) -> usize {
86        self.locks
87            .lock()
88            .values()
89            .filter(|weak| weak.strong_count() > 0)
90            .count()
91    }
92}
93
94// The atomic-replacement primitives live with the local vault, which is the
95// one implementation of `Vault::write_atomic` on disk; re-exported here so the
96// rest of the server keeps a single import for "how files are written".
97pub use mbr_core::vault::{atomic_write, create_unique_temp_file};
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    /// Names in `dir` that look like one of our temp files.
104    fn temp_files_in(dir: &Path) -> Vec<String> {
105        std::fs::read_dir(dir)
106            .expect("read dir")
107            .filter_map(|entry| entry.ok()?.file_name().into_string().ok())
108            .filter(|name| name.ends_with(".mbr-tmp"))
109            .collect()
110    }
111
112    #[test]
113    fn unique_temp_files_never_share_a_name() {
114        let temp = tempfile::tempdir().expect("temp dir");
115        let (a, _fa) = create_unique_temp_file(temp.path(), "note.md").expect("first");
116        let (b, _fb) = create_unique_temp_file(temp.path(), "note.md").expect("second");
117        assert_ne!(a, b);
118        for path in [&a, &b] {
119            let name = path.file_name().and_then(|n| n.to_str()).expect("name");
120            assert!(
121                name.starts_with(".note.md.") && name.ends_with(".mbr-tmp"),
122                "{name}"
123            );
124        }
125    }
126
127    /// Concurrent writers to one file each get their own temp file, so every
128    /// rename succeeds (the shared `.{name}.mbr-tmp` made the loser `ENOENT`)
129    /// and nothing is left behind.
130    #[test]
131    fn concurrent_atomic_writes_all_succeed_and_leave_no_temp_files() {
132        let temp = tempfile::tempdir().expect("temp dir");
133        let path = temp.path().join("note.md");
134        std::fs::write(&path, "start\n").expect("seed");
135        let results: Vec<_> = std::thread::scope(|scope| {
136            (0..16)
137                .map(|i| {
138                    let path = &path;
139                    scope.spawn(move || atomic_write(path, format!("writer {i}\n").as_bytes()))
140                })
141                .collect::<Vec<_>>()
142                .into_iter()
143                .map(|handle| handle.join().expect("writer thread"))
144                .collect()
145        });
146        for result in &results {
147            assert!(result.is_ok(), "{result:?}");
148        }
149        let text = std::fs::read_to_string(&path).expect("read back");
150        assert!(text.starts_with("writer "), "{text}");
151        assert_eq!(temp_files_in(temp.path()), Vec::<String>::new());
152    }
153
154    #[cfg(unix)]
155    #[test]
156    fn atomic_write_keeps_the_file_permissions() {
157        use std::os::unix::fs::PermissionsExt;
158        let temp = tempfile::tempdir().expect("temp dir");
159        let path = temp.path().join("private.md");
160        std::fs::write(&path, "secret\n").expect("seed");
161        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
162        atomic_write(&path, b"still secret\n").expect("write");
163        let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
164        assert_eq!(mode & 0o777, 0o600);
165        assert_eq!(std::fs::read_to_string(&path).unwrap(), "still secret\n");
166    }
167
168    #[tokio::test]
169    async fn locks_serialize_one_path_and_forget_it_afterwards() {
170        let locks = Arc::new(FileWriteLocks::default());
171        let a = Path::new("/repo/a.md");
172        let guard = locks.lock(a).await;
173        // Another file is independent.
174        drop(locks.lock(Path::new("/repo/b.md")).await);
175
176        // The same file waits until the first guard is dropped.
177        let waiter = {
178            let locks = Arc::clone(&locks);
179            tokio::spawn(async move { drop(locks.lock(Path::new("/repo/a.md")).await) })
180        };
181        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
182        assert!(!waiter.is_finished(), "second lock on a.md must wait");
183        drop(guard);
184        waiter.await.expect("waiter");
185
186        // Nothing holds or awaits a lock now, so nothing is kept alive.
187        assert_eq!(locks.live(), 0);
188        drop(locks.lock(a).await);
189        assert!(locks.locks.lock().len() <= 1, "dead entries are swept");
190    }
191
192    /// The blocking and async halves share one mutex per path.
193    #[tokio::test]
194    async fn blocking_and_async_locks_exclude_each_other() {
195        let locks = Arc::new(FileWriteLocks::default());
196        let guard = locks.lock(Path::new("/repo/a.md")).await;
197        let blocked = {
198            let locks = Arc::clone(&locks);
199            tokio::task::spawn_blocking(move || drop(locks.lock_blocking(Path::new("/repo/a.md"))))
200        };
201        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
202        assert!(
203            !blocked.is_finished(),
204            "blocking lock must wait for async holder"
205        );
206        drop(guard);
207        blocked.await.expect("blocking locker");
208    }
209
210    /// Two threads locking the same pair in opposite orders: sorting inside
211    /// `lock_all_blocking` is what keeps this from deadlocking.
212    #[test]
213    fn lock_all_blocking_orders_paths_and_dedups() {
214        let locks = Arc::new(FileWriteLocks::default());
215        let (a, b) = (Path::new("/repo/a.md"), Path::new("/repo/b.md"));
216        std::thread::scope(|scope| {
217            for round in 0..200 {
218                let locks = &locks;
219                let (first, second) = if round % 2 == 0 { (a, b) } else { (b, a) };
220                scope.spawn(move || drop(locks.lock_all_blocking(&[first, second])));
221            }
222        });
223        // A path named twice is locked once (a second lock would self-deadlock).
224        assert_eq!(locks.lock_all_blocking(&[a, a]).len(), 1);
225    }
226}