host/observer: phase 3r adapter — bind every Node and Device

The I/O half of round 8 (Codex, gpt-5.6-sol xhigh; reviewed, formatted and
extended here). The adapter now reads `object.serial` and nothing else off a
Node or Device global, binds the object, and takes every property the engine
reasons about from its `info` props.

- `BoundProxy` generalises `BoundLink` to Node/Device/Link, each holding its
  listener *before* its proxy so the listener is dropped first — the original
  Link variant had that order inverted.
- Bind attachment now finds its slot by never-recycled serial rather than
  taking the queue's back, so nested callback activity during a bind cannot
  attach one generation's proxy to another's slot on a recycled id. A proxy
  that finds no slot is returned to the caller and dropped after the borrow
  ends. Removal still pops oldest-first, matching the model's `live_ids`.
- An `info` is parsed and emitted on the first callback carrying props and
  thereafter only when `change_mask` contains PROPS. I considered emitting
  unconditionally and leaning on the model's suppression rule, and rejected
  it: if a state-only `info` ever delivered a partial props dict, that would
  overwrite a complete observation with an incomplete one — a worse failure
  than the one it guards against, and the same class as F1.
- Ports stay unbound (v3.5 §6.7 / impl plan §4 item 6).

Gates: exit-gate row 1 (live prop recovery) passes on this host — the tagged
null sink projects `peerspeak.owned`, `pulse.module.id`, `node.passthrough`,
the loopback legs share a `node.link-group`, and a real ALSA node classifies
`session_device`.

Added a second live test for the Device half. Row 1's `session_device`
assertion is satisfied by a *union*: WirePlumber 0.5.15 copies `device.api`
and `alsa.driver_name` onto ALSA nodes here, so it passes through the node
fallback and would keep passing if the Device bind delivered nothing —
leaving §6.7 decision 4 ungated on the development machine. The new test
binds every Device and requires an ALSA card to announce both keys.
Mutation-verified: breaking the Device-side driver read fails the new test
while row 1 still passes, which is the gap as claimed.

