fix(host): contain capture owners and fail closed

Bound the libpipewire router shutdown without detaching its OS handle, contain GStreamer and pactl children in parent-bound process groups, and make ownership failures terminal through the capture supervisor.\n\nAdd focused lifecycle tests plus a serialized live router teardown gate.
This commit is contained in:
2026-08-15 15:30:43 -04:00
parent 70820cf903
commit 5d3da8b006
11 changed files with 1060 additions and 72 deletions
+83
View File
@@ -0,0 +1,83 @@
//! Terminal health shared by capture components and the host supervisor.
//!
//! A capture-side ownership failure is not recoverable inside the same host
//! process: a wedged PipeWire owner or an unaccounted Pulse module can collide
//! with the next capture. Health is therefore one-way (`Healthy -> Poisoned`)
//! and first-fault-wins so a later, less precise teardown symptom cannot erase
//! the original cause.
use std::sync::Arc;
use tokio::sync::watch;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum State {
Healthy,
Poisoned(Arc<str>),
}
#[derive(Clone)]
pub(super) struct Reporter {
tx: watch::Sender<State>,
}
pub(super) struct Monitor {
rx: watch::Receiver<State>,
}
pub(super) fn channel() -> (Reporter, Monitor) {
let (tx, rx) = watch::channel(State::Healthy);
(Reporter { tx }, Monitor { rx })
}
impl Reporter {
/// Poison the host exactly once. Returns true for the first fault.
pub(super) fn poison(&self, reason: impl Into<Arc<str>>) -> bool {
let reason = reason.into();
self.tx.send_if_modified(move |state| {
if matches!(state, State::Healthy) {
*state = State::Poisoned(reason);
true
} else {
false
}
})
}
#[cfg(test)]
pub(super) fn fault(&self) -> Option<Arc<str>> {
match &*self.tx.borrow() {
State::Healthy => None,
State::Poisoned(reason) => Some(Arc::clone(reason)),
}
}
}
impl Monitor {
pub(super) fn fault(&self) -> Option<Arc<str>> {
match &*self.rx.borrow() {
State::Healthy => None,
State::Poisoned(reason) => Some(Arc::clone(reason)),
}
}
pub(super) async fn changed(&mut self) {
// The supervisor owns a Reporter for the whole run, so closure is not a
// normal state. Treat it like a wake and let `fault()` decide.
let _ = self.rx.changed().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn poison_is_terminal_and_first_fault_wins() {
let (reporter, mut monitor) = channel();
assert!(reporter.poison("first"));
monitor.changed().await;
assert_eq!(monitor.fault().as_deref(), Some("first"));
assert!(!reporter.poison("second"));
assert_eq!(reporter.fault().as_deref(), Some("first"));
}
}