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`] has its own lock file, 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/// The processes that must run at most once per user, each on its own lock.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum Role {
69    /// The background agent — real or mock — that owns the devices and the IPC
70    /// socket. A tool that must keep every agent away from the hardware while
71    /// it works takes this lock itself.
72    Agent,
73    /// The GUI: one settings window per user.
74    Gui,
75}
76
77impl Role {
78    /// The lock file's name under [`paths::config_dir`].
79    const fn lock_file(self) -> &'static str {
80        match self {
81            Self::Agent => "agent.lock",
82            Self::Gui => "openlogi.lock",
83        }
84    }
85}
86
87/// Acquire the single-instance lock for `role` (a file under
88/// [`paths::config_dir`]). Returns `Ok(guard)` on success — keep the guard
89/// alive until the process is about to exit.
90///
91/// `AlreadyRunning` is the polite "another copy is open" signal callers
92/// surface to the user (and exit with a non-error status). Other variants
93/// indicate filesystem trouble.
94///
95/// # Errors
96///
97/// Returns [`InstanceError`] if the lock path can't be resolved, the lock file
98/// can't be opened, another instance already holds the lock, or the lock
99/// syscall itself fails.
100pub fn acquire(role: Role) -> Result<InstanceGuard, InstanceError> {
101    let path = paths::config_dir()?.join(role.lock_file());
102    if let Some(parent) = path.parent() {
103        std::fs::create_dir_all(parent).map_err(|source| InstanceError::Open {
104            path: path.clone(),
105            source,
106        })?;
107    }
108    let file = OpenOptions::new()
109        .read(true)
110        .write(true)
111        .create(true)
112        .truncate(false)
113        .open(&path)
114        .map_err(|source| InstanceError::Open {
115            path: path.clone(),
116            source,
117        })?;
118    match file.try_lock() {
119        Ok(()) => {
120            debug!(path = %path.display(), "single-instance lock acquired");
121            Ok(InstanceGuard { _handle: file })
122        }
123        Err(TryLockError::WouldBlock) => Err(InstanceError::AlreadyRunning { path }),
124        Err(TryLockError::Error(source)) => Err(InstanceError::LockFailed { path, source }),
125    }
126}