195 unit + 3 live green, clippy -D warnings and fmt clean.
This commit is contained in:
2026-07-25 18:32:27 -04:00
parent b3d71724ae
commit 306b601490
+405 -35
View File
@@ -4,9 +4,14 @@
//! callbacks into [`RegEvent`]s, and publishes the latest [`Projection`] for //! callbacks into [`RegEvent`]s, and publishes the latest [`Projection`] for
//! consumers running outside the PipeWire thread. //! consumers running outside the PipeWire thread.
use super::{EventKind, LinkEndpoints, Outcome, Projection, RegEvent, RegistryModel}; use super::classify::{DeviceClaim, DeviceProps};
use super::{
EventKind, LinkEndpoints, NodeObservation, Outcome, Projection, RegEvent, RegistryModel,
};
use crate::host::audio::parse_object_serial; use crate::host::audio::parse_object_serial;
use crate::host::taint::snapshot::{ClientSnapshot, GlobalId, PortDirection, PortSnapshot, Serial}; use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use pipewire::{self as pw, types::ObjectType}; use pipewire::{self as pw, types::ObjectType};
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
@@ -101,14 +106,24 @@ impl Drop for RegistryObserverHandle {
} }
} }
struct BoundLink { enum BoundProxy {
_proxy: pw::link::Link, Node {
_listener: pw::node::NodeListener,
_proxy: pw::node::Node,
},
Device {
_listener: pw::device::DeviceListener,
_proxy: pw::device::Device,
},
Link {
_listener: pw::link::LinkListener, _listener: pw::link::LinkListener,
_proxy: pw::link::Link,
},
} }
#[derive(Default)]
struct LiveGlobal { struct LiveGlobal {
bound_link: Option<BoundLink>, serial: Serial,
bound_proxy: Option<BoundProxy>,
} }
struct ObserverState { struct ObserverState {
@@ -139,12 +154,13 @@ impl ObserverState {
} }
} }
fn apply(&mut self, event: RegEvent) { fn apply(&mut self, event: RegEvent) -> Outcome {
// Taken before the model consumes the event: the sink is told what kind // Taken before the model consumes the event: the sink is told what kind
// of observation produced the projection, and deriving that from the // of observation produced the projection, and deriving that from the
// event itself is what stops the two from ever disagreeing. // event itself is what stops the two from ever disagreeing.
let kind = event.kind(); let kind = event.kind();
let mut outcome = self.model.apply(event); let event_outcome = self.model.apply(event);
let mut outcome = event_outcome;
let candidate = self.model.pulse_pid_candidate(); let candidate = self.model.pulse_pid_candidate();
if candidate != self.last_candidate { if candidate != self.last_candidate {
@@ -169,6 +185,7 @@ impl ObserverState {
if outcome == Outcome::Applied { if outcome == Outcome::Applied {
self.publish(kind); self.publish(kind);
} }
event_outcome
} }
fn publish(&mut self, kind: EventKind) { fn publish(&mut self, kind: EventKind) {
@@ -187,40 +204,65 @@ impl ObserverState {
} }
/// Record the global's id and apply its add event as one step, so the /// Record the global's id and apply its add event as one step, so the
/// bound-link FIFO stays provably lockstep with the model's own `live_ids` /// bound-proxy FIFO stays provably lockstep with the model's own `live_ids`
/// index. Recording only on *applied* adds (never on unknown object types /// index. Recording only on *applied* adds (never on unknown object types
/// or globals dropped for a missing serial) is what keeps the two id /// or globals dropped for a missing serial) is what keeps the two id
/// queues the same length per id — otherwise a phantom slot ahead of a /// queues the same length per id — otherwise a phantom slot could pop
/// bound Link would be popped on removal, leaking that Link's proxy. /// another generation's proxy after an id is recycled.
fn add(&mut self, id: GlobalId, event: RegEvent) { fn add(&mut self, serial: Serial, id: GlobalId, event: RegEvent) {
if self.apply(event) == Outcome::Applied {
self.live_globals self.live_globals
.entry(id) .entry(id)
.or_default() .or_default()
.push_back(LiveGlobal::default()); .push_back(LiveGlobal {
self.apply(event); serial,
bound_proxy: None,
});
}
} }
fn attach_bound_link(&mut self, id: GlobalId, bound_link: BoundLink) { /// Return a proxy that could not be attached so its listener is dropped
let Some(global) = self.live_globals.get_mut(&id).and_then(VecDeque::back_mut) else { /// after the caller releases the `RefCell` borrow.
fn attach_bound_proxy(
&mut self,
id: GlobalId,
serial: Serial,
bound_proxy: BoundProxy,
) -> Option<BoundProxy> {
let Some(global) = self
.live_globals
.get_mut(&id)
.and_then(|globals| globals.iter_mut().find(|global| global.serial == serial))
else {
tracing::warn!( tracing::warn!(
global_id = id.0, global_id = id.0,
"registry observer: link bind completed without a live global slot" serial = serial.0,
"registry observer: bind completed without a live global slot"
); );
return; return Some(bound_proxy);
}; };
global.bound_link = Some(bound_link); if global.bound_proxy.is_some() {
tracing::warn!(
global_id = id.0,
serial = serial.0,
"registry observer: live global slot already has a bound proxy"
);
return Some(bound_proxy);
}
global.bound_proxy = Some(bound_proxy);
None
} }
fn remove_global(&mut self, id: GlobalId) -> Option<BoundLink> { fn remove_global(&mut self, id: GlobalId) -> Option<BoundProxy> {
let (bound_link, empty) = { let (bound_proxy, empty) = {
let globals = self.live_globals.get_mut(&id)?; let globals = self.live_globals.get_mut(&id)?;
let bound_link = globals.pop_front().and_then(|global| global.bound_link); let bound_proxy = globals.pop_front().and_then(|global| global.bound_proxy);
(bound_link, globals.is_empty()) (bound_proxy, globals.is_empty())
}; };
if empty { if empty {
self.live_globals.remove(&id); self.live_globals.remove(&id);
} }
bound_link bound_proxy
} }
} }
@@ -291,9 +333,59 @@ fn run_observer(
else { else {
return; return;
}; };
state_for_global state_for_global.borrow_mut().add(
.borrow_mut() serial,
.add(id, RegEvent::NodeAdded { serial, id }); id,
RegEvent::NodeAdded { serial, id },
);
let Some(registry) = registry_weak.upgrade() else {
return;
};
let node: pw::node::Node = match registry.bind(obj) {
Ok(node) => node,
Err(e) => {
tracing::warn!(
node_id = obj.id,
"registry observer: failed to bind Node for properties: {e}"
);
return;
}
};
// This bit only recognizes the initial callback for the
// change-mask fast path. Admission vs update remains
// entirely the model's decision.
let first_info = Cell::new(true);
let state_for_info = Rc::downgrade(&state_for_global);
let listener = node
.add_listener_local()
.info(move |info| {
let Some(props) = info.props() else {
return;
};
let first = first_info.replace(false);
if !first
&& !info.change_mask().contains(pw::node::NodeChangeMask::PROPS)
{
return;
}
if let Some(state) = state_for_info.upgrade() {
state.borrow_mut().apply(RegEvent::NodeInfo {
serial,
observation: node_observation_from_props(props),
});
}
})
.register();
let unattached = state_for_global.borrow_mut().attach_bound_proxy(
id,
serial,
BoundProxy::Node {
_listener: listener,
_proxy: node,
},
);
drop(unattached);
} }
ObjectType::Port => { ObjectType::Port => {
let Some(props) = obj.props.as_ref() else { let Some(props) = obj.props.as_ref() else {
@@ -332,6 +424,7 @@ fn run_observer(
} }
}; };
state_for_global.borrow_mut().add( state_for_global.borrow_mut().add(
serial,
id, id,
RegEvent::PortAdded(PortSnapshot { RegEvent::PortAdded(PortSnapshot {
serial, serial,
@@ -356,6 +449,7 @@ fn run_observer(
return; return;
}; };
state_for_global.borrow_mut().add( state_for_global.borrow_mut().add(
serial,
id, id,
RegEvent::ClientAdded(ClientSnapshot { RegEvent::ClientAdded(ClientSnapshot {
serial, serial,
@@ -381,9 +475,58 @@ fn run_observer(
else { else {
return; return;
}; };
state_for_global state_for_global.borrow_mut().add(
.borrow_mut() serial,
.add(id, RegEvent::DeviceAdded { serial, id }); id,
RegEvent::DeviceAdded { serial, id },
);
let Some(registry) = registry_weak.upgrade() else {
return;
};
let device: pw::device::Device = match registry.bind(obj) {
Ok(device) => device,
Err(e) => {
tracing::warn!(
device_id = obj.id,
"registry observer: failed to bind Device for properties: {e}"
);
return;
}
};
let first_info = Cell::new(true);
let state_for_info = Rc::downgrade(&state_for_global);
let listener = device
.add_listener_local()
.info(move |info| {
let Some(props) = info.props() else {
return;
};
let first = first_info.replace(false);
if !first
&& !info
.change_mask()
.contains(pw::device::DeviceChangeMask::PROPS)
{
return;
}
if let Some(state) = state_for_info.upgrade() {
state.borrow_mut().apply(RegEvent::DeviceInfo {
serial,
props: device_props_from_props(props),
});
}
})
.register();
let unattached = state_for_global.borrow_mut().attach_bound_proxy(
id,
serial,
BoundProxy::Device {
_listener: listener,
_proxy: device,
},
);
drop(unattached);
} }
ObjectType::Link => { ObjectType::Link => {
let Some(props) = obj.props.as_ref() else { let Some(props) = obj.props.as_ref() else {
@@ -399,6 +542,7 @@ fn run_observer(
}; };
let endpoints = link_endpoints_from_props(props); let endpoints = link_endpoints_from_props(props);
state_for_global.borrow_mut().add( state_for_global.borrow_mut().add(
serial,
id, id,
RegEvent::LinkAdded { RegEvent::LinkAdded {
serial, serial,
@@ -445,24 +589,26 @@ fn run_observer(
} }
}) })
.register(); .register();
state_for_global.borrow_mut().attach_bound_link( let unattached = state_for_global.borrow_mut().attach_bound_proxy(
id, id,
BoundLink { serial,
_proxy: link, BoundProxy::Link {
_listener: listener, _listener: listener,
_proxy: link,
}, },
); );
drop(unattached);
} }
_ => {} _ => {}
} }
}) })
.global_remove(move |id| { .global_remove(move |id| {
let id = GlobalId(id); let id = GlobalId(id);
let bound_link = state_for_remove.borrow_mut().remove_global(id); let bound_proxy = state_for_remove.borrow_mut().remove_global(id);
state_for_remove state_for_remove
.borrow_mut() .borrow_mut()
.apply(RegEvent::Removed { id }); .apply(RegEvent::Removed { id });
drop(bound_link); drop(bound_proxy);
}) })
.register(); .register();
@@ -506,6 +652,45 @@ fn truthy(value: Option<&str>) -> bool {
value.is_some_and(|value| value != "false" && value != "0") value.is_some_and(|value| value != "false" && value != "0")
} }
fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation {
NodeObservation {
name: props.get("node.name").map(str::to_string),
role: MediaRole::parse(props.get("media.class")),
props: NodeProps {
peerspeak_owned: truthy(props.get("peerspeak.owned")),
pulse_module_id: props
.get("pulse.module.id")
.and_then(|value| value.parse::<u64>().ok()),
link_group: props.get("node.link-group").map(str::to_string),
client_id: props
.get("client.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
process_id: props
.get("application.process.id")
.and_then(|value| value.parse::<u32>().ok()),
passthrough: truthy(props.get("node.passthrough")),
session_device: false,
},
device_claim: DeviceClaim {
device_id: props
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
device_api: props.get("device.api").map(str::to_string),
factory_name: props.get("factory.name").map(str::to_string),
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
},
}
}
fn device_props_from_props(props: &pw::spa::utils::dict::DictRef) -> DeviceProps {
DeviceProps {
device_api: props.get("device.api").map(str::to_string),
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
}
}
fn link_endpoints_from_props(props: &pw::spa::utils::dict::DictRef) -> Option<LinkEndpoints> { fn link_endpoints_from_props(props: &pw::spa::utils::dict::DictRef) -> Option<LinkEndpoints> {
let output_node = props.get("link.output.node")?.parse::<u32>().ok()?; let output_node = props.get("link.output.node")?.parse::<u32>().ok()?;
let input_node = props.get("link.input.node")?.parse::<u32>().ok()?; let input_node = props.get("link.input.node")?.parse::<u32>().ok()?;
@@ -606,6 +791,191 @@ mod tests {
.any(|node| node.name.as_deref() == Some(name)) .any(|node| node.name.as_deref() == Some(name))
} }
/// Phase 3r exit-gate row 1, the Device half — and the reason it needs its
/// own test.
///
/// `live_bound_properties_recover_node_and_device_inputs` asserts
/// `session_device`, which the classifier grants on a **union**:
/// `device.api` and `alsa.driver_name` may come from the bound Device *or*
/// from the node's own copies. On this host (WirePlumber 0.5.15 ≥ 0.5.13)
/// the session manager *does* copy both onto ALSA nodes, so that assertion
/// passes through the node fallback and would keep passing if the Device
/// bind delivered nothing at all — leaving v3.5 §6.7 decision 4, the whole
/// authoritative path, ungated on the machine we develop on.
///
/// So assert the Device side directly: bind every Device global and require
/// that at least one ALSA card announces **both** keys on its `info` props.
/// A failure here means the fix for the phase-3 review's owed finding (a
/// real card over-excluded on installs that do not copy `alsa.*` onto the
/// node) rests on nothing.
#[test]
#[ignore = "needs live pipewire"]
fn live_device_bind_carries_api_and_driver_name() {
pw::init();
let main_loop = pw::main_loop::MainLoopRc::new(None).expect("pw main loop");
let context = pw::context::ContextRc::new(&main_loop, None).expect("pw context");
let core = context.connect_rc(None).expect("pw core connect");
let registry = core.get_registry_rc().expect("pw registry");
// Devices bound off the registry, each holding its proxy + listener so
// the callback lives long enough to fire, exactly as the adapter does.
let bound: Rc<RefCell<Vec<(pw::device::Device, pw::device::DeviceListener)>>> =
Rc::new(RefCell::new(Vec::new()));
let observed: Rc<RefCell<Vec<DeviceProps>>> = Rc::new(RefCell::new(Vec::new()));
let bound_for_global = Rc::clone(&bound);
let observed_for_global = Rc::clone(&observed);
let registry_weak = registry.downgrade();
let _listener = registry
.add_listener_local()
.global(move |obj| {
if obj.type_ != ObjectType::Device {
return;
}
let Some(registry) = registry_weak.upgrade() else {
return;
};
let Ok(device) = registry.bind::<pw::device::Device, _>(obj) else {
return;
};
let observed_for_info = Rc::clone(&observed_for_global);
let listener = device
.add_listener_local()
.info(move |info| {
if let Some(props) = info.props() {
observed_for_info
.borrow_mut()
.push(device_props_from_props(props));
}
})
.register();
bound_for_global.borrow_mut().push((device, listener));
})
.register();
// Two seconds is the same budget the observer gives its own binds.
let main_loop_for_timer = main_loop.clone();
let timer = main_loop
.loop_()
.add_timer(move |_| main_loop_for_timer.quit());
timer
.update_timer(Some(Duration::from_secs(2)), None)
.into_result()
.expect("arm the test deadline");
main_loop.run();
let observed = observed.borrow();
assert!(
!observed.is_empty(),
"no Device delivered info props at all — the Device bind path is dead"
);
assert!(
observed.iter().any(|props| {
props.device_api.as_deref() == Some("alsa") && props.alsa_driver_name.is_some()
}),
"no bound Device carried both device.api=alsa and alsa.driver_name; \
observed: {observed:?}"
);
}
// Phase 3r exit-gate row 1: failure means the observation boundary regressed.
#[test]
#[ignore = "needs live pipewire"]
fn live_bound_properties_recover_node_and_device_inputs() {
pw::init();
let observer = RegistryObserverHandle::spawn().expect("observer thread must spawn");
wait_for(&observer, |projection| projection.graph_ready);
let unique = format!("pixelpass_observer_props_test_{}", std::process::id());
let capture_name = format!("{unique}_capture");
let playback_name = format!("{unique}_playback");
let null_sink = PactlModule::load(
"module-null-sink",
&[
format!("sink_name={unique}"),
"sink_properties=peerspeak.owned=true node.passthrough=true".to_string(),
],
);
let null_sink_id = null_sink.id.expect("null-sink module must have an id");
let loopback = PactlModule::load(
"module-loopback",
&[
format!("source={unique}.monitor"),
format!("sink={unique}"),
format!("source_output_properties=node.name={capture_name}"),
format!("sink_input_properties=node.name={playback_name}"),
],
);
let projection = wait_for(&observer, |projection| {
projection.graph_ready
&& has_node(projection, &unique)
&& has_node(projection, &capture_name)
&& has_node(projection, &playback_name)
});
let tagged_sink = projection
.snapshot
.nodes()
.find(|node| node.name.as_deref() == Some(&unique))
.expect("tagged null sink must be projected");
assert!(tagged_sink.props.peerspeak_owned);
assert!(tagged_sink.props.passthrough);
assert_eq!(
tagged_sink.props.pulse_module_id,
Some(u64::from(null_sink_id))
);
let capture = projection
.snapshot
.nodes()
.find(|node| node.name.as_deref() == Some(&capture_name))
.expect("loopback capture leg must be projected");
let playback = projection
.snapshot
.nodes()
.find(|node| node.name.as_deref() == Some(&playback_name))
.expect("loopback playback leg must be projected");
let capture_group = capture
.props
.link_group
.as_ref()
.expect("loopback capture leg must carry node.link-group");
let playback_group = playback
.props
.link_group
.as_ref()
.expect("loopback playback leg must carry node.link-group");
assert_eq!(capture_group, playback_group);
assert!(
projection
.snapshot
.nodes()
.any(|node| node.props.process_id.is_some()),
"at least one projected node must carry application.process.id"
);
let session_device = projection.snapshot.nodes().find(|node| {
node.props.session_device
&& (node
.name
.as_deref()
.is_some_and(|name| name.contains("alsa"))
|| matches!(node.role, MediaRole::Sink | MediaRole::Source))
});
assert!(
session_device.is_some(),
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device"
);
assert!(projection.graph_ready);
loopback.unload();
null_sink.unload();
wait_for(&observer, |projection| {
!has_node(projection, &unique)
&& !has_node(projection, &capture_name)
&& !has_node(projection, &playback_name)
});
}
#[test] #[test]
#[ignore = "needs live pipewire"] #[ignore = "needs live pipewire"]
fn live_topology_diff_tracks_null_sink_and_loopback() { fn live_topology_diff_tracks_null_sink_and_loopback() {