diff --git a/src/main.rs b/src/main.rs index 66c91b8..7a03b40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3333,6 +3333,25 @@ fn main() { } }); + // Best-effort cleanup when killed (Ctrl+C in the terminal, a dev-loop + // `kill`, systemd stop): kill the OSD's `pactl subscribe` children + // before exiting; the exit hook in osd.rs covers normal exits and + // panics. Without either, each breadbar restart orphaned a `pactl + // subscribe` holding its PulseAudio connection open until + // pipewire-pulse's client cap filled up and new clients — settings + // apps among them — were refused ("no devices in settings"). + relm4::spawn(async { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler"); + let mut intr = signal(SignalKind::interrupt()).expect("SIGINT handler"); + tokio::select! { + _ = term.recv() => {} + _ = intr.recv() => {} + } + crate::osd::kill_watchers(); + std::process::exit(0); + }); + // `with_args(vec![])` stops relm4 from handing our own --screenshot/ // --output flags to GLib's option parser (`app.run()`'s default), which // would otherwise reject them as unrecognized before Cli::parse() above diff --git a/src/osd.rs b/src/osd.rs index 4fbef8f..6860c3f 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -1,6 +1,8 @@ use std::{ cell::{Cell, RefCell}, + process::Child, rc::Rc, + sync::{Mutex, Once}, time::Duration, }; @@ -22,6 +24,46 @@ pub enum SampleKind { Brightness, } +/// Live `pactl subscribe` children spawned by [`volume_watcher`]. Kept so a +/// best-effort cleanup can kill them when breadbar exits: `pactl subscribe` +/// blocks until the server connection dies, so without this every breadbar +/// restart orphaned one that kept its PulseAudio connection open — until +/// pipewire-pulse's client cap filled up and new clients (settings apps +/// included) were refused, showing "no devices". Also reaped here, so a +/// watcher that dies on its own never lingers as a zombie. +static WATCHER_CHILDREN: Mutex> = Mutex::new(Vec::new()); +static REGISTER_EXIT_HOOK: Once = Once::new(); + +extern "C" { + /// libc `atexit(3)`. Declared directly rather than pulling the libc + /// crate in for a single function. + fn atexit(cb: extern "C" fn()) -> i32; +} + +extern "C" fn exit_cleanup() { + kill_watchers(); +} + +fn register_exit_hook() { + // Safety: `atexit` is provided by libc on every Linux target; the + // callback is a `static` C-ABI fn valid for the whole process. + unsafe { + let _ = atexit(exit_cleanup); + } +} + +/// Kill any live `pactl subscribe` watcher children and reap them. Safe to +/// call more than once (an already-dead child is a no-op). Runs from the +/// process-exit hook and the SIGINT/SIGTERM handlers in `main`. +pub fn kill_watchers() { + if let Ok(mut children) = WATCHER_CHILDREN.lock() { + for mut child in children.drain(..) { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + impl SampleKind { fn sample_event(&self) -> OsdEvent { match self { @@ -73,6 +115,14 @@ fn volume_watcher(tx: mpsc::Sender) { }; let Some(stdout) = child.stdout.take() else { return }; + // The child is meant to outlive this reader loop (it blocks until + // breadbar itself dies), so hand it to `kill_watchers` — the exit + // hook plus the SIGINT/SIGTERM handlers in main — instead of letting + // it orphan on restart. + REGISTER_EXIT_HOOK.call_once(register_exit_hook); + if let Ok(mut children) = WATCHER_CHILDREN.lock() { + children.push(child); + } let reader = BufReader::new(stdout); for line in reader.lines().map_while(Result::ok) {