host/taint: close five leaks found in Codex round 1

All five were reachable, all five now have a regression test, and each
test was verified by injecting the mutation that reverts its fix.

1. Sticky taint ignored surviving Client members. An app can close every
   stream while keeping its PipeWire connection open and then open a new
   one — Firefox does this constantly — and the new leg came back
   Eligible while the owner's buffers still held the call. Sticky seeding
   now resolves live Client serials to their current nodes.
2. "Receives audio" was inferred from `media.class` alone, so a node with
   an absent or unexpected class sitting on a real inbound link could not
   start an owner bridge and its sibling re-emitted the call. A node is
   now a receiver if it appears as a resolved `link.input.node` OR has a
   receiving role.
3. The device-node coarse-key exception was keyed on `media.class` being
   `Audio/Sink|Source|Duplex`, which also stripped the only correlation a
   *native virtual sink* has (own client, no link-group, no module id).
   Now keyed on `device.id`, measured on the live graph as the exact
   discriminator: the 5 ALSA nodes carry device.id 43/45/46 and share
   `client.id` 42 (`WirePlumber [export]`); the 3 `support.null-audio-sink`
   nodes carry no device.id and hold their own clients.
4. The unbounded-owner backstop required the tainted *reader* to be
   unbounded. Properties can be asymmetric — a reader with a link-group
   whose re-emitting leg has none is bounded while its sibling is not
   findable — so that condition is dropped; targets stay restricted to
   unbounded output legs, which keeps the blast radius small.
5. A not-ready snapshot could retire sticky owners, erasing taint history
   on the strength of a graph already declared untrustworthy. `evaluate`
   now returns the prior state unchanged while `!graph_ready`.

Test-quality findings, also fixed:
- a single pass of each rule survived all 32 tests (every fixture needed
  at most one owner hop) → two-chained-forwarder test with a clean
  control, plus a 60-layer chain to catch an accidental blow-up
- first-write-wins `raise()` survived → a node reached by bridge on one
  pass and by a direct link on the next must report the stronger reason
- `drop_clients` left the fixture's client caches stale, so "a fresh
  client after teardown" was really a dangling id; the recycling row now
  reuses node id, client id AND `pulse.module.id` verbatim

Also corrected the cost claim: this is O((V+E)·D) for owner-bridge depth
D, not O(V+E) as v3.4 §6.4 states. Owner keys are now computed once per
snapshot instead of per candidate pair.

