fix(host): close unsupported fanout gates
This commit is contained in:
+121
-1
@@ -332,6 +332,17 @@ impl MutationProjectionSink for FanoutController {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match state {
|
match state {
|
||||||
|
StreamCaptureState::Excluded { reason } if reason.reports_stream_unsupported() => {
|
||||||
|
tracing::warn!(
|
||||||
|
serial = serial.0,
|
||||||
|
reason = reason.code(),
|
||||||
|
"audio fan-out: stream refused"
|
||||||
|
);
|
||||||
|
stream_statuses.push(AudioExclusionEvent::StreamUnsupported {
|
||||||
|
stream_serial: serial.0,
|
||||||
|
reason: reason.code(),
|
||||||
|
});
|
||||||
|
}
|
||||||
StreamCaptureState::Unsupported { issue } => {
|
StreamCaptureState::Unsupported { issue } => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
serial = serial.0,
|
serial = serial.0,
|
||||||
@@ -523,7 +534,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::host::observer::Readiness;
|
use crate::host::observer::Readiness;
|
||||||
use crate::host::taint::fixture::{Graph, PULSE_PID};
|
use crate::host::taint::fixture::{Graph, PULSE_PID};
|
||||||
use crate::host::taint::snapshot::{MediaRole, NodeProps, PortDirection};
|
use crate::host::taint::snapshot::{MediaRole, NodeProps, PortDirection, StreamFormat};
|
||||||
use crate::host::taint::{ExclusionCtx, StickyState, evaluate};
|
use crate::host::taint::{ExclusionCtx, StickyState, evaluate};
|
||||||
|
|
||||||
fn decisions(graph: &Graph) -> (GraphSnapshot, Decisions) {
|
fn decisions(graph: &Graph) -> (GraphSnapshot, Decisions) {
|
||||||
@@ -637,6 +648,115 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn refusal_graph(
|
||||||
|
stream_format: StreamFormat,
|
||||||
|
passthrough: bool,
|
||||||
|
exclusive: bool,
|
||||||
|
) -> (Graph, Serial, Serial) {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let client = graph.client_of_app(70);
|
||||||
|
let stream = graph.node(
|
||||||
|
"refused",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(client),
|
||||||
|
process_id: Some(70),
|
||||||
|
passthrough,
|
||||||
|
stream_format,
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let sink = graph.native_virtual_node("pixelpass_capture_test", MediaRole::Sink, 43);
|
||||||
|
graph.port_on_channel(stream, PortDirection::Out, exclusive, Some("FL"));
|
||||||
|
graph.port_on_channel(sink, PortDirection::In, false, Some("FL"));
|
||||||
|
(graph, stream.serial, sink.serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_local_refusal(graph: &Graph, stream: Serial, sink: Serial, expected: Reason) {
|
||||||
|
let mut links = FakeLinks::default();
|
||||||
|
let (mut controller, mut status_rx) = controller_with_status(sink, AecConfig::Off);
|
||||||
|
drive(&mut controller, graph, &mut links);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
links.creates, 0,
|
||||||
|
"a refused stream must never reach create_link"
|
||||||
|
);
|
||||||
|
assert!(links.held.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
controller.states()[&stream],
|
||||||
|
StreamCaptureState::Excluded { reason: expected }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_rx.try_recv(),
|
||||||
|
Ok(AudioExclusionEvent::StreamUnsupported {
|
||||||
|
stream_serial: stream.0,
|
||||||
|
reason: expected.code(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
drive(&mut controller, graph, &mut links);
|
||||||
|
assert_eq!(links.creates, 0);
|
||||||
|
assert_eq!(
|
||||||
|
status_rx.try_recv(),
|
||||||
|
Err(mpsc::error::TryRecvError::Empty),
|
||||||
|
"an unchanged refusal must not repeat the status"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn row_6a_exclusive_output_port_is_refused_with_its_reason() {
|
||||||
|
let (graph, stream, sink) = refusal_graph(StreamFormat::Raw, false, true);
|
||||||
|
assert_local_refusal(&graph, stream, sink, Reason::PortExclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn row_6b_encoded_stream_is_refused_with_its_reason() {
|
||||||
|
let (graph, stream, sink) = refusal_graph(StreamFormat::Encoded, false, false);
|
||||||
|
assert_local_refusal(&graph, stream, sink, Reason::Encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn row_6c_iec958_stream_is_refused_with_its_reason() {
|
||||||
|
let (graph, stream, sink) = refusal_graph(StreamFormat::Iec958, false, false);
|
||||||
|
assert_local_refusal(&graph, stream, sink, Reason::Iec958Passthrough);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_passthrough_property_is_an_independent_iec958_refusal() {
|
||||||
|
let (graph, stream, sink) = refusal_graph(StreamFormat::Raw, true, false);
|
||||||
|
assert_local_refusal(&graph, stream, sink, Reason::Iec958Passthrough);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_format_is_fail_closed_without_a_false_unsupported_status() {
|
||||||
|
// `refusal_graph` is deterministic, so these snapshots name the same
|
||||||
|
// serials/ids before and after the Format reply arrives.
|
||||||
|
let (pending, stream, sink) = refusal_graph(StreamFormat::Unknown, false, false);
|
||||||
|
let (raw, raw_stream, raw_sink) = refusal_graph(StreamFormat::Raw, false, false);
|
||||||
|
assert_eq!((stream, sink), (raw_stream, raw_sink));
|
||||||
|
|
||||||
|
let mut links = FakeLinks::default();
|
||||||
|
let (mut controller, mut status_rx) = controller_with_status(sink, AecConfig::Off);
|
||||||
|
drive(&mut controller, &pending, &mut links);
|
||||||
|
assert_eq!(links.creates, 0);
|
||||||
|
assert_eq!(
|
||||||
|
controller.states()[&stream],
|
||||||
|
StreamCaptureState::Excluded {
|
||||||
|
reason: Reason::FormatUnknown,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_rx.try_recv(),
|
||||||
|
Err(mpsc::error::TryRecvError::Empty),
|
||||||
|
"the normal Node-info-before-Format ordering must not warn the user"
|
||||||
|
);
|
||||||
|
|
||||||
|
drive(&mut controller, &raw, &mut links);
|
||||||
|
assert_eq!(links.creates, 1);
|
||||||
|
assert_eq!(controller.states()[&stream], StreamCaptureState::Captured);
|
||||||
|
assert_eq!(status_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stereo_ports_pair_by_channel_not_enumeration_order() {
|
fn stereo_ports_pair_by_channel_not_enumeration_order() {
|
||||||
let (graph, app, sink, [app_fl, app_fr, sink_fl, sink_fr]) = stereo_graph();
|
let (graph, app, sink, [app_fl, app_fr, sink_fl, sink_fr]) = stereo_graph();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::host::fanout::{
|
|||||||
};
|
};
|
||||||
use crate::host::taint::snapshot::{
|
use crate::host::taint::snapshot::{
|
||||||
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
||||||
|
StreamFormat,
|
||||||
};
|
};
|
||||||
use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE};
|
use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -188,7 +189,7 @@ impl Drop for RegistryObserverHandle {
|
|||||||
enum BoundProxy {
|
enum BoundProxy {
|
||||||
Node {
|
Node {
|
||||||
_listener: pw::node::NodeListener,
|
_listener: pw::node::NodeListener,
|
||||||
_proxy: pw::node::Node,
|
_proxy: Rc<pw::node::Node>,
|
||||||
},
|
},
|
||||||
Device {
|
Device {
|
||||||
_listener: pw::device::DeviceListener,
|
_listener: pw::device::DeviceListener,
|
||||||
@@ -648,11 +649,15 @@ fn run_observer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let node = Rc::new(node);
|
||||||
// This bit only recognizes the initial callback for the
|
// This bit only recognizes the initial callback for the
|
||||||
// change-mask fast path. Admission vs update remains
|
// change-mask fast path. Admission vs update remains
|
||||||
// entirely the model's decision.
|
// entirely the model's decision.
|
||||||
let first_info = Cell::new(true);
|
let first_info = Cell::new(true);
|
||||||
|
let format_subscribed = Cell::new(false);
|
||||||
|
let node_for_info = Rc::downgrade(&node);
|
||||||
let state_for_info = Rc::downgrade(&state_for_global);
|
let state_for_info = Rc::downgrade(&state_for_global);
|
||||||
|
let state_for_format = Rc::downgrade(&state_for_global);
|
||||||
let listener = node
|
let listener = node
|
||||||
.add_listener_local()
|
.add_listener_local()
|
||||||
.info(move |info| {
|
.info(move |info| {
|
||||||
@@ -660,15 +665,65 @@ fn run_observer(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let first = first_info.replace(false);
|
let first = first_info.replace(false);
|
||||||
if !first
|
let props_changed = first
|
||||||
&& !info.change_mask().contains(pw::node::NodeChangeMask::PROPS)
|
|| info.change_mask().contains(pw::node::NodeChangeMask::PROPS);
|
||||||
{
|
let params_changed = first
|
||||||
|
|| info
|
||||||
|
.change_mask()
|
||||||
|
.contains(pw::node::NodeChangeMask::PARAMS);
|
||||||
|
if !props_changed && !params_changed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(state) = state_for_info.upgrade() {
|
let observation = node_observation_from_props(props);
|
||||||
|
if props_changed && let Some(state) = state_for_info.upgrade() {
|
||||||
state.borrow_mut().apply(RegEvent::NodeInfo {
|
state.borrow_mut().apply(RegEvent::NodeInfo {
|
||||||
serial,
|
serial,
|
||||||
observation: node_observation_from_props(props),
|
observation: observation.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !observation.role.is_candidate() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let format_readable = info.params().iter().any(|param| {
|
||||||
|
param.id() == pw::spa::param::ParamType::Format
|
||||||
|
&& param.flags().contains(pw::spa::param::ParamInfoFlags::READ)
|
||||||
|
});
|
||||||
|
if !format_readable {
|
||||||
|
if params_changed && let Some(state) = state_for_info.upgrade() {
|
||||||
|
state.borrow_mut().apply(RegEvent::NodeFormat {
|
||||||
|
serial,
|
||||||
|
format: StreamFormat::Unknown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !format_subscribed.replace(true)
|
||||||
|
&& let Some(node) = node_for_info.upgrade()
|
||||||
|
{
|
||||||
|
// Subscription covers later renegotiation;
|
||||||
|
// enumeration supplies the current configured
|
||||||
|
// format. Only candidates advertising a
|
||||||
|
// readable Format are queried, so ordinary
|
||||||
|
// driver Nodes cannot turn their expected
|
||||||
|
// ENOENT/EIO replies into host health faults.
|
||||||
|
node.subscribe_params(&[pw::spa::param::ParamType::Format]);
|
||||||
|
node.enum_params(
|
||||||
|
0,
|
||||||
|
Some(pw::spa::param::ParamType::Format),
|
||||||
|
0,
|
||||||
|
u32::MAX,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.param(move |_seq, id, _index, _next, param| {
|
||||||
|
if id != pw::spa::param::ParamType::Format {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(state) = state_for_format.upgrade() {
|
||||||
|
state.borrow_mut().apply(RegEvent::NodeFormat {
|
||||||
|
serial,
|
||||||
|
format: stream_format_from_pod(param),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -958,6 +1013,63 @@ fn truthy(value: Option<&str>) -> bool {
|
|||||||
value.is_some_and(|value| value != "false" && value != "0")
|
value.is_some_and(|value| value != "false" && value != "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Classify the configured SPA Format without depending on producer-specific
|
||||||
|
/// property aliases. `Unknown` is the safe result for an absent or malformed
|
||||||
|
/// param; the taint engine refuses that stream until a usable format arrives.
|
||||||
|
fn stream_format_from_pod(param: Option<&pw::spa::pod::Pod>) -> StreamFormat {
|
||||||
|
let Some(param) = param else {
|
||||||
|
return StreamFormat::Unknown;
|
||||||
|
};
|
||||||
|
let Ok((media_type, media_subtype)) = pw::spa::param::format_utils::parse_format(param) else {
|
||||||
|
tracing::warn!("registry observer: could not parse Node Format media type");
|
||||||
|
return StreamFormat::Unknown;
|
||||||
|
};
|
||||||
|
let audio_format = if media_type == pw::spa::param::format::MediaType::Audio
|
||||||
|
&& media_subtype == pw::spa::param::format::MediaSubtype::Raw
|
||||||
|
{
|
||||||
|
let mut raw = pw::spa::param::audio::AudioInfoRaw::new();
|
||||||
|
match raw.parse(param) {
|
||||||
|
Ok(_) => Some(raw.format()),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
?error,
|
||||||
|
"registry observer: could not parse raw audio Format"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
classify_stream_format(media_type, media_subtype, audio_format)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_stream_format(
|
||||||
|
media_type: pw::spa::param::format::MediaType,
|
||||||
|
media_subtype: pw::spa::param::format::MediaSubtype,
|
||||||
|
audio_format: Option<pw::spa::param::audio::AudioFormat>,
|
||||||
|
) -> StreamFormat {
|
||||||
|
use pw::spa::param::audio::AudioFormat;
|
||||||
|
use pw::spa::param::format::{MediaSubtype, MediaType};
|
||||||
|
|
||||||
|
if media_type != MediaType::Audio {
|
||||||
|
return StreamFormat::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
match media_subtype {
|
||||||
|
MediaSubtype::Unknown => StreamFormat::Unknown,
|
||||||
|
MediaSubtype::Iec958 => StreamFormat::Iec958,
|
||||||
|
MediaSubtype::Raw => match audio_format {
|
||||||
|
Some(AudioFormat::Encoded) => StreamFormat::Encoded,
|
||||||
|
Some(AudioFormat::Unknown) | None => StreamFormat::Unknown,
|
||||||
|
Some(_) => StreamFormat::Raw,
|
||||||
|
},
|
||||||
|
// Any configured non-raw audio subtype is encoded. Keeping this
|
||||||
|
// conservative also covers codecs newer than this libspa binding.
|
||||||
|
_ => StreamFormat::Encoded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The ownership carrier is matched **exactly**, not leniently (round 10,
|
/// The ownership carrier is matched **exactly**, not leniently (round 10,
|
||||||
/// R10-4).
|
/// R10-4).
|
||||||
///
|
///
|
||||||
@@ -1004,6 +1116,7 @@ fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObs
|
|||||||
.get("application.process.id")
|
.get("application.process.id")
|
||||||
.and_then(|value| value.parse::<u32>().ok()),
|
.and_then(|value| value.parse::<u32>().ok()),
|
||||||
passthrough: truthy(props.get("node.passthrough")),
|
passthrough: truthy(props.get("node.passthrough")),
|
||||||
|
stream_format: StreamFormat::Unknown,
|
||||||
device_id,
|
device_id,
|
||||||
session_device: false,
|
session_device: false,
|
||||||
},
|
},
|
||||||
@@ -1056,6 +1169,53 @@ mod tests {
|
|||||||
assert!(!recoverable_core_error(0));
|
assert!(!recoverable_core_error(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_format_classifier_separates_raw_encoded_and_iec958() {
|
||||||
|
use pw::spa::param::audio::AudioFormat;
|
||||||
|
use pw::spa::param::format::{MediaSubtype, MediaType};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
classify_stream_format(
|
||||||
|
MediaType::Audio,
|
||||||
|
MediaSubtype::Raw,
|
||||||
|
Some(AudioFormat::F32LE),
|
||||||
|
),
|
||||||
|
StreamFormat::Raw
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_stream_format(MediaType::Audio, MediaSubtype::Mp3, None),
|
||||||
|
StreamFormat::Encoded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_stream_format(
|
||||||
|
MediaType::Audio,
|
||||||
|
MediaSubtype::Raw,
|
||||||
|
Some(AudioFormat::Encoded),
|
||||||
|
),
|
||||||
|
StreamFormat::Encoded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_stream_format(MediaType::Audio, MediaSubtype::Iec958, None),
|
||||||
|
StreamFormat::Iec958
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_stream_format(MediaType::Video, MediaSubtype::Raw, None),
|
||||||
|
StreamFormat::Unknown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_stream_format(
|
||||||
|
MediaType::Audio,
|
||||||
|
MediaSubtype::Raw,
|
||||||
|
Some(AudioFormat::Unknown),
|
||||||
|
),
|
||||||
|
StreamFormat::Unknown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_stream_format(MediaType::Audio, MediaSubtype::Unknown, None),
|
||||||
|
StreamFormat::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// **R10-4.** The ownership carrier is matched exactly; the lenient
|
/// **R10-4.** The ownership carrier is matched exactly; the lenient
|
||||||
/// [`truthy`] spelling is wrong for it.
|
/// [`truthy`] spelling is wrong for it.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ mod tests;
|
|||||||
|
|
||||||
use crate::host::taint::snapshot::{
|
use crate::host::taint::snapshot::{
|
||||||
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
||||||
PortSnapshot, Serial,
|
PortSnapshot, Serial, StreamFormat,
|
||||||
};
|
};
|
||||||
use classify::{Classification, DeviceClaim, DeviceProps};
|
use classify::{Classification, DeviceClaim, DeviceProps};
|
||||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||||
@@ -145,6 +145,13 @@ pub enum RegEvent {
|
|||||||
serial: Serial,
|
serial: Serial,
|
||||||
observation: NodeObservation,
|
observation: NodeObservation,
|
||||||
},
|
},
|
||||||
|
/// The Node's configured `SPA_PARAM_Format`. This is independent of
|
||||||
|
/// [`RegEvent::NodeInfo`]: property and parameter callbacks have separate
|
||||||
|
/// lifetimes, and either may change without the other.
|
||||||
|
NodeFormat {
|
||||||
|
serial: Serial,
|
||||||
|
format: StreamFormat,
|
||||||
|
},
|
||||||
/// A Port global appeared.
|
/// A Port global appeared.
|
||||||
PortAdded(PortSnapshot),
|
PortAdded(PortSnapshot),
|
||||||
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
|
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
|
||||||
@@ -305,6 +312,10 @@ struct NodeEntry {
|
|||||||
/// `None` while the bind is outstanding — withheld from the snapshot and
|
/// `None` while the bind is outstanding — withheld from the snapshot and
|
||||||
/// an outstanding readiness obligation (v3.5 §6.7 decision 3).
|
/// an outstanding readiness obligation (v3.5 §6.7 decision 3).
|
||||||
obs: Option<NodeObservation>,
|
obs: Option<NodeObservation>,
|
||||||
|
/// `Unknown` until the bound Node returns its configured Format param.
|
||||||
|
/// Unknown streams remain projected but are refused locally, so one idle
|
||||||
|
/// app cannot hold the entire graph's readiness epoch open.
|
||||||
|
format: StreamFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A live Device: its global id plus its bound properties once they arrive.
|
/// A live Device: its global id plus its bound properties once they arrive.
|
||||||
@@ -427,7 +438,14 @@ impl RegistryModel {
|
|||||||
match event {
|
match event {
|
||||||
RegEvent::NodeAdded { serial, id } => {
|
RegEvent::NodeAdded { serial, id } => {
|
||||||
self.push_id(id, Slot::Node(serial));
|
self.push_id(id, Slot::Node(serial));
|
||||||
self.nodes.insert(serial, NodeEntry { id, obs: None });
|
self.nodes.insert(
|
||||||
|
serial,
|
||||||
|
NodeEntry {
|
||||||
|
id,
|
||||||
|
obs: None,
|
||||||
|
format: StreamFormat::Unknown,
|
||||||
|
},
|
||||||
|
);
|
||||||
// A node awaiting its bind is a fresh obligation, so this can
|
// A node awaiting its bind is a fresh obligation, so this can
|
||||||
// only ever *hold* readiness, never complete it — but the
|
// only ever *hold* readiness, never complete it — but the
|
||||||
// re-check is cheap and keeps the invariant local.
|
// re-check is cheap and keeps the invariant local.
|
||||||
@@ -438,6 +456,7 @@ impl RegistryModel {
|
|||||||
serial,
|
serial,
|
||||||
observation,
|
observation,
|
||||||
} => self.on_node_info(serial, observation),
|
} => self.on_node_info(serial, observation),
|
||||||
|
RegEvent::NodeFormat { serial, format } => self.on_node_format(serial, format),
|
||||||
RegEvent::PortAdded(port) => {
|
RegEvent::PortAdded(port) => {
|
||||||
self.push_id(port.id, Slot::Port(port.serial));
|
self.push_id(port.id, Slot::Port(port.serial));
|
||||||
self.ports.insert(port.serial, port);
|
self.ports.insert(port.serial, port);
|
||||||
@@ -517,6 +536,21 @@ impl RegistryModel {
|
|||||||
Outcome::Applied
|
Outcome::Applied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_node_format(&mut self, serial: Serial, format: StreamFormat) -> Outcome {
|
||||||
|
let Some(entry) = self.nodes.get_mut(&serial) else {
|
||||||
|
tracing::debug!(
|
||||||
|
serial = serial.0,
|
||||||
|
"observer: node format for an unknown node"
|
||||||
|
);
|
||||||
|
return Outcome::Suppressed;
|
||||||
|
};
|
||||||
|
if entry.format == format {
|
||||||
|
return Outcome::Suppressed;
|
||||||
|
}
|
||||||
|
entry.format = format;
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
|
||||||
fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome {
|
fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome {
|
||||||
let Some(entry) = self.devices.get_mut(&serial) else {
|
let Some(entry) = self.devices.get_mut(&serial) else {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -737,6 +771,7 @@ impl RegistryModel {
|
|||||||
};
|
};
|
||||||
let mut props = obs.props.clone();
|
let mut props = obs.props.clone();
|
||||||
props.session_device = session_device;
|
props.session_device = session_device;
|
||||||
|
props.stream_format = entry.format;
|
||||||
Some(NodeSnapshot {
|
Some(NodeSnapshot {
|
||||||
serial,
|
serial,
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use super::pulse_pid;
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::host::taint::snapshot::{
|
use crate::host::taint::snapshot::{
|
||||||
ClientSnapshot, GlobalId, IdLookup, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
ClientSnapshot, GlobalId, IdLookup, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
||||||
|
StreamFormat,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- builders -------------------------------------------------------------
|
// ---- builders -------------------------------------------------------------
|
||||||
@@ -657,6 +658,46 @@ fn model_adds_all_four_object_types() {
|
|||||||
assert_eq!(snap.links().count(), 1);
|
assert_eq!(snap.links().count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_projects_configured_node_format_independently_of_props() {
|
||||||
|
let mut m = model();
|
||||||
|
add_stream_out(&mut m, 100, 50);
|
||||||
|
assert_eq!(
|
||||||
|
m.project()
|
||||||
|
.snapshot
|
||||||
|
.node(ser(100))
|
||||||
|
.unwrap()
|
||||||
|
.props
|
||||||
|
.stream_format,
|
||||||
|
StreamFormat::Unknown
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
m.apply(RegEvent::NodeFormat {
|
||||||
|
serial: ser(100),
|
||||||
|
format: StreamFormat::Encoded,
|
||||||
|
}),
|
||||||
|
Outcome::Applied
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
m.project()
|
||||||
|
.snapshot
|
||||||
|
.node(ser(100))
|
||||||
|
.unwrap()
|
||||||
|
.props
|
||||||
|
.stream_format,
|
||||||
|
StreamFormat::Encoded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
m.apply(RegEvent::NodeFormat {
|
||||||
|
serial: ser(100),
|
||||||
|
format: StreamFormat::Encoded,
|
||||||
|
}),
|
||||||
|
Outcome::Suppressed,
|
||||||
|
"an unchanged param must not inflate the graph event stream"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_removes_all_four_object_types() {
|
fn model_removes_all_four_object_types() {
|
||||||
let mut m = model();
|
let mut m = model();
|
||||||
|
|||||||
+36
-8
@@ -201,9 +201,15 @@ pub enum Reason {
|
|||||||
/// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local
|
/// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local
|
||||||
/// to the node; does not propagate.
|
/// to the node; does not propagate.
|
||||||
PortExclusive,
|
PortExclusive,
|
||||||
/// An encoded/passthrough stream — a second link would corrupt it.
|
/// No usable configured Format param has arrived. Fan-out cannot prove a
|
||||||
|
/// second link is safe. Local to the node; does not propagate.
|
||||||
|
FormatUnknown,
|
||||||
|
/// An encoded stream — a second raw-audio link would refuse or corrupt.
|
||||||
/// Local to the node; does not propagate.
|
/// Local to the node; does not propagate.
|
||||||
Passthrough,
|
Encoded,
|
||||||
|
/// An IEC958/S/PDIF passthrough stream. Local to the node; does not
|
||||||
|
/// propagate.
|
||||||
|
Iec958Passthrough,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Reason {
|
impl Reason {
|
||||||
@@ -219,10 +225,25 @@ impl Reason {
|
|||||||
Self::UnresolvedOwner => "unresolved-owner",
|
Self::UnresolvedOwner => "unresolved-owner",
|
||||||
Self::GraphNotReady => "graph-not-ready",
|
Self::GraphNotReady => "graph-not-ready",
|
||||||
Self::PortExclusive => "port-exclusive",
|
Self::PortExclusive => "port-exclusive",
|
||||||
Self::Passthrough => "passthrough",
|
Self::FormatUnknown => "format-unknown",
|
||||||
|
Self::Encoded => "encoded",
|
||||||
|
Self::Iec958Passthrough => "iec958-passthrough",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A clean, otherwise-eligible stream whose known format/port shape this
|
||||||
|
/// fan-out mode cannot link safely. These reasons are user-visible
|
||||||
|
/// `stream_unsupported` statuses; taint roots and observation gating are
|
||||||
|
/// intentional exclusions, not failures. `FormatUnknown` is deliberately
|
||||||
|
/// omitted because the initial Node-info callback can precede the Format
|
||||||
|
/// reply; reporting that transient would produce a false warning.
|
||||||
|
pub(super) fn reports_stream_unsupported(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::PortExclusive | Self::Encoded | Self::Iec958Passthrough
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Lower wins. A node can acquire taint several ways in one recompute
|
/// Lower wins. A node can acquire taint several ways in one recompute
|
||||||
/// and the reported reason must not depend on traversal order, or the
|
/// and the reported reason must not depend on traversal order, or the
|
||||||
/// audit output is unstable and the fixture tests are flaky. Explicit
|
/// audit output is unstable and the fixture tests are flaky. Explicit
|
||||||
@@ -241,7 +262,9 @@ impl Reason {
|
|||||||
// because it is only consulted for untainted candidates.
|
// because it is only consulted for untainted candidates.
|
||||||
Self::GraphNotReady => 8,
|
Self::GraphNotReady => 8,
|
||||||
Self::PortExclusive => 9,
|
Self::PortExclusive => 9,
|
||||||
Self::Passthrough => 10,
|
Self::FormatUnknown => 10,
|
||||||
|
Self::Encoded => 11,
|
||||||
|
Self::Iec958Passthrough => 12,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,13 +1080,18 @@ fn build_decisions(
|
|||||||
/// clean. These do not propagate — an exclusive-port stream is unlinkable,
|
/// clean. These do not propagate — an exclusive-port stream is unlinkable,
|
||||||
/// not hazardous.
|
/// not hazardous.
|
||||||
fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option<Reason> {
|
fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option<Reason> {
|
||||||
if node.props.passthrough {
|
|
||||||
return Some(Reason::Passthrough);
|
|
||||||
}
|
|
||||||
if snapshot.ports_of(node.id).any(|port| port.exclusive) {
|
if snapshot.ports_of(node.id).any(|port| port.exclusive) {
|
||||||
return Some(Reason::PortExclusive);
|
return Some(Reason::PortExclusive);
|
||||||
}
|
}
|
||||||
None
|
if node.props.passthrough {
|
||||||
|
return Some(Reason::Iec958Passthrough);
|
||||||
|
}
|
||||||
|
match node.props.stream_format {
|
||||||
|
snapshot::StreamFormat::Raw => None,
|
||||||
|
snapshot::StreamFormat::Unknown => Some(Reason::FormatUnknown),
|
||||||
|
snapshot::StreamFormat::Encoded => Some(Reason::Encoded),
|
||||||
|
snapshot::StreamFormat::Iec958 => Some(Reason::Iec958Passthrough),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sticky bookkeeping for the next recompute: every tainted owner, with
|
/// Sticky bookkeeping for the next recompute: every tainted owner, with
|
||||||
|
|||||||
@@ -52,6 +52,25 @@ pub enum MediaRole {
|
|||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The configured format of an application playback stream.
|
||||||
|
///
|
||||||
|
/// The production observer reads this from the Node's `SPA_PARAM_Format`.
|
||||||
|
/// Fixtures default to [`Self::Raw`] because almost every graph fixture models
|
||||||
|
/// ordinary PCM playback; the observer explicitly uses [`Self::Unknown`]
|
||||||
|
/// until PipeWire supplies a format. Unknown is fail-closed at eligibility.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum StreamFormat {
|
||||||
|
/// Ordinary PCM audio, safe to fan out subject to the other predicates.
|
||||||
|
#[default]
|
||||||
|
Raw,
|
||||||
|
/// No usable configured format has been observed yet.
|
||||||
|
Unknown,
|
||||||
|
/// Encoded audio other than IEC958 passthrough.
|
||||||
|
Encoded,
|
||||||
|
/// IEC958/S/PDIF passthrough.
|
||||||
|
Iec958,
|
||||||
|
}
|
||||||
|
|
||||||
impl MediaRole {
|
impl MediaRole {
|
||||||
pub fn parse(media_class: Option<&str>) -> Self {
|
pub fn parse(media_class: Option<&str>) -> Self {
|
||||||
match media_class {
|
match media_class {
|
||||||
@@ -136,9 +155,13 @@ pub struct NodeProps {
|
|||||||
/// module-created streams this is pipewire-pulse's own PID, which is
|
/// module-created streams this is pipewire-pulse's own PID, which is
|
||||||
/// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists.
|
/// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists.
|
||||||
pub process_id: Option<u32>,
|
pub process_id: Option<u32>,
|
||||||
/// The stream negotiated an encoded/passthrough format; a second link
|
/// `node.passthrough`; an explicit producer/session-manager refusal flag.
|
||||||
/// would refuse or corrupt it (v3.4 §6.2).
|
/// Kept separate from [`Self::stream_format`] so the two Phase-6 refusal
|
||||||
|
/// predicates cannot accidentally mask one another.
|
||||||
pub passthrough: bool,
|
pub passthrough: bool,
|
||||||
|
/// The Node's configured `SPA_PARAM_Format`, classified at the observer
|
||||||
|
/// boundary. Encoded and IEC958 streams are distinct refusal predicates.
|
||||||
|
pub stream_format: StreamFormat,
|
||||||
/// `device.id` — the snapshot-local PipeWire Device this node belongs
|
/// `device.id` — the snapshot-local PipeWire Device this node belongs
|
||||||
/// to. This is retained separately from [`Self::session_device`]: the
|
/// to. This is retained separately from [`Self::session_device`]: the
|
||||||
/// latter says the node is a positively-classified passive hardware
|
/// latter says the node is a positively-classified passive hardware
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ fn port_exclusive_and_passthrough_are_local_exclusions() {
|
|||||||
&[("ok", ok)],
|
&[("ok", ok)],
|
||||||
&[
|
&[
|
||||||
("exclusive", exclusive, "port-exclusive"),
|
("exclusive", exclusive, "port-exclusive"),
|
||||||
("passthrough", passthrough, "passthrough"),
|
("passthrough", passthrough, "iec958-passthrough"),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
// Neither is hazardous — an unlinkable stream must not taint anything.
|
// Neither is hazardous — an unlinkable stream must not taint anything.
|
||||||
|
|||||||
Reference in New Issue
Block a user