From bc3a0bab823af1921dbdf2bee89ca4bc75721ef8 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Tue, 29 Sep 2026 23:26:18 +0300 Subject: [PATCH] feat: single-instance lock and detached background child --- src/detach.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++ src/lock.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 ++ 3 files changed, 149 insertions(+) create mode 100644 src/detach.rs create mode 100644 src/lock.rs diff --git a/src/detach.rs b/src/detach.rs new file mode 100644 index 0000000..1eeba77 --- /dev/null +++ b/src/detach.rs @@ -0,0 +1,71 @@ +use std::fs::File; +use std::io; + +use anyhow::{Context, Result}; + +use crate::lock::Lock; + +/// Runs `work` in a forked child that outlives valw, like `wl-copy` does. +/// The parent returns right away. +/// +/// The child drops the capture lock (so the next capture isn't blocked) and +/// points stdio at /dev/null (so `valw ... -o - | x` sees EOF without waiting +/// for the clipboard to be replaced). +pub fn spawn(lock: Lock, work: impl FnOnce()) -> Result<()> { + lock.release(); + // SAFETY: valw is single-threaded at this point, and the child only + // touches its own state before exiting with _exit. + match unsafe { libc::fork() } { + -1 => Err(io::Error::last_os_error()).context("could not fork the clipboard server"), + 0 => { + let _ = rustix::process::setsid(); + let _ = detach_stdio(); + work(); + // SAFETY: skips atexit handlers and destructors that belong to the parent. + unsafe { libc::_exit(0) } + } + _ => Ok(()), + } +} + +fn detach_stdio() -> io::Result<()> { + let null = File::options().read(true).write(true).open("/dev/null")?; + rustix::stdio::dup2_stdin(&null)?; + rustix::stdio::dup2_stdout(&null)?; + rustix::stdio::dup2_stderr(&null)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn child_holds_neither_lock_nor_stdio() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("valw.lock"); + let lock = Lock::acquire(&path).unwrap(); + let (report_r, report_w) = rustix::pipe::pipe().unwrap(); + let (go_r, go_w) = rustix::pipe::pipe().unwrap(); + + spawn(lock, move || { + // Report whether stdout is /dev/null, then wait for the parent. + let null = rustix::fs::stat("/dev/null").unwrap(); + let out = rustix::fs::fstat(rustix::stdio::stdout()).unwrap(); + let is_null = out.st_rdev == null.st_rdev && out.st_ino == null.st_ino; + rustix::io::write(&report_w, &[is_null as u8]).unwrap(); + let _ = rustix::io::read(&go_r, &mut [0u8; 1]); + }) + .unwrap(); + + // The child is still running, yet the lock is free. + Lock::acquire(&path).unwrap(); + + let mut is_null = [0u8; 1]; + rustix::io::read(&report_r, &mut is_null).unwrap(); + assert_eq!(is_null, [1], "child stdout should be /dev/null"); + + // Closing our end lets the child exit. + drop(go_w); + } +} diff --git a/src/lock.rs b/src/lock.rs new file mode 100644 index 0000000..0010e90 --- /dev/null +++ b/src/lock.rs @@ -0,0 +1,76 @@ +use std::fs::File; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use rustix::fs::{FlockOperation, flock}; +use rustix::io::Errno; + +use crate::error::HintExt; + +/// Held for the whole capture so two captures never overlap. The kernel +/// drops the lock when the process dies, so a crash can't leave it stuck. +#[derive(Debug)] +pub struct Lock { + _file: File, +} + +/// `$XDG_RUNTIME_DIR/valw.lock`, falling back to the temp dir. +pub fn default_path() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("valw.lock") +} + +impl Lock { + pub fn acquire(path: &Path) -> Result { + let file = File::options() + .create(true) + .truncate(false) + .write(true) + .open(path) + .with_context(|| format!("could not open lock file {}", path.display()))?; + match flock(&file, FlockOperation::NonBlockingLockExclusive) { + Ok(()) => Ok(Lock { _file: file }), + Err(Errno::WOULDBLOCK) => Err(anyhow::anyhow!("valw is already running")) + .hint("wait for the other capture to finish, or press Esc in it"), + Err(e) => { + bail!("could not lock {}: {e}", path.display()) + } + } + } + + /// Releases the lock. Must happen before forking, or the child would + /// inherit the file and keep the lock held. + pub fn release(self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn second_acquire_fails_until_release() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("valw.lock"); + + let first = Lock::acquire(&path).unwrap(); + let err = Lock::acquire(&path).unwrap_err(); + assert_eq!( + err.to_string(), + "wait for the other capture to finish, or press Esc in it" + ); + assert_eq!(err.root_cause().to_string(), "valw is already running"); + + first.release(); + Lock::acquire(&path).unwrap(); + } + + #[test] + fn stale_lock_file_is_fine() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("valw.lock"); + File::create(&path).unwrap(); + Lock::acquire(&path).unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index d3b6d90..bc7e635 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,9 @@ #![allow(dead_code)] mod config; +mod detach; mod error; +mod lock; mod log; use clap::Parser;