42 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:47:38 -04:00
co-authored by Claude Opus 4.8
parent 6ead1fe9f8
commit a46c4cd20c
5 changed files with 616 additions and 77 deletions
+124 -53
View File
@@ -305,14 +305,25 @@ impl Decisions {
/// 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.
/// Full recompute per graph event is the v1 design; there is deliberately
/// no incremental dirty-set.
///
/// ⚠️ **Cost is not O(V+E), despite what v3.4 §6.4 says.** Each fixpoint
/// pass re-runs a full link BFS *and* a full owner scan, and one pass is
/// consumed per layer of owner-bridge hops, so it is O((V+E)·D) for a
/// bridge depth D. D is 1 for every topology observed so far and 2 for a
/// forwarder feeding a forwarder; a 60-layer chain is covered by a test
/// purely to catch an accidental blow-up. Phase 5 records the real
/// recompute-duration distribution and maximum, which is what the "full
/// recompute is fine for v1" claim should actually rest on — a measured
/// headroom, not a node count.
pub fn evaluate(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
prior: &StickyState,
) -> (Decisions, StickyState) {
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
@@ -331,19 +342,29 @@ pub fn evaluate(
// 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);
let edges = 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);
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
if !changed {
break;
}
}
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
let next_sticky = build_sticky(snapshot, &components, &taint, prior);
let next_sticky = if ctx.graph_ready {
build_sticky(snapshot, &components, &taint, prior)
} else {
// ⚠️ Never retire a sticky owner on the strength of a snapshot we
// have already declared untrustworthy (Codex round 1). An object
// missing from a partial graph has not been observed to disappear,
// and `build_sticky` drops entries whose members are all absent —
// so one not-ready recompute could erase the taint history and the
// next ready one would hand back a clean bill of health.
prior.clone()
};
(decisions, next_sticky)
}
@@ -402,14 +423,25 @@ fn seed_sticky(
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();
let mut live_nodes: Vec<Serial> = Vec::new();
for member in &entry.members {
match member {
ObjectRef::Node(serial) => {
if snapshot.node(*serial).is_some() {
live_nodes.push(*serial);
}
}
// ⚠️ A surviving **Client** re-seeds too. An app can close
// every stream it had while keeping its PipeWire connection
// open, then open a fresh one — Firefox does exactly this.
// Seeding only from live nodes let that new leg come back
// Eligible while the owner was still, by v3.4 §6.1.3's own
// rule, tainted (its buffers outlive its streams).
ObjectRef::Client(serial) => {
live_nodes.extend(nodes_of_client(snapshot, *serial));
}
}
}
// 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 {
@@ -423,24 +455,45 @@ fn seed_sticky(
}
}
/// Nodes currently attached to a client, by the client's **serial**. The
/// client's snapshot-local id is resolved fresh each time, so a recycled id
/// can never resurrect a dead owner.
fn nodes_of_client(snapshot: &GraphSnapshot, client: Serial) -> Vec<Serial> {
let Some(id) = snapshot
.clients()
.find(|c| c.serial == client)
.map(|c| c.id)
else {
return Vec::new();
};
// If that id is ambiguous, two clients claim it and we cannot say which
// nodes belong to the sticky one — so claim them all, which is the
// fail-closed direction.
snapshot
.nodes()
.filter(|node| node.props.client_id == Some(id))
.map(|node| node.serial)
.collect()
}
/// `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>> {
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
let mut receivers: BTreeSet<Serial> = BTreeSet::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);
receivers.insert(to);
}
(_, Some(IdLookup::Unique(to))) => {
// Something feeds this node and we cannot say what.
raise(taint, to, Reason::UnresolvedAncestry);
receivers.insert(to);
}
_ => {}
}
@@ -449,7 +502,29 @@ fn downstream_edges(
targets.sort_unstable();
targets.dedup();
}
edges
// A node that receives audio by *role* counts even with no inbound link
// yet: a pixelpass capture sink is a taint root the moment it exists,
// and its owner's re-emitting leg must be bridged from it immediately.
receivers.extend(
snapshot
.nodes()
.filter(|node| node.role.receives_audio())
.map(|node| node.serial),
);
Edges { edges, receivers }
}
/// Resolved signal edges plus the set of nodes that can receive audio.
struct Edges {
edges: BTreeMap<Serial, Vec<Serial>>,
/// ⚠️ Membership is "appears as a resolved `link.input.node`" **or**
/// "has a receiving role" — deliberately not role alone. Codex round 1:
/// a node whose `media.class` is absent or unexpected (`Other`), or an
/// `Audio/Source` that is really a filter output, can sit on an inbound
/// link carrying tainted audio; inferring "receives audio" from the role
/// alone left such a node unable to start an owner bridge, and its
/// sibling output leg stayed Eligible while re-emitting the call.
receivers: BTreeSet<Serial>,
}
fn propagate_links(
@@ -482,21 +557,19 @@ fn propagate_links(
/// 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,
keys: &owner::OwnerKeyIndex,
components: &OwnerComponents,
edges: &Edges,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let mut changed = false;
for members in components.components() {
let sources: Vec<Serial> = members
let sources: BTreeSet<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())
&& edges.receivers.contains(serial)
})
.collect();
if sources.is_empty() {
@@ -506,17 +579,11 @@ fn propagate_owner_bridge(
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)
})
.filter_map(|source| keys.strongest_shared(*source, *target))
.min();
changed |= raise(taint, *target, Reason::TaintedOwnerBridge { key });
}
@@ -525,40 +592,44 @@ fn propagate_owner_bridge(
}
/// 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.
/// paragraph): something read tainted audio and nothing about the output
/// legs on this box lets us enumerate which of them are its siblings, so we
/// cannot know which one 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:
/// Two restrictions keep the blast radius at "small", as the design
/// promises — and one earlier restriction was **removed** as unsafe:
///
/// - **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.
/// - **Source must not be a real device node.** A tainted hardware 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 whole 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.
/// never swept up; in practice only daemon-owned keyless module streams
/// are.
/// - ⚠️ **Removed (Codex round 1): the source no longer has to be unbounded
/// itself.** Properties can be asymmetric — a reader carrying a
/// `node.link-group` whose re-emitting leg carries none is *bounded* while
/// its sibling is not findable — and requiring an unbounded source let
/// exactly that shape through as Eligible.
fn propagate_unresolved_owner(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
keys: &owner::OwnerKeyIndex,
edges: &Edges,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let unbounded_reader = snapshot.nodes().any(|node| {
node.role == MediaRole::StreamInput
let tainted_reader = snapshot.nodes().any(|node| {
node.props.device_id.is_none()
&& edges.receivers.contains(&node.serial)
&& taint.get(&node.serial).is_some_and(|r| r.propagates())
&& !owner::owner_is_bounded(node, ctx.pipewire_pulse_pid)
});
if !unbounded_reader {
if !tainted_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)
{
if node.role == MediaRole::StreamOutput && !keys.is_bounded(node.serial) {
changed |= raise(taint, node.serial, Reason::UnresolvedOwner);
}
}