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:
+197
-26
@@ -6,22 +6,122 @@
|
||||
//! and lives here. Backends call [`spawn`] with just their source-element args.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
use nix::sys::signal::Signal;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::audio::Routing;
|
||||
use super::health;
|
||||
use super::quality::EffectiveQuality;
|
||||
use super::serve::Serve;
|
||||
use crate::cli::HostOpts;
|
||||
use crate::common::contained;
|
||||
|
||||
pub struct CaptureHandle {
|
||||
gst: Option<Child>,
|
||||
const GST_TERM_BUDGET: Duration = Duration::from_secs(1);
|
||||
const GST_KILL_BUDGET: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Owns the contained GStreamer process from spawn through confirmed reap.
|
||||
///
|
||||
/// Keeping this guard alive during `Serve::bind` closes the old constructor
|
||||
/// leak: any error after spawn kills the whole process group, not merely the
|
||||
/// direct child. An unconfirmed Drop poisons the host so the supervisor cannot
|
||||
/// start another capture on top of a possibly-live portal/PipeWire owner.
|
||||
struct CaptureProcess {
|
||||
child: Child,
|
||||
leader_pid: u32,
|
||||
reaped: bool,
|
||||
health: health::Reporter,
|
||||
}
|
||||
|
||||
impl CaptureProcess {
|
||||
fn new(child: Child, health: health::Reporter) -> Result<Self> {
|
||||
let leader_pid = child
|
||||
.id()
|
||||
.context("gst-launch-1.0 exited before its process id was recorded")?;
|
||||
Ok(Self {
|
||||
child,
|
||||
leader_pid,
|
||||
reaped: false,
|
||||
health,
|
||||
})
|
||||
}
|
||||
|
||||
fn take_stdout(&mut self) -> Option<tokio::process::ChildStdout> {
|
||||
self.child.stdout.take()
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) {
|
||||
// Reap an already-dead child before addressing its process group. A
|
||||
// zombie still reserves its pid, but after `try_wait` succeeds that pid
|
||||
// may be reused; returning here avoids ever signalling a new group that
|
||||
// inherited the old numeric id.
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
self.reaped = true;
|
||||
self.health
|
||||
.poison("gst-launch-1.0 exited before PixelPass began capture shutdown");
|
||||
return;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => tracing::warn!(
|
||||
"capture: could not inspect gst before SIGTERM ({e}); continuing teardown"
|
||||
),
|
||||
}
|
||||
|
||||
let _ = contained::signal_group(self.leader_pid, Signal::SIGTERM);
|
||||
match timeout(GST_TERM_BUDGET, self.child.wait()).await {
|
||||
Ok(Ok(_)) => {
|
||||
self.reaped = true;
|
||||
return;
|
||||
}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
"capture: gst wait after SIGTERM failed ({e}); escalating to SIGKILL"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
"capture: gst did not exit within {GST_TERM_BUDGET:?}; escalating to SIGKILL"
|
||||
),
|
||||
}
|
||||
|
||||
let _ = contained::signal_group(self.leader_pid, Signal::SIGKILL);
|
||||
let _ = self.child.start_kill();
|
||||
match timeout(GST_KILL_BUDGET, self.child.wait()).await {
|
||||
Ok(Ok(_)) => self.reaped = true,
|
||||
Ok(Err(e)) => {
|
||||
self.health.poison(format!(
|
||||
"gst-launch-1.0 could not be reaped after SIGKILL: {e}"
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
self.health.poison(format!(
|
||||
"gst-launch-1.0 did not exit within {GST_KILL_BUDGET:?} after SIGKILL"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CaptureProcess {
|
||||
fn drop(&mut self) {
|
||||
if self.reaped {
|
||||
return;
|
||||
}
|
||||
let _ = contained::signal_group(self.leader_pid, Signal::SIGKILL);
|
||||
let _ = self.child.start_kill();
|
||||
self.health.poison(
|
||||
"gst-launch-1.0 was dropped before a confirmed reap; its process group was killed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct CaptureHandle {
|
||||
gst: Option<CaptureProcess>,
|
||||
audio: Option<Routing>,
|
||||
serve: Option<Serve>,
|
||||
stopping: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
@@ -32,19 +132,15 @@ impl CaptureHandle {
|
||||
.local_port()
|
||||
}
|
||||
|
||||
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL,
|
||||
/// Graceful teardown: SIGTERM the gst process group, give it ~1s to exit,
|
||||
/// then SIGKILL and a second bounded reap,
|
||||
/// unload audio routing (if any), then tear down the serve layer.
|
||||
/// The serve reader will see EOF on gst stdout and exit on its own;
|
||||
/// serve.shutdown() is the backstop.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(child) = self.gst.as_mut()
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
|
||||
}
|
||||
if let Some(child) = self.gst.as_mut() {
|
||||
let _ = timeout(Duration::from_millis(1000), child.wait()).await;
|
||||
let _ = child.start_kill();
|
||||
self.stopping.store(true, Ordering::Release);
|
||||
if let Some(mut gst) = self.gst.take() {
|
||||
gst.shutdown().await;
|
||||
}
|
||||
if let Some(audio) = self.audio.take() {
|
||||
audio.shutdown().await;
|
||||
@@ -57,10 +153,9 @@ impl CaptureHandle {
|
||||
|
||||
impl Drop for CaptureHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.gst.as_mut() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
// Routing's and Serve's own Drop impls handle the rest.
|
||||
self.stopping.store(true, Ordering::Release);
|
||||
// CaptureProcess kills the whole process group and poisons the host;
|
||||
// Routing's and Serve's own Drop impls handle their respective layers.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,14 +168,15 @@ impl Drop for CaptureHandle {
|
||||
/// `after_spawn` runs once, immediately after the gst child is launched —
|
||||
/// Wayland uses it to `close` the pipewire fd it leaked into the child; X11
|
||||
/// passes a no-op.
|
||||
pub async fn spawn(
|
||||
pub(super) async fn spawn(
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
source_dims: Option<(u32, u32)>,
|
||||
source_args: Vec<String>,
|
||||
health: health::Reporter,
|
||||
after_spawn: impl FnOnce(),
|
||||
) -> Result<CaptureHandle> {
|
||||
let (audio_routing, audio_device) = setup_audio(opts).await?;
|
||||
let (audio_routing, audio_device) = setup_audio(opts, health.clone()).await?;
|
||||
let args = build_args(&source_args, &audio_device, opts, quality, source_dims);
|
||||
|
||||
let mut gst_cmd = Command::new("gst-launch-1.0");
|
||||
@@ -88,29 +184,32 @@ pub async fn spawn(
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() {
|
||||
gst_cmd.env("GST_DEBUG", "3");
|
||||
}
|
||||
let mut gst = gst_cmd.spawn().context("failed to spawn gst-launch-1.0")?;
|
||||
let gst = contained::spawn_tokio(&mut gst_cmd).context("failed to spawn gst-launch-1.0")?;
|
||||
let mut gst = CaptureProcess::new(gst, health.clone())?;
|
||||
|
||||
// Backend-specific post-spawn cleanup (Wayland closes its leaked pw fd here,
|
||||
// once gst has inherited its own copy).
|
||||
after_spawn();
|
||||
|
||||
let gst_stdout = gst
|
||||
.stdout
|
||||
.take()
|
||||
.take_stdout()
|
||||
.context("gst-launch-1.0 stdout pipe unavailable")?;
|
||||
|
||||
// Hand stdout to the serve layer, which binds the localhost HTTP listener
|
||||
// and runs the broadcast fanout. No demux/remux, no codec assumptions.
|
||||
let serve = Serve::bind(gst_stdout).await?;
|
||||
let stopping = Arc::new(AtomicBool::new(false));
|
||||
let serve = Serve::bind(gst_stdout, health.clone(), Arc::clone(&stopping)).await?;
|
||||
|
||||
Ok(CaptureHandle {
|
||||
gst: Some(gst),
|
||||
audio: audio_routing,
|
||||
serve: Some(serve),
|
||||
stopping,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,12 +219,15 @@ pub async fn spawn(
|
||||
/// is set (no app filter — captures everything via the null-sink, used for
|
||||
/// dogfooding the loopback path). Otherwise we capture the default sink's
|
||||
/// monitor (system audio out), not the default source (the mic).
|
||||
async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
|
||||
async fn setup_audio(
|
||||
opts: &HostOpts,
|
||||
health: health::Reporter,
|
||||
) -> Result<(Option<Routing>, String)> {
|
||||
let routing_requested =
|
||||
opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
|
||||
let audio_routing = if routing_requested {
|
||||
Some(
|
||||
Routing::start(opts)
|
||||
Routing::start(opts, health)
|
||||
.await
|
||||
.context("audio routing setup failed")?,
|
||||
)
|
||||
@@ -349,3 +451,72 @@ async fn default_audio_monitor() -> Result<String> {
|
||||
}
|
||||
Ok(format!("{sink}.monitor"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn process_is_running(pid: u32) -> bool {
|
||||
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||
return false;
|
||||
};
|
||||
stat.rsplit_once(") ")
|
||||
.and_then(|(_, rest)| rest.as_bytes().first().copied())
|
||||
.is_some_and(|state| state != b'Z' && state != b'X')
|
||||
}
|
||||
|
||||
fn sleeping_capture(health: health::Reporter) -> CaptureProcess {
|
||||
let mut command = Command::new("sleep");
|
||||
command.arg("30").kill_on_drop(true);
|
||||
let child = contained::spawn_tokio(&mut command).expect("spawn contained fixture");
|
||||
CaptureProcess::new(child, health).expect("capture process guard")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn graceful_capture_shutdown_confirms_reap_without_poison() {
|
||||
let (health, _) = health::channel();
|
||||
let mut capture = sleeping_capture(health.clone());
|
||||
capture.shutdown().await;
|
||||
assert!(health.fault().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_already_exited_capture_is_not_misreported_as_a_clean_shutdown() {
|
||||
let (health, _) = health::channel();
|
||||
let mut command = Command::new("true");
|
||||
command.kill_on_drop(true);
|
||||
let child = contained::spawn_tokio(&mut command).expect("spawn short contained fixture");
|
||||
let mut capture = CaptureProcess::new(child, health.clone()).expect("capture guard");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
while process_is_running(capture.leader_pid) && Instant::now() < deadline {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert!(
|
||||
!process_is_running(capture.leader_pid),
|
||||
"short fixture must exit before shutdown begins"
|
||||
);
|
||||
|
||||
capture.shutdown().await;
|
||||
assert_eq!(
|
||||
health.fault().as_deref(),
|
||||
Some("gst-launch-1.0 exited before PixelPass began capture shutdown")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_live_capture_kills_its_group_and_poisons() {
|
||||
let (health, _) = health::channel();
|
||||
let capture = sleeping_capture(health.clone());
|
||||
let pid = capture.leader_pid;
|
||||
drop(capture);
|
||||
assert!(health.fault().is_some());
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while process_is_running(pid) && Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(!process_is_running(pid));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user