docs: v3.2 audio-exclusion — signal-graph taint, measured
Round 5. Codex and I converged independently on the same conclusion — a Link-only ancestry walk does not catch the leak — it from the crate/header/ WirePlumber sources, me from the live graph. Its sandbox could not reach the daemon (pw-dump: Operation not permitted), so the measurements are mine. Reproduced the EasyEffects topology with module-null-sink + module-loopback (same shape, no EasyEffects needed). Result: there is NO Link object between a forwarder's input leg and its output leg. Walking upstream from the leaking node over Links alone finds no inbound links at all — a dead end that reads as "clean". The legs are related only by shared node.link-group / client.id / pulse.module.id. So the signal graph needs three edge types: 1. Link edges — measured: registry Links carry all four endpoint props. 2. Sink-monitor — measured FREE at node granularity: the monitor connection IS a real Link whose output node is the sink itself. Codex held that this must be modelled explicitly; that is true only for a port-granular walk. Taint walks at node granularity, links are created per port. 3. Owner bridge — node.link-group when present, else client.id (measured shared across the forwarder's legs, distinct per app). Only modules set link-group, so client.id is what covers ordinary apps. New §6.1.1: bridge taint must be CONDITIONAL on the input leg being tainted. "Client has both legs ⇒ exclude" would exclude every app using a microphone. Firefox in a Meet call stays shareable; Firefox sharing desktop audio does not. Also: §6.5 rejects the cheap "hardware-sink-only" predicate with a measurement — the forwarder's output leg links directly to alsa_output, so the shortcut passes the leak and excludes the innocent app, backwards on both halves. §6.3 barrier corrected: core sync/done is a previous-work roundtrip, not graph quiescence. §6.4 adds crate version, endpoint fast path + bind fallback, and full-recompute cost. §5.2 correction 5 rewritten: application.process.id lives on the Node and is the app's own PID; pipewire.sec.pid lives on the Client and is pipewire-pulse's for every Pulse client. That resolves four rounds of contradictory PID claims. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Design v3: whole-desktop screen-share audio without self-echo
|
||||
|
||||
**Status:** v3.1 — round-4 findings applied; **still BLOCKED pending round-5 re-review** (§14).
|
||||
**Date:** 2026-07-21 (v1: 07-19 · v2: 07-20 · Option C adopted 07-20 · v3.1 round-4 revision)
|
||||
**Status:** v3.2 — round-5 measurements applied; **awaiting round-6 re-review** (§14).
|
||||
**Date:** 2026-07-21 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 round 4 · v3.2 round 5)
|
||||
**Origin:** Joe's suggestion — "whitelist all audio except audio coming from peerspeak."
|
||||
**Scope:** a new capture mode in pixelpass (`src/host/pipeline.rs`, `src/host/audio.rs`),
|
||||
playback tagging + AEC-identity export + teardown-ordering invariants in peerspeak.
|
||||
@@ -214,11 +214,22 @@ exclude every node where pulse.module.id == <the index pactl returned to the liv
|
||||
foreign or second AEC is a product-policy question (§5.4). The better long-term answer
|
||||
is the **native** PipeWire AEC module, which exposes `playback.props` and would let us
|
||||
stamp our own random token directly on the playback leg.
|
||||
5. **`application.process.id` on the AEC client is `1974` = pipewire-pulse** (verified).
|
||||
This reconciles two contradictory prior claims: *real* Pulse clients (paplay) report
|
||||
their own PID; *module-created* streams report pipewire-pulse's, because
|
||||
pipewire-pulse is the client. Both were right about different cases. **PID-based AEC
|
||||
exclusion stays unusable**; PID remains a fallback hint, never identity.
|
||||
5. **PID — the whole muddle, finally resolved** (third measurement, 2026-07-21, settles
|
||||
four rounds of contradictory claims). Two *different properties on two different
|
||||
object types* were being conflated:
|
||||
|
||||
| property | lives on | value for Pulse-emulated clients |
|
||||
| --- | --- | --- |
|
||||
| `application.process.id` | the **Node** | the app's **own** PID (Firefox `11114`, paplay's own) |
|
||||
| `pipewire.sec.pid` | the **Client** | **pipewire-pulse's** PID — `2541` for *every* Pulse client on this box (Firefox, Steam, KDE Connect, libcanberra all identical) |
|
||||
|
||||
Module-created streams (the AEC) are the exception on the *node* side: their
|
||||
`application.process.id` is pipewire-pulse's (`1974` at the time of that run), because
|
||||
pipewire-pulse genuinely is the client. Earlier notes claiming `pipewire.sec.pid` was
|
||||
"absent on these nodes" were right — it is absent from **nodes** because it is a
|
||||
**Client** property. **PID-based AEC exclusion stays unusable**; PID is a fallback
|
||||
hint, never identity. `client.id` — *not* PID — is the usable per-app correlation key
|
||||
(§6.1 edge type 3).
|
||||
|
||||
Parse defensively: `pulse.module.id` renders as a JSON **number** in `pw-dump` but SPA
|
||||
props are strings. `536870919 = 0x20000007`; pipewire-pulse indices start at `0x20000000`
|
||||
@@ -302,12 +313,81 @@ the spike, which is *why* the spike did not catch this. Same shape: combine-sink
|
||||
`module-loopback`, virtual-sink forwarders, tunnel/RTP sinks, DSP bridges, and any
|
||||
monitor-sourced stream.
|
||||
|
||||
**So the predicate is transitive upstream reachability over the live graph:**
|
||||
**So the predicate is transitive TAINT over the live SIGNAL graph** — and "signal graph"
|
||||
is doing real work in that sentence, because it is **not** the graph of `Link` objects.
|
||||
|
||||
> A node is eligible only if **no** path through Node/Port/Link globals reaches a
|
||||
> peerspeak-owned node, the live AEC identity, any pixelpass-owned object (ours *or*
|
||||
> another host's `pixelpass_capture_*`), or a monitor/capture source that cannot be
|
||||
> proven clean.
|
||||
> 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 (ours *or* another host's
|
||||
> `pixelpass_capture_*`). Unresolvable ancestry is **not** eligible.
|
||||
|
||||
⚠️ **A Link-only walk does NOT catch the leak.** Measured on the live graph 2026-07-21
|
||||
by reproducing the forwarder topology with `module-null-sink` + `module-loopback`
|
||||
(same shape as EasyEffects, no EasyEffects required):
|
||||
|
||||
```
|
||||
fabletest_sink:monitor_FL ──Link 87──► input.loopback-2541-13 (Stream/Input/Audio, id 109)
|
||||
⋮ ← NO LINK OBJECT EXISTS HERE
|
||||
output.loopback-2541-13 ──Link 105─► alsa_output…analog-stereo (id 56)
|
||||
(Stream/Output/Audio, id 86)
|
||||
```
|
||||
|
||||
**There is no `Link` between the forwarder's input leg (109) and its output leg (86).**
|
||||
Walking upstream from the leaking node 86 over Links alone finds *no inbound links at
|
||||
all* — a dead end that reads as "clean," and the node gets fanned out. The two legs are
|
||||
related only by shared properties:
|
||||
|
||||
| key | input leg (109) | output leg (86) |
|
||||
| --- | --- | --- |
|
||||
| `node.link-group` | `loopback-2541-13` | `loopback-2541-13` |
|
||||
| `client.id` | `107` | `107` |
|
||||
| `pulse.module.id` | `536870917` | `536870917` |
|
||||
|
||||
So the signal graph needs **three** edge types:
|
||||
|
||||
1. **Link edges** — `link.output.node → link.input.node`. ✅ Measured: registry Link
|
||||
objects carry all four endpoint props (`link.output.node`, `link.output.port`,
|
||||
`link.input.node`, `link.input.port`).
|
||||
2. **Sink-monitor edges** — ✅ **free at node granularity.** Measured: the monitor
|
||||
connection *is* a real `Link` whose output node is the sink node itself
|
||||
(`out-node=104 (fabletest_sink) → in-node=109`). So a node-level walk crosses
|
||||
`app → sink → monitor-reader` with no synthetic edge. A **port**-granular walk *does*
|
||||
need an explicit rule (inputs feed `port.monitor=true` outputs); a node-granular one
|
||||
does not. **Use node granularity for taint; use ports only for link creation.**
|
||||
3. **Owner-bridge edges** — the intra-process hop the graph cannot see. Bridge on
|
||||
`node.link-group` when present, else `client.id`. ✅ Measured: `client.id` is shared
|
||||
by the forwarder's two legs (`107`) and is distinct per application (Firefox `96`,
|
||||
Steam `82`, loopback `107`), so it is a usable fallback where `node.link-group` is
|
||||
absent — which is the case for ordinary apps (OBS, browsers, recorders) that read a
|
||||
monitor and re-emit, since only PipeWire *modules* set `link-group`.
|
||||
|
||||
### 6.1.1 ⚠️ Bridge taint must be conditional, or it over-excludes badly
|
||||
|
||||
The owner bridge must propagate taint **only when the input leg is itself tainted.**
|
||||
The naive rule "this client has both an input and an output leg ⇒ exclude the output" is
|
||||
catastrophic: it excludes any app using a microphone. Firefox in a video call has both
|
||||
legs, and its playback is perfectly shareable.
|
||||
|
||||
The correct rule:
|
||||
|
||||
1. Taint roots: peerspeak-owned nodes, live AEC identity, pixelpass-owned objects.
|
||||
2. Taint flows downstream along Link edges (crossing sinks and monitors for free, §6.1
|
||||
edge type 2). A sink carrying tainted audio has a **tainted monitor**.
|
||||
3. A `Stream/Input/Audio` reading a tainted monitor becomes tainted.
|
||||
4. **Only then** does the owner bridge carry taint to that client's `Stream/Output/Audio`
|
||||
legs.
|
||||
|
||||
Result, both cases correct:
|
||||
|
||||
| scenario | outcome |
|
||||
| --- | --- |
|
||||
| Firefox playing music (output leg only) | **eligible** — shared |
|
||||
| Firefox in a Meet call (mic input leg, untainted source) | **eligible** — shared |
|
||||
| Firefox screen-sharing *with desktop audio* (input leg on a tainted monitor) | **excluded** |
|
||||
| EasyEffects / loopback / combine-sink forwarding a mix containing peerspeak | **excluded** |
|
||||
|
||||
Where a client has a tainted input leg and an output leg with **no** resolvable bridge,
|
||||
fail closed and exclude the output leg. Note this only ever engages for clients actually
|
||||
reading a tainted monitor, so the blast radius is small.
|
||||
|
||||
This means pixelpass must **track Node, Port and Link globals** — not just node
|
||||
global-add, which is all the existing router does (`pixelpass/src/host/audio.rs:523-584`
|
||||
@@ -327,9 +407,9 @@ E2 makes mis-selection cheap *for the user's audio graph*, but it is not cheap f
|
||||
| --- | --- |
|
||||
| `peerspeak.owned` present | §5.1 env tag |
|
||||
| `pulse.module.id` == the live AEC index | §5.2, exact equality only |
|
||||
| **any upstream ancestry reaching an excluded node** | §6.1 — the load-bearing one |
|
||||
| **tainted via the signal graph** (Link + monitor + conditional owner bridge) | §6.1 — the load-bearing one |
|
||||
| pixelpass-owned objects, incl. **other hosts'** `pixelpass_capture_*` ancestry | cycle prevention |
|
||||
| unresolvable / unknown ancestry | fail closed |
|
||||
| tainted input leg + output leg with no resolvable bridge | §6.1.1, fail closed |
|
||||
| `port.exclusive` ports, encoded/passthrough streams | fan-out will refuse or corrupt |
|
||||
| links we already own for that node | idempotence |
|
||||
|
||||
@@ -342,14 +422,52 @@ A node can appear with incomplete ancestry, pass the predicate, get fanned out,
|
||||
*then* receive an inbound link from a filter input or `pixelpass_capture_<pid>.monitor`.
|
||||
An add-only listener never sees it. Required:
|
||||
|
||||
1. An **initial registry sync barrier** — enumerate to a `core sync`/`done` before
|
||||
deciding anything; a partially-populated registry is not a graph.
|
||||
1. An **initial registry sync barrier** — register the listener, call `Core::sync()`, wait
|
||||
for `done`, and only then consider the initial pass complete.
|
||||
⚠️ **Know what this barrier is and is not.** PipeWire's `core.h:261` defines sync as a
|
||||
*previous-work roundtrip*: because methods and events are ordered, `done` means
|
||||
"everything emitted so far has been handled." It is **not** graph quiescence and does
|
||||
not promise the graph has stopped changing. Readiness therefore = `done` **plus** "no
|
||||
unresolved required Link/Port observations outstanding," with a fail-closed timeout.
|
||||
2. Candidates stay **pending** until their ports *and* current inbound links are known.
|
||||
3. **Revalidate immediately before creating each Link**, not just at selection time.
|
||||
4. On any later link add/remove that makes ancestry unsafe, **drop the owned link
|
||||
proxies** for the affected node — retention (§4.2) is what makes revocation possible.
|
||||
|
||||
## 6.5 Foreign/hazard nodes
|
||||
### 6.4 Implementation shape and cost
|
||||
|
||||
`pipewire` crate **0.9.2** (`pixelpass/Cargo.toml:48`, `Cargo.lock:4121`).
|
||||
|
||||
- **Endpoint props fast path.** Registry `Link` globals carry all four endpoint keys
|
||||
(`/usr/include/pipewire-0.3/pipewire/keys.h:256`), and they were observed present on
|
||||
every live Link. **Residual doubt:** the observation was via `pw-dump`, which binds
|
||||
objects — so "present on the object" is measured, "always present in the *global-add*
|
||||
props" is not strictly proven. Cheap resolution: log the raw global-add props once in
|
||||
the implementation spike.
|
||||
- **Fallback if a prop is missing:** bind that Link, install an info listener, read the
|
||||
endpoint ids off `LinkInfoRef` (`pipewire-0.9.2/src/link.rs:153` exposes all four
|
||||
without params), then **drop the observer proxy**. Retained proxies are needed only for
|
||||
*our own* fan-out links, where dropping is the ownership/lifecycle mechanism (§4.2) —
|
||||
not for external links we merely observe.
|
||||
- **Full recompute per graph event is fine for v1.** Rebuild taint by BFS/DFS from the
|
||||
hazard roots, O(V+E). A typical desktop graph is ~40 nodes / ~120 links; this is
|
||||
sub-millisecond-class in Rust. No incremental dirty-set in v1; coalesce bursts only if
|
||||
logs show churn.
|
||||
|
||||
### 6.5 Rejected: the "hardware-sink-only" shortcut
|
||||
|
||||
Considered and **rejected**: "fan out only nodes whose output links to a real hardware
|
||||
sink, excluding anything feeding a virtual/null sink." It is cheaper and it is **unsound
|
||||
for exactly the case that matters** — measured: `output.loopback-2541-13` links *directly*
|
||||
to `alsa_output.pci-0000_10_00.6.analog-stereo`. The forwarder's output leg **is** a
|
||||
hardware-linked node, so the shortcut passes it and leaks the mix. The original app stream
|
||||
would meanwhile be excluded for feeding the virtual sink — i.e. the shortcut gets both
|
||||
halves backwards.
|
||||
|
||||
It survives only as an optional degraded fallback mode whose stated behaviour is "no
|
||||
EasyEffects/filter/loopback-routed audio is captured at all."
|
||||
|
||||
### 6.6 Foreign/hazard nodes
|
||||
|
||||
Treat an unvalidated `echo-cancel-*` output node as an excluded hazard rather than an
|
||||
eligible stream (§5.4).
|
||||
|
||||
Reference in New Issue
Block a user