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.
27#[allow(
28 dead_code,
29 reason = "the File is held only so the OS keeps the lock — not read again"
30)]
31pub struct InstanceGuard {
32 _handle: File,
33}
34
35/// Failure acquiring the single-instance lock.
36/// [`InstanceError::AlreadyRunning`] is the expected "another copy is open"
37/// signal; every other variant indicates filesystem trouble.
38#[derive(Debug, Error)]
39pub enum InstanceError {
40 /// The lock file's directory could not be resolved (no home directory).
41 #[error("could not resolve lock path")]
42 Path(#[from] PathsError),
43 /// Creating or opening the lock file failed.
44 #[error("could not open lock file at {path}")]
45 Open {
46 /// The lock file being opened.
47 path: PathBuf,
48 /// The underlying I/O error.
49 #[source]
50 source: io::Error,
51 },
52 /// Another process of the same role already holds the lock — surface it
53 /// politely and exit with a non-error status.
54 #[error("another instance already holds the lock at {path}")]
55 AlreadyRunning {
56 /// The contested lock file.
57 path: PathBuf,
58 },
59 /// The lock syscall itself failed, as opposed to the lock being held.
60 #[error("lock attempt at {path} failed")]
61 LockFailed {
62 /// The lock file the attempt targeted.
63 path: PathBuf,
64 /// The underlying I/O error.
65 #[source]
66 source: io::Error,
67 },
68}
69
70/// Acquire the single-instance lock on `lock_name` (a bare file name resolved
71/// under [`paths::config_dir`]). Returns `Ok(guard)` on success — keep the
72/// guard alive until the process is about to exit.
73///
74/// `AlreadyRunning` is the polite "another copy is open" signal callers
75/// surface to the user (and exit with a non-error status). Other variants
76/// indicate filesystem trouble.
77///
78/// # Errors
79///
80/// Returns [`InstanceError`] if the lock path can't be resolved, the lock file
81/// can't be opened, another instance already holds the lock, or the lock
82/// syscall itself fails.
83pub fn acquire(lock_name: &str) -> Result<InstanceGuard, InstanceError> {
84 let path = paths::config_dir()?.join(lock_name);
85 if let Some(parent) = path.parent() {
86 std::fs::create_dir_all(parent).map_err(|source| InstanceError::Open {
87 path: path.clone(),
88 source,
89 })?;
90 }
91 let file = OpenOptions::new()
92 .read(true)
93 .write(true)
94 .create(true)
95 .truncate(false)
96 .open(&path)
97 .map_err(|source| InstanceError::Open {
98 path: path.clone(),
99 source,
100 })?;
101 match file.try_lock() {
102 Ok(()) => {
103 debug!(path = %path.display(), "single-instance lock acquired");
104 Ok(InstanceGuard { _handle: file })
105 }
106 Err(TryLockError::WouldBlock) => Err(InstanceError::AlreadyRunning { path }),
107 Err(TryLockError::Error(source)) => Err(InstanceError::LockFailed { path, source }),
108 }
109}