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},
14 io,
15 path::PathBuf,
16};
17
18use fs4::{FileExt, TryLockError};
19use thiserror::Error;
20use tracing::debug;
21
22use crate::paths::{self, PathsError};
23
24/// Held by `main` for the duration of the run; dropped on exit (the OS
25/// releases the underlying file lock at the same time). The `_handle` field
26/// is intentionally unused — the value is alive only for its `Drop` side
27/// effect of closing the fd.
28#[allow(
29 dead_code,
30 reason = "the File is held only so the OS keeps the lock — not read again"
31)]
32pub struct InstanceGuard {
33 _handle: File,
34}
35
36#[derive(Debug, Error)]
37pub enum InstanceError {
38 #[error("could not resolve lock path")]
39 Path(#[from] PathsError),
40 #[error("could not open lock file at {path}")]
41 Open {
42 path: PathBuf,
43 #[source]
44 source: io::Error,
45 },
46 #[error("another instance already holds the lock at {path}")]
47 AlreadyRunning { path: PathBuf },
48 #[error("lock attempt at {path} failed")]
49 LockFailed {
50 path: PathBuf,
51 #[source]
52 source: io::Error,
53 },
54}
55
56/// Acquire the single-instance lock on `lock_name` (a bare file name resolved
57/// under [`paths::config_dir`]). Returns `Ok(guard)` on success — keep the
58/// guard alive until the process is about to exit.
59///
60/// `AlreadyRunning` is the polite "another copy is open" signal callers
61/// surface to the user (and exit with a non-error status). Other variants
62/// indicate filesystem trouble.
63///
64/// # Errors
65///
66/// Returns [`InstanceError`] if the lock path can't be resolved, the lock file
67/// can't be opened, another instance already holds the lock, or the lock
68/// syscall itself fails.
69pub fn acquire(lock_name: &str) -> Result<InstanceGuard, InstanceError> {
70 let path = paths::config_dir()?.join(lock_name);
71 if let Some(parent) = path.parent() {
72 std::fs::create_dir_all(parent).map_err(|source| InstanceError::Open {
73 path: path.clone(),
74 source,
75 })?;
76 }
77 let file = OpenOptions::new()
78 .read(true)
79 .write(true)
80 .create(true)
81 .truncate(false)
82 .open(&path)
83 .map_err(|source| InstanceError::Open {
84 path: path.clone(),
85 source,
86 })?;
87 match FileExt::try_lock(&file) {
88 Ok(()) => {
89 debug!(path = %path.display(), "single-instance lock acquired");
90 Ok(InstanceGuard { _handle: file })
91 }
92 Err(TryLockError::WouldBlock) => Err(InstanceError::AlreadyRunning { path }),
93 Err(TryLockError::Error(source)) => Err(InstanceError::LockFailed { path, source }),
94 }
95}