Phase 3r landed §6.7 and the phase-5 audit was re-run against it immediately.
It found a second measured defect within minutes: a real hardware sink
carrying `unresolved-ancestry` permanently, from one link observed while its
output node was still unbound during enumeration. Round 8 made that
systematic rather than rare, because every node is now withheld until its
bind resolves.
New §6.8: sticky taint is a claim about history, and uncertainty is not
history. Retiring by reason code would not be enough — an unresolved node
propagates `TaintedUpstream`, which is indistinguishable from real
contamination once recorded — so the split is by provenance: the engine runs
its fixpoint twice, and only the evidence-only pass may feed sticky state.
Decisions are unchanged and still fail closed.
Also recorded in §6.8, both from Codex's round-9 review and both pre-existing:
hardware playback-to-capture paths ("Stereo Mix") defeat the `session_device`
classifier in a way the driver denylist cannot detect — a real echo path
needing a design call — and the 2 s readiness budget has no calibration
argument beyond one measurement on one idle desktop.
Impl plan: phase 3r marked built and merged with its gate results, including
the extra Device-side live gate and why row 1 alone could not cover it.
1247 lines
75 KiB
Markdown
1247 lines
75 KiB
Markdown
# Design v3: whole-desktop screen-share audio without self-echo
|
||
|
||
**Status:** 🟠 **v3.6 — round 9, opened by a second MEASURED finding, this time from a live
|
||
audit run of the *fixed* observer.** v3.4's architecture is still unchanged and converged.
|
||
Round 8 revised the **observation boundary** (§6.7); round 9 revises what stickiness is
|
||
allowed to remember (new §6.8). Both were found by running code, not by reading it.
|
||
**Date:** 2026-07-25 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
|
||
v3.4 r7 · v3.5 r8 · v3.6 r9)
|
||
**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.
|
||
|
||
**Why v3 is a rewrite, not a patch.** v1 and v2 both described a design that *moves*
|
||
desktop streams onto a capture sink. That design is dead. v2's §5.1 adopted **Option C
|
||
(copy via a second owned link)** after a measured feasibility spike, and the AEC
|
||
identity gate — the one thing both reviewers agreed nothing could go ahead of — has
|
||
since passed. Roughly two thirds of v2 described problems Option C does not have.
|
||
Carrying that text forward as "retained for the record" made the live plan unreadable.
|
||
v1/v2 remain in git history at `88ad5a0` and `10203e1`.
|
||
|
||
**Review history**
|
||
| Round | Artifact | Verdict |
|
||
| --- | --- | --- |
|
||
| v1 review | `~/Documents/handoff-docs/Codex/peerspeak/review-2026-07-19-audio-exclusion-design.md` | not sound as written; 4 blockers, all verified correct |
|
||
| v2 review | `…/review-2026-07-20-audio-exclusion-design-v2.md` | blocked; produced Option C |
|
||
| fan-out spike | `~/Documents/handoff-docs/Claude/peerspeak/fanout-spike-results-2026-07-20.md` + Codex rounds 3/4 | **Option C adopted**, ratified |
|
||
| AEC identity gate | `~/Documents/handoff-docs/Claude/peerspeak/aec-playback-leg-identity-2026-07-20.md` | **🟢 gate passed**, both models agree after 2 adversarial rounds |
|
||
| **phase 5 dry-run gate (r8)** | `docs/screenshare-audio-exclusion-phase5-results.md` | **🚦 GATE FAILED** — the observation boundary is wrong (§6.7); architecture unaffected |
|
||
|
||
---
|
||
|
||
## 1. The problem
|
||
|
||
A sharer using whole-desktop audio sends their entire output mix to viewers. That mix
|
||
necessarily includes peerspeak's own playback — remote peers' voices, remote screen-share
|
||
audio — so viewers hear themselves.
|
||
|
||
`--strict-audio` solved this for *per-app* mode by refusing to mirror the desktop at all.
|
||
Whole-desktop mode has no equivalent, so a sharer today chooses between echo and sharing
|
||
exactly one app's audio. Joe's ask is the missing third option: **share the desktop,
|
||
minus peerspeak.**
|
||
|
||
## 2. Framing
|
||
|
||
PipeWire has no capture-side filter. A monitor port carries an already-summed signal;
|
||
once peerspeak's output is in that mix it cannot be subtracted out. What PipeWire does
|
||
provide is **graph topology**: a node's output ports may link to more than one consumer.
|
||
|
||
So the feature is built by constructing a sink that only *eligible* streams feed, and
|
||
capturing that sink's monitor. Same observable behaviour as a filter, entirely different
|
||
mechanism.
|
||
|
||
This is **not** echo cancellation. `src/audio/echo_cancel.rs` addresses the microphone
|
||
path, where coupling is acoustic and needs adaptive cancellation. Here the signals never
|
||
need to be summed at all.
|
||
|
||
**Fidelity is not claimed.** The *membership* of the constructed mix is exact — peerspeak's
|
||
audio is never summed in. The *delivered audio* is not lossless: pixelpass downmixes to
|
||
48 kHz stereo and AAC-encodes at 128 kbps (`pixelpass/src/host/pipeline.rs:303-324`).
|
||
Adding a second link also makes the source node participate in a second format/buffer
|
||
negotiation, which can perturb it even though the original link survives (§9.3).
|
||
|
||
## 3. What exists today
|
||
|
||
`setup_audio` (`pixelpass/src/host/pipeline.rs:123-142`) activates `Routing` **only** when
|
||
`--app` is set or `PIXELPASS_AUDIO_VIA_NULL_SINK` is set. peerspeak's no-app argv is
|
||
`--host --output json` (`peerspeak/src/screenshare/mod.rs:135-165`), so normal
|
||
whole-desktop capture **bypasses `Routing` entirely** and hands the real default monitor
|
||
to `pulsesrc`. This feature is therefore a genuinely new mode, not an inverted predicate.
|
||
|
||
`Routing::start` (`pixelpass/src/host/audio.rs:65-212`) today provides:
|
||
|
||
| Piece | Condition |
|
||
| --- | --- |
|
||
| `module-null-sink pixelpass_capture_<pid>` | always, when `Routing` runs at all |
|
||
| `module-loopback @DEFAULT_SINK@.monitor → capture` | skipped only when `--app` **and** `--strict-audio` |
|
||
| `StreamRouter` (libpipewire thread) | only when `opts.app` is `Some` |
|
||
| local-monitor `capture.monitor → @DEFAULT_SINK@` | on `FirstRoutedStream`, unloaded on `LastRoutedStreamGone` |
|
||
|
||
Under Option C the null sink is reused, the monitor loopback is never loaded in this
|
||
mode, the local monitor is not needed at all, and `StreamRouter` is replaced by a
|
||
link manager rather than extended. Two existing defects in that file are inherited and
|
||
must be dealt with as prerequisites (§10).
|
||
|
||
## 4. Architecture — Option C: copy, don't move
|
||
|
||
pixelpass creates a *second*, pixelpass-owned link from each eligible playback stream's
|
||
output ports to the capture sink, and leaves the stream's existing route untouched.
|
||
|
||
```
|
||
app output ──────────────────────► existing hardware / filter path (UNTOUCHED)
|
||
└── pixelpass-owned link ────► pixelpass_capture_<pid> ──► gst pulsesrc ──► viewers
|
||
|
||
peerspeak-owned playback ────────► speakers only (never linked to capture)
|
||
AEC playback leg ────────────────► speakers only (never linked to capture)
|
||
```
|
||
|
||
### 4.1 Why this beats the move-based design
|
||
|
||
| Problem under move (Option A) | Under copy |
|
||
| --- | --- |
|
||
| local-monitor loopback needed to keep the sharer hearing audio | not needed — audio never leaves the speakers |
|
||
| added playback latency for the sharer | gone |
|
||
| output-device switch mid-share strands the desktop | gone — the app's own route is never touched |
|
||
| prior `target.object` capture/restore | gone — nothing is retargeted |
|
||
| pavucontrol conflicts | gone |
|
||
| two concurrent hosts fight over `target.object` | gone — links are independent per host |
|
||
| SIGKILL strands the whole desktop on an orphan sink | gone — see §4.2 |
|
||
|
||
**The decisive measurement (spike E2): destroying the capture sink mid-share left the
|
||
application playing to its speakers undisturbed.** Capture-side failure degrades to "not
|
||
captured," never to "the user's audio is broken." That is Option C's entire case, and it
|
||
is the reason a broader eligibility predicate is now acceptable (§6).
|
||
|
||
### 4.2 Ownership of links is the safety mechanism
|
||
|
||
In the Rust `pipewire` crate, **dropping a `Link` proxy destroys the object.** So:
|
||
|
||
- Links are created **non-lingering** and their proxies are **retained** for the life of
|
||
the share.
|
||
- Process death (including SIGKILL) destroys the connection, which destroys the links.
|
||
Measured: non-lingering links die on SIGKILL of the owner.
|
||
- A stream is counted as captured only once **every** required link reaches `ACTIVE`.
|
||
- On any link failure: leave the original route alone, report the stream as unsupported.
|
||
Never fall back to "capture the default monitor instead" — that fallback *is* the echo.
|
||
|
||
⚠️ **Rig gotchas that constrain how this is built and tested** (learned the hard way):
|
||
`pw-link --props object.linger=false` and `pw-cli create-link <props>` both **ignore**
|
||
linger and force it on; only `pw-link -m` yields a non-lingering link. `pw-cli
|
||
create-object` does not exist in 1.6.8 — the verb is `create-link <node> <port> <node>
|
||
<port>`. And links do **not** self-restore after a node or sink is recreated: a *live
|
||
owner* must re-link. That re-link loop is the pattern pixelpass has to implement, and
|
||
it is also what makes the daemon-restart case (§12) untested rather than free.
|
||
|
||
## 5. Ownership: identifying peerspeak's own audio
|
||
|
||
Every peerspeak-owned playback path is either a stream peerspeak itself creates, a
|
||
`Command::new(…)` spawn, or a pactl-loaded module. Three mechanisms cover all three.
|
||
|
||
### 5.1 Env-inherited tag, for spawned children — mechanism ✅ MEASURED, not yet built
|
||
|
||
⚠️ **This table lists IMPLEMENTATION SITES, not current behaviour.** None of these sites
|
||
tags anything today: `pipewire_impl.rs:374-388` sets no `peerspeak.owned` prop, and
|
||
neither the mpv/VLC spawn (`screenshare/mod.rs:768-775`) nor the notification spawn
|
||
(`notify.rs:265-272`) sets any env. The *mechanism* is measured working (below); the
|
||
*wiring* is work.
|
||
|
||
| Owner | Site | Mechanism to add |
|
||
| --- | --- | --- |
|
||
| native call playback | `src/audio/pipewire_impl.rs:374-388` | add the tag to the stream's property dict |
|
||
| mpv / VLC | `src/screenshare/mod.rs:768-775` | `.env("PULSE_PROP", …)` + `PIPEWIRE_PROPS` on the `Command` |
|
||
| `pw-play` / `paplay` / `aplay` | `src/notify.rs:265-272` | same |
|
||
|
||
Exact strings to be pinned at implementation time (§11), and each site needs a test:
|
||
native props asserted on the constructed dict, child sites asserted on the `Command` env.
|
||
|
||
Measured 2026-07-20 on this box (PipeWire 1.6.8): `peerspeak.owned=1` reached the graph
|
||
node for **paplay ✅, mpv ✅, VLC ✅**. `PULSE_PROP` and `PULSE_PROP_OVERRIDE` are both
|
||
present in `/usr/lib/pulseaudio/libpulsecommon-17.0.so`. (Codex asserted this mechanism
|
||
did not exist; it was disproved empirically. VLC has no native PipeWire aout on this box,
|
||
which is true and irrelevant — its Pulse path honours `PULSE_PROP`.)
|
||
|
||
**The tag is a correctness mechanism, not a security boundary.** Any same-user client can
|
||
set `peerspeak.owned=1` and opt itself out of capture, and a child can sanitize its own
|
||
environment. PipeWire is explicit that only `pipewire.*` properties are usable for
|
||
security decisions. Acceptable here: the threat model is "don't echo the user's own call
|
||
back at them," not "defend against a hostile local process." Stated, not assumed.
|
||
|
||
⚠️ **Known leak: a stable boolean is inherited by grandchildren.** Anything mpv or VLC
|
||
spawns is exempted too. Accepted for now; revisit if it bites.
|
||
|
||
⚠️ **Round 8 — the tag needs a SECOND, registry-visible carrier (user's call, 2026-07-25).**
|
||
`peerspeak.owned` is invisible to the registry `global` event (§6.7) and is readable only via
|
||
a node bind. That is safe — an unread tag means the node is withheld or the epoch fails
|
||
closed, i.e. silence, never echo — but it makes the primary taint root depend on the one
|
||
mechanism the round-8 finding proved fragile. So each tagging site sets **both**:
|
||
|
||
| carrier | visibility | role |
|
||
| --- | --- | --- |
|
||
| `peerspeak.owned=1` | bind only | primary root; keeps the measured `PULSE_PROP` env-inheritance mechanism for mpv/VLC/paplay |
|
||
| a pinned `node.name` prefix | **announced by the registry** | secondary root; fires with no bind at all |
|
||
|
||
The prefix mechanism is already proven in this codebase — `pixelpass_capture_*` is matched on
|
||
`node.name` and was the *only* root still functioning under the defect. Either carrier alone
|
||
marks a node owned (union, not intersection: fail-closed direction). `node.description` is
|
||
deliberately left untouched so volume mixers still read "mpv", not "peerspeak: mpv". The
|
||
literal is a cross-repo wire contract and is pinned in the implementation plan §3 alongside
|
||
the property literal.
|
||
|
||
### 5.2 The AEC playback leg — 🟢 gate passed, with five load-bearing corrections
|
||
|
||
`module-echo-cancel` is loaded via pactl (`peerspeak/src/audio/echo_cancel.rs:83-94`), so
|
||
its playback leg lives inside `pipewire-pulse` and **cannot inherit an env tag.** It is a
|
||
`Stream/Output/Audio` node linked straight to the speakers, so a broad selector *will*
|
||
pick it up unless explicitly excluded — and that is not a nicety:
|
||
|
||
> **Measured (3 arms, links verified in-graph before measuring, recorded via
|
||
> `parec -d <sink>.monitor` — the production path):** naive fan-out that includes the AEC
|
||
> leg copies remote-call audio into the share at **≈desktop level** (−45.0 dB vs the
|
||
> −44.5 dB desktop tone). Exact exclusion produces **−83.3 dB, matching the control floor
|
||
> to 0.1 dB.**
|
||
|
||
**What `module-echo-cancel` actually creates (RESULT 1):** four nodes, not two —
|
||
`Audio/Sink peerspeak_ec_sink.<pid>`, `Audio/Source peerspeak_ec_source.<pid>`,
|
||
`Stream/Output/Audio **echo-cancel-playback**` (passive, virtual), and
|
||
`Stream/Input/Audio echo-cancel-capture`. Codex's "Pulse-compat exposes only
|
||
`sink_properties`" concern is real about the *module args* and irrelevant to the *graph
|
||
props*.
|
||
|
||
**The identity (RESULT 2):** all four nodes carry `pulse.module.id` equal, byte for byte,
|
||
to the module index `pactl load-module` returned — the value peerspeak already stores in
|
||
`EchoCancelGuard::module_index` (`echo_cancel.rs:106-112`, a `String` validated as `u64`).
|
||
No PID guesswork, no name matching, no leg correlation.
|
||
|
||
```
|
||
exclude every node where pulse.module.id == <the index pactl returned to the live guard>
|
||
```
|
||
|
||
**⚠️ The five corrections. These are the design, not footnotes:**
|
||
|
||
1. **This is not an "identity contract."** It is an exact correlation *observed on
|
||
PipeWire 1.6.8*, not a documented API. No PipeWire source is installed on this box and
|
||
neither model had network, so whether the property is the *mechanism* pipewire-pulse
|
||
uses to reap module-created objects or a cosmetic mirror is **UNRESOLVED**.
|
||
⇒ **Runtime-validate and fail closed** (§5.3).
|
||
2. **"Has any `pulse.module.id`" is REJECTED as an exclusion rule.** Tunnel, RTP and
|
||
loopback modules may be the *only* carrier of audio the user legitimately wants
|
||
shared. **Exact equality with the owned live index — nothing weaker.**
|
||
3. **Module index and `node.link-group` are REUSED verbatim across unload/reload** (both
|
||
came back `536870919` / `echo-cancel-1974-13`), and **node IDs are recycled *and
|
||
reassigned across legs*** — id 136 was the playback leg on load 1 and the capture leg
|
||
on load 2. ⇒ **Never cache a node id. Never assume leg order. Never cache the module
|
||
index across an unload** — it is only trustworthy as "the index for the currently-live
|
||
guard," so pixelpass must be (re)told on every load.
|
||
4. **The `echo-cancel-` group prefix is hazard *detection*, not ownership.** Neither it
|
||
nor the fixed node name `echo-cancel-playback` identifies *peerspeak's* instance. A
|
||
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. **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`
|
||
so they fit u32 — but this sits right next to the `object.serial` u32-truncation bug
|
||
(§10.1), so compare as strings or as `u64`, never as `u32`.
|
||
|
||
### 5.3 Fail closed — validation and revocation
|
||
|
||
Because the identity is an observed correlation rather than a contract, pixelpass must
|
||
**verify it at runtime and refuse to run the mode when it cannot.** A one-shot
|
||
"enumerate at start" check is *not* good enough — it races, in both directions:
|
||
|
||
- peerspeak's `enable()` returns once `wait_for_nodes` sees the virtual **source and
|
||
sink** by name (`echo_cancel.rs:132-158`, polling `pactl list sources/sinks short`).
|
||
It does **not** wait for the playback `Stream/Output/Audio` hazard leg, which is the
|
||
node we actually need to exclude.
|
||
- pixelpass's capture spawns **lazily, on first viewer** (`pixelpass/src/host/mod.rs:300-304`),
|
||
so enumeration happens at a moment peerspeak does not control, and a refusal surfaces
|
||
*after* the ticket has been handed out.
|
||
|
||
**So validation is a bounded state machine, not a check:**
|
||
|
||
```
|
||
NotConfigured ──(--aec=off)──────────────► fan-out proceeds, no AEC exclusion
|
||
Validating ──(--aec=pulse-module:<i>)─► enumerate after a registry sync barrier;
|
||
wait up to a bounded deadline for ≥1 node
|
||
with pulse.module.id == i
|
||
Validated ──► fan-out permitted, excluding that identity transitively (§6.1)
|
||
Failed ──► deadline expired, identity never observed ⇒ NO FAN-OUT
|
||
Revoked ──► the live module identity disappeared ⇒ STOP FAN-OUT NOW
|
||
```
|
||
|
||
- **No fan-out occurs in `Validating`.** Silence is the safe direction; echo is not.
|
||
- **`Failed` is fail-closed**: report a capability failure, do not silently share.
|
||
Fall back to a mode with no echo risk, or an explicit user override.
|
||
- **Revocation is loss of the live *module* identity** — *all* nodes bearing the index
|
||
gone — not the transient absence of one playback leg, which can cork or relink. Getting
|
||
this wrong turns a normal cork into a spurious share-wide audio stop.
|
||
- On `Revoked`, **drop the link proxies**; do not keep the numeric index and hope. The
|
||
index is reused (§5.2 correction 3), so a retained stale index can alias onto an
|
||
unrelated future module.
|
||
- **Never** infer exclusion from `node.name == "echo-cancel-playback"` alone — it is a
|
||
fixed, non-unique, trivially spoofable string. Usable only as belt-and-braces *after*
|
||
the exact match, or as foreign-instance hazard detection.
|
||
|
||
### 5.4 Foreign or second AEC instances — a product decision
|
||
|
||
If a node matching `node.link-group` prefix `echo-cancel-` exists that is **not** ours,
|
||
peerspeak is not the only echo canceller on the box. Options: (a) fail closed — refuse
|
||
the mode; (b) warn and exclude all `echo-cancel-*` groups, accepting that a legitimate
|
||
unrelated AEC's output silently won't be shared. **Recommendation: (b) with a visible
|
||
warning** — the failure it prevents (echo) is worse than the failure it causes (one
|
||
app's audio missing), and it matches §6's overall posture. **Open decision D3 (§13).**
|
||
|
||
## 6. Eligibility — a broad guarded selector
|
||
|
||
Under a *move* design, mis-selection rewired the user's desktop, which forced a narrow
|
||
allowlist. Under copy, mis-selection costs at most one stream's capture — so the narrow
|
||
allowlist is out. But "fan out everything" is still wrong: it recreates self-echo, it
|
||
recreates call echo, it can build cycles, and a second link participates in format/buffer
|
||
negotiation.
|
||
|
||
### 6.1 ⚠️ Eligibility is a GRAPH property, not a node property
|
||
|
||
**This is the single biggest correction in round 4, and it is reachable on the target
|
||
machine, not hypothetical.**
|
||
|
||
A node-local property check is unsound, because **exclusion does not propagate downstream.**
|
||
If any node re-emits audio it received, the re-emitting node is a fresh, *untagged*
|
||
`Stream/Output/Audio` carrying the mix — including peerspeak's playback and the AEC leg,
|
||
both of which were correctly excluded one hop earlier.
|
||
|
||
### 6.1.0 The hazard is REACHABLE on this machine — it was live when measured
|
||
|
||
⚠️ **Round-8 correction to this heading, and it is the point of the correction.** v3.4 said
|
||
"🔴 LIVE on this machine **right now**." That was true at 2026-07-21 ~09:00 and **false by
|
||
~14:55 the same day** — six hours later the default sink was
|
||
`alsa_output.pci-0000_10_00.6.analog-stereo` (IDLE), all three `sink-sunshine-*` null sinks
|
||
SUSPENDED, with Sunshine still running the whole time. So the topology appears when
|
||
**Sunshine actually routes desktop audio through its null-sink chain**, which is strictly
|
||
narrower than "Sunshine is running."
|
||
|
||
**Nothing may gate on this topology being present.** The reachability argument below is
|
||
unaffected and stands: a stock third-party application puts a virtual-sink forwarder in the
|
||
default audio path on an ordinary desktop, no EasyEffects required. But as a *test* input it
|
||
is opportunistic only — the controlled `module-null-sink` + `module-loopback` fixture is the
|
||
authoritative one because it is deterministic and always available.
|
||
|
||
Measured 2026-07-21 ~09:00. **The user's default sink was not hardware.** `pactl info`
|
||
reported:
|
||
|
||
```
|
||
Default Sink: sink-sunshine-stereo ← factory.name = support.null-audio-sink
|
||
```
|
||
|
||
All three real hardware sinks (`Arctis_1_Wireless`, `pci-…analog-stereo`,
|
||
`…hdmi-stereo-extra3`) are **SUSPENDED**; the only `RUNNING` sink is Sunshine's virtual
|
||
one. The live graph is:
|
||
|
||
```
|
||
Firefox:output_{FL,FR} ──► sink-sunshine-stereo:playback_{FL,FR} (a null sink)
|
||
sink-sunshine-stereo:monitor_{FL,FR} ──► sunshine:input_{FL,FR}
|
||
```
|
||
|
||
That is **exactly the forwarder shape** this section is about, active in the default audio
|
||
path, with no EasyEffects involved. Consequences:
|
||
|
||
1. **Reachability is settled.** Earlier rounds argued from "EasyEffects is installed";
|
||
the real machine is already running a virtual-sink forwarder topology full time.
|
||
peerspeak's own playback would land in `sink-sunshine-stereo` and its monitor.
|
||
2. **§6.5's "hardware-sink-only" shortcut wouldn't just leak — it would capture
|
||
NOTHING here**, because no application links to a hardware sink at all.
|
||
3. Sunshine happens to have **only** a `Stream/Input/Audio` leg (id 168) — it encodes and
|
||
sends over the network rather than re-emitting locally — so it is not itself a fan-out
|
||
leak source. The topology, not this particular app, is the point.
|
||
4. §10 item 2 (`@DEFAULT_SINK@` resolved once) is worse than described: the default sink
|
||
here is a *transient app-owned null sink* that appears and disappears with Sunshine.
|
||
|
||
**Separate item this raised for peerspeak itself — RESOLVED for this machine, kept as a
|
||
low-priority general defect** (not part of this feature): `echo_cancel::enable` appends
|
||
`sink_master=` / `source_master=` only when the configured device is `Some` and non-empty
|
||
(`echo_cancel.rs:89-94`); otherwise `module-echo-cancel` binds to whatever PipeWire calls
|
||
the default. On a box where an application owns the default sink — as Sunshine does here —
|
||
that means the AEC would bind to a null sink.
|
||
|
||
✅ **Not live for this user:** `~/.config/peerspeak/config.json` pins
|
||
`output_device = alsa_output.usb-SteelSeries_…Arctis_1_Wireless-00.analog-stereo` and
|
||
`input_device = …mono-fallback`, so `sink_master` is always passed explicitly and the AEC
|
||
binds to the Arctis regardless of the default. The exposure exists only for a user on
|
||
"system default". Possible hardening (own task, not this feature): resolve and validate
|
||
the default sink before load, and refuse or warn when it is a null/virtual sink.
|
||
|
||
The canonical *installed* instance: **EasyEffects.** When it is running, applications play into
|
||
`ee_sink` and EasyEffects emits a single combined output stream to the hardware. That
|
||
output leg has no `peerspeak.owned` tag and no `pulse.module.id` matching our AEC, so
|
||
**both §5.1 and §5.2 pass it**, and fanning it out re-injects the entire call into the
|
||
share at full level — the exact bug this feature exists to prevent, arriving through the
|
||
front door. ⚠️ **`easyeffects` is installed on this machine** (`pacman -Qq easyeffects`),
|
||
and `libpipewire-module-filter-chain.so` is present. It happened not to be running during
|
||
the spike, which is *why* the spike did not catch this. Same shape: combine-sinks,
|
||
`module-loopback`, virtual-sink forwarders, tunnel/RTP sinks, DSP bridges, and any
|
||
monitor-sourced stream.
|
||
|
||
**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** 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.**
|
||
⚠️ **Accepted cost — duplex over-taint.** Node granularity smears taint across
|
||
multi-role nodes: an `Audio/Duplex` node whose playback side is tainted will have its
|
||
capture side treated as tainted too. That is **fail-closed** (over-exclusion, not a
|
||
leak) and is accepted for v1, but it can contradict the "Firefox with a microphone
|
||
stays shareable" promise in §6.1.1 on a duplex device. Port-modelling `Audio/Duplex`
|
||
and unknown-role nodes is the fix if this bites in field testing.
|
||
3. **Owner-bridge edges** — the intra-process hop the graph cannot see. This one is
|
||
genuinely hard; see §6.1.2. A single key does **not** work.
|
||
|
||
### 6.1.2 ⚠️ The owner bridge — no single key works (measured refutation)
|
||
|
||
**I claimed `client.id` was the fallback bridge key for apps without a `link-group`.
|
||
I disproved my own claim; Codex independently reached the same verdict.**
|
||
|
||
Test: one `gst-launch-1.0 pulsesrc device=fable_src.monitor ! audioconvert ! pulsesink
|
||
device=fable_dst` process — one PID, an input leg and an output leg, the exact
|
||
OBS/recorder/forwarder shape, and the same framework pixelpass itself uses.
|
||
|
||
| leg | `client.id` | `node.link-group` | `application.process.id` |
|
||
| --- | --- | --- | --- |
|
||
| `Stream/Input/Audio` | **209** | null | 20172 |
|
||
| `Stream/Output/Audio` | **210** | null | 20172 |
|
||
|
||
**Two distinct client objects for one process**, both named `gst-launch-1.0`, both with
|
||
`pipewire.sec.pid=2541`. So `client.id` bridges *a client connection*, not *an owner* —
|
||
an app is free to open one connection per stream, and GStreamer does exactly that. The
|
||
earlier "libcanberra appears as clients 74 and 77" observation was the same signal and I
|
||
under-weighted it.
|
||
|
||
**Replacement: a conservative union of owner keys, strongest first.**
|
||
|
||
⚠️ **Wording trap — "resolves" means "yields a MATCH between the two legs," NOT "is the
|
||
first property present on the node."** A naive first-present implementation reproduces the
|
||
exact bug: in the gst-launch case `client.id` **is present on both legs** (209 and 210), so
|
||
first-present stops at key 3, finds the values differ, and concludes "different owners —
|
||
not bridged." The leak survives. The rule is: *try each key in order; a key resolves only
|
||
if both legs carry it and the values are equal; otherwise fall through to the next key.*
|
||
This must be an explicit unit test (§12).
|
||
|
||
| # | key | scope | notes |
|
||
| --- | --- | --- | --- |
|
||
| 1 | `node.link-group` | per module/filter instance | precise; set by PipeWire modules (loopback, echo-cancel, filter-chain) |
|
||
| 2 | `pulse.module.id` | per pactl module | precise; module-created streams only |
|
||
| 3 | `client.id` | per **connection** | correct when an app uses one connection; insufficient alone |
|
||
| 4 | `application.process.id` (on the **Node**) | per process | ✅ the only key that bridged the gst-launch legs (`20172` on both) |
|
||
| — | else | — | **unresolved ⇒ fail closed** (exclude the output leg) |
|
||
|
||
⚠️ **Key 4 has a trap that must be coded explicitly.** For *module-created* streams,
|
||
`application.process.id` is **pipewire-pulse's own PID** (§5.2 correction 5). Bridging on
|
||
it would fuse *every* Pulse module's legs into one owner, so a single tainted module input
|
||
would exclude every module-created stream on the box — mass over-exclusion, and exactly
|
||
the failure correction 2 warned about (a tunnel/RTP module may be the sole carrier of
|
||
audio the user wants shared). **So: never bridge on key 4 when the value equals the
|
||
pipewire-pulse PID.** Those cases are already covered precisely by keys 1 and 2.
|
||
|
||
Note the irony worth recording: PID returns to the design, but in a different *role*.
|
||
It is unusable as **identity** ("is this peerspeak's audio?") and workable as
|
||
**correlation** ("are these two legs the same app?") — and in the correlation role a
|
||
wrong answer fails closed rather than leaking.
|
||
|
||
**How pixelpass learns the pipewire-pulse PID** (needed for the key-4 exception). It must
|
||
derive this itself; it cannot assume a value, and peerspeak can supply only a *hint*:
|
||
|
||
- Read `pipewire.sec.pid` from the **Client** objects of Pulse-emulated streams. Measured:
|
||
it is `2541` for Firefox, Steam, KDE Connect, sunshine and libcanberra alike, while each
|
||
node's own `application.process.id` differs (Firefox `11114`, sunshine `4119`).
|
||
- Require a **single consistent** value across those clients, and validate it by reading
|
||
`/proc/<pid>/comm` (or cmdline) and confirming it is `pipewire-pulse`.
|
||
- "This PID owns implausibly many unrelated streams" is a **diagnostic**, never
|
||
correctness logic.
|
||
|
||
Failure modes: if pixelpass fails to identify the real pipewire-pulse PID, the result is
|
||
broad **over-exclusion** (annoying, safe). If it wrongly suppresses a genuine app PID, the
|
||
result is over-exclusion **for that app** — safe *only* because unresolved ancestry is
|
||
fail-closed. If unresolved ancestry were ever implemented fail-open, both of these become
|
||
leaks. That is the invariant holding this whole section up.
|
||
|
||
### 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 an owner has a tainted input leg and an output leg with **no** resolvable bridge
|
||
(§6.1.2), fail closed and exclude the output leg. This only ever engages for owners
|
||
actually reading a tainted monitor, so the blast radius is small.
|
||
|
||
### 6.1.3 ⚠️ Taint must be STICKY — current topology is not enough
|
||
|
||
**Conceded to Codex in round 6; my "conditional bridge" was correct about topology and
|
||
wrong about time.** Taint computed from the *current* graph forgets **buffered audio**.
|
||
|
||
Failure: an app reads a tainted monitor, buffers or delays (a recorder with a 5 s ring
|
||
buffer, a DAW with latency compensation, anything doing lookahead), and then its input leg
|
||
untaints or disappears — the user stops sharing to it, the stream corks, the app closes
|
||
the capture. A purely topological recompute now sees a clean input, un-taints the owner,
|
||
and pixelpass **relinks the output leg while it is still emitting peerspeak audio from
|
||
the buffer.** No graph event marks the moment the buffer drains.
|
||
|
||
**Rule: taint is sticky per owner for the duration of the share.** Once an owner is
|
||
tainted it stays tainted until its client/output nodes disappear. Re-eligibility requires
|
||
teardown, not a topology change. A timed drain is strictly weaker and would need
|
||
measurement to justify; do not ship one in v1.
|
||
|
||
⚠️ **Stickiness must be lifetime-aware, never keyed on a raw recyclable id.** `client.id`,
|
||
node ids, module indices, `node.link-group` values and PIDs **all recycle on this stack** —
|
||
measured directly for module indices and node ids (§5.2 correction 3), and the same
|
||
`link-group` string came back verbatim across an unload/reload. If sticky taint were stored
|
||
against a bare key, a later unrelated app inheriting that recycled id would inherit the
|
||
taint and be silently excluded forever.
|
||
|
||
Store stickiness against a **live owner component** — the concrete set of node/client
|
||
objects observed to form that owner — and clear it only once **all** member objects have
|
||
disappeared. A key that reappears after full teardown is a new owner and starts clean.
|
||
|
||
The cost is that an app which *once* read a tainted monitor stays unshared for the rest of
|
||
the share. That is the right trade — it is silence for one app, versus echo for everyone.
|
||
|
||
### 6.1.4 The transition window is real but bounded
|
||
|
||
Taint can also *arrive*: a peer joins and speaks, or the AEC loads, mid-share. Recompute
|
||
on every graph event and dropping links for newly tainted nodes is **necessary but not
|
||
zero-leak**, because a topology change races audio already in flight.
|
||
|
||
Order of magnitude on this box: a graph quantum is ~`512/48000`–`1024/48000` ⇒ **≈10.6–21.3
|
||
ms**, plus main-loop scheduling. App-internal buffers are unbounded in principle. So the
|
||
worst case is a sub-quantum-to-tens-of-ms audible sliver at the transition.
|
||
|
||
⚠️ Mitigating structure: if peerspeak's playback **roots already exist** in the graph and
|
||
merely become non-silent (the common case — the AEC and call playback nodes are created at
|
||
join, long before anyone speaks), then topological taint has **already** excluded
|
||
everything downstream and there is no window at all. The window exists only when a taint
|
||
root is *newly created* mid-share.
|
||
|
||
⚠️ **Round-8 correction: v3.4 nominated an unreachable test case here** ("that makes
|
||
AEC-load-mid-share the case to test"), **and so did its first replacement.** The conclusion
|
||
above is unaffected; only the example was wrong.
|
||
|
||
- *AEC-load-mid-share is unreachable.* There is exactly one `echo_cancel::enable` site, at
|
||
session join (`core/mod.rs:1850`); the guard moves into `ActiveSession` (`:2729`); and
|
||
`StartScreenShare` rejects `active_session == None` (`:3397-3404`). The AEC always
|
||
predates the share.
|
||
- *"A peer joins and their playback node is created" is also unreachable.* peerspeak starts
|
||
**one mixed playback stream** at session construction (`core/mod.rs:1900`); `PeerJoined`
|
||
(`:2396-2409`) admits and connects the sender and creates no per-peer node.
|
||
|
||
**The reachable newly-created mid-share taint roots are: a notification sound played
|
||
mid-share** (`notify.rs:265-272`) **and starting to view another share mid-share**, which
|
||
spawns a tagged mpv/VLC (`screenshare/mod.rs:768-775`). Those are the transition-window
|
||
field tests. Owned-AEC mid-share load stays a **synthetic** test until a second `enable`
|
||
site or a hot reload arms it.
|
||
|
||
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`
|
||
subscribes to global-add/global-remove and never binds links). That is a materially
|
||
larger component than v3.0 implied.
|
||
|
||
Ancestry that cannot be resolved (unknown forwarder shape, unresolvable monitor source)
|
||
is **not** eligible. That is a deliberate reversal of v3.0's "unrecognized ⇒ fan out":
|
||
E2 makes mis-selection cheap *for the user's audio graph*, but it is not cheap for
|
||
**echo**, and echo is the whole feature.
|
||
|
||
### 6.2 The predicate
|
||
|
||
**Start from all `Stream/Output/Audio` nodes, then exclude:**
|
||
|
||
| Exclusion | Basis |
|
||
| --- | --- |
|
||
| `peerspeak.owned` present | §5.1 env tag |
|
||
| `pulse.module.id` == the live AEC index | §5.2, exact equality only |
|
||
| **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 |
|
||
| 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 |
|
||
|
||
- `node.dont-move` **drops out entirely** — fan-out is not a metadata move.
|
||
- Links are created **per port**; channel-count and layout mismatches are per-port.
|
||
|
||
### 6.3 Dynamic graph handling — revalidate, don't fire-and-forget
|
||
|
||
A node can appear with incomplete ancestry, pass the predicate, get fanned out, and only
|
||
*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** — 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.4 Implementation shape and cost
|
||
|
||
`pipewire` crate **0.9.2** (`pixelpass/Cargo.toml:48`, `Cargo.lock:4121`).
|
||
|
||
- **Endpoint props are an OPTIMIZATION, not the correctness path** (settled in round 6).
|
||
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 — but the observation was via `pw-dump`, which binds objects, and
|
||
neither the headers nor the crate prove those keys are *always* present in the
|
||
**global-add** props. So: use them when present, and **rely on the bind fallback for
|
||
correctness.** A raw global-add log in the implementation spike settles current
|
||
behaviour, but the fallback must exist regardless.
|
||
- **Fallback:** 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.
|
||
⚠️ **Round 8: measured, the Link endpoint props are always present** — this fallback is
|
||
correctness insurance that never fires on this host. The bullet above it is still right;
|
||
it was just under-applied. See **§6.7**.
|
||
- 🔴 **Node and Device props are NOT AVAILABLE from the global at all** — not an
|
||
optimisation, not usually-present, never. Binding each Node and Device is the **only**
|
||
path to `peerspeak.owned`, `pulse.module.id`, `node.link-group`,
|
||
`application.process.id`, `node.passthrough`, `factory.name`, `device.api` and
|
||
`alsa.driver_name`. **§6.7** is the whole story; it is the round-8 finding and it
|
||
supersedes any reading of this section that treats node props as observable off the
|
||
registry.
|
||
- **Readiness epoch.** Start fan-out only when: initial `core.sync`/`done` received **and**
|
||
every required Link/Port/Client/Node observer fallback has resolved or hit a fail-closed
|
||
timeout. Then **revalidate in the same graph epoch immediately before creating each
|
||
owned link** — a stale decision from a previous epoch is not a decision.
|
||
- **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).
|
||
|
||
### 6.7 🔴 The observation boundary — registry props are a FILTERED SUBSET (round 8, MEASURED)
|
||
|
||
**This is the finding that failed the phase-5 gate**, and it is the one place v3.4 was
|
||
materially wrong. Full evidence: `docs/screenshare-audio-exclusion-phase5-results.md` (F1).
|
||
|
||
**The fact.** The registry `global` event does **not** carry an object's properties. It
|
||
carries a small fixed subset the daemon copies onto the global. Measured on PipeWire 1.6.8 /
|
||
WirePlumber 0.5.15, the complete union of keys announced for a `Node` global across every
|
||
node on the box is:
|
||
|
||
```
|
||
application.name client.api client.id device.id factory.id media.class
|
||
node.description node.name node.nick object.path object.serial
|
||
priority.driver priority.session
|
||
```
|
||
|
||
Eight properties this design depends on are **never** announced, and a props lookup for them
|
||
returns absent rather than failing — so an implementation reading them off the global is
|
||
silently, permanently wrong:
|
||
|
||
| property | what dies without it |
|
||
| --- | --- |
|
||
| `peerspeak.owned` | **the primary taint root** — §5.1, all of phase 1 |
|
||
| `pulse.module.id` | **AEC identity exclusion** — §5.2 and the §5.3 validator |
|
||
| `node.link-group` | owner key 1 — echo-cancel, EasyEffects, loopback siblings (§6.1.2) |
|
||
| `application.process.id` | owner key 4 — the measured GStreamer split-client case (§6.1.2) |
|
||
| `node.passthrough` | the passthrough exclusion (§6.2) |
|
||
| `device.api` | `session_device` classification |
|
||
| `factory.name` | `session_device` classification — the discriminator itself |
|
||
| `alsa.driver_name` | `session_device` classification — the `snd_aloop` denylist |
|
||
|
||
`port.exclusive` is likewise absent from `Port` globals, so that §6.2 exclusion never fires.
|
||
**Links and Clients are unaffected** — every endpoint key and `pipewire.sec.pid` is announced,
|
||
so §6.1.2's pulse-PID derivation works as designed.
|
||
|
||
The consequence measured live was not degradation but inversion: with `peerspeak.owned`
|
||
invisible the tagged sink is **not a taint root at all** (an echo, with AEC off), while the
|
||
one root that still fires — the `pixelpass_capture_*` name prefix, `node.name` being
|
||
announced — leaves every candidate with no *strong* owner key, which trips §6.1.1's
|
||
fail-closed backstop and excludes every stream on the machine. Both halves broken, in
|
||
opposite directions, from one cause.
|
||
|
||
**The rule (round 8).**
|
||
|
||
> **A node's properties come from a bind, never from the global.** The registry global is an
|
||
> *index* — it tells us an object exists, its id, its serial, its media class and its name —
|
||
> and nothing it says about ownership, grouping or hardware identity may be trusted or even
|
||
> read. Every `Node` global is bound and its `info` props are the sole source of node
|
||
> properties. Same for `Device`.
|
||
|
||
Verified recoverable by bind on the same objects that lack them from the registry:
|
||
`factory.name = api.alsa.pcm.sink`, `device.api = alsa`, `alsa.driver_name = snd_usb_audio`,
|
||
`peerspeak.owned = true`, `pulse.module.id`, `node.link-group = loopback-2528-13`,
|
||
`application.process.id`.
|
||
|
||
Four decisions fix the shape (user's calls, 2026-07-25):
|
||
|
||
1. **Bind every `Node` global, unconditionally** — no filtering by `media.class`. Deciding
|
||
which nodes matter *before* their props exist is the same class of mistake as reading the
|
||
props off the global, and a node skipped for looking irrelevant has no owner keys, which
|
||
is exactly the unbounded-reader condition that produced the machine-wide cascade. Cost is
|
||
~14 nodes at rest on this box.
|
||
2. **Track prop changes for the node's lifetime.** `info` fires again with `PROPS` set in
|
||
`change_mask`; re-read, re-classify, re-emit. A one-shot read (the Link-fallback pattern)
|
||
would miss a `node.link-group` or `pulse.module.id` set after node creation. Measured
|
||
cheap: one change event in 8 s at rest.
|
||
3. **One readiness obligation per unbound node**, and a node with no `info` yet is **withheld
|
||
from the snapshot entirely** — never admitted with provisional ownership (the §6.1.3 rule,
|
||
already applied to device-unresolved nodes). If a bind never resolves, readiness goes
|
||
sticky-`TimedOut` and there is no fan-out at all: identical to a never-resolving Link
|
||
bind, one rule for both. ⚠️ *Owed hardening:* 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.
|
||
4. **`session_device` reads from both.** `factory.name` exists only on the node, so the node
|
||
bind is required regardless. But `device.api` **and** `alsa.driver_name` are on the bound
|
||
`Device`'s `info` props (measured — they are absent from the Device *global*, which is
|
||
what the phase-5 results file checked). Reading the ALSA driver from the backing Device is
|
||
authoritative and closes the phase-3 review's owed fix: on PipeWire ≥ 1.2.6 with
|
||
WirePlumber < 0.5.13 the driver name is not copied to the node, and the fail-closed
|
||
"absent driver ⇒ not a session device" rule would over-exclude real cards.
|
||
|
||
**Why v3.4 missed it, which is the transferable lesson.** Round 6 settled the *same question*
|
||
for Links and got it right — "endpoint props are an OPTIMISATION, the bind is the correctness
|
||
path" (§6.4) — on the explicit reasoning that `pw-dump` binds, so observing a key via
|
||
`pw-dump` says nothing about whether the **global-add** carries it. Every property in the
|
||
table above was likewise confirmed via `pw-dump`. The doubt was correctly formed and then
|
||
applied to exactly one object type. The general form: **`pw-dump` is a bound view; the
|
||
registry is not, and the difference is silent.**
|
||
|
||
Two things this does *not* change: the architecture (Option C, taint as a graph property, the
|
||
owner-key union, sticky taint — all vindicated, and the fixtures that fed them correct props
|
||
produced correct answers), and the Link/Client observation path.
|
||
|
||
⚠️ **O5 must be re-measured.** The phase-5 numbers (max 15 µs recompute, `busy_fraction`
|
||
0.0004) were taken on the graph this defect produces and do not include per-node bind I/O.
|
||
|
||
### 6.8 🔴 Uncertainty is not history — what stickiness may remember (round 9, MEASURED)
|
||
|
||
**Found by the phase-5 audit on the live graph within minutes of §6.7's fix landing**, which
|
||
is the strongest available argument for the dry-run phase existing at all.
|
||
|
||
**The observation.** With phase 3r running, a real hardware sink
|
||
(`alsa_output.usb-…Arctis_1_Wireless…`) carried `unresolved-ancestry` **permanently** — the
|
||
mark survived the readiness epoch, 21 recomputes and deliberate module churn. Traced to a
|
||
single event during enumeration: a link was observed while its output node was still
|
||
unbound, so §6.1.4's fail-closed rule correctly raised `UnresolvedAncestry` on the input
|
||
side. That mark was then written into sticky state, and §6.1.3 retires a sticky entry only
|
||
when **every member object is absent** — which a live sound card never is.
|
||
|
||
**Why round 8 made it systematic rather than rare.** Under §6.7 every node is withheld until
|
||
its bind resolves, so *any* link observed across that gap raises unresolved ancestry. It
|
||
fires at startup, every startup, on whichever node happens to lose the race.
|
||
|
||
**The rule (round 9).**
|
||
|
||
> **Sticky taint is a claim about history, and uncertainty is not history.** A node tainted
|
||
> only because the graph could not be seen has had nothing observed about it. Decisions
|
||
> still fail closed on it — that is unchanged and non-negotiable — but nothing about it may
|
||
> be *remembered* once the uncertainty is gone.
|
||
|
||
**Retiring by reason code is not sufficient, and this is the load-bearing part.** Uncertainty
|
||
launders itself: an unresolved node propagates `TaintedUpstream` to everything downstream,
|
||
and that reason is indistinguishable from real contamination once recorded. The split must
|
||
be by **provenance**, so the engine runs its fixpoint twice per recompute:
|
||
|
||
| pass | uncertainty roots | consumed by |
|
||
| --- | --- | --- |
|
||
| fail-closed | raised (`UnresolvedAncestry`, `UnresolvedOwner`) | **every decision** — unchanged from v3.5 |
|
||
| evidence-only | never raised, so nothing derived from one exists | **sticky state, and only sticky state** |
|
||
|
||
Evidence-based taint — `peerspeak.owned`, the AEC identity, pixelpass-owned objects, a
|
||
foreign echo canceller, and anything propagated from them — keeps §6.1.3's absence rule
|
||
exactly as written. That is what stops an app buffering the call and laundering itself
|
||
through a teardown, and it is unaffected by this change.
|
||
|
||
Cost: two fixpoints per graph event. Measured 80 µs worst case against a 47 Hz event rate,
|
||
so the O5 headroom absorbs it without argument.
|
||
|
||
⚠️ **Owed, from the round-9 review (Codex, P1 "worth checking"): hardware
|
||
playback-to-capture paths.** A card offering "Stereo Mix" / "Digital Loopback" presents an
|
||
ordinary driver name (`snd_hda_intel`), so both its sink and its source classify
|
||
`session_device` — and audio written to the sink reappears on the source through a hop the
|
||
Link graph cannot see. This is the `snd_aloop` hazard (§6.1.1, phase-3 review finding 2) in
|
||
a form the driver denylist cannot detect. It is **not new in round 9** and not introduced by
|
||
either recent round; distinguishing it needs ALSA control inspection, a new I/O surface and
|
||
therefore a design decision. Until then a card with that path enabled can carry the call
|
||
from sink to source untainted, and a capture app reading it can re-emit: **echo**.
|
||
|
||
⚠️ **Also owed: a calibration argument for the readiness budget.** The observer times out
|
||
after 2 s and `TimedOut` is sticky by design, so a process that never sees one
|
||
obligation-free instant during initial enumeration is silent for its lifetime. Measured on
|
||
this host: readiness at ~3 ms with 19 binds. The margin is three orders of magnitude, which
|
||
is an argument, but it is one measurement on one idle desktop.
|
||
|
||
## 7. Lifecycle and teardown invariants
|
||
|
||
### 7.1 ⚠️ The invariant
|
||
|
||
> **The AEC module must not unload while pixelpass is alive and fanning out.**
|
||
|
||
If it does, the index pixelpass holds becomes stale, and — because indices are reused
|
||
(§5.2 correction 3) — it can alias onto an unrelated future module, silently un-excluding
|
||
the real hazard or excluding innocent audio.
|
||
|
||
### 7.2 ⚠️ VERIFIED DEFECT — implicit-drop order is inverted (not yet fixed)
|
||
|
||
`ActiveSession` (`peerspeak/src/core/mod.rs:668-691`) declares:
|
||
|
||
```rust
|
||
echo_cancel: Option<EchoCancelGuard>, // :682
|
||
screenshare_host: Option<tokio::process::Child>, // :685 (kill_on_drop)
|
||
```
|
||
|
||
Rust drops fields in **declaration order**. On the **implicit-drop** path — the command
|
||
channels close at `core/mod.rs:1512` (`reliable_rx.recv()` returns `None` → `break`), or
|
||
the core loop unwinds — `ActiveSession` is dropped **without** `shutdown()` running. So
|
||
`echo_cancel` unloads (a blocking `pactl unload`) *before* `screenshare_host`'s
|
||
`kill_on_drop` even fires: **the AEC unloads while pixelpass is still alive.** Exactly the
|
||
ordering the invariant forbids. Verified in source.
|
||
|
||
`shutdown()` (`:694-730`) gets it right — it kills `screenshare_host` at `:699` and drops
|
||
`echo_cancel` at `:730` — but only as an emergent property of statement order, which any
|
||
refactor can silently invert.
|
||
|
||
**⚠️ Reordering the fields is NOT sufficient** (round-4 correction). `screenshare_host`
|
||
is spawned with `kill_on_drop(true)` (`peerspeak/src/screenshare/mod.rs:402`). Tokio's
|
||
`Drop` for such a `Child` *sends* the kill and hands the process to a best-effort orphan
|
||
reaper — it does **not** wait, and gives no promptness guarantee, especially if the
|
||
runtime is itself shutting down. Explicit `child.kill().await` is SIGKILL **plus wait**;
|
||
implicit drop is SIGKILL and move on. So even with `echo_cancel` declared last, the
|
||
`pactl unload` can still run while pixelpass is briefly alive.
|
||
|
||
**Fix, in order of importance:**
|
||
|
||
1. **Eliminate the implicit path.** At both channel-close `break` sites
|
||
(`core/mod.rs:1516` and `:1532`), explicitly `take()` the session and
|
||
`shutdown().await` it rather than letting it drop. The ordered teardown should be the
|
||
*only* teardown.
|
||
2. **Move `echo_cancel` to the last declared field** anyway, with a comment naming the
|
||
invariant — defence in depth for paths 1 does not cover (panics, unwinds).
|
||
3. **Last-ditch ordering in the drop path**: a wrapper whose `Drop` does `start_kill` +
|
||
a bounded `try_wait` loop on the host before the AEC guard unloads.
|
||
|
||
**D4 regression guard** (concrete, replacing v3.0's vague "field-order assertion"): a
|
||
test using a **fake host** and **fake echo-cancel guard** that each record a timestamped
|
||
event into a shared slot on teardown, asserting `host kill+wait completed` strictly
|
||
precedes `echo unload` — plus a core-loop test that closes the command channel and proves
|
||
`shutdown()` actually ran. A field-order assertion alone does not test the behaviour that
|
||
matters.
|
||
|
||
### 7.3 Construction paths
|
||
|
||
There is exactly **one** `echo_cancel::enable` call site (`core/mod.rs:1850`, at session
|
||
join), the guard moves into `ActiveSession`, and there is exactly **one** explicit drop
|
||
(`:730`). **No mid-call AEC reload path exists.** This is what downgraded Codex's demanded
|
||
QUIESCE/SET_AEC/RESUME two-process epoch protocol to:
|
||
|
||
> **P1-impact latent hazard, currently unreachable in normal operation.** Required now:
|
||
> encode teardown ordering for explicit *and* implicit destruction, prevent or review
|
||
> additional AEC construction paths, fail closed if identity is missing or revoked.
|
||
> **An epoch protocol becomes required if and only if hot AEC reload is added.**
|
||
|
||
Anyone adding a second `enable` site, or any hot-reload, re-opens that requirement.
|
||
|
||
### 7.4 Graceful stop is still owed
|
||
|
||
**Stop Share is currently SIGKILL** (`peerspeak/src/core/mod.rs:699` + `:3480`, Tokio
|
||
`Child::kill()`). Under Option C this no longer strands the user's desktop audio — the
|
||
links die with the connection, which is the point. But it still leaks:
|
||
|
||
- The capture null sink is **pactl-loaded and pipewire-pulse-owned**
|
||
(`pixelpass/src/host/audio.rs:69`), so **Stop Share leaks one null-sink module every
|
||
time.** This is true today, independent of this feature.
|
||
|
||
Required:
|
||
1. A graceful control path in peerspeak: **SIGINT** (not SIGTERM — pixelpass installs
|
||
only `tokio::signal::ctrl_c()`, `pixelpass/src/common/signal.rs:6`), bounded wait,
|
||
SIGKILL fallback.
|
||
2. The capture sink should become **connection-owned** rather than pactl-owned, so it
|
||
shares the links' death-with-the-process property.
|
||
3. `--repair` (`pixelpass/src/repair.rs:15-63`) extended to this mode, and safe with a
|
||
second live host.
|
||
|
||
## 8. IPC: getting the index to pixelpass
|
||
|
||
pixelpass is a **separate process** and cannot learn the module index on its own.
|
||
peerspeak spawns it, so:
|
||
|
||
- New pixelpass flag: `--exclude-pulse-module <idx>` (naming, §11).
|
||
- peerspeak passes `EchoCancelGuard`'s index at spawn. The field is currently private
|
||
with only `source_name()` / `sink_name()` accessors — add `module_index()`.
|
||
- If the AEC is **off**, the flag is absent and pixelpass must not invent an exclusion.
|
||
- If the AEC is **on** but the flag is absent, that is a bug in peerspeak; pixelpass
|
||
cannot detect it. ⇒ peerspeak should pass an explicit `--aec=off|<idx>` rather than
|
||
"flag present or not," so pixelpass can distinguish "no AEC" from "someone forgot."
|
||
**Open decision D5 (§13).**
|
||
- Because no hot-reload path exists (§7.3), spawn-time argv is sufficient today. A
|
||
runtime channel is required only alongside hot reload.
|
||
|
||
## 9. What is proven, and what is not
|
||
|
||
### 9.1 Proven by measurement on this machine (PipeWire 1.6.8 / WirePlumber 0.5.15)
|
||
|
||
- Fan-out carries full-level audio (−24.1 dB, matching the speaker monitor) for **paplay,
|
||
mpv, VLC**; the app keeps its speaker link.
|
||
- WirePlumber does **not** reap foreign links across default-sink switch, switch-back,
|
||
suspend, resume, or 100 s steady state.
|
||
- Non-lingering links die on **SIGKILL** of the owner.
|
||
- **E2:** destroying the capture sink mid-share left the app playing to speakers
|
||
undisturbed.
|
||
- `peerspeak.owned=1` lands on paplay/mpv/VLC nodes via `PULSE_PROP`.
|
||
- `pulse.module.id` on all four AEC nodes == the index pactl returned.
|
||
- Naive fan-out of the AEC leg leaks remote audio at ≈desktop level; exact exclusion sits
|
||
at the control floor.
|
||
|
||
**Round 8 (2026-07-25), from the phase-5 dry-run against the live graph:**
|
||
|
||
- The registry `global` event announces **13 keys** for a Node and **none** of the eight
|
||
ownership/identity properties this design reads (§6.7). `port.exclusive` is absent from
|
||
Port globals. Link endpoint keys and Client `pipewire.sec.pid` **are** announced, on every
|
||
object, always.
|
||
- **Binding recovers all of them** — `factory.name`, `device.api`, `alsa.driver_name`,
|
||
`peerspeak.owned`, `pulse.module.id`, `node.link-group`, `application.process.id` read off
|
||
the bound object's `info` props.
|
||
- `factory.id` is **not** a shortcut to `factory.name`: every ALSA node claims `factory.id`
|
||
19, whose Factory resolves to `"adapter"`, not `api.alsa.pcm.sink`.
|
||
- `alsa.driver_name` and `device.api` **are** on the **bound `Device`**'s `info` props
|
||
(absent from the Device *global*) — the authoritative source for §6.7 decision 4.
|
||
- Full recompute per graph event: **max 15 µs, mean 4 µs** over 334 recomputes at a 47 Hz
|
||
event rate under deliberate churn; `busy_fraction` 0.0004. Closes O5 for the engine —
|
||
⚠️ but measured on the degraded graph, with no per-node bind I/O in it.
|
||
|
||
### 9.2 ⚠️ Wording discipline — three overclaims already made, do not make a fourth
|
||
|
||
The correct statement of the exclusion result is:
|
||
|
||
> *"No incremental 1500 Hz energy was detectable above the control floor at the analysis
|
||
> resolution in this steady-state run."*
|
||
|
||
**Not** "absent," **not** "conclusive." What survives is the gross-leak distinction: the
|
||
naive arm copies the probe at ≈desktop level, exact exclusion does not. Likewise, equal
|
||
`mean_volume` on a sine proves **signal presence, not fidelity** (blind to xruns, drift,
|
||
dropouts, channel swap, quantum change), and "copy semantics" is a **topology** result —
|
||
it shows the tested link operation did not move the tested Pulse stream, and says nothing
|
||
about gain, latency, continuity, or native clients.
|
||
|
||
The rig is additionally blind to: startup/teardown/cork/relink transients (hidden by
|
||
whole-file averaging), broadband and out-of-band leakage, level-dependent and nonlinear
|
||
products, and nodes created after enumeration.
|
||
|
||
**Minimum rig upgrade before shipping:** two orthogonal PN/MLS probes + windowed
|
||
per-channel normalized cross-correlation, reporting max per-window correlation, plus xrun
|
||
telemetry.
|
||
|
||
### 9.3 Untested — carried forward, each one a real risk
|
||
|
||
AEC reload mid-share · two concurrent AEC instances · two concurrent capture sinks · the
|
||
**native** PipeWire AEC module · a native-PipeWire (non-Pulse) app · pixelpass's own
|
||
null-sink + loopback present simultaneously · the full GStreamer/AAC/network path ·
|
||
fidelity (needs broadband source + correlation + xrun telemetry) · PipeWire daemon restart
|
||
· quantum/latency perturbation from the second link · sample-rate mismatch, surround,
|
||
IEC958 passthrough · browser and game stream shapes.
|
||
|
||
## 10. Prerequisite fixes (split out, land before the feature)
|
||
|
||
Both reviews agree these are their own tasks, not part of this feature.
|
||
|
||
1. **`object.serial` u32 truncation** — 64-bit in PipeWire, parsed as `u32` at
|
||
`pixelpass/src/host/audio.rs:534-540`. Global IDs are reused; serial is the recommended
|
||
stable identifier. Must be fixed before the router grows.
|
||
2. **`@DEFAULT_SINK@` resolved once at module load** (`audio.rs:139-151`), no
|
||
default-metadata listener — the user's `Ctrl+Meta+F` / `Ctrl+Meta+S` output-switch
|
||
hotkeys strand the local monitor mid-share. **This already hurts per-app mode today.**
|
||
Option C does not need the local monitor, so this is decoupled from the feature — but
|
||
it is a live bug.
|
||
3. **`try_flush` records intent, not success** (`audio.rs:642-655`): it ignores
|
||
`Metadata::set_property`'s return and appends every pending ID to `routed_node_ids`.
|
||
Any status surfaced to the user inherits that dishonesty. Option C's link-state
|
||
`ACTIVE` check (§4.2) is the honest replacement for the new mode; the old path should
|
||
be fixed or deleted.
|
||
4. **Graceful stop + connection-owned capture sink** (§7.4).
|
||
5. **Drop-order fix + regression guard** (§7.2).
|
||
|
||
## 11. Naming — the user's call
|
||
|
||
Placeholders throughout. pixelpass mode: `--audio-mode=desktop-shared` /
|
||
`--audio-mode=desktop-excluding`. Exclusion flag: `--exclude-pulse-module <idx>` (or
|
||
`--aec <idx|off>`, §8). Do **not** surface `--exclude-pid` — it names an unreliable
|
||
mechanism and reads like process control.
|
||
|
||
peerspeak picker wording: something like "System audio except peerspeak," noting that
|
||
call and watched-share playback are excluded. Whether "All system audio" stays alongside
|
||
it (with a stated echo risk) or is replaced when the capability is present is a product
|
||
decision.
|
||
|
||
## 12. Testing
|
||
|
||
Pure, unit-testable seams with PipeWire at the edges, per house style:
|
||
|
||
- `fn eligibility(node_props: &Props, ctx: &ExclusionCtx) -> Eligibility` with
|
||
`NotEligible { reason }` so status can explain itself. Cases: `peerspeak.owned` present;
|
||
`pulse.module.id` == live index; `pulse.module.id` present but **different** (must be
|
||
ELIGIBLE — correction 2); `pulse.module.id` absent; a pixelpass-owned node; capture-sink
|
||
ancestry; `port.exclusive`; passthrough; non-`Stream/Output/Audio`; missing props
|
||
entirely; the capture sink itself; a foreign `echo-cancel-*` group.
|
||
- `pulse.module.id` parsing: JSON number **and** string forms, values > `u32::MAX`,
|
||
absent, malformed.
|
||
- AEC-identity validation state machine: `NotConfigured` / `Validated` / `Revoked`, and
|
||
the assertion that `Revoked` stops fan-out.
|
||
- Pure capability-probe parsing (§13 D2).
|
||
- Link bookkeeping: per-port link set, "captured" only at all-`ACTIVE`, idempotent
|
||
re-enumeration, proxy retention/drop.
|
||
|
||
**⚠️ Node-local eligibility tests are NOT sufficient.** The C2/C3-class defects all live in
|
||
the graph engine, so the taint engine needs its own pure test surface, fed synthetic
|
||
Node/Port/Link/Client fixtures:
|
||
|
||
| test | catches |
|
||
| --- | --- |
|
||
| GStreamer **split clients** (two `client.id`s, one `application.process.id`) are bridged | the C2 refutation |
|
||
| the key union **falls through** a present-but-unequal key (`client.id` differs ⇒ try key 4) | the §6.1.2 wording trap |
|
||
| the pipewire-pulse PID does **not** bridge unrelated modules | mass over-exclusion |
|
||
| a module forwarder with neither `link-group` nor `pulse.module.id` ⇒ **unresolved ⇒ excluded** | fail-closed invariant |
|
||
| sticky taint **survives** the tainted input leg unlinking/disappearing while the output leg lives | the C3 buffered-audio defect |
|
||
| sticky taint **clears** once every owner member object is gone | over-exclusion forever |
|
||
| a **recycled** `client.id` / module index / node id / `link-group` does **not** inherit taint | §6.1.3 lifetime-awareness |
|
||
| the readiness epoch blocks stale or unresolved link decisions | §6.4 |
|
||
| taint crosses `app → sink → monitor-reader` at node granularity | §6.1 edge type 2 |
|
||
| an `Audio/Duplex` node over-taints (asserted as *known accepted* behaviour, so a future fix is a deliberate change) | §6.1 caveat |
|
||
|
||
Field tests — the only thing that can prove a viewer does not hear themselves:
|
||
|
||
1. Sharer in a call while sharing, **AEC on and AEC off**.
|
||
2. Sharer simultaneously *viewing* another share while sharing.
|
||
3. Lifecycle, each separately: Stop button, room leave, UI crash, pixelpass panic, SIGINT,
|
||
SIGTERM, SIGKILL, last-viewer disconnect, pipewire-pulse restart, PipeWire daemon
|
||
restart, `pixelpass --repair`.
|
||
4. Output-device switch mid-share via the real hotkey scripts.
|
||
5. Two concurrent hosts (second pixelpass CLI, second peerspeak instance).
|
||
6. A notification sound firing mid-share.
|
||
7. An app that **starts playing after** the share began (dynamic node).
|
||
8. Sample-rate / channel / passthrough behaviour on real sinks; CPU cost.
|
||
|
||
⚠️ **Test-rig discipline** (each of these already produced a wrong result once):
|
||
filter on `media.class` **first** when selecting nodes with `jq` — `media.name` also
|
||
matches the Client object; **never** filter stderr out of a measurement run; **verify the
|
||
link exists in the graph** before measuring; reproduce over the transport production
|
||
actually uses (`parec -d <sink>.monitor`, not `pw-record --target <sink-node-id>`, which
|
||
reads −91 dB silence); **(r8)** never conclude a property is *observable* because
|
||
`pw-dump` shows it — `pw-dump` **binds**, the registry does not, and the eight properties
|
||
of §6.7 look identical in `pw-dump` to properties the code can actually see. Compare
|
||
`pw-cli ls <Type>` (registry view) against `pw-dump` (bound view) before depending on any
|
||
key.
|
||
|
||
## 13. Decisions — resolved in round 4
|
||
|
||
Both reviewers agree on all seven. Recorded as decided; reopen only with new evidence.
|
||
|
||
- **D1 — fail closed, but only after a bounded validation wait** (§5.3). Never "share
|
||
anyway, warn" as the default. ✅
|
||
- **D2 — a versioned machine-readable capability response or bitset.** The
|
||
`pixelpass --help` substring probe (`src/screenshare/mod.rs:245-278`, invoked at
|
||
`src/core/mod.rs:3368`) stays only as a compatibility fallback, and this mode must
|
||
**not** overload `app_audio_supported: bool` — strict per-app and desktop-excluding are
|
||
independent capabilities. ✅
|
||
- **D3 — warn and exclude foreign `echo-cancel-*` nodes** for the first implementation;
|
||
never fan them out. ✅
|
||
- **D4 — explicit async shutdown + a fake-resource ordering test** (§7.2). A field-order
|
||
assertion alone is inadequate. ✅
|
||
- **D5 — `--aec=off|pulse-module:<idx>`, always passed.** Absence of the flag is not a
|
||
protocol state (§8). ✅
|
||
- **D6 — land §10 items 1, 4 and 5 first**, plus the new graph-ancestry predicate (§6.1).
|
||
Items 2 and 3 are real per-app debt but do not block Option C unless the new mode reuses
|
||
those code paths. ✅
|
||
- **D7 — no materially simpler design exists** that still meets Joe's ask. The available
|
||
simplification is to *narrow v1 scope*, not to change architecture. ✅
|
||
|
||
## 14. Readiness — 🟠 v3.6 (round 9): architecture converged; observation boundary and sticky provenance REVISED.
|
||
|
||
**Round 9 (2026-07-25, same day).** Phase 3r shipped §6.7 and the audit was re-run
|
||
immediately; it found a *second* measured defect within minutes — a permanent sticky taint
|
||
on a hardware sink (§6.8). Both rounds share a shape worth naming: **the architecture was
|
||
right and the instrumentation was wrong**, and only running the code against a live daemon
|
||
found either.
|
||
|
||
| | verdict |
|
||
| --- | --- |
|
||
| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged, twice vindicated** |
|
||
| §6.8 | **new** — sticky state is built from an evidence-only pass; decisions still fail closed |
|
||
| §6.1.3 | **narrowed** — the absence rule now governs evidence-based taint only |
|
||
| Phase 3r | **built and merged**, four-part gate passed incl. live |
|
||
| Phase 5 | **machinery unchanged and correct** — it has now caught two real defects on first contact with the live graph |
|
||
|
||
|
||
|
||
**Round 8 was not a review round.** It was opened by the phase-5 dry-run audit failing its
|
||
gate on the first live run: the engine built to v3.4 was measured **non-functional** — it
|
||
failed to recognise its own primary taint root (an echo) while excluding every stream on the
|
||
machine (silence). One cause, §6.7: node properties are not observable from the registry.
|
||
|
||
What that does and does not touch:
|
||
|
||
| | verdict |
|
||
| --- | --- |
|
||
| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged and vindicated.** Fed correct properties, the engine decided correctly in every fixture; the defect is entirely at the observation boundary |
|
||
| §5.1 tagging | **revised** — a second, registry-visible carrier added |
|
||
| §6.4 implementation shape | **revised** — node/device props require a bind |
|
||
| §6.7 | **new** — the observation boundary, and the rule that the global is an index, not a source of truth |
|
||
| §6.1.0, §6.1.4 | **corrected** — a time-dependent claim and an unreachable test case |
|
||
| Phases 2 and 4 (pure engine, AEC validator) | **no change owed** |
|
||
| Phase 3 (observer) | **must be revised before phase 5 re-runs** |
|
||
|
||
The §5.1 exact-partition requirement is what caught this, exactly as argued: the build's
|
||
exclusions were all defensible, and an exclusion-only checklist would have passed it. It was
|
||
the *eligible* half of the partition being empty that exposed the failure.
|
||
|
||
**Rounds 1–7 record.** Both reviewers agreed v3.4 was ready to become the implementation
|
||
plan; every blocker raised was either fixed or refuted with evidence. That remains true —
|
||
round 8 found something no design review could have, because it required running the code
|
||
against a live daemon.
|
||
|
||
How the blockers closed:
|
||
|
||
| round | blocker | outcome |
|
||
| --- | --- | --- |
|
||
| 4 | eligibility is node-local | **fixed** — §6 rewritten as graph taint |
|
||
| 4 | one-shot AEC validation | **fixed** — §5.3 bounded state machine |
|
||
| 4 | field reorder insufficient | **fixed** — §7.2 explicit shutdown + ordering test |
|
||
| 5 | Link-only walk misses forwarders | **fixed** — §6.1 three edge types, measured |
|
||
| 5 | monitor edge needs synthetic modelling | **refuted** — free at node granularity (C1) |
|
||
| 6 | `client.id` is not an owner key | **refuted my own claim** — §6.1.2 owner-key union |
|
||
| 6 | current-only taint forgets buffers | **conceded** — §6.1.3 sticky taint |
|
||
| 7 | "resolves" = first-present would still leak | **fixed** — §6.1.2 wording trap + test |
|
||
| 7 | stickiness on recyclable ids | **fixed** — §6.1.3 lifetime-aware owner components |
|
||
| **8** | **node props are not on the registry global (measured, phase 5)** | **fixed** — §6.7 bind-every-node rule; §5.1 second carrier |
|
||
| **9** | **a fail-closed unresolved mark became permanent sticky taint (measured, phase 5 re-run)** | **fixed** — §6.8 evidence-only sticky pass |
|
||
| **9** | `device_props` tested for one live *Device* rather than one live *global* on the id (Codex, certain) | **fixed** in phase 3r — stale `session_device` on a contested id is an echo path |
|
||
| **9** | `device.api` corroborated by presence, so `v4l2` under an ALSA factory passed (Codex) | **fixed** in phase 3r — the API must equal the allowlist's own |
|
||
| **9** | hardware playback-to-capture ("Stereo Mix") defeats the `session_device` classifier (Codex, P1 worth checking) | **OPEN — design decision owed**, §6.8; pre-existing, needs ALSA control inspection |
|
||
| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; ~3 ms observed on this host |
|
||
|
||
### v1 scope — agreed
|
||
|
||
Option C fan-out · explicit `--aec=off|pulse-module:<idx>` · peerspeak playback and child
|
||
tagging · exact AEC module validation · graph taint with the owner-key union and the
|
||
pipewire-pulse PID exception · sticky taint · readiness epoch · fail-closed unresolved
|
||
ancestry · owned non-lingering links · §10 items 1, 4 and 5 landed first.
|
||
|
||
### Deliberately OUT of v1
|
||
|
||
Node-granular taint only (no port modelling, duplex over-taint accepted) · no timed drain ·
|
||
no hot-AEC-reload epoch protocol · no native PipeWire AEC support · no incremental
|
||
dirty-set (full recompute) · no "implausibly many streams" heuristic as correctness ·
|
||
pipewire-pulse/PipeWire daemon restart handled as **revoke and stop**, not seamless
|
||
recovery · **(r8)** no per-node quarantine — an unresolvable node bind fails the whole
|
||
readiness epoch closed rather than isolating that node (§6.7 decision 3) · **(r8)** no Port
|
||
binding, so `port.exclusive` is never observed and the §6.2 row it guards relies on the link
|
||
create failing cleanly · **(r8)** no serial-continuity signal for the AEC validator's
|
||
no-coalescing contract.
|
||
|
||
### Next step (round 9)
|
||
|
||
Phases 0a, 2, 3, 3r, 4 and 5 are built; §6.7 and §6.8 are implemented and merged. What
|
||
remains before phase 6 unblocks:
|
||
|
||
1. ~~**Revise phase 3** to §6.7~~ — **done**, phase 3r merged, four-part gate passed
|
||
including the live prop-recovery row and an added live gate for the Device-side path.
|
||
2. **Revise phase 1** to emit both carriers (§5.1), literals pinned in plan §3. Unblocked
|
||
and next.
|
||
3. **Re-run the whole phase-5 §5.1 matrix** — no row was completable under the round-8
|
||
defect, so nothing carries over — and **re-measure O5** with bind I/O *and* the round-9
|
||
second fixpoint in it. Phase 6 stays blocked until that results file passes.
|
||
4. Decide the two items §6.8 leaves open: hardware playback-to-capture paths (a real echo
|
||
path, needs a design call) and the readiness-budget calibration.
|
||
|
||
Still owed beyond that, unchanged: the §9.2 rig upgrade before any exclusion claim is
|
||
published, and **field-test §12** — nothing in this design has been tested over the real
|
||
GStreamer/AAC/network path or on two machines.
|