host/taint: pure graph model + taint engine (phase 2)

Implements design v3.4 §6.1–§6.1.3 behind a fixture test surface. No
PipeWire types in any signature; nothing here links against libpipewire.
Not wired into anything yet — phase 3's registry observer is what will
feed it, so the module is `#![allow(dead_code)]` for now.

    evaluate(&GraphSnapshot, &ExclusionCtx, &StickyState)
        -> (Decisions, StickyState)

- snapshot.rs: owned Node/Port/Link/Client model keyed on `Serial`
  (object.serial, 64-bit, identity) with `GlobalId` retained strictly as
  a snapshot-local lookup key. Two live objects claiming one id resolve
  as `Ambiguous`, which fails closed.
- owner.rs: the owner bridge — the key union (link-group, pulse.module.id,
  client.id, application.process.id) with equality-not-first-present
  semantics, transitive union-find components, and both suppression rules.
- mod.rs: monotone fixpoint over link edges, the conditional owner bridge
  (gated on the tainted member being one that *receives* audio) and the
  unbounded-owner backstop, then sticky merge. Stable `Reason` codes with
  an explicit priority so the reported reason never depends on traversal
  order.

Three judgement calls that go beyond what v3.4 spells out, all flagged
in the source:

1. Coarse keys (client.id, application.process.id) may not bridge
   device-role nodes. Every ALSA device is created by one WirePlumber
   process, so they share a client and a PID; peerspeak's playback taints
   the default sink on every recompute, and without this rule that taint
   reaches the microphone source and then every app holding a mic loses
   its playback — the §6.1.1 catastrophe by another route.
2. "Owner is bounded" is not "has a usable key": client.id alone does not
   bound an owner (the measured GStreamer split-client refutation), so
   the fail-closed backstop keys on strong keys or a usable PID.
3. Sticky entries record a reason per node rather than one per owner, so
   a forwarder's output leg keeps `tainted-owner-bridge` instead of
   inheriting its input leg's `tainted-upstream`.

