daemonbit_lockfile/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) $year Kamil Becmer
3
4//noinspection RsCompileErrorMacro
5#[cfg(all(not(unix), not(test)))]
6mod api {
7    compile_error!("unsupported platform");
8}
9
10#[cfg(all(unix, not(test)))]
11mod api {
12    use std::io;
13    pub use std::{
14        fs::{File, remove_file},
15        process::id as pid,
16    };
17
18    pub use nix::errno::Errno;
19
20    pub type Flock = nix::fcntl::Flock<File>;
21
22    pub fn open<P: AsRef<std::path::Path>>(path: P) -> io::Result<File> {
23        std::fs::OpenOptions::new()
24            .read(true)
25            .write(true)
26            .create(true)
27            .open(path)
28    }
29    pub fn lock_exclusive(file: File) -> Result<Flock, (File, Errno)> {
30        Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock)
31    }
32}
33
34//noinspection RsUnresolvedPath,RsInherentImplDifferentCrate,RsInvalidFieldsInStructLiteral,RsNonExistentFieldAccess
35#[cfg(test)]
36mod api {
37    use std::{
38        cell::RefCell,
39        io,
40        ops::{Deref, DerefMut},
41        path::Path,
42    };
43
44    pub struct File {
45        data: io::Cursor<Vec<u8>>,
46    }
47    impl File {
48        fn new(data: Vec<u8>) -> Self {
49            Self {
50                data: io::Cursor::new(data),
51            }
52        }
53        pub fn set_len(&mut self, len: usize) -> io::Result<()> {
54            self.data.get_mut().truncate(len);
55            Ok(())
56        }
57        pub fn sync_all(&mut self) -> io::Result<()> {
58            Ok(())
59        }
60    }
61    impl Deref for File {
62        type Target = io::Cursor<Vec<u8>>;
63        fn deref(&self) -> &Self::Target {
64            &self.data
65        }
66    }
67    impl DerefMut for File {
68        fn deref_mut(&mut self) -> &mut Self::Target {
69            &mut self.data
70        }
71    }
72
73    pub struct Flock {
74        file: File,
75    }
76    impl Deref for Flock {
77        type Target = File;
78        fn deref(&self) -> &Self::Target {
79            &self.file
80        }
81    }
82    impl DerefMut for Flock {
83        fn deref_mut(&mut self) -> &mut Self::Target {
84            &mut self.file
85        }
86    }
87
88    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
89    #[repr(i32)]
90    #[non_exhaustive]
91    pub enum Errno {
92        EWOULDBLOCK = 11,
93        EACCES = 13,
94    }
95
96    thread_local! {
97        static PID: RefCell<u32> = RefCell::new(0);
98        static OPEN: RefCell<io::Result<Vec<u8>>> = RefCell::new(Ok(Vec::new()));
99        static LOCK: RefCell<Option<Errno>> = RefCell::new(None);
100    }
101    pub fn mock(current_pid: u32, lockfile_data: io::Result<Vec<u8>>, lock_errno: Option<Errno>) {
102        PID.set(current_pid);
103        OPEN.set(lockfile_data);
104        LOCK.set(lock_errno);
105    }
106    pub fn pid() -> u32 {
107        PID.with(|v| *v.borrow())
108    }
109    pub fn open<P: AsRef<Path>>(_: P) -> io::Result<File> {
110        Ok(File::new(OPEN.replace(Ok(Vec::new()))?))
111    }
112    pub fn lock_exclusive(file: File) -> Result<Flock, (File, Errno)> {
113        match LOCK.take() {
114            None => Ok(Flock { file }),
115            Some(errno) => Err((file, errno)),
116        }
117    }
118    pub fn remove_file<P: AsRef<Path>>(_: P) -> io::Result<()> {
119        Ok(())
120    }
121}
122
123use std::{
124    borrow::Cow,
125    fmt, io,
126    io::{Read, Seek, Write},
127    marker::PhantomData,
128    mem::ManuallyDrop,
129    ops::Deref,
130    path::{Path, PathBuf},
131};
132
133use daemonbit_core::{Acquire, DaemonScope, ScopeKey, Scoped, TryAcquire};
134use daemonbit_rundir::{RuntimeDirectory, ScopedRuntimeDirectory};
135use tracing::warn;
136
137pub struct Lockfile<T> {
138    raw: RawLockfile,
139    _scope: PhantomData<fn(T) -> T>,
140}
141impl<T: DaemonScope> Scoped for Lockfile<T> {
142    fn scope(&self) -> ScopeKey {
143        T::key()
144    }
145}
146impl<T: DaemonScope> Acquire<T> for Lockfile<T> {
147    fn acquire() -> Self {
148        match RuntimeDirectory::<T>::get() {
149            Ok(rundir) => {
150                let raw = RawLockfile::acquire_inner(rundir.join("lock"), Some(rundir.into()));
151                Self {
152                    raw,
153                    _scope: PhantomData,
154                }
155            }
156            Err((path, source)) => {
157                let path = path.join("lock");
158                panic!("{}", RawLockfileError::AccessFailed { path, source });
159            }
160        }
161    }
162}
163
164impl<T: DaemonScope> TryAcquire<T> for Lockfile<T> {
165    type Error = LockfileError;
166    fn try_acquire() -> Result<Self, Self::Error> {
167        match RuntimeDirectory::<T>::get() {
168            Ok(rundir) => {
169                match RawLockfile::try_acquire_inner(rundir.join("lock"), Some(rundir.into())) {
170                    Ok(raw) => Ok(Self {
171                        raw,
172                        _scope: PhantomData,
173                    }),
174                    Err(e) => {
175                        let scope = T::key();
176                        Err(match e {
177                            RawLockfileError::AccessFailed { path, source } => {
178                                LockfileError::AccessFailed {
179                                    scope,
180                                    path,
181                                    source,
182                                }
183                            }
184                            RawLockfileError::AlreadyLocked { path, pid } => {
185                                LockfileError::AlreadyLocked { scope, path, pid }
186                            }
187                        })
188                    }
189                }
190            }
191            Err((path, source)) => {
192                let scope = T::key();
193                let path = path.join("lock");
194                Err(LockfileError::AccessFailed {
195                    scope,
196                    path,
197                    source,
198                })
199            }
200        }
201    }
202}
203impl<T: DaemonScope> Deref for Lockfile<T> {
204    type Target = RawLockfile;
205    fn deref(&self) -> &Self::Target {
206        &self.raw
207    }
208}
209
210pub struct RawLockfile {
211    pid: u32,
212    path: PathBuf,
213    flock: ManuallyDrop<api::Flock>,
214    rundir: Option<ScopedRuntimeDirectory>,
215}
216impl RawLockfile {
217    pub fn acquire_with_path<P: AsRef<Path>>(path: P) -> Self {
218        Self::acquire_inner(path, None)
219    }
220    pub fn try_acquire_with_path<P: AsRef<Path>>(path: P) -> Result<Self, RawLockfileError> {
221        Self::try_acquire_inner(path, None)
222    }
223    fn acquire_inner<P: AsRef<Path>>(path: P, rundir: Option<ScopedRuntimeDirectory>) -> Self {
224        match Self::try_acquire_inner(path, rundir) {
225            Ok(lockfile) => lockfile,
226            Err(e) => panic!("{e}"),
227        }
228    }
229    fn try_acquire_inner<P: AsRef<Path>>(
230        path: P,
231        rundir: Option<ScopedRuntimeDirectory>,
232    ) -> Result<Self, RawLockfileError> {
233        let path = path.as_ref().to_path_buf();
234
235        let file = match api::open(&path) {
236            Ok(file) => file,
237            Err(source) => return Err(RawLockfileError::AccessFailed { path, source }),
238        };
239
240        match api::lock_exclusive(file) {
241            Ok(mut flock) => {
242                let pid = api::pid();
243                let path = UnparsedPid::read(path, &mut *flock)?
244                    .parse_checked(pid)
245                    .write(pid, &mut *flock)?;
246                let flock = ManuallyDrop::new(flock);
247                Ok(RawLockfile {
248                    pid,
249                    path,
250                    flock,
251                    rundir,
252                })
253            }
254            Err((mut file, api::Errno::EWOULDBLOCK)) => Err(UnparsedPid::read(path, &mut file)?
255                .parse()
256                .into_already_locked_error()),
257            Err((_, errno)) => {
258                #[allow(clippy::unnecessary_cast)]
259                let source = io::Error::from_raw_os_error(errno as i32);
260                Err(RawLockfileError::AccessFailed { path, source })
261            }
262        }
263    }
264    pub fn pid(&self) -> u32 {
265        self.pid
266    }
267    pub fn path(&self) -> &Path {
268        &self.path
269    }
270}
271impl fmt::Debug for RawLockfile {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        f.debug_struct("Lockfile")
274            .field("pid", &self.pid)
275            .field("path", &self.path)
276            .finish()
277    }
278}
279impl Drop for RawLockfile {
280    fn drop(&mut self) {
281        if let Err(error) = api::remove_file(&self.path) {
282            warn!(?error, "cannot remove lockfile")
283        }
284        unsafe { ManuallyDrop::drop(&mut self.flock) };
285        self.rundir.take();
286    }
287}
288
289#[derive(Debug)]
290struct ParsedPid {
291    path: PathBuf,
292    pid: Option<u32>,
293}
294impl ParsedPid {
295    fn parse(path: PathBuf, input: &[u8]) -> Result<Self, (PathBuf, io::Error)> {
296        if input.is_empty() {
297            return Ok(Self { path, pid: None });
298        }
299
300        let result = match input.iter().all(u8::is_ascii_digit) {
301            true => Ok(input),
302            false => Err(io::Error::new(io::ErrorKind::InvalidData, "digit expected")),
303        }
304        .and_then(|input| std::str::from_utf8(input).or_invalid_data())
305        .and_then(|s| s.parse::<u32>().or_invalid_data());
306
307        match result {
308            Ok(pid) => Ok(Self {
309                path,
310                pid: Some(pid),
311            }),
312            Err(source) => Err((path, source)),
313        }
314    }
315    fn write(self, pid: u32, output: &mut api::File) -> Result<PathBuf, RawLockfileError> {
316        match output
317            .rewind()
318            .and_then(|_| output.set_len(0))
319            .and_then(|_| {
320                let pid = pid.to_string();
321                output.write_all(pid.as_bytes())
322            })
323            .and_then(|_| output.sync_all())
324        {
325            Ok(_) => Ok(self.path),
326            Err(source) => Err(RawLockfileError::AccessFailed {
327                path: self.path,
328                source,
329            }),
330        }
331    }
332    fn into_already_locked_error(self) -> RawLockfileError {
333        RawLockfileError::AlreadyLocked {
334            path: self.path,
335            pid: self.pid,
336        }
337    }
338}
339
340struct UnparsedPid {
341    path: PathBuf,
342    data: Vec<u8>,
343}
344impl UnparsedPid {
345    fn read(path: PathBuf, input: &mut api::File) -> Result<Self, RawLockfileError> {
346        match input.rewind().and_then(|_| {
347            let mut data = Vec::<u8>::with_capacity(10);
348            input.read_to_end(&mut data).map(|_| data)
349        }) {
350            Ok(data) => Ok(Self { path, data }),
351            Err(source) => Err(RawLockfileError::AccessFailed { path, source }),
352        }
353    }
354    fn parse_checked(self, current_pid: u32) -> ParsedPid {
355        match ParsedPid::parse(self.path, self.data.trim_ascii()) {
356            Ok(parsed) => {
357                match parsed.pid {
358                    Some(written_pid) if written_pid != current_pid => {
359                        warn!(written_pid, current_pid, lockfile = %parsed.path.display(), "encountered stale lockfile");
360                    }
361                    _ => {}
362                }
363                parsed
364            }
365            Err((path, error)) => {
366                warn!(current_pid, lockfile = %path.display(), %error, "encountered invalid lockfile");
367                ParsedPid { path, pid: None }
368            }
369        }
370    }
371    fn parse(self) -> ParsedPid {
372        ParsedPid::parse(self.path, self.data.trim_ascii())
373            .unwrap_or_else(|(path, _)| ParsedPid { path, pid: None })
374    }
375}
376
377trait OrInvalidData: Sized {
378    type Output;
379    fn or_invalid_data(self) -> io::Result<Self::Output>;
380}
381impl<T, E> OrInvalidData for Result<T, E>
382where
383    E: std::error::Error + Send + Sync + 'static,
384{
385    type Output = T;
386    fn or_invalid_data(self) -> io::Result<T> {
387        match self {
388            Ok(v) => Ok(v),
389            Err(e) => Err(io::Error::new(io::ErrorKind::InvalidData, e)),
390        }
391    }
392}
393
394#[derive(Debug)]
395pub enum LockfileError {
396    AccessFailed {
397        scope: ScopeKey,
398        path: PathBuf,
399        source: io::Error,
400    },
401    AlreadyLocked {
402        scope: ScopeKey,
403        path: PathBuf,
404        pid: Option<u32>,
405    },
406}
407impl Scoped for LockfileError {
408    fn scope(&self) -> ScopeKey {
409        match *self {
410            Self::AccessFailed { scope, .. } => scope,
411            Self::AlreadyLocked { scope, .. } => scope,
412        }
413    }
414}
415impl LockfileError {
416    pub fn path(&self) -> &Path {
417        use LockfileError::*;
418        let (AccessFailed { path, .. } | AlreadyLocked { path, .. }) = self;
419        path
420    }
421    pub fn into_path(self) -> PathBuf {
422        use LockfileError::*;
423        let (AccessFailed { path, .. } | AlreadyLocked { path, .. }) = self;
424        path
425    }
426}
427impl fmt::Display for LockfileError {
428    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429        match *self {
430            Self::AccessFailed {
431                ref path,
432                ref source,
433                ..
434            } => write!(f, "access failed: {}: {source}", path.display()),
435            Self::AlreadyLocked { ref path, pid, .. } => {
436                let pid = match pid {
437                    Some(pid) => Cow::Owned(pid.to_string()),
438                    None => Cow::Borrowed("unknown"),
439                };
440                write!(f, "already locked: {}: {pid}", path.display())
441            }
442        }
443    }
444}
445impl std::error::Error for LockfileError {}
446
447#[derive(Debug)]
448pub enum RawLockfileError {
449    AccessFailed { path: PathBuf, source: io::Error },
450    AlreadyLocked { path: PathBuf, pid: Option<u32> },
451}
452impl RawLockfileError {
453    pub fn path(&self) -> &Path {
454        use RawLockfileError::*;
455        let (AccessFailed { path, .. } | AlreadyLocked { path, .. }) = self;
456        path
457    }
458    pub fn into_path(self) -> PathBuf {
459        use RawLockfileError::*;
460        let (AccessFailed { path, .. } | AlreadyLocked { path, .. }) = self;
461        path
462    }
463}
464impl fmt::Display for RawLockfileError {
465    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
466        match *self {
467            Self::AccessFailed {
468                ref path,
469                ref source,
470            } => write!(f, "access failed: {}: {source}", path.display()),
471            Self::AlreadyLocked { ref path, pid } => {
472                let pid = match pid {
473                    Some(pid) => Cow::Owned(pid.to_string()),
474                    None => Cow::Borrowed("unknown"),
475                };
476                write!(f, "already locked: {}: {pid}", path.display())
477            }
478        }
479    }
480}
481impl std::error::Error for RawLockfileError {}
482
483#[cfg(test)]
484mod tests {
485    use claims::assert_matches;
486    use daemonbit_test::capture_tracing;
487
488    use super::*;
489
490    macro_rules! pid {
491        ($pid:expr) => {{
492            let pid = ($pid).to_string();
493            pid.as_bytes().to_vec()
494        }};
495    }
496
497    macro_rules! unparsed {
498        ($pid:expr) => {
499            UnparsedPid {
500                path: PathBuf::new(),
501                data: $pid,
502            }
503        };
504    }
505
506    macro_rules! parsed {
507        ($pid:expr) => {
508            ParsedPid {
509                path: PathBuf::new(),
510                pid: $pid,
511            }
512        };
513    }
514
515    const CURRENT_PID: u32 = 123;
516    const FOREIGN_PID: u32 = 456;
517    const INVALID_DATA: &[u8] = b"invalid";
518
519    #[test]
520    fn parse_empty() {
521        let result = ParsedPid::parse(PathBuf::new(), b"");
522        assert_matches!(result, Ok(ParsedPid { pid: None, .. }));
523    }
524
525    #[test]
526    fn parse_invalid_utf8() {
527        let result = ParsedPid::parse(PathBuf::new(), b"\xff");
528        assert_matches!(result, Err((_, e)) if e.kind() == io::ErrorKind::InvalidData);
529    }
530
531    #[test]
532    fn parse_garbage() {
533        let result = ParsedPid::parse(PathBuf::new(), b"123 pid");
534        assert_matches!(result, Err((_, e)) if e.kind() == io::ErrorKind::InvalidData);
535    }
536
537    #[test]
538    fn parse_negative_number() {
539        let result = ParsedPid::parse(PathBuf::new(), b"-123");
540        assert_matches!(result, Err((_, e)) if e.kind() == io::ErrorKind::InvalidData);
541    }
542
543    #[test]
544    fn parse_zero() {
545        let result = ParsedPid::parse(PathBuf::new(), b"0");
546        assert_matches!(result, Ok(ParsedPid { pid: Some(0), .. }));
547    }
548
549    #[test]
550    fn parse_positive_number_signed() {
551        let result = ParsedPid::parse(PathBuf::new(), b"+123");
552        assert_matches!(result, Err((_, e)) if e.kind() == io::ErrorKind::InvalidData);
553    }
554
555    #[test]
556    fn parse_positive_number_unsigned() {
557        let result = ParsedPid::parse(PathBuf::new(), b"123");
558        assert_matches!(result, Ok(ParsedPid { pid: Some(123), .. }));
559    }
560
561    #[test]
562    fn try_parse_different_pid_warns_stale() {
563        let parsed = capture_tracing(|| unparsed!(pid!(FOREIGN_PID)).parse_checked(CURRENT_PID));
564        assert!(parsed.logged("encountered stale lockfile"));
565        assert_matches!(parsed.pid, Some(FOREIGN_PID));
566    }
567
568    #[test]
569    fn try_parse_same_pid_do_not_warn() {
570        let parsed = capture_tracing(|| unparsed!(pid!(CURRENT_PID)).parse_checked(CURRENT_PID));
571        assert!(!parsed.logged("encountered stale lockfile"));
572        assert_matches!(parsed.pid, Some(CURRENT_PID));
573    }
574
575    #[test]
576    fn try_parse_empty_do_not_warn() {
577        let parsed = capture_tracing(|| unparsed!(Vec::new()).parse_checked(CURRENT_PID));
578        assert!(!parsed.logged("encountered stale lockfile"));
579        assert_matches!(parsed.pid, None);
580    }
581
582    #[test]
583    fn try_parse_unparseable_pid_warns_invalid() {
584        let parsed =
585            capture_tracing(|| unparsed!(INVALID_DATA.to_vec()).parse_checked(CURRENT_PID));
586        assert!(parsed.logged("encountered invalid lockfile"));
587        assert_matches!(parsed.pid, None);
588    }
589
590    #[test]
591    fn into_already_locked_error_with_different_pid() {
592        let error = parsed!(Some(FOREIGN_PID)).into_already_locked_error();
593        assert_matches!(
594            error,
595            RawLockfileError::AlreadyLocked {
596                pid: Some(FOREIGN_PID),
597                ..
598            }
599        );
600    }
601
602    #[test]
603    fn into_already_locked_error_with_none() {
604        let error = parsed!(None).into_already_locked_error();
605        assert_matches!(error, RawLockfileError::AlreadyLocked { pid: None, .. });
606    }
607
608    #[test]
609    fn lockfile_open_writes_pid() {
610        api::mock(CURRENT_PID, Ok(pid!(FOREIGN_PID)), None);
611        let lockfile = RawLockfile::try_acquire_with_path("").unwrap();
612        assert_eq!(lockfile.pid, CURRENT_PID);
613        assert_eq!(lockfile.flock.get_ref(), &pid!(CURRENT_PID));
614    }
615
616    #[test]
617    fn lockfile_open_not_found() {
618        api::mock(
619            CURRENT_PID,
620            Err(io::Error::new(
621                io::ErrorKind::NotFound,
622                "no such file or directory",
623            )),
624            None,
625        );
626        let result = RawLockfile::try_acquire_with_path("");
627        assert_matches!(result, Err(RawLockfileError::AccessFailed { .. }));
628    }
629
630    #[test]
631    fn lockfile_open_already_locked() {
632        api::mock(
633            CURRENT_PID,
634            Ok(pid!(FOREIGN_PID)),
635            Some(api::Errno::EWOULDBLOCK),
636        );
637        let result = RawLockfile::try_acquire_with_path("");
638        assert_matches!(
639            result,
640            Err(RawLockfileError::AlreadyLocked {
641                pid: Some(FOREIGN_PID),
642                ..
643            })
644        );
645    }
646
647    #[test]
648    fn lockfile_open_other_lock_error() {
649        api::mock(CURRENT_PID, Ok(pid!(FOREIGN_PID)), Some(api::Errno::EACCES));
650        let result = RawLockfile::try_acquire_with_path("");
651        assert_matches!(result, Err(RawLockfileError::AccessFailed { .. }));
652    }
653}