fix: PID-based volume lock with state persistence and multiarch default path
Some checks failed
Test / test (push) Failing after 1m23s
Some checks failed
Test / test (push) Failing after 1m23s
Volume lock file now stores {pid}:{volume}:{muted} instead of "1":
- Allows recovery of saved volume state if the holder is SIGKILLed
- On stale lock detection (holder PID not alive), inherit saved volume state
and take ownership — prevents permanent volume loss and infinite lockout
PAM module DEFAULT_PATH now baked in at build time via AHFAIL_LIBDIR env var
passed by Meson, fixing the wrong path on multiarch Debian/Ubuntu where libdir
is /usr/lib/x86_64-linux-gnu rather than /usr/lib.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
fn main() {
|
||||
println!("cargo:rustc-link-lib=pam");
|
||||
|
||||
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch
|
||||
// systems (e.g. Debian/Ubuntu where libdir is /usr/lib/x86_64-linux-gnu).
|
||||
// Meson passes AHFAIL_LIBDIR=<libdir> when building; fall back to /usr/lib otherwise.
|
||||
let libdir = std::env::var("AHFAIL_LIBDIR").unwrap_or_else(|_| "/usr/lib".to_string());
|
||||
println!("cargo:rustc-env=AHFAIL_INSTALL_DIR={}/ahfail", libdir);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ pub const PAM_DATA_REPLACE: c_int = 0x00000002;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
||||
|
||||
// Default install path for ahfail-display
|
||||
// Default install path for ahfail-display.
|
||||
// On Linux: built from AHFAIL_INSTALL_DIR emitted by build.rs, which reads AHFAIL_LIBDIR
|
||||
// passed by Meson — correct on multiarch systems (e.g. /usr/lib/x86_64-linux-gnu/ahfail).
|
||||
#[cfg(target_os = "macos")]
|
||||
const DEFAULT_PATH: &str = "/usr/local/lib/ahfail/ahfail-display";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const DEFAULT_PATH: &str = "/usr/lib/ahfail/ahfail-display";
|
||||
const DEFAULT_PATH: &str = concat!(env!("AHFAIL_INSTALL_DIR"), "/ahfail-display");
|
||||
|
||||
pub fn default_display_path() -> &'static str { DEFAULT_PATH }
|
||||
|
||||
|
||||
@@ -9,14 +9,27 @@ pub struct VolumeState {
|
||||
pub lock_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Atomically creates lock file. Returns true if this process is first (primary).
|
||||
pub fn try_acquire_lock(path: &std::path::Path) -> bool {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map(|mut f| { let _ = f.write_all(b"1"); true })
|
||||
.unwrap_or(false)
|
||||
// Lock file format: "{pid}:{volume}:{muted}\n"
|
||||
// Written atomically on acquire; persists volume state so it survives SIGKILL.
|
||||
fn lock_content(pid: u32, volume: u32, muted: bool) -> String {
|
||||
format!("{}:{}:{}\n", pid, volume, u8::from(muted))
|
||||
}
|
||||
|
||||
fn parse_lock_content(s: &str) -> Option<(u32, u32, bool)> {
|
||||
let mut parts = s.trim().splitn(3, ':');
|
||||
let pid: u32 = parts.next()?.parse().ok()?;
|
||||
let vol: u32 = parts.next()?.parse().ok().filter(|&v| v <= 100)?;
|
||||
let muted: bool = parts.next()?.trim() == "1";
|
||||
Some((pid, vol, muted))
|
||||
}
|
||||
|
||||
fn pid_is_alive(pid: u32) -> bool {
|
||||
let pid_t = pid as libc::pid_t;
|
||||
if pid_t <= 0 {
|
||||
return false;
|
||||
}
|
||||
// kill(pid, 0) returns 0 if the process exists, -1 (ESRCH) if not.
|
||||
unsafe { libc::kill(pid_t, 0) == 0 }
|
||||
}
|
||||
|
||||
/// Returns lock file path: $XDG_RUNTIME_DIR/ahfail.lock or /tmp/ahfail-{uid}.lock
|
||||
@@ -34,12 +47,44 @@ fn get_uid() -> u32 {
|
||||
|
||||
/// If primary: save current system volume/mute state, set volume to 100% unmuted.
|
||||
/// Returns Some(VolumeState) if this process is the primary (should restore on exit).
|
||||
///
|
||||
/// Uses a PID-based lock file that persists the saved volume state. If a stale lock
|
||||
/// exists (holder process dead), recovers the saved volume from the file — preventing
|
||||
/// permanent volume loss after SIGKILL — and takes ownership.
|
||||
pub fn save_and_set_max() -> Option<VolumeState> {
|
||||
let path = lock_path();
|
||||
if !try_acquire_lock(&path) { return None; }
|
||||
let our_pid = std::process::id();
|
||||
|
||||
// Try atomic create (O_CREAT | O_EXCL — race-free on POSIX local filesystems).
|
||||
if fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.map(|mut f| { let _ = f.write_all(b"0:100:0\n"); })
|
||||
.is_ok()
|
||||
{
|
||||
// We are primary. Read current volume, overwrite placeholder, set max.
|
||||
let (volume, muted) = get_current_volume();
|
||||
let _ = fs::write(&path, lock_content(our_pid, volume, muted));
|
||||
set_volume_max();
|
||||
Some(VolumeState { volume, muted, lock_path: path })
|
||||
return Some(VolumeState { volume, muted, lock_path: path });
|
||||
}
|
||||
|
||||
// File exists — check if the holder PID is still alive.
|
||||
if let Ok(existing) = fs::read_to_string(&path) {
|
||||
if let Some((holder_pid, saved_vol, saved_muted)) = parse_lock_content(&existing) {
|
||||
if !pid_is_alive(holder_pid) {
|
||||
// Stale lock: recover saved volume state and take ownership.
|
||||
// The previous holder already set volume to max, so we skip set_volume_max().
|
||||
let content = lock_content(our_pid, saved_vol, saved_muted);
|
||||
if fs::write(&path, content).is_ok() {
|
||||
return Some(VolumeState { volume: saved_vol, muted: saved_muted, lock_path: path });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Restores volume from saved state and removes lock file.
|
||||
@@ -135,12 +180,84 @@ fn restore_volume(volume: u32, muted: bool) {
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
fn get_current_volume() -> (u32, bool) {
|
||||
(100, false)
|
||||
}
|
||||
fn get_current_volume() -> (u32, bool) { (100, false) }
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
fn set_volume_max() {}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
fn restore_volume(_volume: u32, _muted: bool) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_lock_content_roundtrip() {
|
||||
let content = lock_content(1234, 75, true);
|
||||
let (pid, vol, muted) = parse_lock_content(&content).unwrap();
|
||||
assert_eq!(pid, 1234);
|
||||
assert_eq!(vol, 75);
|
||||
assert!(muted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lock_content_rejects_invalid_volume() {
|
||||
assert!(parse_lock_content("123:101:0").is_none());
|
||||
assert!(parse_lock_content("123:abc:0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_is_alive_current_process() {
|
||||
assert!(pid_is_alive(std::process::id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_is_alive_zero_is_not_treated_as_alive() {
|
||||
// PID 0 has special kill() semantics; we guard against it.
|
||||
assert!(!pid_is_alive(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_lock_creates_file_and_stale_is_recovered() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let lock = dir.path().join("ahfail.lock");
|
||||
|
||||
// Write a stale lock file: PID 99_999_999 is above Linux's max_pid (4_194_304)
|
||||
// and will always fail kill(pid, 0) with ESRCH.
|
||||
std::fs::write(&lock, "99999999:60:1\n").unwrap();
|
||||
|
||||
// Simulate save_and_set_max() stale-recovery path directly.
|
||||
let our_pid = std::process::id();
|
||||
if let Ok(existing) = std::fs::read_to_string(&lock) {
|
||||
if let Some((holder_pid, saved_vol, saved_muted)) = parse_lock_content(&existing) {
|
||||
assert!(!pid_is_alive(holder_pid), "test PID should not be alive");
|
||||
let content = lock_content(our_pid, saved_vol, saved_muted);
|
||||
std::fs::write(&lock, content).unwrap();
|
||||
let (pid, vol, muted) = parse_lock_content(&std::fs::read_to_string(&lock).unwrap()).unwrap();
|
||||
assert_eq!(pid, our_pid);
|
||||
assert_eq!(vol, 60);
|
||||
assert!(muted);
|
||||
} else {
|
||||
panic!("parse_lock_content failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_acquisition_blocked_by_alive_pid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let lock = dir.path().join("ahfail.lock");
|
||||
|
||||
// Write a lock file held by our own (alive) PID.
|
||||
let our_pid = std::process::id();
|
||||
std::fs::write(&lock, lock_content(our_pid, 80, false)).unwrap();
|
||||
|
||||
// Attempt to acquire — should be blocked because holder is alive.
|
||||
if let Ok(existing) = std::fs::read_to_string(&lock) {
|
||||
if let Some((holder_pid, _, _)) = parse_lock_content(&existing) {
|
||||
assert!(pid_is_alive(holder_pid), "our own PID should be alive");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
// Lock file behavior is tested via unit tests in src/volume.rs.
|
||||
// Integration-level: verify that a fresh save_and_set_max() call creates the lock file.
|
||||
// (pactl/osascript may not be available in CI — volume operations are best-effort.)
|
||||
|
||||
#[test]
|
||||
fn lock_file_created_and_prevents_second_acquisition() {
|
||||
fn save_and_set_max_creates_lock_file() {
|
||||
// Override XDG_RUNTIME_DIR so we use a temp path, not the real user's runtime dir.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let lock_path = dir.path().join("ahfail.lock");
|
||||
std::env::set_var("XDG_RUNTIME_DIR", dir.path());
|
||||
|
||||
let acquired = ahfail_ui::volume::try_acquire_lock(&lock_path);
|
||||
assert!(acquired);
|
||||
assert!(lock_path.exists());
|
||||
let result = ahfail_ui::volume::save_and_set_max();
|
||||
// Should succeed (lock file didn't exist).
|
||||
assert!(result.is_some());
|
||||
assert!(dir.path().join("ahfail.lock").exists());
|
||||
|
||||
let acquired2 = ahfail_ui::volume::try_acquire_lock(&lock_path);
|
||||
assert!(!acquired2);
|
||||
// Cleanup: restore() removes the lock file.
|
||||
ahfail_ui::volume::restore(result.unwrap());
|
||||
assert!(!dir.path().join("ahfail.lock").exists());
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ pam_cargo = custom_target(
|
||||
output: ['libahfail_pam.so'],
|
||||
command: [
|
||||
'sh', '-c',
|
||||
'cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"'
|
||||
# Pass libdir so build.rs emits the correct AHFAIL_INSTALL_DIR (needed for multiarch).
|
||||
'AHFAIL_LIBDIR=' + get_option('libdir') + ' cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"'
|
||||
],
|
||||
build_by_default: true,
|
||||
install: true,
|
||||
|
||||
Reference in New Issue
Block a user