diff --git a/crates/ahfail-pam/build.rs b/crates/ahfail-pam/build.rs index 78a800c..7f711ea 100644 --- a/crates/ahfail-pam/build.rs +++ b/crates/ahfail-pam/build.rs @@ -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= 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); } diff --git a/crates/ahfail-pam/src/lib.rs b/crates/ahfail-pam/src/lib.rs index 13f384e..2b0da5d 100644 --- a/crates/ahfail-pam/src/lib.rs +++ b/crates/ahfail-pam/src/lib.rs @@ -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 } diff --git a/crates/ahfail-ui/src/volume.rs b/crates/ahfail-ui/src/volume.rs index 138f3a2..0d9ad63 100644 --- a/crates/ahfail-ui/src/volume.rs +++ b/crates/ahfail-ui/src/volume.rs @@ -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 { let path = lock_path(); - if !try_acquire_lock(&path) { return None; } - let (volume, muted) = get_current_volume(); - set_volume_max(); - Some(VolumeState { volume, muted, lock_path: path }) + 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(); + 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"); + } + } + } +} diff --git a/crates/ahfail-ui/tests/volume_tests.rs b/crates/ahfail-ui/tests/volume_tests.rs index 1708ac2..9438c62 100644 --- a/crates/ahfail-ui/tests/volume_tests.rs +++ b/crates/ahfail-ui/tests/volume_tests.rs @@ -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()); } diff --git a/meson.build b/meson.build index 793a9df..9d5abf1 100644 --- a/meson.build +++ b/meson.build @@ -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,