octl_core/lock.rs
1//! Per-run advisory `flock` primitive (design.md §4).
2//!
3//! The lock is also the source of a **compile-time witness**, [`LockedRun`],
4//! that the run's exclusive lock is held. The unlocked event-append entry points
5//! ([`crate::append_and_apply_unlocked`] and friends) take a `&LockedRun`
6//! parameter, so the type system — not a "the caller must hold the lock" doc
7//! comment — proves a writer holds the `flock` before it appends. Only the
8//! **exclusive** guard mints a witness ([`RunLock::witness`]); a shared
9//! (`LOCK_SH`) reader has no write capability and cannot produce one, because
10//! [`RunLock`] is a typestate generic ([`Exclusive`] vs [`Shared`]) and
11//! `witness` exists only on `RunLock<Exclusive>`.
12
13use std::fs::{File, OpenOptions};
14use std::io::ErrorKind;
15use std::marker::PhantomData;
16use std::path::Path;
17
18use fs4::FileExt;
19
20use crate::error::{Error, Result};
21use crate::paths::{reject_symlink, RunPaths};
22
23/// Typestate marker: the **exclusive** (`LOCK_EX`) lock. A `RunLock<Exclusive>`
24/// grants write access — it alone can mint a [`LockedRun`] witness via
25/// [`RunLock::witness`]. Uninhabited: it exists only as a type tag.
26pub enum Exclusive {}
27
28/// Typestate marker: the **shared** (`LOCK_SH`) lock. A `RunLock<Shared>` is
29/// read-only and cannot produce a [`LockedRun`] witness, so it can never be
30/// used to reach a write-side entry point. Uninhabited: only a type tag.
31pub enum Shared {}
32
33/// Compile-time proof that the holder is inside a critical section guarded by
34/// the run's **exclusive** `flock`.
35///
36/// The unlocked append primitives ([`append_and_apply_unlocked`],
37/// [`append_event_with_seq`], [`quarantine_corrupt_lines_unlocked`]) take a
38/// `&LockedRun` so they cannot be called without proof the lock is held —
39/// replacing the old "caller must already hold the `RunLock`" contract that was
40/// enforced only by a doc comment. Obtain one from
41/// [`RunLock::with_lock`] (which threads it into the closure) or
42/// [`RunLock::witness`] (for a manually-held [`RunLock::acquire`] guard).
43///
44/// The witness is a zero-sized borrow of the guard: its lifetime `'a` ties it to
45/// the [`RunLock`] it was minted from, so it cannot outlive the lock. It is
46/// deliberately **non-`Send` and non-`Sync`** (the `PhantomData<*const …>`) — a
47/// proof that *this thread* holds the `flock` must not cross a task or thread
48/// boundary, where the lock would no longer apply.
49///
50/// [`append_and_apply_unlocked`]: crate::append_and_apply_unlocked
51/// [`append_event_with_seq`]: crate::events
52/// [`quarantine_corrupt_lines_unlocked`]: crate::quarantine_corrupt_lines_unlocked
53pub struct LockedRun<'a> {
54 // `*const` makes the witness `!Send + !Sync`; the `&'a ()` ties it to the
55 // borrowed guard's lifetime. Zero-sized — it carries no data, only proof.
56 _lock: PhantomData<*const &'a ()>,
57}
58
59/// RAII guard holding the run's `flock`.
60///
61/// Released on drop. Acquired exclusively ([`RunLock::acquire`]) and held across
62/// all writes for a single logical mutation, or shared ([`RunLock::acquire_shared`])
63/// for the duration of a reader's multi-file scan so a concurrent reducer cannot
64/// leave the reader with a half-updated projection set. A shared guard may hold
65/// no lock at all when the run has no `.lock` file yet — see
66/// [`RunLock::acquire_shared`].
67///
68/// The `Mode` typestate ([`Exclusive`] / [`Shared`]) records which kind of lock
69/// is held: only `RunLock<Exclusive>` exposes [`RunLock::witness`], so a shared
70/// reader can never forge the write capability a [`LockedRun`] represents.
71pub struct RunLock<Mode = Exclusive> {
72 file: Option<File>,
73 _mode: PhantomData<Mode>,
74}
75
76impl RunLock<Exclusive> {
77 /// Acquire the exclusive lock on `<run-dir>/.lock`, creating the file if
78 /// needed. Blocks until the lock is available.
79 ///
80 /// Best-effort symlink containment: a `.lock` that is a symlink is refused
81 /// ([`Error::SymlinkStateFile`]) so `flock` cannot be taken on a file
82 /// outside the run tree, which would silently break mutual exclusion. This
83 /// guards the lock file's own final component; a symlinked *run root* is
84 /// caught downstream when the held critical section opens `events.jsonl` /
85 /// the projections (both re-guard the root before writing). See
86 /// [`reject_symlink`](crate::paths) for the check-then-open TOCTOU caveat.
87 pub fn acquire(lock_path: &Path) -> Result<Self> {
88 // Test-only spy: count this acquisition so a test can assert a
89 // multi-write transaction (e.g. `cancel_run`) takes the lock exactly
90 // once, not once per appended event.
91 #[cfg(test)]
92 ACQUIRE_COUNT.with(|c| c.set(c.get() + 1));
93 if let Some(p) = lock_path.parent() {
94 std::fs::create_dir_all(p).map_err(|e| Error::io(p, e))?;
95 }
96 reject_symlink(lock_path, || Error::SymlinkStateFile {
97 name: "lock",
98 path: lock_path.to_path_buf(),
99 })?;
100 let mut opts = OpenOptions::new();
101 opts.create(true).read(true).write(true).truncate(false);
102 // `O_NOFOLLOW`: refuse to take `flock` through a symlinked `.lock`, the
103 // file-level backstop to the `reject_symlink` check above.
104 crate::paths::nofollow(&mut opts);
105 let file = opts.open(lock_path).map_err(|e| Error::io(lock_path, e))?;
106 // Fully-qualified to call fs4's trait method, not `std::fs::File::lock`
107 // (an inherent method stable since 1.89 that would otherwise shadow it
108 // on newer toolchains). fs4 renamed `fs2`'s `lock_exclusive` to `lock`
109 // to mirror std.
110 <File as FileExt>::lock(&file).map_err(|e| Error::io(lock_path, e))?;
111 Ok(Self {
112 file: Some(file),
113 _mode: PhantomData,
114 })
115 }
116
117 /// Mint a [`LockedRun`] witness proving this exclusive guard holds the
118 /// run's `flock`. The witness borrows `self`, so the borrow checker forbids
119 /// it from outliving the guard (and thus the lock). Use this for the
120 /// manually-held [`RunLock::acquire`] pattern — when the locked body needs
121 /// control flow ([`with_lock`](RunLock::with_lock)'s closure cannot express)
122 /// — then pass `&witness` to the unlocked append entry points.
123 // `&self` is load-bearing despite the body not reading it: it borrows the
124 // guard so the returned `LockedRun<'_>` is lifetime-tied to the held lock
125 // and cannot outlive it. That is the whole point — not an accidental unused
126 // receiver, so this method must stay a method, never an associated fn.
127 #[allow(clippy::unused_self)]
128 pub fn witness(&self) -> LockedRun<'_> {
129 LockedRun { _lock: PhantomData }
130 }
131
132 /// Convenience: run `f` with the exclusive lock held, passing it a
133 /// [`LockedRun`] witness it can thread into the unlocked append entry
134 /// points, releasing the lock afterwards.
135 pub fn with_lock<R>(paths: &RunPaths, f: impl FnOnce(&LockedRun) -> Result<R>) -> Result<R> {
136 let guard = Self::acquire(&paths.lock())?;
137 let r = f(&guard.witness());
138 drop(guard);
139 r
140 }
141}
142
143impl RunLock<Shared> {
144 /// Acquire a **shared** (`LOCK_SH`) lock on an existing `<run-dir>/.lock`,
145 /// blocking until no writer holds the exclusive lock. The mirror of
146 /// [`RunLock::acquire`] for the read side: many readers may hold the shared
147 /// lock at once, but the exclusive lock a reducer takes excludes them all,
148 /// so a multi-file read taken under this lock never observes the torn state
149 /// a mid-flight reducer would otherwise expose (design.md §4).
150 ///
151 /// Unlike [`RunLock::acquire`], this **never creates** the run directory or
152 /// the lock file — a reader must not bring run-tree state into existence. A
153 /// missing `.lock` means no writer has ever locked this run, so a lock-free
154 /// read is already coherent: the returned guard then holds nothing and drops
155 /// to a no-op. The same symlink containment as [`RunLock::acquire`] applies;
156 /// the file is opened read-only with `O_NOFOLLOW`.
157 ///
158 /// Nesting is safe: a shared lock is compatible with other shared locks, so
159 /// a read path that calls another read helper (each on its own descriptor)
160 /// cannot deadlock against itself.
161 pub fn acquire_shared(lock_path: &Path) -> Result<Self> {
162 reject_symlink(lock_path, || Error::SymlinkStateFile {
163 name: "lock",
164 path: lock_path.to_path_buf(),
165 })?;
166 let mut opts = OpenOptions::new();
167 // Read-only, no `create`: a reader never authors the lock file or its
168 // parent run directory.
169 opts.read(true);
170 // `O_NOFOLLOW`: refuse to take `flock` through a symlinked `.lock`, the
171 // file-level backstop to the `reject_symlink` check above.
172 crate::paths::nofollow(&mut opts);
173 let file = match opts.open(lock_path) {
174 Ok(f) => f,
175 Err(e) if e.kind() == ErrorKind::NotFound => {
176 // No `.lock` (or no run dir): nothing to serialize against.
177 return Ok(Self {
178 file: None,
179 _mode: PhantomData,
180 });
181 }
182 Err(e) => return Err(Error::io(lock_path, e)),
183 };
184 // Fully-qualified to call fs4's trait method rather than the inherent
185 // `std::fs::File::lock_shared` (stable 1.89) that would shadow it on
186 // newer toolchains — same reasoning as the exclusive `lock` above.
187 <File as FileExt>::lock_shared(&file).map_err(|e| Error::io(lock_path, e))?;
188 Ok(Self {
189 file: Some(file),
190 _mode: PhantomData,
191 })
192 }
193
194 /// Convenience: run `f` with the shared lock held, releasing afterwards.
195 /// The read-side counterpart to [`RunLock::with_lock`] — but the closure
196 /// gets **no** [`LockedRun`] witness: a shared reader has no write
197 /// capability, so it can never reach a write-side entry point. (It also
198 /// takes the lock path directly rather than a [`RunPaths`], since a reader
199 /// may run before the run dir is fully materialized.)
200 pub fn with_shared_lock<T>(lock_path: &Path, f: impl FnOnce() -> Result<T>) -> Result<T> {
201 let guard = Self::acquire_shared(lock_path)?;
202 let r = f();
203 drop(guard);
204 r
205 }
206}
207
208impl<Mode> Drop for RunLock<Mode> {
209 fn drop(&mut self) {
210 if let Some(f) = self.file.take() {
211 // Best-effort unlock — kernel releases on file close anyway.
212 // Use the fs4 trait method explicitly to avoid clashing with
213 // `std::fs::File::unlock` (stable since 1.89, above our MSRV).
214 let _ = <File as FileExt>::unlock(&f);
215 }
216 }
217}
218
219#[cfg(test)]
220thread_local! {
221 /// Test-only spy counter for [`RunLock::acquire`] calls on the current
222 /// thread. `cargo test` runs each test on its own thread and `cancel_run`
223 /// does all its work synchronously on the calling thread, so a test can
224 /// reset this and assert the exact number of lock acquisitions a
225 /// transaction performed without cross-test interference.
226 pub(crate) static ACQUIRE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use tempfile::TempDir;
233
234 #[test]
235 fn acquire_succeeds_on_a_regular_lock_file() {
236 let tmp = TempDir::new().unwrap();
237 let lock = tmp.path().join(".lock");
238 // First acquire creates the file; a second acquire after drop succeeds.
239 drop(RunLock::acquire(&lock).unwrap());
240 assert!(RunLock::acquire(&lock).is_ok());
241 }
242
243 /// Build a valid `RunPaths` rooted at a fresh temp dir for the witness tests.
244 fn fresh_paths(tmp: &TempDir) -> RunPaths {
245 let run_id = "01jxsnap000000000000000000";
246 let dir = tmp.path().join(run_id);
247 std::fs::create_dir_all(&dir).unwrap();
248 RunPaths::new(dir, run_id).unwrap()
249 }
250
251 /// `with_lock` runs the closure under the exclusive lock, hands it a
252 /// [`LockedRun`] witness, and returns the closure's value.
253 #[test]
254 fn with_lock_passes_a_witness_and_returns_the_closure_value() {
255 let tmp = TempDir::new().unwrap();
256 let paths = fresh_paths(&tmp);
257 let got = RunLock::with_lock(&paths, |_witness: &LockedRun| Ok(7u8)).unwrap();
258 assert_eq!(got, 7);
259 }
260
261 /// A manually-held exclusive guard ([`RunLock::acquire`]) can mint a witness
262 /// — the escape hatch for lock-held bodies that `with_lock`'s closure shape
263 /// cannot express.
264 #[test]
265 fn manually_acquired_exclusive_guard_mints_a_witness() {
266 let tmp = TempDir::new().unwrap();
267 let lock = tmp.path().join(".lock");
268 let guard = RunLock::acquire(&lock).unwrap();
269 // The witness borrows the guard, so it cannot outlive the held lock.
270 let _witness: LockedRun<'_> = guard.witness();
271 }
272
273 /// A shared lock on a run with no `.lock` file is a no-op guard, not an
274 /// error: a reader must not create run-tree state, and a missing lock file
275 /// means no writer can race the read anyway.
276 #[test]
277 fn acquire_shared_on_missing_lock_file_is_a_noop_guard() {
278 let tmp = TempDir::new().unwrap();
279 let lock = tmp.path().join(".lock");
280 let guard = RunLock::acquire_shared(&lock).expect("missing lock file is fine");
281 assert!(guard.file.is_none(), "no lock file ⇒ guard holds nothing");
282 // The read must not have created the lock file (or its parent run dir).
283 assert!(!lock.exists(), "a reader must never author the lock file");
284 }
285
286 /// A writer holding the exclusive lock blocks a shared reader until release.
287 #[test]
288 fn exclusive_writer_blocks_shared_reader_until_release() {
289 use std::sync::mpsc;
290 use std::thread;
291 use std::time::Duration;
292
293 let tmp = TempDir::new().unwrap();
294 let lock = tmp.path().join(".lock");
295 // The writer's exclusive acquire creates the lock file the reader opens.
296 let writer = RunLock::acquire(&lock).unwrap();
297
298 let (tx, rx) = mpsc::channel();
299 let lock2 = lock.clone();
300 let reader = thread::spawn(move || {
301 // Blocks until the exclusive lock is released.
302 let _g = RunLock::acquire_shared(&lock2).unwrap();
303 tx.send(()).unwrap();
304 });
305
306 // While the exclusive lock is held, the reader cannot proceed.
307 assert!(
308 rx.recv_timeout(Duration::from_millis(250)).is_err(),
309 "shared reader must block while the exclusive lock is held"
310 );
311 drop(writer);
312 // Once released, the reader acquires the shared lock and reports.
313 assert!(
314 rx.recv_timeout(Duration::from_secs(5)).is_ok(),
315 "shared reader must proceed after the exclusive lock is released"
316 );
317 reader.join().unwrap();
318 }
319
320 /// Two shared readers hold the lock concurrently — neither blocks the other.
321 #[test]
322 fn two_shared_readers_proceed_concurrently() {
323 use std::sync::mpsc;
324 use std::thread;
325 use std::time::Duration;
326
327 let tmp = TempDir::new().unwrap();
328 let lock = tmp.path().join(".lock");
329 // Create the lock file (writer makes it, then releases).
330 drop(RunLock::acquire(&lock).unwrap());
331
332 // First reader takes and holds the shared lock.
333 let r1 = RunLock::acquire_shared(&lock).unwrap();
334 assert!(
335 r1.file.is_some(),
336 "lock file exists ⇒ real shared lock held"
337 );
338
339 // A second reader must acquire it without blocking on the first.
340 let (tx, rx) = mpsc::channel();
341 let lock2 = lock.clone();
342 let r2 = thread::spawn(move || {
343 let _g = RunLock::acquire_shared(&lock2).unwrap();
344 tx.send(()).unwrap();
345 });
346 assert!(
347 rx.recv_timeout(Duration::from_secs(5)).is_ok(),
348 "a second shared reader must not block on the first"
349 );
350 r2.join().unwrap();
351 }
352
353 /// Nested `with_shared_lock` calls (each on its own descriptor) must not
354 /// deadlock — shared locks are compatible with one another.
355 #[test]
356 fn nested_shared_locks_do_not_deadlock() {
357 let tmp = TempDir::new().unwrap();
358 let lock = tmp.path().join(".lock");
359 drop(RunLock::acquire(&lock).unwrap());
360 let r = RunLock::with_shared_lock(&lock, || RunLock::with_shared_lock(&lock, || Ok(42)))
361 .unwrap();
362 assert_eq!(r, 42);
363 }
364
365 #[cfg(unix)]
366 #[test]
367 fn acquire_rejects_a_symlinked_lock_file() {
368 // A symlinked `.lock` would take `flock` on a file outside the run,
369 // silently breaking mutual exclusion — refuse it.
370 use std::os::unix::fs::symlink;
371 let tmp = TempDir::new().unwrap();
372 let target = tmp.path().join("outside.lock");
373 let lock = tmp.path().join(".lock");
374 symlink(&target, &lock).unwrap();
375 assert!(matches!(
376 RunLock::acquire(&lock),
377 Err(Error::SymlinkStateFile { name: "lock", .. })
378 ));
379 // The forged lock never touched the symlink target.
380 assert!(!target.exists());
381 }
382}