32 fixture tests, each asserting an exact partition of the full candidate
universe rather than spot-checking named nodes: v3.4 §12's matrix, the
impl plan's degenerate-snapshot boundary, and the eligible half of every
scenario so an exclude-everything build fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:24:15 -04:00
co-authored by Claude Opus 4.8
parent d54e2b99fc
commit 6ead1fe9f8
6 changed files with 2492 additions and 0 deletions
+745
View File
@@ -0,0 +1,745 @@
//! The taint engine — decides which `Stream/Output/Audio` nodes may be
//! fanned out into the screen-share capture without echoing peerspeak's own
//! audio back at the viewer.
//!
//! Implements design v3.4 §6.1–§6.1.3 (`peerspeak/docs/
//! screenshare-audio-exclusion-plan.md`), phase 2 of the implementation
//! plan. **Pure**: no PipeWire types appear in any signature, nothing here
//! touches the daemon, and every test builds its own graph.
//!
//! ## The one-sentence predicate
//!
//! > A node is eligible only if **no** signal path reaches it from a
//! > peerspeak-owned node, the live AEC identity, or any pixelpass-owned
//! > object. **Unresolvable ancestry is not eligible.**
//!
//! That last sentence is the invariant the whole design rests on: every
//! other failure mode in here degrades into over-exclusion (one app's audio
//! silently missing from the share) rather than into echo.
//!
//! ## Why a graph walk and not a property check
//!
//! Exclusion does not propagate downstream by itself. Any node that
//! re-emits audio it received is a fresh, *untagged* `Stream/Output/Audio`
//! carrying the mix — including the one peerspeak playback stream that was
//! correctly excluded one hop earlier. EasyEffects, `module-loopback`,
//! combine-sinks, tunnel/RTP sinks and virtual-sink forwarders all have this
//! shape, and at least one such topology has been observed live on the
//! target machine.
//!
//! Taint therefore flows over **three** edge types:
//!
//! 1. **Link edges** — `link.output.node → link.input.node`.
//! 2. **Sink → monitor** — free at node granularity: the monitor connection
//! *is* a real Link whose output node is the sink node itself (measured).
//! A port-granular walk would need a synthetic edge; a node-granular one
//! does not.
//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See
//! [`owner`]; this is the hard one.
//!
//! ## Stickiness
//!
//! Taint is **sticky per owner** for the duration of the share, because a
//! topological recompute forgets *buffered* audio: an app can read a tainted
//! monitor into a 5-second ring buffer, then have its input leg vanish, and
//! a purely topological engine would relink its output while it is still
//! emitting peerspeak's audio out of that buffer. No graph event marks the
//! moment a buffer drains.
//!
//! Stickiness is keyed on [`Serial`] — never on a node id, `client.id`,
//! module index or `link-group` string, **all of which recycle on this
//! stack**. An entry is cleared only once every member object has
//! disappeared; a key that reappears after full teardown is a new owner and
//! starts clean.
// Phase 2 lands the engine behind its own test surface and nothing else:
// the registry observer that will feed it is phase 3, so in a non-test
// build every item here is legitimately unreachable for now.
#![allow(dead_code)]
pub mod owner;
pub mod snapshot;
#[cfg(test)]
mod fixture;
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use owner::{OwnerComponents, OwnerKey};
use snapshot::{GraphSnapshot, IdLookup, MediaRole, NodeSnapshot, Serial};
/// The `node.name` prefix of a pixelpass capture sink. Any host's sink
/// counts, not just ours — fanning out a stream that is downstream of
/// *another* pixelpass host's capture sink builds a cycle (v3.4 §6.2).
pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_";
/// `node.link-group` prefix that marks *some* echo canceller. Hazard
/// detection only — it does **not** identify peerspeak's instance, which is
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
/// Why a node is tainted or excluded. Stable machine-readable codes: this
/// value is the phase 5 audit output, the phase 6 status event, and the
/// eventual answer to "why isn't this app being shared?".
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Reason {
/// Carries the `peerspeak.owned` tag (v3.4 §5.1).
PeerspeakOwned,
/// `pulse.module.id` equals the live AEC module index — exact equality
/// only. "Has any `pulse.module.id`" is explicitly rejected as a rule:
/// tunnel/RTP/loopback modules may be the only carrier of audio the
/// user legitimately wants shared (v3.4 §5.2 correction 2).
AecIdentity,
/// A pixelpass-owned object, ours or another host's capture sink.
PixelpassOwned,
/// An `echo-cancel-*` group that is **not** our validated identity.
/// Decision D3: warn and exclude rather than fan out.
ForeignEchoCancel,
/// Reached by a signal path from a tainted node (link or monitor edge).
TaintedUpstream,
/// Reached across an owner bridge; the key that did it, when the
/// tainted member shares one directly rather than transitively.
TaintedOwnerBridge { key: Option<OwnerKey> },
/// A link endpoint, or a node's own id, could not be resolved in this
/// snapshot. Fail closed (v3.4 §6.1.4).
UnresolvedAncestry,
/// A tainted capture stream whose owner cannot be bounded by any usable
/// key, so its sibling output legs cannot be identified. Fail closed
/// (v3.4 §6.1.1, final paragraph).
UnresolvedOwner,
/// The observer has not reached a complete, coherent view of the graph
/// yet. No decision made from a partial graph is a decision.
GraphNotReady,
/// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local
/// to the node; does not propagate.
PortExclusive,
/// An encoded/passthrough stream — a second link would corrupt it.
/// Local to the node; does not propagate.
Passthrough,
}
impl Reason {
pub fn code(self) -> &'static str {
match self {
Self::PeerspeakOwned => "peerspeak-owned",
Self::AecIdentity => "aec-identity",
Self::PixelpassOwned => "pixelpass-owned",
Self::ForeignEchoCancel => "foreign-echo-cancel",
Self::TaintedUpstream => "tainted-upstream",
Self::TaintedOwnerBridge { .. } => "tainted-owner-bridge",
Self::UnresolvedAncestry => "unresolved-ancestry",
Self::UnresolvedOwner => "unresolved-owner",
Self::GraphNotReady => "graph-not-ready",
Self::PortExclusive => "port-exclusive",
Self::Passthrough => "passthrough",
}
}
/// Lower wins. A node can acquire taint several ways in one recompute
/// and the reported reason must not depend on traversal order, or the
/// audit output is unstable and the fixture tests are flaky. Explicit
/// priority, not BFS arrival order.
fn priority(self) -> u8 {
match self {
Self::PeerspeakOwned => 0,
Self::AecIdentity => 1,
Self::PixelpassOwned => 2,
Self::ForeignEchoCancel => 3,
Self::TaintedUpstream => 4,
Self::TaintedOwnerBridge { .. } => 5,
Self::UnresolvedAncestry => 6,
Self::UnresolvedOwner => 7,
// Non-propagating; never competes with the taint reasons above
// because it is only consulted for untainted candidates.
Self::GraphNotReady => 8,
Self::PortExclusive => 9,
Self::Passthrough => 10,
}
}
/// Does this reason spread to downstream nodes and owner siblings?
fn propagates(self) -> bool {
self.priority() <= Self::UnresolvedOwner.priority()
}
}
/// Everything the engine needs that is not in the graph itself.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ExclusionCtx {
/// The **validated** live AEC module index, or `None` for `--aec=off`.
/// The validation state machine (phase 4) owns the transitions; if it
/// is still `Validating` or has `Failed`, its caller must not fan out at
/// all rather than passing `None` here, which would merely mean "there
/// is no AEC".
pub aec_module_id: Option<u64>,
/// pipewire-pulse's own PID, derived by the observer (phase 3) from a
/// consistent `pipewire.sec.pid` across Pulse clients validated against
/// `/proc/<pid>/comm`. `None` is safe but coarse — see [`owner`].
pub pipewire_pulse_pid: Option<u32>,
/// Serials of objects pixelpass itself created this run.
pub pixelpass_owned: BTreeSet<Serial>,
/// False until the readiness epoch has been reached (phase 3). Every
/// candidate is then ineligible: a decision from a partial graph is not
/// a decision.
pub graph_ready: bool,
}
/// Object identity for sticky bookkeeping. Always a [`Serial`] — never a
/// recyclable id (v3.4 §6.1.3).
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum ObjectRef {
Node(Serial),
Client(Serial),
}
/// One owner that has been tainted, and every object observed to constitute
/// it. Cleared only when **all** of them are gone.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StickyOwner {
/// Every object seen to be part of this owner, ever. Membership
/// accumulates: that is what makes "clear only once all member objects
/// have disappeared" true across churn.
pub members: BTreeSet<ObjectRef>,
/// The reason recorded for each node that was tainted in its own right.
/// Kept per node rather than collapsed to one owner-wide reason, or a
/// forwarder's output leg inherits its *input* leg's `tainted-upstream`
/// and the audit output stops naming the mechanism that actually
/// excluded it.
pub node_reasons: BTreeMap<Serial, Reason>,
}
impl StickyOwner {
/// The reason to apply to a member: its own recorded one, or — for a
/// leg that appeared later — the fact that it belongs to a tainted
/// owner, which is a bridge by definition.
fn reason_for(&self, serial: Serial) -> Reason {
self.node_reasons
.get(&serial)
.copied()
.unwrap_or(Reason::TaintedOwnerBridge { key: None })
}
}
/// Threaded explicitly through [`evaluate`] so stickiness is testable as a
/// sequence of snapshots rather than as hidden mutable state.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StickyState {
pub owners: Vec<StickyOwner>,
}
impl StickyState {
pub fn is_empty(&self) -> bool {
self.owners.is_empty()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Eligibility {
Eligible,
NotEligible {
reason: Reason,
/// The taint was carried over from a previous snapshot rather than
/// derived from the current topology.
sticky: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeDecision {
pub serial: Serial,
pub name: Option<String>,
pub eligibility: Eligibility,
}
impl NodeDecision {
pub fn is_eligible(&self) -> bool {
matches!(self.eligibility, Eligibility::Eligible)
}
pub fn reason(&self) -> Option<Reason> {
match self.eligibility {
Eligibility::Eligible => None,
Eligibility::NotEligible { reason, .. } => Some(reason),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct TaintEntry {
pub reason: Reason,
pub sticky: bool,
}
/// The result of one recompute.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Decisions {
/// Every `Stream/Output/Audio` node in the snapshot — the complete
/// candidate universe, so callers can assert an exact partition rather
/// than spot-checking named nodes.
pub candidates: BTreeMap<Serial, NodeDecision>,
/// Taint over *all* node roles, for diagnostics and for the phase 5
/// audit output.
pub taint: BTreeMap<Serial, TaintEntry>,
}
impl Decisions {
/// Serials of eligible candidates, ascending.
pub fn eligible(&self) -> Vec<Serial> {
self.candidates
.values()
.filter(|d| d.is_eligible())
.map(|d| d.serial)
.collect()
}
/// `(serial, reason code)` for excluded candidates, ascending.
pub fn excluded(&self) -> Vec<(Serial, &'static str)> {
self.candidates
.values()
.filter_map(|d| d.reason().map(|r| (d.serial, r.code())))
.collect()
}
}
/// Recompute eligibility for the whole graph.
///
/// Full recompute per graph event is the v1 design (O(V+E) over a ~40-node
/// desktop graph); there is deliberately no incremental dirty-set.
pub fn evaluate(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
prior: &StickyState,
) -> (Decisions, StickyState) {
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
seed_local_roots(snapshot, ctx, &mut taint);
seed_sticky(
snapshot,
prior,
&components,
&mut taint,
&mut sticky_serials,
);
// Monotone fixpoint: every step only adds taint, or lowers a node's
// reason priority, both of which are bounded. Link propagation and the
// owner bridge feed each other — a bridged output leg has downstream
// links, and a downstream monitor reader bridges to its own siblings —
// so neither can be run once.
let downstream = downstream_edges(snapshot, &mut taint);
loop {
let mut changed = false;
changed |= propagate_links(&downstream, &mut taint);
changed |= propagate_owner_bridge(snapshot, ctx, &components, &mut taint);
changed |= propagate_unresolved_owner(snapshot, ctx, &mut taint);
if !changed {
break;
}
}
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
let next_sticky = build_sticky(snapshot, &components, &taint, prior);
(decisions, next_sticky)
}
/// Roots that are visible on the node itself.
fn seed_local_roots(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
taint: &mut BTreeMap<Serial, Reason>,
) {
for node in snapshot.nodes() {
if let Some(reason) = local_root_reason(node, ctx) {
raise(taint, node.serial, reason);
}
// A node whose own global id is ambiguous cannot be the reliable
// endpoint of any link, so its ancestry is unresolvable.
if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) {
raise(taint, node.serial, Reason::UnresolvedAncestry);
}
}
}
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
if node.props.peerspeak_owned {
return Some(Reason::PeerspeakOwned);
}
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
&& module == aec
{
return Some(Reason::AecIdentity);
}
if ctx.pixelpass_owned.contains(&node.serial)
|| node
.name
.as_deref()
.is_some_and(|name| name.starts_with(CAPTURE_SINK_PREFIX))
{
return Some(Reason::PixelpassOwned);
}
if node
.props
.link_group
.as_deref()
.is_some_and(|group| group.starts_with(ECHO_CANCEL_GROUP_PREFIX))
{
return Some(Reason::ForeignEchoCancel);
}
None
}
/// Carry taint forward from previous snapshots (v3.4 §6.1.3).
fn seed_sticky(
snapshot: &GraphSnapshot,
prior: &StickyState,
components: &OwnerComponents,
taint: &mut BTreeMap<Serial, Reason>,
sticky_serials: &mut BTreeSet<Serial>,
) {
for entry in &prior.owners {
let live_nodes: Vec<Serial> = entry
.members
.iter()
.filter_map(|member| match member {
ObjectRef::Node(serial) => snapshot.node(*serial).map(|_| *serial),
ObjectRef::Client(_) => None,
})
.collect();
// The owner is sticky, not the individual node: a leg that appears
// later in the same still-live owner inherits the taint.
for serial in live_nodes {
for member in components.members_with(serial) {
let reason = entry.reason_for(*member);
if raise(taint, *member, reason) || taint.get(member) == Some(&reason) {
sticky_serials.insert(*member);
}
}
}
}
}
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
/// that does not resolve taints the *other* end as unresolved ancestry when
/// that other end is the input side — we cannot know what is feeding it.
fn downstream_edges(
snapshot: &GraphSnapshot,
taint: &mut BTreeMap<Serial, Reason>,
) -> BTreeMap<Serial, Vec<Serial>> {
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
for link in snapshot.links() {
let from = snapshot.node_by_id(link.output_node);
let to = snapshot.node_by_id(link.input_node);
match (from, to) {
(Some(IdLookup::Unique(from)), Some(IdLookup::Unique(to))) => {
edges.entry(from).or_default().push(to);
}
(_, Some(IdLookup::Unique(to))) => {
// Something feeds this node and we cannot say what.
raise(taint, to, Reason::UnresolvedAncestry);
}
_ => {}
}
}
for targets in edges.values_mut() {
targets.sort_unstable();
targets.dedup();
}
edges
}
fn propagate_links(
downstream: &BTreeMap<Serial, Vec<Serial>>,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let mut changed = false;
let mut queue: VecDeque<Serial> = taint
.iter()
.filter(|(_, reason)| reason.propagates())
.map(|(serial, _)| *serial)
.collect();
while let Some(serial) = queue.pop_front() {
let Some(targets) = downstream.get(&serial) else {
continue;
};
for target in targets {
if raise(taint, *target, Reason::TaintedUpstream) {
changed = true;
queue.push_back(*target);
}
}
}
changed
}
/// The conditional owner bridge (v3.4 §6.1.1): taint crosses to an owner's
/// other legs **only** when the tainted member is one that actually
/// receives audio. The naive "this owner has both an input and an output
/// leg ⇒ exclude the output" rule would exclude every app using a
/// microphone, Firefox in a video call included.
fn propagate_owner_bridge(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
components: &OwnerComponents,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let mut changed = false;
for members in components.components() {
let sources: Vec<Serial> = members
.iter()
.copied()
.filter(|serial| {
taint.get(serial).is_some_and(|r| r.propagates())
&& snapshot
.node(*serial)
.is_some_and(|n| n.role.receives_audio())
})
.collect();
if sources.is_empty() {
continue;
}
for target in members {
if sources.contains(target) {
continue;
}
let Some(target_node) = snapshot.node(*target) else {
continue;
};
// Name the strongest key shared directly with any tainted
// member; `None` means the two are only transitively related.
let key = sources
.iter()
.filter_map(|source| snapshot.node(*source))
.filter_map(|source| {
owner::strongest_shared_key(source, target_node, ctx.pipewire_pulse_pid)
})
.min();
changed |= raise(taint, *target, Reason::TaintedOwnerBridge { key });
}
}
changed
}
/// Fail-closed backstop for an owner we cannot bound (v3.4 §6.1.1, final
/// paragraph): a capture stream is reading tainted audio and nothing about
/// it lets us enumerate its sibling output legs, so we cannot know which
/// output leg is re-emitting what it read. Exclude the output legs that are
/// equally unbounded.
///
/// Two deliberate restrictions keep the blast radius at "small", as the
/// design promises:
///
/// - **Source must be a `Stream/Input/Audio`.** A tainted *device* sink is
/// the normal case, not an anomaly — peerspeak's own playback taints the
/// default sink on literally every recompute — and letting that trip this
/// rule would exclude the entire desktop.
/// - **Targets must themselves be unbounded.** Ordinary application streams
/// carry a real `application.process.id` and are bounded, so they are
/// never swept up; in practice only other daemon-owned keyless module
/// streams are.
fn propagate_unresolved_owner(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let unbounded_reader = snapshot.nodes().any(|node| {
node.role == MediaRole::StreamInput
&& taint.get(&node.serial).is_some_and(|r| r.propagates())
&& !owner::owner_is_bounded(node, ctx.pipewire_pulse_pid)
});
if !unbounded_reader {
return false;
}
let mut changed = false;
for node in snapshot.nodes() {
if node.role == MediaRole::StreamOutput
&& !owner::owner_is_bounded(node, ctx.pipewire_pulse_pid)
{
changed |= raise(taint, node.serial, Reason::UnresolvedOwner);
}
}
changed
}
fn build_decisions(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
taint: &BTreeMap<Serial, Reason>,
sticky_serials: &BTreeSet<Serial>,
) -> Decisions {
let mut candidates = BTreeMap::new();
for node in snapshot.nodes().filter(|n| n.role.is_candidate()) {
let sticky = sticky_serials.contains(&node.serial);
let eligibility = if !ctx.graph_ready {
Eligibility::NotEligible {
reason: Reason::GraphNotReady,
sticky: false,
}
} else if let Some(reason) = taint.get(&node.serial) {
Eligibility::NotEligible {
reason: *reason,
sticky,
}
} else if let Some(reason) = local_exclusion(snapshot, node) {
Eligibility::NotEligible {
reason,
sticky: false,
}
} else {
Eligibility::Eligible
};
candidates.insert(
node.serial,
NodeDecision {
serial: node.serial,
name: node.name.clone(),
eligibility,
},
);
}
Decisions {
candidates,
taint: taint
.iter()
.map(|(serial, reason)| {
(
*serial,
TaintEntry {
reason: *reason,
sticky: sticky_serials.contains(serial),
},
)
})
.collect(),
}
}
/// Node-local reasons a link cannot be created even though the node is
/// clean. These do not propagate — an exclusive-port stream is unlinkable,
/// not hazardous.
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) {
return Some(Reason::PortExclusive);
}
None
}
/// Sticky bookkeeping for the next recompute: every tainted owner, with
/// every object observed to constitute it, merged with any prior entry that
/// still overlaps. Members accumulate — that is what makes "clear only once
/// all member objects have disappeared" true across churn.
fn build_sticky(
snapshot: &GraphSnapshot,
components: &OwnerComponents,
taint: &BTreeMap<Serial, Reason>,
prior: &StickyState,
) -> StickyState {
let mut entries: Vec<StickyOwner> = Vec::new();
// Carry forward prior entries that still have at least one live member.
// An entry with none is gone for good: serials never recycle, so a
// vanished member can never come back.
for entry in &prior.owners {
if entry
.members
.iter()
.any(|member| is_live(snapshot, *member))
{
entries.push(entry.clone());
}
}
for members in components.components() {
let node_reasons: BTreeMap<Serial, Reason> = members
.iter()
.filter_map(|serial| {
taint
.get(serial)
.filter(|reason| reason.propagates())
.map(|reason| (*serial, *reason))
})
.collect();
if node_reasons.is_empty() {
continue;
}
let mut refs: BTreeSet<ObjectRef> = members.iter().map(|s| ObjectRef::Node(*s)).collect();
refs.extend(
owner::client_serials_of(snapshot, members)
.into_iter()
.map(ObjectRef::Client),
);
entries.push(StickyOwner {
members: refs,
node_reasons,
});
}
StickyState {
owners: merge_overlapping(entries),
}
}
fn is_live(snapshot: &GraphSnapshot, member: ObjectRef) -> bool {
match member {
ObjectRef::Node(serial) => snapshot.node(serial).is_some(),
ObjectRef::Client(serial) => snapshot.clients().any(|c| c.serial == serial),
}
}
/// Merge entries that share any member, keeping the strongest reason.
/// Owners fuse over time (a component that gains a leg belonging to a
/// previously separate sticky owner is one owner now); splitting them back
/// apart would drop taint, which is the unsafe direction.
fn merge_overlapping(mut entries: Vec<StickyOwner>) -> Vec<StickyOwner> {
let mut merged: Vec<StickyOwner> = Vec::new();
while let Some(mut entry) = entries.pop() {
let mut absorbed = true;
while absorbed {
absorbed = false;
let mut rest = Vec::with_capacity(entries.len());
for other in entries.drain(..) {
if entry.members.is_disjoint(&other.members) {
rest.push(other);
} else {
for (serial, reason) in other.node_reasons {
entry
.node_reasons
.entry(serial)
.and_modify(|existing| {
if reason.priority() < existing.priority() {
*existing = reason;
}
})
.or_insert(reason);
}
entry.members.extend(other.members);
absorbed = true;
}
}
entries = rest;
}
merged.push(entry);
}
merged.sort_by(|a, b| a.members.iter().next().cmp(&b.members.iter().next()));
merged
}
/// Record `reason` for `serial` if it is new or strictly stronger than what
/// is already recorded. Returns whether anything changed — the fixpoint's
/// termination argument rests on this being monotone.
fn raise(taint: &mut BTreeMap<Serial, Reason>, serial: Serial, reason: Reason) -> bool {
match taint.get(&serial) {
Some(existing) if existing.priority() <= reason.priority() => false,
_ => {
taint.insert(serial, reason);
true
}
}
}