fix: PID-based volume lock with state persistence and multiarch default path
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:
Asger Geel Weirsøe
2026-05-06 12:41:06 +02:00
parent 3323844c33
commit 702f449d0e
5 changed files with 158 additions and 25 deletions

View File

@@ -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);
}

View File

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

View File

@@ -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");
}
}
}
}

View File

@@ -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());
}

View File

@@ -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,