//! The registry observer's **pure core** (impl plan ยง4, phases 3 and 3r). //! //! This is my half of the phase-3 split: a reducer that folds a stream of //! typed [`RegEvent`]s into a live model of the PipeWire graph and projects //! the [`GraphSnapshot`] + context the taint engine (phase 2) consumes. **No //! PipeWire types appear here** โ€” the I/O adapter (Codex's half) translates //! live registry callbacks, binds, `/proc` reads, and the `core.sync`/`done` //! round-trip into these events and feeds them in. Every test in this module //! builds the event stream by hand. //! //! ## ๐Ÿ”ด Round 8 (v3.5 ยง6.7): the global is an INDEX, not a source of truth //! //! Phase 3 shipped reading node properties off the registry `global` event. //! The registry announces only a fixed 13-key subset for a Node, and **eight //! properties this feature depends on are never among them** โ€” they read as //! absent rather than failing, so the engine was silently, permanently //! starved of both its primary taint root and every strong owner key (the //! phase-5 gate failure, F1/F2). The rule that replaces it: //! //! > A node's properties come from a **bind**, never from the global. The //! > global tells us an object exists, its id and its serial. Everything //! > else โ€” including `node.name` and `media.class`, so there is exactly one //! > source โ€” arrives on [`RegEvent::NodeInfo`]. Same for `Device` //! > ([`RegEvent::DeviceInfo`]). //! //! Consequences visible in this file: a Node is admitted to the snapshot //! **only** once its `info` has arrived (until then it is withheld and is a //! readiness obligation); a Device resolves a node's claim only once *its* //! `info` has arrived; and `info` may fire again for the lifetime of the //! object, so [`RegEvent::NodeInfo`] is both the first resolution and every //! later property change (v3.5 ยง6.7 decisions 1โ€“4). //! //! Three things this core is shaped to get right, each an exit-gate row: //! //! - **Removal by recycled id.** `global_remove` names only a 32-bit global //! id, and those recycle. The model keeps an insertion-ordered index per id //! so a removal accounts for the *oldest* generation first, and the //! snapshot projection treats any id still claimed by two live objects as //! [`IdLookup::Ambiguous`] โ€” fail closed (v3.4 ยง6.1.3). Everything the //! model *owns* is keyed by never-recycled `object.serial`; ids are only //! ever a lookup. //! - **The readiness epoch.** `graph_ready` is false until the initial graph //! is fully observed: the server has synced **and** no binds/withheld nodes //! remain outstanding. A bounded timeout makes it fail closed. It gates //! sticky *retirement* only; withholding after completion is per-object. //! - **Withholding on unresolved input.** A node with no `info` yet, or one //! claiming a `device.id` whose Device we have not resolved, is held out of //! the snapshot entirely rather than admitted with provisional ownership //! (see [`classify`]). //! //! **Three accepted limitations, all low-reachability, owed to a later //! hardening round:** //! //! - *A Link dropped for a missing `object.serial`/props is unrepresented.* //! The adapter drops such a global before it reaches [`RegistryModel`], so //! readiness can reach `Complete` while permanently omitting that Link โ€” an //! invisible edge that could hide tainted ancestry. **Not reachable in //! practice:** PipeWire's native protocol defines `object.serial` as the //! unique identity every global carries, so a Link without one requires a //! protocol/server failure, not ordinary churn. (The live gate is //! consistent with this but does not *prove* it โ€” it only counts Links the //! strict parser already admitted.) A full fix needs a pure //! "required-observation-failed" token that holds readiness false; deferred //! rather than built for a case that does not occur. //! - *Removal generation ordering assumes no removal is silently lost.* On a //! recycled id with two live claimants, [`Self::on_removed`] retires the //! oldest generation first; if the *first* generation's removal was never //! delivered, a later removal is misattributed. PipeWire's registry does not //! silently drop `global_remove`, so this needs callback loss to trigger. //! The snapshot treats the two-claimant window as [`IdLookup::Ambiguous`] //! (fail closed) meanwhile. //! - *An unresolvable bind takes the whole graph down, not just its node* //! (v3.5 ยง6.7 decision 3). A node whose `info` never arrives keeps //! readiness false until the deadline, then sticky-[`Readiness::TimedOut`] //! โ€” no fan-out at all, identical to a never-resolving Link bind. Per-node //! quarantine (that node ineligible **and** taint-bearing, the rest of the //! graph still working) is strictly better and is deferred because it is a //! new concept in the *pure engine*, not a fix to the observer. #![allow(dead_code)] // Wired by the phase-3 adapter (Codex's half) and consumed by later phases. pub mod adapter; pub mod classify; pub mod pulse_pid; #[cfg(test)] mod tests; use crate::host::taint::snapshot::{ ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot, PortSnapshot, Serial, }; use classify::{Classification, DeviceClaim, DeviceProps}; use std::collections::{BTreeMap, VecDeque}; /// A monotonic millisecond clock value, supplied by the adapter via /// [`RegEvent::Tick`]. Kept as a bare integer rather than /// [`std::time::Instant`] so the readiness timeout is deterministic in tests. pub type Millis = u64; /// A Node's **bound `info` properties** โ€” the sole source of node properties /// (v3.5 ยง6.7), delivered by [`RegEvent::NodeInfo`]. /// /// This carries no identity: the serial names the node on the event and the /// global id was recorded by [`RegEvent::NodeAdded`], so the adapter cannot /// contradict the index it already published. `session_device` inside /// [`NodeObservation::props`] is left at its `false` default; the model /// overwrites it from the [`classify`] result at projection time, once the /// backing Device (if any) is resolved. #[derive(Clone, Debug, PartialEq, Eq)] pub struct NodeObservation { pub name: Option, pub role: MediaRole, pub props: NodeProps, pub device_claim: DeviceClaim, } /// The four endpoint references a Link carries. Node endpoints are required โ€” /// a Link with unknown nodes is useless โ€” so this whole struct is what the /// adapter must resolve (from the global's props if present, else by binding /// `LinkInfoRef`, the correctness path) before a Link enters the snapshot. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct LinkEndpoints { pub output_node: GlobalId, pub input_node: GlobalId, pub output_port: Option, pub input_port: Option, } /// A typed observation of the live graph. The adapter produces these; the /// model consumes them in [`RegistryModel::apply`]. #[derive(Clone, Debug, PartialEq, Eq)] pub enum RegEvent { /// A Node global appeared. **Index only** โ€” the global's properties are a /// filtered subset and are not read (v3.5 ยง6.7). The node is withheld /// from the snapshot and is a readiness obligation until its /// [`RegEvent::NodeInfo`] arrives. NodeAdded { serial: Serial, id: GlobalId }, /// A bound Node's `info` properties. **Both** the first resolution and /// every later `PROPS` change for the node's lifetime โ€” the model tells /// them apart, so the adapter holds no per-node "have I seen info yet?" /// state to get wrong. An `info` for a serial we do not hold (a node /// already removed) is ignored. NodeInfo { serial: Serial, observation: NodeObservation, }, /// A Port global appeared. PortAdded(PortSnapshot), /// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`. ClientAdded(ClientSnapshot), /// A Device global appeared. Index only, exactly as for a Node: it does /// not resolve anything until [`RegEvent::DeviceInfo`] arrives. DeviceAdded { serial: Serial, id: GlobalId }, /// A bound Device's `info` properties โ€” the **authoritative** source of /// `device.api` and `alsa.driver_name` (v3.5 ยง6.7 decision 4). Resolves /// every node withheld on this Device's id. DeviceInfo { serial: Serial, props: DeviceProps }, /// A Link global appeared. `endpoints` is `Some` when the global carried /// them (the optimisation) and `None` when the adapter must bind to learn /// them (the correctness path) โ€” the latter is an outstanding obligation /// until a matching [`RegEvent::LinkEndpointsResolved`] arrives. /// /// Unlike Nodes and Devices, Link endpoint props **are** announced on the /// global (measured, phase-5 results F1), so this asymmetry is real and /// deliberate. LinkAdded { serial: Serial, id: GlobalId, endpoints: Option, }, /// The bind-`LinkInfoRef` fallback resolved a Link's endpoints. LinkEndpointsResolved { serial: Serial, endpoints: LinkEndpoints, }, /// The adapter read `/proc//comm` (`None` = the read failed / the /// process is gone). Validates the pulse-PID candidate. ProcCommProbed { pid: u32, comm: Option }, /// Any global was removed. Only its 32-bit id is known. Removed { id: GlobalId }, /// A `core.sync()` issued after the initial enumeration completed its /// round-trip (`done`). One half of readiness; the other is that no /// binds/withheld nodes are still outstanding. ServerSynced, /// A monotonic clock sample. Drives the readiness timeout only. Tick { now: Millis }, } /// What kind of observation drove a projection. /// /// Derived from the event itself ([`RegEvent::kind`]) rather than passed /// alongside it, so a consumer's view of "was this a real graph change?" cannot /// disagree with what the model was actually fed. The distinction matters to the /// phase-5 audit twice over: ticks arrive at a constant rate and would inflate /// any measured graph-event rate, and a record that is identical to the previous /// one is worth suppressing on a tick but never on a graph event. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EventKind { /// A registry observation: an add, a removal, a bind resolution, a `/proc` /// probe, or the server sync. Graph, /// The periodic clock sample. Carries no graph information; it exists so the /// readiness timeout and the AEC validation deadline have a clock. Tick, } impl EventKind { pub fn code(self) -> &'static str { match self { Self::Graph => "graph", Self::Tick => "tick", } } } impl RegEvent { pub fn kind(&self) -> EventKind { match self { Self::Tick { .. } => EventKind::Tick, _ => EventKind::Graph, } } } /// Whether an applied event could have changed the projection. /// /// The suppression rule of v3.5 ยง6.7 decision 2, in the one place that can /// enforce it: **a property update may be dropped only when the resulting /// [`Projection`] is identical to the current one.** The projection is a pure /// function of model state, so "state provably unchanged" *is* "projection /// identical" โ€” which is what [`Outcome::Suppressed`] means and why the check /// is a cheap field comparison rather than building and diffing two snapshots. /// /// Anything looser (dropping updates that do change state) breaks phase 4's /// no-coalescing contract, which needs to see the empty gap between an AEC /// module unload and a reload that reuses the index. Anything stricter /// (publishing on every `info`, including the state-only changes PipeWire /// emits constantly) inflates the O5 event rate with non-events. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Outcome { /// Model state may have changed; the caller must publish the projection. Applied, /// Model state provably did not change; publishing is optional and the /// adapter skips it. Suppressed, } /// Which slot in the id index a live object occupies. `global_remove` gives /// only the id, so the index remembers what each id currently holds. Every /// slot names its object by never-recycled serial. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Slot { Node(Serial), Port(Serial), Link(Serial), Client(Serial), Device(Serial), } /// The readiness epoch. A one-time transition out of [`Readiness::Waiting`]; /// both terminal states are sticky (a completed graph is not un-completed by /// later per-object withholding, and a timed-out observer stays fail-closed /// for its lifetime). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Readiness { /// The initial enumeration is still in flight. Waiting, /// The initial enumeration finished at least once (server synced with no /// obligations then outstanding). **Sticky** โ€” later per-object /// withholding does not revert it. Note this is *not* the same as /// [`RegistryModel::graph_ready`], which additionally requires no *current* /// obligation (Codex finding 1); `Complete` only records that the epoch /// was reached. Complete, /// The bounded deadline passed with obligations outstanding. /// `graph_ready` stays false โ€” fail closed. TimedOut, } /// The pure handoff to the taint engine: a coherent [`GraphSnapshot`] plus the /// two context fields phase 3 owns. The caller merges these into /// [`crate::host::taint::ExclusionCtx`] alongside `aec_module_id` (phase 4) /// and `pixelpass_owned` (pixelpass's own tracking). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Projection { pub snapshot: GraphSnapshot, pub pipewire_pulse_pid: Option, pub graph_ready: bool, /// The sticky readiness epoch behind `graph_ready`. Carried so a consumer /// can tell the three not-ready causes apart โ€” enumeration still in flight /// ([`Readiness::Waiting`]), a fail-closed timeout ([`Readiness::TimedOut`]), /// or a completed epoch momentarily blocked on a current obligation /// ([`Readiness::Complete`] with `graph_ready == false`). `graph_ready` /// alone collapses all three into "no". The phase-5 audit reports it as the /// epoch column; nothing gates on it. pub readiness: Readiness, } /// A live Node: its global id (for link endpoint lookup) plus its bound /// properties once they arrive. #[derive(Clone, Debug, PartialEq, Eq)] struct NodeEntry { id: GlobalId, /// `None` while the bind is outstanding โ€” withheld from the snapshot and /// an outstanding readiness obligation (v3.5 ยง6.7 decision 3). obs: Option, } /// A live Device: its global id plus its bound properties once they arrive. #[derive(Clone, Debug, PartialEq, Eq)] struct DeviceEntry { id: GlobalId, /// `None` while the bind is outstanding. A node claiming this Device /// stays withheld until it is `Some` โ€” the Device's `device.api` and /// `alsa.driver_name` are the authoritative inputs to `session_device` /// (v3.5 ยง6.7 decision 4), so classifying without them would be the same /// provisional answer the contract forbids. props: Option, } /// The live model. Folds [`RegEvent`]s; project with [`RegistryModel::project`]. #[derive(Clone, Debug)] pub struct RegistryModel { /// **Every** live Node, keyed by serial โ€” admitted or withheld. Admission /// is decided at projection time from the entry's own state, so there is /// no admitted/withheld pair of maps to drift apart. nodes: BTreeMap, /// Every live Device, keyed by serial. devices: BTreeMap, ports: BTreeMap, links: BTreeMap, clients: BTreeMap, /// Links whose endpoints the adapter is still binding; the id is kept so /// removal and resolution can find them. pending_links: BTreeMap, /// Insertion-ordered holders of each live global id. `global_remove` /// accounts for the oldest generation first (v3.4 ยง6.1.3). live_ids: BTreeMap>, /// `/proc//comm` reads keyed by pid, for pulse-PID validation. probed_comm: BTreeMap>, server_synced: bool, readiness: Readiness, deadline: Millis, last_now: Millis, } impl RegistryModel { /// `now` seeds the clock; `timeout` is the readiness budget. The deadline /// is `now + timeout`; a [`RegEvent::Tick`] at or past it while still /// [`Readiness::Waiting`] fails the epoch closed. pub fn new(now: Millis, timeout: Millis) -> Self { Self { nodes: BTreeMap::new(), devices: BTreeMap::new(), ports: BTreeMap::new(), links: BTreeMap::new(), clients: BTreeMap::new(), pending_links: BTreeMap::new(), live_ids: BTreeMap::new(), probed_comm: BTreeMap::new(), server_synced: false, readiness: Readiness::Waiting, deadline: now.saturating_add(timeout), last_now: now, } } pub fn readiness(&self) -> Readiness { self.readiness } /// Whether the graph is trustworthy enough to make eligibility and sticky /// **retirement** decisions right now. /// /// This is **dynamic**, not the sticky [`Readiness::Complete`] flag: it is /// true only when the initial enumeration has completed **and** there are /// no current obligations outstanding (a node whose bind is outstanding, a /// node withheld on an unresolved Device, or a Link still being bound). /// The distinction is the fix for Codex phase-3 review finding 1: a Link /// whose endpoints are still resolving is an **invisible edge** โ€” it is /// absent from the snapshot, not merely dangling โ€” so a decision made /// while one exists can miss real tainted ancestry and wrongly report a /// candidate eligible. Unresolved ancestry โ‡’ fail closed is the governing /// invariant (v3.4 ยง6.1), and round 8 adds the far more common case: an /// unbound node is an invisible *vertex*, which hides everything the edge /// case hides and its ownership besides. /// /// [`Readiness::Complete`] stays sticky (it records that the initial /// enumeration happened, for logging and to distinguish "not started" from /// "momentarily churning"); `graph_ready` layers the dynamic obligation /// check on top. Downstream (phase 6) may debounce the brief blips a /// normal bind causes; the observer's job is to report the truth. pub fn graph_ready(&self) -> bool { matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding() } /// The pulse-PID candidate the adapter should be probing (`None` = no /// repeated `sec_pid`, nothing to probe). Exposed so the adapter re-probes /// only when the candidate changes. pub fn pulse_pid_candidate(&self) -> Option { let clients: Vec = self.clients.values().cloned().collect(); pulse_pid::candidate(&clients) } /// Fold one observation into the model. The returned [`Outcome`] tells the /// caller whether the projection can have changed; see [`Outcome`] for why /// that is the only sound place to enforce the suppression rule. pub fn apply(&mut self, event: RegEvent) -> Outcome { match event { RegEvent::NodeAdded { serial, id } => { self.push_id(id, Slot::Node(serial)); self.nodes.insert(serial, NodeEntry { id, obs: None }); // A node awaiting its bind is a fresh obligation, so this can // only ever *hold* readiness, never complete it โ€” but the // re-check is cheap and keeps the invariant local. self.maybe_complete(); Outcome::Applied } RegEvent::NodeInfo { serial, observation, } => self.on_node_info(serial, observation), RegEvent::PortAdded(port) => { self.push_id(port.id, Slot::Port(port.serial)); self.ports.insert(port.serial, port); Outcome::Applied } RegEvent::ClientAdded(client) => { self.push_id(client.id, Slot::Client(client.serial)); self.clients.insert(client.serial, client); // A new client can change the pulse candidate; the adapter // learns that via `pulse_pid_candidate`. No readiness effect. Outcome::Applied } RegEvent::DeviceAdded { serial, id } => { self.push_id(id, Slot::Device(serial)); self.devices.insert(serial, DeviceEntry { id, props: None }); self.maybe_complete(); Outcome::Applied } RegEvent::DeviceInfo { serial, props } => self.on_device_info(serial, props), RegEvent::LinkAdded { serial, id, endpoints, } => { self.on_link_added(serial, id, endpoints); Outcome::Applied } RegEvent::LinkEndpointsResolved { serial, endpoints } => { self.on_link_resolved(serial, endpoints) } RegEvent::ProcCommProbed { pid, comm } => { let previous = self.probed_comm.insert(pid, comm.clone()); if previous.as_ref() == Some(&comm) { Outcome::Suppressed } else { Outcome::Applied } } RegEvent::Removed { id } => self.on_removed(id), RegEvent::ServerSynced => { let already = self.server_synced; self.server_synced = true; self.maybe_complete(); if already { Outcome::Suppressed } else { Outcome::Applied } } RegEvent::Tick { now } => { self.last_now = now; self.maybe_timeout(now); Outcome::Applied } } } /// First resolution *and* every later property change (v3.5 ยง6.7 /// decision 2). The model distinguishes them by what it already holds, so /// the adapter can forward every `info` callback unconditionally. fn on_node_info(&mut self, serial: Serial, observation: NodeObservation) -> Outcome { let Some(entry) = self.nodes.get_mut(&serial) else { // A late `info` for a node already removed. Re-inserting it here // would resurrect a dead node with no id index behind it. tracing::debug!(serial = serial.0, "observer: node info for an unknown node"); return Outcome::Suppressed; }; if entry.obs.as_ref() == Some(&observation) { // The state-only `info` callbacks PipeWire emits constantly: same // properties, so the projection is provably identical. return Outcome::Suppressed; } entry.obs = Some(observation); // The first `info` retires this node's obligation, which can be the // last one outstanding. self.maybe_complete(); Outcome::Applied } fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome { let Some(entry) = self.devices.get_mut(&serial) else { tracing::debug!( serial = serial.0, "observer: device info for an unknown device" ); return Outcome::Suppressed; }; if entry.props.as_ref() == Some(&props) { return Outcome::Suppressed; } entry.props = Some(props); // Resolving a Device admits every node that was withheld on it โ€” // which happens at projection time; here it can only retire // obligations. self.maybe_complete(); Outcome::Applied } fn on_link_added(&mut self, serial: Serial, id: GlobalId, endpoints: Option) { self.push_id(id, Slot::Link(serial)); match endpoints { Some(e) => { self.links.insert(serial, link_snapshot(serial, id, e)); } None => { // Correctness path: withhold the Link until the bind fallback // resolves it. Counts as an outstanding obligation. self.pending_links.insert(serial, id); } } self.maybe_complete(); } fn on_link_resolved(&mut self, serial: Serial, endpoints: LinkEndpoints) -> Outcome { // `remove` also guards against a stale resolution for a Link already // gone: unknown serial โ‡’ ignore. if let Some(id) = self.pending_links.remove(&serial) { self.links .insert(serial, link_snapshot(serial, id, endpoints)); self.maybe_complete(); Outcome::Applied } else { Outcome::Suppressed } } fn on_removed(&mut self, id: GlobalId) -> Outcome { let Some(queue) = self.live_ids.get_mut(&id) else { tracing::warn!(global_id = id.0, "observer: remove for an id we never saw"); return Outcome::Suppressed; }; // Oldest generation first โ€” the id may be shared during a // missed-removal window. let slot = queue.pop_front(); if queue.is_empty() { self.live_ids.remove(&id); } match slot { Some(Slot::Node(serial)) => { self.nodes.remove(&serial); } Some(Slot::Port(serial)) => { self.ports.remove(&serial); } Some(Slot::Link(serial)) => { self.links.remove(&serial); self.pending_links.remove(&serial); } Some(Slot::Client(serial)) => { self.clients.remove(&serial); } Some(Slot::Device(serial)) => { self.devices.remove(&serial); } None => { tracing::warn!(global_id = id.0, "observer: empty id slot on remove"); return Outcome::Suppressed; } } // A removal can drain the last obligation (an unbound node, a node // withheld on a Device, or a pending link vanished before it // resolved). self.maybe_complete(); Outcome::Applied } fn push_id(&mut self, id: GlobalId, slot: Slot) { self.live_ids.entry(id).or_default().push_back(slot); } /// The bound properties of the Device a node claims by global id, or /// `None` when that claim is unresolved โ€” which covers all three /// fail-closed cases at once: no such Device observed, its bind still /// outstanding, or **two live Devices sharing the recycled id**, where /// there is no way to tell whose properties these are (v3.4 ยง6.1.3). fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> { let mut found: Option = None; for slot in self.live_ids.get(&id)? { if let Slot::Device(serial) = slot { if found.is_some() { return None; // Ambiguous โ‡’ unresolved โ‡’ withheld. } found = Some(*serial); } } self.devices.get(&found?)?.props.as_ref() } /// Classify one node's device claim against the currently resolved /// Devices. Recomputed per projection rather than cached at admission: /// the inputs (this node's props, its Device's props) both change over an /// object's lifetime now, and a cached classification is exactly the kind /// of stale provisional answer ยง6.1.3 forbids. fn classification(&self, obs: &NodeObservation) -> Classification { let device = obs .device_claim .device_id .and_then(|id| self.device_props(id)); classify::classify(&obs.device_claim, device) } /// Every obligation that must clear before the initial graph is trusted: /// no node awaiting its bind, no node withheld on an unresolved Device, /// no Link awaiting its bind. fn obligations_outstanding(&self) -> bool { if !self.pending_links.is_empty() { return true; } self.nodes.values().any(|entry| match &entry.obs { None => true, Some(obs) => matches!(self.classification(obs), Classification::Withhold { .. }), }) } /// Completion needs no clock โ€” only the sync flag and an empty obligation /// set โ€” so it may fire on any mutating event. Sticky once reached. fn maybe_complete(&mut self) { if self.readiness != Readiness::Waiting { return; } if self.server_synced && !self.obligations_outstanding() { self.readiness = Readiness::Complete; tracing::info!("observer: readiness epoch reached (synced + no obligations)"); } } /// Only the timeout consults the clock. fn maybe_timeout(&mut self, now: Millis) { if self.readiness != Readiness::Waiting { return; } if now >= self.deadline { self.readiness = Readiness::TimedOut; tracing::warn!( unbound_nodes = self.unbound_node_count(), withheld = self.withheld_node_count(), pending_links = self.pending_links.len(), "observer: readiness epoch timed out with obligations outstanding โ€” fail closed" ); } } /// Nodes whose bind has not delivered `info` yet โ€” diagnostics only. fn unbound_node_count(&self) -> usize { self.nodes .values() .filter(|entry| entry.obs.is_none()) .count() } /// Nodes held out on an unresolved Device โ€” diagnostics only. fn withheld_node_count(&self) -> usize { self.nodes .values() .filter(|entry| { entry.obs.as_ref().is_some_and(|obs| { matches!(self.classification(obs), Classification::Withhold { .. }) }) }) .count() } /// pipewire-pulse's PID from the current clients, validated against the /// probed `comm`. `None` whenever anything is ambiguous or unconfirmed โ€” /// the safe answer (key 4 unusable). fn pulse_pid(&self) -> Option { let candidate = self.pulse_pid_candidate()?; let comm = self.probed_comm.get(&candidate).and_then(|c| c.as_deref()); pulse_pid::validate(candidate, comm) } /// Project the current state into the taint engine's inputs. /// /// A node enters the snapshot only if its bind has delivered `info` /// **and** its device claim classifies terminally; anything else is /// withheld (and is already holding `graph_ready` false). pub fn project(&self) -> Projection { let nodes: Vec = self .nodes .iter() .filter_map(|(&serial, entry)| { let obs = entry.obs.as_ref()?; let session_device = match self.classification(obs) { Classification::Withhold { .. } => return None, Classification::SessionDevice => true, Classification::NotADevice | Classification::NotSessionDevice => false, }; let mut props = obs.props.clone(); props.session_device = session_device; Some(NodeSnapshot { serial, id: entry.id, name: obs.name.clone(), role: obs.role, props, }) }) .collect(); let snapshot = GraphSnapshot::new( nodes, self.ports.values().cloned().collect(), self.links.values().cloned().collect(), self.clients.values().cloned().collect(), ); Projection { snapshot, pipewire_pulse_pid: self.pulse_pid(), graph_ready: self.graph_ready(), readiness: self.readiness, } } } fn link_snapshot(serial: Serial, id: GlobalId, e: LinkEndpoints) -> LinkSnapshot { LinkSnapshot { serial, id, output_node: e.output_node, input_node: e.input_node, output_port: e.output_port, input_port: e.input_port, } }