Skip to main content

oino_session/manager/
lease.rs

1//! D-LOCK single-writer lease per session file, kernel-arbitrated (spec `docs/dev/v2/specs/core.md`
2//! §2.1 D-LOCK / D-TAKEOVER).
3//!
4//! The lease is an exclusive `flock` (POSIX) / `LockFileEx` byte-range lock (Windows) on a
5//! `<session file>.lock` sibling. The kernel releases both on process death, so a crashed
6//! holder never blocks a successor and there is no expiry that could expropriate a stalled
7//! writer. The lock file is never unlinked on release (POSIX locks name an inode); it is
8//! removed only when the session file itself is gone (best-effort — see [`SessionLease`]).
9//!
10//! Advisory metadata is kept next to the lock (`<session file>.lock.json`) and a takeover
11//! request is signalled through `<session file>.takeover` (D-TAKEOVER). Both are hints: the
12//! kernel lock remains the arbiter.
13//!
14//! An in-process registry guards against the same process locking twice (`flock` is
15//! per-descriptor): a second acquire fails with [`SessionLockError::HeldBy`] naming the
16//! caller's own process.
17//!
18//! NFS/home mounts are out of scope (documented in the spec).
19
20use std::collections::HashMap;
21use std::fs::File;
22use std::path::{Path, PathBuf};
23use std::sync::{Mutex, MutexGuard, OnceLock};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use fs4::fs_std::FileExt as _;
27use serde::{Deserialize, Serialize};
28
29use super::files;
30use crate::error::SessionLockError;
31
32/// Holder identity recorded in the advisory lock metadata.
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct LeaseInfo {
36    /// Holder process id.
37    pub pid: u32,
38    /// Machine boot id the holder started under (see [`LeaseInfo::for_process`]).
39    pub boot_id: String,
40    /// Host name (diagnostics; same-host comparison for [`LeaseInfo::dead`]).
41    pub host: String,
42    /// Human label of the holding client, e.g. `oino rpc` or an embedding host.
43    pub client: String,
44}
45
46/// `<session file>.lock.json` advisory metadata (D-LOCK).
47#[derive(Clone, Debug, Serialize, Deserialize)]
48struct LeaseMetadata {
49    /// Format version.
50    v: u32,
51    #[serde(flatten)]
52    /// Holder identity.
53    info: LeaseInfo,
54    /// Acquisition time (Unix ms).
55    #[serde(rename = "startedAt")]
56    started_at: u64,
57}
58
59/// `<session file>.takeover` request (D-TAKEOVER).
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct TakeoverRequest {
62    /// Format version.
63    pub v: u32,
64    /// Who asked for the session.
65    pub requester: LeaseInfo,
66    /// Request time (Unix ms).
67    pub at: u64,
68}
69
70/// In-process registry of held leases: `flock` is per-descriptor, so without this the same
71/// process could lock a file twice (spec D-LOCK).
72static SESSION_LOCKS: OnceLock<Mutex<HashMap<PathBuf, LeaseInfo>>> = OnceLock::new();
73/// Process-wide boot id (first caller's state dir wins; boot ids are machine-global).
74static BOOT_ID: OnceLock<String> = OnceLock::new();
75
76fn registry() -> MutexGuard<'static, HashMap<PathBuf, LeaseInfo>> {
77    SESSION_LOCKS
78        .get_or_init(|| Mutex::new(HashMap::new()))
79        .lock()
80        .unwrap_or_else(std::sync::PoisonError::into_inner)
81}
82
83fn now_ms() -> u64 {
84    SystemTime::now()
85        .duration_since(UNIX_EPOCH)
86        .map_or(0, |since| u64::try_from(since.as_millis()).unwrap_or(0))
87}
88
89/// `<session file>.lock` sibling path.
90#[must_use]
91pub fn lock_file_path(session_file: &Path) -> PathBuf {
92    sibling(session_file, ".lock")
93}
94
95/// `<session file>.lock.json` metadata path.
96#[must_use]
97pub fn lock_metadata_path(session_file: &Path) -> PathBuf {
98    sibling(session_file, ".lock.json")
99}
100
101/// `<session file>.takeover` request path.
102#[must_use]
103pub fn takeover_path(session_file: &Path) -> PathBuf {
104    sibling(session_file, ".takeover")
105}
106
107fn sibling(session_file: &Path, suffix: &str) -> PathBuf {
108    let mut name = session_file.as_os_str().to_os_string();
109    name.push(suffix);
110    session_file.with_file_name(name)
111}
112
113fn hostname() -> String {
114    if cfg!(target_os = "linux")
115        && let Ok(host) = std::fs::read_to_string("/proc/sys/kernel/hostname")
116        && !host.trim().is_empty()
117    {
118        return host.trim().to_owned();
119    }
120    if cfg!(windows)
121        && let Some(host) = std::env::var_os("COMPUTERNAME")
122    {
123        return host.to_string_lossy().into_owned();
124    }
125    std::env::var_os("HOSTNAME").map_or_else(
126        || "unknown-host".to_owned(),
127        |host| host.to_string_lossy().into_owned(),
128    )
129}
130
131/// Machine boot id: `/proc/sys/kernel/random/boot_id` (Linux), else a random id persisted
132/// once under `<state dir>/oino.boot-id` (spec D-LOCK).
133fn boot_id(state_dir: &Path) -> String {
134    if let Ok(boot) = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
135        && !boot.trim().is_empty()
136    {
137        return boot.trim().to_owned();
138    }
139    let path = state_dir.join("oino.boot-id");
140    if let Ok(existing) = std::fs::read_to_string(&path)
141        && !existing.trim().is_empty()
142    {
143        return existing.trim().to_owned();
144    }
145    let id = uuid::Uuid::new_v4().to_string();
146    let _ = std::fs::create_dir_all(state_dir);
147    let _ = std::fs::write(&path, &id);
148    id
149}
150
151/// Boot id of this machine right now (cache, then `/proc`; `None` where unknowable).
152fn current_boot_id() -> Option<String> {
153    BOOT_ID.get().cloned().or_else(|| {
154        std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
155            .ok()
156            .map(|boot| boot.trim().to_owned())
157            .filter(|boot| !boot.is_empty())
158    })
159}
160
161/// Whether `pid` is alive. Used for diagnostics and the takeover wait loop only — the kernel
162/// lock remains the arbiter, so a wrong answer never breaks correctness (worst case: no
163/// fast path / a stale wait).
164fn pid_alive(pid: u32) -> bool {
165    if pid == 0 {
166        return false;
167    }
168    #[cfg(unix)]
169    {
170        // `kill(pid, 0)`: `Ok` or `EPERM` (exists, not ours) ⇒ alive; `ESRCH` ⇒ gone.
171        let sent = nix::sys::signal::kill(
172            nix::unistd::Pid::from_raw(i32::try_from(pid).unwrap_or(-1)),
173            None,
174        );
175        !matches!(sent, Err(nix::errno::Errno::ESRCH))
176    }
177    #[cfg(windows)]
178    {
179        // `tasklist` filter is the std-only probe; on any doubt, assume alive.
180        std::process::Command::new("tasklist")
181            .args(["/NH", "/FI", &format!("PID eq {pid}")])
182            .output()
183            .map(|output| {
184                output.status.success()
185                    && String::from_utf8_lossy(&output.stdout).contains(&pid.to_string())
186            })
187            .unwrap_or(true)
188    }
189    #[cfg(not(any(unix, windows)))]
190    {
191        true
192    }
193}
194
195impl LeaseInfo {
196    /// Identity of the current process as a lease holder. `state_dir` is only consulted on
197    /// machines without `/proc` (see [`boot_id`]); the first call wins for the process.
198    #[must_use]
199    pub fn for_process(client: &str, state_dir: &Path) -> Self {
200        let boot_id = BOOT_ID.get_or_init(|| boot_id(state_dir)).clone();
201        Self {
202            pid: std::process::id(),
203            boot_id,
204            host: hostname(),
205            client: client.to_owned(),
206        }
207    }
208
209    /// `"oino rpc (pid 123 on host)"`-style label for messages.
210    #[must_use]
211    pub fn describe(&self) -> String {
212        format!("{} (pid {} on {})", self.client, self.pid, self.host)
213    }
214
215    /// Same-host, same-boot holder whose process is gone. Advisory only: the kernel lock
216    /// decides, this only skips the takeover dance for a certainly-dead holder (kernel
217    /// release lag) and powers diagnostics.
218    #[must_use]
219    pub fn dead(&self) -> bool {
220        self.pid != 0
221            && !self.host.is_empty()
222            && self.host == hostname()
223            && current_boot_id().is_some_and(|boot| boot == self.boot_id)
224            && !pid_alive(self.pid)
225    }
226}
227
228fn read_metadata(session_file: &Path) -> LeaseInfo {
229    let fallback = LeaseInfo {
230        pid: 0,
231        boot_id: String::new(),
232        host: String::new(),
233        client: "another Oino process".to_owned(),
234    };
235    std::fs::read_to_string(lock_metadata_path(session_file))
236        .ok()
237        .and_then(|text| serde_json::from_str::<LeaseMetadata>(&text).ok())
238        .map_or(fallback, |metadata| metadata.info)
239}
240
241fn write_metadata(session_file: &Path, holder: &LeaseInfo) {
242    let metadata = LeaseMetadata {
243        v: 1,
244        info: holder.clone(),
245        started_at: now_ms(),
246    };
247    if let Ok(text) = serde_json::to_string(&metadata) {
248        let _ = files::write_atomic(&lock_metadata_path(session_file), &text);
249    }
250}
251
252/// A held single-writer lease on one session file. Dropping (or [`SessionLease::release`])
253/// drops the kernel lock, removes the advisory metadata and any pending takeover request.
254///
255/// The `.lock` file itself is left in place while the session file exists (POSIX locks name
256/// an inode); it is removed when the session file does not exist (a lease eagerly taken for
257/// a session that was never written), best-effort on Windows where a live byte-range lock
258/// blocks deletion.
259#[derive(Debug)]
260pub struct SessionLease {
261    session_file: PathBuf,
262    lock_file: PathBuf,
263    file: Option<File>,
264    info: LeaseInfo,
265}
266
267impl SessionLease {
268    /// Acquires the exclusive lease on `session_file` for `holder`. For a session file that
269    /// does not exist yet (new session, file created at the first flush) the parent directory
270    /// is created so the `.lock` sibling can claim the planned path before the file exists
271    /// (D-LOCK “lazily at the first flush … before the file is created”).
272    ///
273    /// # Errors
274    ///
275    /// [`SessionLockError::HeldBy`] when this process or another writer already holds it
276    /// (the info names the holder when the advisory metadata is readable);
277    /// [`SessionLockError::Io`] when the lock file cannot be created or locked.
278    pub fn acquire(session_file: &Path, holder: &LeaseInfo) -> Result<Self, SessionLockError> {
279        let session_file = files::resolve_path(session_file);
280        let lock_file = lock_file_path(&session_file);
281        let mut held = registry();
282        if let Some(existing) = held.get(&session_file) {
283            return Err(SessionLockError::HeldBy(existing.clone()));
284        }
285        if !session_file.exists()
286            && let Some(parent) = session_file.parent()
287        {
288            // Pre-create the sessions layout directory for planned files so the `.lock`
289            // sibling can claim the path (best-effort: the open below reports real errors).
290            let _ = std::fs::create_dir_all(parent);
291        }
292        let file = std::fs::OpenOptions::new()
293            .read(true)
294            .write(true)
295            .create(true)
296            .truncate(false)
297            .open(&lock_file)
298            .map_err(|source| SessionLockError::Io {
299                path: lock_file.clone(),
300                source,
301            })?;
302        match file.try_lock_exclusive() {
303            Ok(true) => {}
304            Ok(false) => {
305                return Err(SessionLockError::HeldBy(read_metadata(&session_file)));
306            }
307            Err(source) => {
308                return Err(SessionLockError::Io {
309                    path: lock_file,
310                    source,
311                });
312            }
313        }
314        held.insert(session_file.clone(), holder.clone());
315        drop(held);
316        write_metadata(&session_file, holder);
317        Ok(Self {
318            session_file,
319            lock_file,
320            file: Some(file),
321            info: holder.clone(),
322        })
323    }
324
325    /// The holder identity this lease was acquired for.
326    #[must_use]
327    pub fn info(&self) -> &LeaseInfo {
328        &self.info
329    }
330
331    /// The leased session file (resolved absolute path).
332    #[must_use]
333    pub fn session_file(&self) -> &Path {
334        &self.session_file
335    }
336
337    /// Releases the lease: deletes any pending takeover request, drops the kernel lock and
338    /// removes the advisory metadata (all best-effort except the lock itself).
339    pub fn release(self) {
340        // `Drop` performs the release; consuming here is the explicit API.
341    }
342}
343
344impl Drop for SessionLease {
345    fn drop(&mut self) {
346        registry().remove(&self.session_file);
347        self.file = None; // kernel lock goes with the descriptor
348        let _ = std::fs::remove_file(lock_metadata_path(&self.session_file));
349        let _ = std::fs::remove_file(takeover_path(&self.session_file));
350        if !self.session_file.exists() {
351            let _ = std::fs::remove_file(&self.lock_file);
352        }
353    }
354}
355
356/// Writes the `<session file>.takeover` request asking the holder to close (D-TAKEOVER).
357///
358/// # Errors
359///
360/// [`SessionLockError::Io`] when the request cannot be written.
361pub fn write_takeover_request(
362    session_file: &Path,
363    requester: &LeaseInfo,
364) -> Result<(), SessionLockError> {
365    let request = TakeoverRequest {
366        v: 1,
367        requester: requester.clone(),
368        at: now_ms(),
369    };
370    let text = serde_json::to_string(&request).map_err(|source| SessionLockError::Io {
371        path: takeover_path(session_file),
372        source: std::io::Error::other(source),
373    })?;
374    files::write_atomic(&takeover_path(session_file), &text).map_err(|source| {
375        SessionLockError::Io {
376            path: takeover_path(session_file),
377            source: std::io::Error::other(source.to_string()),
378        }
379    })
380}
381
382/// Reads a pending takeover request, when present and well-formed.
383#[must_use]
384pub fn read_takeover_request(session_file: &Path) -> Option<TakeoverRequest> {
385    std::fs::read_to_string(takeover_path(session_file))
386        .ok()
387        .and_then(|text| serde_json::from_str(&text).ok())
388}
389
390/// Removes a pending takeover request (best-effort).
391pub fn clear_takeover_request(session_file: &Path) {
392    let _ = std::fs::remove_file(takeover_path(session_file));
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn info(client: &str) -> LeaseInfo {
400        LeaseInfo::for_process(client, std::env::temp_dir().as_path())
401    }
402
403    #[test]
404    fn takeover_request_roundtrip() {
405        let dir = tempfile::tempdir().unwrap();
406        let file = dir.path().join("s.jsonl");
407        assert!(read_takeover_request(&file).is_none());
408        write_takeover_request(&file, &info("oino test")).unwrap();
409        let request = read_takeover_request(&file).unwrap();
410        assert_eq!(request.requester, info("oino test"));
411        assert_eq!(request.v, 1);
412        clear_takeover_request(&file);
413        assert!(read_takeover_request(&file).is_none());
414    }
415
416    #[test]
417    fn lock_paths_are_siblings() {
418        let file = Path::new("/sessions/--x--/1_a.jsonl");
419        assert_eq!(lock_file_path(file).file_name().unwrap(), "1_a.jsonl.lock");
420        assert_eq!(
421            lock_metadata_path(file).file_name().unwrap(),
422            "1_a.jsonl.lock.json"
423        );
424        assert_eq!(
425            takeover_path(file).file_name().unwrap(),
426            "1_a.jsonl.takeover"
427        );
428    }
429
430    #[test]
431    fn dead_flags_same_machine_pid() {
432        assert!(!info("x").dead(), "this process is alive");
433        let mut gone = info("x");
434        gone.pid = 2_000_000; // in pid space but (practically) unmapped on this host
435        assert!(gone.dead(), "an unmapped pid on the same host+boot is gone");
436        let mut other_host = info("x");
437        other_host.host = "elsewhere".to_owned();
438        assert!(!other_host.dead(), "another host is none of our business");
439    }
440}