From bd06ea4540b85d98f6edd2af0885253b50cb494f Mon Sep 17 00:00:00 2001 From: a-kenji Date: Thu, 5 Feb 2026 13:14:46 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Restrict=20running=20sessions?= =?UTF-8?q?=20to=20one=20concurrent=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict running sessions to one concurrent one. Uses the `XDG_RUNTIME_DIR` to hold an exclusive runtime lock. The lock is automatically cleaned up upon exit. An earlier implementation used the `fs4` crate, but now this functionality is in the `stdlib` since `rustc --version`: `1.89.0`. Closes: #179 Supercedes: #182 --- client/src/lock.rs | 140 +++++++++++++++++++++++++++++++++++++++++++++ client/src/main.rs | 9 ++- 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 client/src/lock.rs diff --git a/client/src/lock.rs b/client/src/lock.rs new file mode 100644 index 0000000..e56b4ae --- /dev/null +++ b/client/src/lock.rs @@ -0,0 +1,140 @@ +use std::env; +use std::fmt; +use std::fs::File; +use std::fs::TryLockError; +use std::path::{Path, PathBuf}; + +const LOCK_FILE_NAME: &str = "centerpiece.lock"; +const XDG_RUNTIME_DIR_ENV: &str = "XDG_RUNTIME_DIR"; + +#[derive(Debug)] +pub enum LockFileError { + AlreadyLocked(PathBuf), + IoError(std::io::Error), + UnlockError(PathBuf, std::io::Error), +} + +type LockFileResult = Result; + +impl fmt::Display for LockFileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let display_path = |path: &PathBuf| path.to_string_lossy().to_string(); + match self { + LockFileError::AlreadyLocked(path) => { + write!( + f, + "Another instance is already running (lock: {})\n\ + If you think this is an error, please remove the lockfile manually", + display_path(path) + ) + } + LockFileError::IoError(err) => write!(f, "IoError: {}", err), + LockFileError::UnlockError(path, err) => { + write!( + f, + "Failed to remove the lockfile: {} - IoError: {}", + display_path(path), + err + ) + } + } + } +} + +impl From for LockFileError { + fn from(err: std::io::Error) -> Self { + LockFileError::IoError(err) + } +} + +fn get_xdg_runtime_dir() -> Option { + env::var(XDG_RUNTIME_DIR_ENV).ok() +} + +#[derive(Debug)] +pub struct LockFile { + path: PathBuf, + file: Option, +} + +impl LockFile { + fn new() -> Option { + Some(Self { + path: Self::get_lock_file_path()?, + file: None, + }) + } + + fn path(&self) -> &Path { + self.path.as_ref() + } + + fn path_buf(&self) -> PathBuf { + self.path().to_path_buf() + } + + fn get_lock_file_path() -> Option { + let xdg_runtime_dir = get_xdg_runtime_dir()?; + Some(Path::new(&xdg_runtime_dir).join(LOCK_FILE_NAME)) + } + + fn try_lock(&mut self) -> LockFileResult<()> { + let file = std::fs::File::options() + .write(true) + .create(true) + .truncate(false) + .open(self.path())?; + + match file.try_lock() { + Ok(()) => { + self.file = Some(file); + Ok(()) + } + Err(TryLockError::WouldBlock) => Err(LockFileError::AlreadyLocked(self.path_buf())), + Err(TryLockError::Error(e)) => Err(LockFileError::IoError(e)), + } + } + + fn remove_lockfile() -> LockFileResult<()> { + if let Some(lock_file) = Self::new() { + std::fs::remove_file(lock_file.path()) + .map_err(|err| LockFileError::UnlockError(lock_file.path_buf(), err))? + } + Ok(()) + } + + pub fn unlock() { + if let Err(err) = Self::remove_lockfile() { + log::error!("{err}"); + } + } + + pub fn acquire_exclusive_lock() -> LockFileResult { + match Self::new() { + Some(mut lock_file) => { + lock_file.try_lock()?; + Ok(lock_file) + } + None => { + log::warn!("XDG_RUNTIME_DIR not found, running without file lock"); + Err(LockFileError::IoError(std::io::Error::new( + std::io::ErrorKind::NotFound, + "XDG_RUNTIME_DIR environment variable not set", + ))) + } + } + } + + pub fn run_exclusive() -> Self { + match Self::acquire_exclusive_lock() { + Ok(lock_file) => { + log::info!("Successfully acquired exclusive lock"); + lock_file + } + Err(err) => { + log::warn!("{err}"); + std::process::exit(0); + } + } + } +} diff --git a/client/src/main.rs b/client/src/main.rs index 11f85c9..311dc05 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -4,6 +4,7 @@ use clap::Parser; use eframe::egui::{self, Separator}; mod component; +mod lock; mod model; mod plugin; @@ -16,6 +17,8 @@ pub fn main() { simple_logger::init_with_level(log::Level::Info).unwrap(); + let _lock = lock::LockFile::run_exclusive(); + eframe::run_native( "centerpiece", settings(), @@ -311,6 +314,7 @@ impl Centerpiece { self.activate_selected_entry(); } if ctx.input(|i| i.key_pressed(eframe::egui::Key::Escape)) { + lock::LockFile::unlock(); exit(0); } @@ -337,7 +341,10 @@ impl Centerpiece { self.update_entries(plugin_id, entries) } - Message::Exit => exit(0), + Message::Exit => { + lock::LockFile::unlock(); + exit(0); + } } } } From 83538ef23e6b3501d840fe474db375dc62c40597 Mon Sep 17 00:00:00 2001 From: a-kenji Date: Sun, 8 Feb 2026 12:11:37 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20unlock-and-e?= =?UTF-8?q?xit=20into`Centerpiece::exit()`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternatively we could also add a drop `impl`, and call `eguis` native exit handling. --- client/src/main.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/client/src/main.rs b/client/src/main.rs index 311dc05..f1ca143 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,5 +1,3 @@ -use std::process::exit; - use clap::Parser; use eframe::egui::{self, Separator}; @@ -303,6 +301,11 @@ impl Centerpiece { } } + fn exit(&self) -> ! { + lock::LockFile::unlock(); + std::process::exit(0); + } + fn handle_input(&mut self, ctx: &eframe::egui::Context) { if ctx.input(|i| i.key_pressed(eframe::egui::Key::ArrowUp)) { self.select_previous_entry(); @@ -314,8 +317,7 @@ impl Centerpiece { self.activate_selected_entry(); } if ctx.input(|i| i.key_pressed(eframe::egui::Key::Escape)) { - lock::LockFile::unlock(); - exit(0); + self.exit(); } if ctx.input(|i| i.modifiers.ctrl && i.key_pressed(eframe::egui::Key::J)) { @@ -342,8 +344,7 @@ impl Centerpiece { } Message::Exit => { - lock::LockFile::unlock(); - exit(0); + self.exit(); } } } From 06e7ea49fada355d3b4e64d289993148f347a460 Mon Sep 17 00:00:00 2001 From: a-kenji Date: Sun, 15 Feb 2026 16:36:52 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20Restrict=20running=20sessions?= =?UTF-8?q?=20to=20one=20concurrent=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict running sessions to one concurrent one. Uses the `XDG_RUNTIME_DIR` to hold an exclusive runtime lock. As opposed to #258, we don't block starting a new session on the lock, but clean up the old session and start a new session. The end result is similar: There will only be one `centerpiece` session open at the same time. Alternatives: - Use #258 for the other behavior - Link directly to the C functions for kill, but we depend on `libc` transitively anyway already - shell out to the `kill` command, this would mean we depend on the external `kill` command for this functionality --- Cargo.lock | 1 + client/Cargo.toml | 1 + client/src/lock.rs | 181 ++++++++++++++++----------------------------- client/src/main.rs | 10 +-- 4 files changed, 72 insertions(+), 121 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e8e290..6b943ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,7 @@ dependencies = [ "dbus", "eframe", "freedesktop-desktop-entry", + "libc", "log", "networkmanager", "nucleo-matcher", diff --git a/client/Cargo.toml b/client/Cargo.toml index 79fa674..11823f0 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -11,6 +11,7 @@ edition.workspace = true # general settings = { path = "../settings" } anyhow = { version = "1.0.100", features = ["backtrace"] } +libc = "0.2" clap = { version = "4.5.21", features = ["derive", "env"] } log = { version = "0.4.22", features = ["kv_unstable_serde"] } simple_logger = { version = "5.0.0", features = [ diff --git a/client/src/lock.rs b/client/src/lock.rs index e56b4ae..8e00d46 100644 --- a/client/src/lock.rs +++ b/client/src/lock.rs @@ -1,140 +1,89 @@ use std::env; -use std::fmt; -use std::fs::File; -use std::fs::TryLockError; +use std::fs::{File, TryLockError}; +use std::io::{Read, Seek, Write}; use std::path::{Path, PathBuf}; const LOCK_FILE_NAME: &str = "centerpiece.lock"; -const XDG_RUNTIME_DIR_ENV: &str = "XDG_RUNTIME_DIR"; -#[derive(Debug)] -pub enum LockFileError { - AlreadyLocked(PathBuf), - IoError(std::io::Error), - UnlockError(PathBuf, std::io::Error), -} - -type LockFileResult = Result; - -impl fmt::Display for LockFileError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let display_path = |path: &PathBuf| path.to_string_lossy().to_string(); - match self { - LockFileError::AlreadyLocked(path) => { - write!( - f, - "Another instance is already running (lock: {})\n\ - If you think this is an error, please remove the lockfile manually", - display_path(path) - ) - } - LockFileError::IoError(err) => write!(f, "IoError: {}", err), - LockFileError::UnlockError(path, err) => { - write!( - f, - "Failed to remove the lockfile: {} - IoError: {}", - display_path(path), - err - ) - } - } - } -} - -impl From for LockFileError { - fn from(err: std::io::Error) -> Self { - LockFileError::IoError(err) - } -} - -fn get_xdg_runtime_dir() -> Option { - env::var(XDG_RUNTIME_DIR_ENV).ok() -} - -#[derive(Debug)] pub struct LockFile { - path: PathBuf, - file: Option, + _file: File, } impl LockFile { - fn new() -> Option { - Some(Self { - path: Self::get_lock_file_path()?, - file: None, - }) - } - - fn path(&self) -> &Path { - self.path.as_ref() - } - - fn path_buf(&self) -> PathBuf { - self.path().to_path_buf() - } - - fn get_lock_file_path() -> Option { - let xdg_runtime_dir = get_xdg_runtime_dir()?; - Some(Path::new(&xdg_runtime_dir).join(LOCK_FILE_NAME)) - } - - fn try_lock(&mut self) -> LockFileResult<()> { - let file = std::fs::File::options() - .write(true) - .create(true) - .truncate(false) - .open(self.path())?; + pub fn acquire() -> Option { + let path = lock_file_path()?; + let mut file = open_lock_file(&path)?; match file.try_lock() { Ok(()) => { - self.file = Some(file); - Ok(()) + write_pid(&mut file); + log::info!("Acquired exclusive lock"); + Some(Self { _file: file }) + } + Err(TryLockError::WouldBlock) => { + kill_existing_instance(&mut file); + // Blocks until the old process exits and releases the lock + file.lock().ok()?; + write_pid(&mut file); + log::info!("Acquired exclusive lock after killing previous instance"); + Some(Self { _file: file }) + } + Err(TryLockError::Error(e)) => { + log::error!("Failed to acquire lock: {e}"); + None } - Err(TryLockError::WouldBlock) => Err(LockFileError::AlreadyLocked(self.path_buf())), - Err(TryLockError::Error(e)) => Err(LockFileError::IoError(e)), } } - fn remove_lockfile() -> LockFileResult<()> { - if let Some(lock_file) = Self::new() { - std::fs::remove_file(lock_file.path()) - .map_err(|err| LockFileError::UnlockError(lock_file.path_buf(), err))? + pub fn cleanup() { + if let Some(path) = lock_file_path() { + let _ = std::fs::remove_file(path); } - Ok(()) } +} - pub fn unlock() { - if let Err(err) = Self::remove_lockfile() { - log::error!("{err}"); - } +fn lock_file_path() -> Option { + let dir = env::var("XDG_RUNTIME_DIR").ok(); + if dir.is_none() { + log::warn!("XDG_RUNTIME_DIR not set, running without file lock"); } + Some(Path::new(&dir?).join(LOCK_FILE_NAME)) +} - pub fn acquire_exclusive_lock() -> LockFileResult { - match Self::new() { - Some(mut lock_file) => { - lock_file.try_lock()?; - Ok(lock_file) - } - None => { - log::warn!("XDG_RUNTIME_DIR not found, running without file lock"); - Err(LockFileError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, - "XDG_RUNTIME_DIR environment variable not set", - ))) - } - } - } +fn open_lock_file(path: &Path) -> Option { + File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|e| log::error!("Failed to open lock file: {e}")) + .ok() +} - pub fn run_exclusive() -> Self { - match Self::acquire_exclusive_lock() { - Ok(lock_file) => { - log::info!("Successfully acquired exclusive lock"); - lock_file - } - Err(err) => { - log::warn!("{err}"); - std::process::exit(0); - } - } +fn write_pid(file: &mut File) { + let _ = file.set_len(0); + let _ = file.seek(std::io::SeekFrom::Start(0)); + let _ = write!(file, "{}", std::process::id()); + let _ = file.flush(); +} + +fn read_pid(file: &mut File) -> Option { + let mut contents = String::new(); + file.seek(std::io::SeekFrom::Start(0)).ok()?; + file.read_to_string(&mut contents).ok()?; + contents.trim().parse().ok() +} + +fn kill_existing_instance(file: &mut File) { + let Some(pid) = read_pid(file) else { + log::warn!("Lock held but no PID in lock file"); + return; + }; + log::info!("Killing existing instance with PID {pid}"); + // SAFETY: sending SIGTERM to a known PID + unsafe { + libc::kill(pid as i32, libc::SIGTERM); } } + diff --git a/client/src/main.rs b/client/src/main.rs index f1ca143..f49999e 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -15,7 +15,7 @@ pub fn main() { simple_logger::init_with_level(log::Level::Info).unwrap(); - let _lock = lock::LockFile::run_exclusive(); + let _lock = lock::LockFile::acquire(); eframe::run_native( "centerpiece", @@ -301,8 +301,8 @@ impl Centerpiece { } } - fn exit(&self) -> ! { - lock::LockFile::unlock(); + fn exit() -> ! { + lock::LockFile::cleanup(); std::process::exit(0); } @@ -317,7 +317,7 @@ impl Centerpiece { self.activate_selected_entry(); } if ctx.input(|i| i.key_pressed(eframe::egui::Key::Escape)) { - self.exit(); + Self::exit(); } if ctx.input(|i| i.modifiers.ctrl && i.key_pressed(eframe::egui::Key::J)) { @@ -344,7 +344,7 @@ impl Centerpiece { } Message::Exit => { - self.exit(); + Self::exit(); } } }