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}