Skip to main content

client_core/sync/
lockfile.rs

1use fs2::FileExt;
2use serde::Serialize;
3use std::fs::{File, OpenOptions};
4use std::io::Write;
5use std::path::Path;
6use std::time::Duration;
7
8#[derive(Debug, Clone, Copy, Serialize)]
9pub enum LockKind {
10    Desktop,
11    Cli,
12}
13
14#[derive(Serialize)]
15struct LockBody<'a> {
16    pid: u32,
17    kind: &'a str,
18    started_at: String,
19}
20
21pub struct Lockfile {
22    file: File, // dropped → OS releases the lock
23}
24
25impl Lockfile {
26    /// Acquire an exclusive advisory lock at `path` without blocking.
27    /// Returns `Ok(Some(Lockfile))` if acquired, `Ok(None)` if the lock is
28    /// already held by another process.
29    pub fn try_acquire(path: &Path, kind: LockKind) -> std::io::Result<Option<Self>> {
30        if let Some(dir) = path.parent() {
31            std::fs::create_dir_all(dir)?;
32        }
33        let file = OpenOptions::new()
34            .create(true)
35            .read(true)
36            .write(true)
37            .truncate(false)
38            .open(path)?;
39        match file.try_lock_exclusive() {
40            Ok(()) => {
41                let body = LockBody {
42                    pid: std::process::id(),
43                    kind: match kind {
44                        LockKind::Desktop => "desktop",
45                        LockKind::Cli => "cli",
46                    },
47                    started_at: chrono::Utc::now().to_rfc3339(),
48                };
49                let mut f = &file;
50                let _ = f.set_len(0);
51                let _ = writeln!(f, "{}", serde_json::to_string(&body).unwrap_or_default());
52                Ok(Some(Self { file }))
53            }
54            Err(e)
55                if e.kind() == std::io::ErrorKind::WouldBlock
56                    || matches!(e.raw_os_error(), Some(11) | Some(35)) =>
57            {
58                Ok(None)
59            }
60            Err(e) => Err(e),
61        }
62    }
63
64    /// Probe whether the lock is currently free, **without holding it on success**.
65    /// Useful for readers that want to skip backfill when a writer is active.
66    pub fn is_held_by_other(path: &Path) -> std::io::Result<bool> {
67        if let Some(dir) = path.parent() {
68            std::fs::create_dir_all(dir)?;
69        }
70        let f = OpenOptions::new()
71            .create(true)
72            .read(true)
73            .write(true)
74            .truncate(false)
75            .open(path)?;
76        match f.try_lock_exclusive() {
77            Ok(()) => {
78                // Disambiguate from std's File::unlock (stable since 1.89) so
79                // builds on older toolchains continue to use fs2's trait impl.
80                let _ = FileExt::unlock(&f);
81                Ok(false)
82            }
83            Err(_) => Ok(true),
84        }
85    }
86}
87
88impl Drop for Lockfile {
89    fn drop(&mut self) {
90        let _ = FileExt::unlock(&self.file);
91    }
92}
93
94/// Convenience: poll the lock until acquired or `deadline` elapses.
95pub async fn acquire_blocking(
96    path: &Path,
97    kind: LockKind,
98    poll: Duration,
99    deadline: std::time::Instant,
100) -> std::io::Result<Option<Lockfile>> {
101    loop {
102        match Lockfile::try_acquire(path, kind)? {
103            Some(l) => return Ok(Some(l)),
104            None if std::time::Instant::now() >= deadline => return Ok(None),
105            None => tokio::time::sleep(poll).await,
106        }
107    }
108}