feat(host): fan out desktop audio through owned links

This commit is contained in:
2026-08-21 16:12:52 -04:00
parent 781defcd84
commit 98cde2c19b
4 changed files with 1171 additions and 84 deletions
+63 -4
View File
@@ -7,9 +7,14 @@
//! mutate; matching, serial validation, and metadata writes stay ordered on the
//! PipeWire main-loop thread.
use super::aec::AecConfig;
use super::audio::parse_object_serial;
use super::fanout::FanoutController;
use super::health;
use super::observer::Readiness;
use super::observer::adapter::RegistryObserverHandle;
use super::owned_thread::OwnedThread;
use super::taint::snapshot::{GlobalId, Serial};
use crate::repair::plan as repair_plan;
use anyhow::{Context, Result, bail};
use pipewire::proxy::ProxyT;
@@ -38,6 +43,10 @@ const GRAPH_QUIESCE_BUDGET: Duration = Duration::from_secs(2);
const PULSE_MONITOR_READY_BUDGET: Duration = Duration::from_secs(3);
const PULSE_PROBE_BUDGET: Duration = Duration::from_millis(500);
const PULSE_PROBE_INTERVAL: Duration = Duration::from_millis(20);
/// The observer has its own sticky 2 s readiness deadline. This outer budget
/// includes thread startup and lets us turn either a timeout or a missing
/// capture-sink observation into a failed `DesktopExcluding` construction.
const FANOUT_READY_BUDGET: Duration = Duration::from_secs(3);
const GRAPH_STARTING: u8 = 0;
const GRAPH_RUNNING: u8 = 1;
@@ -129,10 +138,13 @@ pub(super) struct AudioGraphOwner {
/// This is the load-bearing type boundary for the phase-0d
/// `DesktopExcluding` plan: owning this value proves the sink exists, but the
/// only operations available are reading its monitor name and shutting down
/// its actor connection. The link manager added in phase 6 will feed it through
/// retained native PipeWire links; legacy `Routing` remains a separate type in
/// `host::audio`.
/// its graph connections. Phase 6 feeds it through a separate observer
/// connection that retains non-lingering native PipeWire links; legacy
/// `Routing` remains a separate type in `host::audio`.
pub(super) struct BareCaptureSink {
// Drop order is load-bearing: close the fan-out observer connection (and
// therefore every non-lingering link) before the sink-owning connection.
fanout: Option<RegistryObserverHandle>,
graph_owner: Option<AudioGraphOwner>,
monitor_name: String,
}
@@ -140,11 +152,25 @@ pub(super) struct BareCaptureSink {
impl BareCaptureSink {
pub(super) async fn start(health: health::Reporter) -> Result<Self> {
let spec = CaptureSinkSpec::for_pid(std::process::id());
let (graph_owner, _event_rx) = AudioGraphOwner::start(None, spec, health)
let (graph_owner, _event_rx) = AudioGraphOwner::start(None, spec, health.clone())
.await
.context("failed to start the bare connection-owned capture sink")?;
let identity = graph_owner.identity().clone();
let fanout = RegistryObserverHandle::spawn_with_mutation_sink(
Box::new(FanoutController::new(
Serial(identity.serial),
AecConfig::Off,
)),
health,
)
.context("failed to start the desktop-excluding fan-out observer")?;
if let Err(error) = wait_for_fanout_ready(&fanout, &identity).await {
drop(fanout);
graph_owner.shutdown().await;
return Err(error).context("desktop-excluding fan-out did not become ready");
}
Ok(Self {
fanout: Some(fanout),
graph_owner: Some(graph_owner),
monitor_name: identity.monitor_name,
})
@@ -162,6 +188,11 @@ impl BareCaptureSink {
}
pub(super) async fn shutdown(mut self) {
// Joining the observer closes its PipeWire connection and drops every
// retained non-lingering link before the capture sink can disappear.
if let Some(fanout) = self.fanout.take() {
drop(fanout);
}
if let Some(graph_owner) = self.graph_owner.take()
&& !graph_owner.shutdown().await
{
@@ -170,6 +201,34 @@ impl BareCaptureSink {
}
}
async fn wait_for_fanout_ready(
observer: &RegistryObserverHandle,
identity: &SinkIdentity,
) -> Result<()> {
let deadline = Instant::now() + FANOUT_READY_BUDGET;
loop {
if let Some(projection) = observer.latest() {
if projection.readiness == Readiness::TimedOut {
bail!("registry observer reached its sticky readiness timeout");
}
let sink_is_current = projection
.snapshot
.node(Serial(identity.serial))
.is_some_and(|node| node.id == GlobalId(identity.global_id));
if projection.graph_ready && sink_is_current {
return Ok(());
}
}
if Instant::now() >= deadline {
bail!(
"capture sink serial {} was not present in a graph-ready projection within {FANOUT_READY_BUDGET:?}",
identity.serial
);
}
tokio::time::sleep(PULSE_PROBE_INTERVAL).await;
}
}
impl AudioGraphOwner {
pub(super) async fn start(
filter_name: Option<String>,