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}