fix(host): close desktop audio failure gates
This commit is contained in:
+149
-3
@@ -21,6 +21,39 @@ use super::graph::BareCaptureSink;
|
||||
use super::health;
|
||||
use crate::cli::{CaptureMode, HostOpts};
|
||||
|
||||
trait CapturePlanBackend {
|
||||
async fn resolve_default_monitor(&mut self) -> Result<DefaultMonitor>;
|
||||
async fn start_routing(&mut self, opts: &HostOpts, health: health::Reporter)
|
||||
-> Result<Routing>;
|
||||
async fn start_bare_capture_sink(
|
||||
&mut self,
|
||||
health: health::Reporter,
|
||||
) -> Result<BareCaptureSink>;
|
||||
}
|
||||
|
||||
struct SystemCapturePlanBackend;
|
||||
|
||||
impl CapturePlanBackend for SystemCapturePlanBackend {
|
||||
async fn resolve_default_monitor(&mut self) -> Result<DefaultMonitor> {
|
||||
DefaultMonitor::resolve().await
|
||||
}
|
||||
|
||||
async fn start_routing(
|
||||
&mut self,
|
||||
opts: &HostOpts,
|
||||
health: health::Reporter,
|
||||
) -> Result<Routing> {
|
||||
Routing::start(opts, health).await
|
||||
}
|
||||
|
||||
async fn start_bare_capture_sink(
|
||||
&mut self,
|
||||
health: health::Reporter,
|
||||
) -> Result<BareCaptureSink> {
|
||||
BareCaptureSink::start(health).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum CapturePlanKind {
|
||||
LegacyDesktop,
|
||||
@@ -99,17 +132,35 @@ pub(super) enum CapturePlan {
|
||||
|
||||
impl CapturePlan {
|
||||
pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> {
|
||||
let mut backend = SystemCapturePlanBackend;
|
||||
Self::start_with_backend(opts, health, &mut backend).await
|
||||
}
|
||||
|
||||
/// One production construction path with an injectable system boundary.
|
||||
///
|
||||
/// Keeping failure policy here makes rows 8d/8e falsifiable in release
|
||||
/// builds: a capture-sink or readiness failure must escape this function.
|
||||
/// It must never be translated into a second attempt through
|
||||
/// `LegacyDesktop`, whose default-monitor source would reintroduce the
|
||||
/// audio this mode exists to exclude.
|
||||
async fn start_with_backend<B: CapturePlanBackend>(
|
||||
opts: &HostOpts,
|
||||
health: health::Reporter,
|
||||
backend: &mut B,
|
||||
) -> Result<Self> {
|
||||
match CapturePlanKind::resolve(opts)? {
|
||||
CapturePlanKind::LegacyDesktop => Ok(Self::LegacyDesktop {
|
||||
source: DefaultMonitor::resolve().await?,
|
||||
source: backend.resolve_default_monitor().await?,
|
||||
}),
|
||||
CapturePlanKind::PerApp => Ok(Self::PerApp {
|
||||
routing: Routing::start(opts, health)
|
||||
routing: backend
|
||||
.start_routing(opts, health)
|
||||
.await
|
||||
.context("audio routing setup failed")?,
|
||||
}),
|
||||
CapturePlanKind::DesktopExcluding => Ok(Self::DesktopExcluding {
|
||||
capture_sink: BareCaptureSink::start(health)
|
||||
capture_sink: backend
|
||||
.start_bare_capture_sink(health)
|
||||
.await
|
||||
.context("desktop-excluding capture-sink setup failed")?,
|
||||
}),
|
||||
@@ -148,6 +199,8 @@ impl CapturePlan {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::Quality;
|
||||
use crate::host::observer::{Projection, Readiness};
|
||||
use crate::host::taint::fixture::Graph;
|
||||
use nix::sys::signal::Signal;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
@@ -224,6 +277,99 @@ mod tests {
|
||||
assert!(matches!(plan, CapturePlan::LegacyDesktop { .. }));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum BackendCall {
|
||||
DefaultMonitor,
|
||||
Routing,
|
||||
BareCaptureSink,
|
||||
}
|
||||
|
||||
struct FailingDesktopBackend {
|
||||
failure: Option<anyhow::Error>,
|
||||
calls: Vec<BackendCall>,
|
||||
}
|
||||
|
||||
impl CapturePlanBackend for FailingDesktopBackend {
|
||||
async fn resolve_default_monitor(&mut self) -> Result<DefaultMonitor> {
|
||||
self.calls.push(BackendCall::DefaultMonitor);
|
||||
panic!("a DesktopExcluding failure must not resolve the legacy default monitor")
|
||||
}
|
||||
|
||||
async fn start_routing(
|
||||
&mut self,
|
||||
_opts: &HostOpts,
|
||||
_health: health::Reporter,
|
||||
) -> Result<Routing> {
|
||||
self.calls.push(BackendCall::Routing);
|
||||
panic!("a DesktopExcluding failure must not construct legacy Routing")
|
||||
}
|
||||
|
||||
async fn start_bare_capture_sink(
|
||||
&mut self,
|
||||
_health: health::Reporter,
|
||||
) -> Result<BareCaptureSink> {
|
||||
self.calls.push(BackendCall::BareCaptureSink);
|
||||
Err(self.failure.take().expect("one injected failure"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_desktop_failure_is_closed(failure: anyhow::Error, expected_cause: &str) {
|
||||
let opts = opts(false, false, CaptureMode::DesktopExcluding, false);
|
||||
assert_eq!(
|
||||
CapturePlanKind::resolve(&opts).expect("fixture mode resolves"),
|
||||
CapturePlanKind::DesktopExcluding
|
||||
);
|
||||
let mut backend = FailingDesktopBackend {
|
||||
failure: Some(failure),
|
||||
calls: Vec::new(),
|
||||
};
|
||||
let (health, _) = health::channel();
|
||||
let error = match CapturePlan::start_with_backend(&opts, health, &mut backend).await {
|
||||
Ok(_) => panic!("DesktopExcluding unexpectedly recovered through another plan"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(backend.calls, vec![BackendCall::BareCaptureSink]);
|
||||
let chain = format!("{error:#}");
|
||||
assert!(chain.contains("desktop-excluding capture-sink setup failed"));
|
||||
assert!(
|
||||
chain.contains(expected_cause),
|
||||
"unexpected error chain: {chain}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_sink_creation_failure_never_falls_back_to_legacy_desktop() {
|
||||
assert_desktop_failure_is_closed(
|
||||
anyhow::anyhow!("injected capture-sink creation failure"),
|
||||
"injected capture-sink creation failure",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_epoch_timeout_never_falls_back_to_legacy_desktop() {
|
||||
let identity = super::super::graph::SinkIdentity {
|
||||
name: "pixelpass_capture_timeout_fixture".to_string(),
|
||||
monitor_name: "pixelpass_capture_timeout_fixture.monitor".to_string(),
|
||||
global_id: 77,
|
||||
serial: 88,
|
||||
};
|
||||
let projection = Projection {
|
||||
snapshot: Graph::new().build(),
|
||||
pipewire_pulse_pid: None,
|
||||
graph_ready: false,
|
||||
readiness: Readiness::TimedOut,
|
||||
};
|
||||
let readiness_error = super::super::graph::fanout_readiness(Some(&projection), &identity)
|
||||
.expect_err("the observer's sticky timeout must fail readiness");
|
||||
|
||||
assert_desktop_failure_is_closed(
|
||||
readiness_error,
|
||||
"registry observer reached its sticky readiness timeout",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn pulse_source_exists(name: &str) -> bool {
|
||||
std::process::Command::new("pactl")
|
||||
.args(["get-source-volume", name])
|
||||
|
||||
Reference in New Issue
Block a user