v3.5 §6.7. The registry `global` event announces only a fixed 13-key subset
of a Node's properties, and eight the engine depends on are never among them
(phase-5 gate failure F1/F2). The core now treats the global as an index and
takes every property from the object's bound `info`.
- `RegEvent::NodeAdded { serial, id }` is identity only; `RegEvent::NodeInfo`
carries the properties and is both the first resolution and every later
PROPS change for the node's lifetime (decision 2). Same split for Device
(`DeviceAdded` / `DeviceInfo`).
- A node with no `info` is withheld from the snapshot and is a readiness
obligation; an unresolvable bind ends in sticky `TimedOut`, fail closed
(decision 3). Devices are keyed by serial too, so a recycled device id with
two live claimants is ambiguous ⇒ withheld rather than guessed.
- One live-node map replaces the admitted/withheld pair; classification is
recomputed at projection time from current inputs, since both sides of it
now change over an object's lifetime.
- `classify` takes the bound Device's props: presence is a union with the
Device winning (this recovers a real card whose node was never given
`alsa.driver_name` — the phase-3 review's owed fix), while the
non-terminal-driver denylist is a union in the safe direction.
- `apply` returns `Outcome`, the only sound place to enforce the suppression
rule: a property update is dropped only when model state provably did not
change, i.e. the resulting projection is identical.
Adapter: stops reading properties off Node/Device globals and emits the new
index events. Binding every Node and Device — the I/O half — is the next
commit (Codex's), so until then every node is withheld and readiness times
out by design.
Tests: 55 observer (was 38) — the prop-update matrix, readiness with node
binds, and recycled-Node-id churn (phase 3r gate rows 2–4). 195 green,
clippy -D warnings and fmt clean.
751 lines
32 KiB
Rust
751 lines
32 KiB
Rust
//! 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<String>,
|
||
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<GlobalId>,
|
||
pub input_port: Option<GlobalId>,
|
||
}
|
||
|
||
/// 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<LinkEndpoints>,
|
||
},
|
||
/// The bind-`LinkInfoRef` fallback resolved a Link's endpoints.
|
||
LinkEndpointsResolved {
|
||
serial: Serial,
|
||
endpoints: LinkEndpoints,
|
||
},
|
||
/// The adapter read `/proc/<pid>/comm` (`None` = the read failed / the
|
||
/// process is gone). Validates the pulse-PID candidate.
|
||
ProcCommProbed { pid: u32, comm: Option<String> },
|
||
/// 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<u32>,
|
||
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<NodeObservation>,
|
||
}
|
||
|
||
/// 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<DeviceProps>,
|
||
}
|
||
|
||
/// 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<Serial, NodeEntry>,
|
||
/// Every live Device, keyed by serial.
|
||
devices: BTreeMap<Serial, DeviceEntry>,
|
||
ports: BTreeMap<Serial, PortSnapshot>,
|
||
links: BTreeMap<Serial, LinkSnapshot>,
|
||
clients: BTreeMap<Serial, ClientSnapshot>,
|
||
|
||
/// Links whose endpoints the adapter is still binding; the id is kept so
|
||
/// removal and resolution can find them.
|
||
pending_links: BTreeMap<Serial, GlobalId>,
|
||
|
||
/// Insertion-ordered holders of each live global id. `global_remove`
|
||
/// accounts for the oldest generation first (v3.4 §6.1.3).
|
||
live_ids: BTreeMap<GlobalId, VecDeque<Slot>>,
|
||
|
||
/// `/proc/<pid>/comm` reads keyed by pid, for pulse-PID validation.
|
||
probed_comm: BTreeMap<u32, Option<String>>,
|
||
|
||
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<u32> {
|
||
let clients: Vec<ClientSnapshot> = 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<LinkEndpoints>) {
|
||
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<Serial> = 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<u32> {
|
||
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<NodeSnapshot> = 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,
|
||
}
|
||
}
|