Skip to main content

openlogi_core/
single_instance.rs

1//! Cross-platform single-instance process guard.
2//!
3//! On startup a process tries to acquire an exclusive, non-blocking lock on a
4//! named file under the user's data dir. Holding the lock keeps a second
5//! invocation of the *same* role from running — the GUI uses it to avoid a
6//! duplicate window, the background agent to avoid two processes fighting over
7//! the same devices and IPC socket. Each role passes its own lock file name so
8//! the GUI and the agent don't lock each other out. The lock is released by the
9//! OS when the process exits, so crash-recovery is free: the next launch
10//! reclaims the lock on the leftover file without any cleanup ceremony.
11
12use std::{
13    fs::{File, OpenOptions, TryLockError},
14    io,
15    path::PathBuf,
16};
17
18use thiserror::Error;
19use tracing::debug;
20
21use crate::paths::{self, PathsError};
22
23/// Held by `main` for the duration of the run; dropped on exit (the OS
24/// releases the underlying file lock at the same time). The `_handle` field
25/// is intentionally unused — the value is alive only for its `Drop` side
26/// effect of closing the fd.
27pub struct InstanceGuard {
28    _handle: File,
29}
30
31/// Failure acquiring the single-instance lock.
32/// [`InstanceError::AlreadyRunning`] is the expected "another copy is open"
33/// signal; every other variant indicates filesystem trouble.
34#[derive(Debug, Error)]
35pub enum InstanceError {
36    /// The lock file's directory could not be resolved (no home directory).
37    #[error("could not resolve lock path")]
38    Path(#[from] PathsError),
39    /// Creating or opening the lock file failed.
40    #[error("could not open lock file at {path}")]
41    Open {
42        /// The lock file being opened.
43        path: PathBuf,
44        /// The underlying I/O error.
45        #[source]
46        source: io::Error,
47    },
48    /// Another process of the same role already holds the lock — surface it
49    /// politely and exit with a non-error status.
50    #[error("another instance already holds the lock at {path}")]
51    AlreadyRunning {
52        /// The contested lock file.
53        path: PathBuf,
54    },
55    /// The lock syscall itself failed, as opposed to the lock being held.
56    #[error("lock attempt at {path} failed")]
57    LockFailed {
58        /// The lock file the attempt targeted.
59        path: PathBuf,
60        /// The underlying I/O error.
61        #[source]
62        source: io::Error,
63    },
64}
65
66/// Acquire the single-instance lock on `lock_name` (a bare file name resolved
67/// under [`paths::config_dir`]). Returns `Ok(guard)` on success — keep the
68/// guard alive until the process is about to exit.
69///
70/// `AlreadyRunning` is the polite "another copy is open" signal callers
71/// surface to the user (and exit with a non-error status). Other variants
72/// indicate filesystem trouble.
73///
74/// # Errors
75///
76/// Returns [`InstanceError`] if the lock path can't be resolved, the lock file
77/// can't be opened, another instance already holds the lock, or the lock
78/// syscall itself fails.
79pub fn acquire(lock_name: &str) -> Result<InstanceGuard, InstanceError> {
80    let path = paths::config_dir()?.join(lock_name);
81    if let Some(parent) = path.parent() {
82        std::fs::create_dir_all(parent).map_err(|source| InstanceError::Open {
83            path: path.clone(),
84            source,
85        })?;
86    }
87    let file = OpenOptions::new()
88        .read(true)
89        .write(true)
90        .create(true)
91        .truncate(false)
92        .open(&path)
93        .map_err(|source| InstanceError::Open {
94            path: path.clone(),
95            source,
96        })?;
97    match file.try_lock() {
98        Ok(()) => {
99            debug!(path = %path.display(), "single-instance lock acquired");
100            Ok(InstanceGuard { _handle: file })
101        }
102        Err(TryLockError::WouldBlock) => Err(InstanceError::AlreadyRunning { path }),
103        Err(TryLockError::Error(source)) => Err(InstanceError::LockFailed { path, source }),
104    }
105}