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 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}