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
+58 -10
View File
@@ -90,18 +90,25 @@ impl Graph {
self.node(name, role, app(client, pid))
}
/// A leg of a pactl-loaded module: one client per module, and the
/// node's `application.process.id` is **pipewire-pulse's own**, because
/// pipewire-pulse genuinely is the client.
pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef {
let client = match self.client_by_module.get(&module) {
/// The client a pactl module holds. Measured: each module gets its own
/// (`sink-sunshine-*` were clients 83/86/92), which is why one tainted
/// module does not fuse with the next.
pub fn client_of_module(&mut self, module: u64) -> GlobalId {
match self.client_by_module.get(&module) {
Some(id) => *id,
None => {
let id = self.client(Some(PULSE_PID));
self.client_by_module.insert(module, id);
id
}
};
}
}
/// A leg of a pactl-loaded module: one client per module, and the
/// node's `application.process.id` is **pipewire-pulse's own**, because
/// pipewire-pulse genuinely is the client.
pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef {
let client = self.client_of_module(module);
self.node(name, role, pulse_module(client, module, PULSE_PID))
}
@@ -112,8 +119,9 @@ impl Graph {
self.node(name, role, link_group(group, client, pid))
}
/// A device node as the session manager creates it: no strong key, and
/// WirePlumber's client and PID — shared with every other device.
/// A device node as the session manager creates it: no strong key,
/// WirePlumber's client and PID — shared with every other device — and
/// a `device.id`, which is what marks it as session-manager-exported.
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
let session = match self.session_client {
Some(id) => id,
@@ -123,7 +131,17 @@ impl Graph {
id
}
};
self.node(name, role, device(session, SESSION_PID))
let device_id = self.id();
self.node(name, role, device(session, SESSION_PID, device_id))
}
/// A **virtual** sink an application created natively: an `Audio/Sink`
/// with no `device.id` and no strong key, sharing one client with the
/// stream that re-emits what it receives. Coarse keys must still bridge
/// these two, or the whole call leaks through the re-emitting leg.
pub fn native_virtual_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, app(client, pid))
}
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
@@ -228,8 +246,31 @@ impl Graph {
}
/// Drop clients too — full owner teardown.
///
/// Invalidates the per-app/per-module caches as well: leaving them
/// stale made a later `client_of_app` hand back the *removed* client's
/// id, so a test that meant "a brand-new client after teardown" was
/// really building a node pointing at a client object that no longer
/// existed (Codex round 1, finding 8).
pub fn drop_clients(&mut self, ids: &[GlobalId]) {
self.clients.retain(|c| !ids.contains(&c.id));
self.client_by_app.retain(|_, id| !ids.contains(id));
self.client_by_module.retain(|_, id| !ids.contains(id));
if self.session_client.is_some_and(|id| ids.contains(&id)) {
self.session_client = None;
}
}
/// A client that reuses a global id a dead client had — the recycling
/// case, with a fresh serial.
pub fn client_with_id(&mut self, id: GlobalId, sec_pid: Option<u32>) -> GlobalId {
let serial = self.serial();
self.clients.push(ClientSnapshot {
serial,
id,
sec_pid,
});
id
}
}
@@ -266,10 +307,17 @@ pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
/// A device node as the session manager creates it: no strong key, and the
/// session manager's own client and PID — shared with every other device.
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
///
/// Measured 2026-07-21: real ALSA device nodes carry the shared
/// `client.id` but **no** `application.process.id` at all. Giving them one
/// here is deliberately *more* pessimistic than reality — it hands the
/// engine a second coarse key it could fuse devices on, so a test that
/// passes here also passes against the real props.
pub fn device(session_client: GlobalId, session_pid: u32, device_id: GlobalId) -> NodeProps {
NodeProps {
client_id: Some(session_client),
process_id: Some(session_pid),
device_id: Some(device_id),
..NodeProps::default()
}
}