docs: phase 5 matrix PASSED (13/13) — design round 10, results run 2
The §5.1 dry-run audit gate passes. All 13 rows completed with a non-empty eligible half in every one, and O5 is re-measured on the fixed graph: worst recompute 67 µs across every run, 10 µs mean under deliberate churn, busy fraction 0.0006, readiness 1-2 ms with 18 binds against a 2000 ms budget. Round 10's finding, and it is the third of exactly the same shape: the pipewire-pulse PID derivation required a SINGLE repeated pipewire.sec.pid. WirePlumber repeats one too (two Clients, both sec_pid 1747), so the derivation returned None permanently on a stock desktop, key 4's suppression never fired, and every Pulse-emulated node fused into one owner. Row 1's CLEAN control forwarder and Firefox were both excluded. Fixed in pixelpass 91c4ded by deleting the heuristic: probe every distinct sec_pid and let /proc/<pid>/comm decide. Three measured rounds now, all at the observation boundary, none in the architecture -- and all three were fail-closed and silent, caught only because §5.1 requires asserting what must remain ELIGIBLE. An exclusion-only checklist would have passed every one of these builds. Rows 4-6 are closed through peerspeak's REAL tagging sites (call, mpv, notify, plus clip) with a hand-launched mpv staying eligible, so the cross-repo contract is proven end to end on live nodes. Row 10 covers the full sticky lifecycle including retirement; row 11 is provably non-vacuous (the recycled node.link-group came back byte-identical and did not inherit taint). Recorded and NOT claimed as passes: substitutions in rows 8, 9 and 13, and two reporting-only findings (the audit's sticky flag is uninformative; a bridge's named key is lost when a leg reappears under a new serial). Phase 6 remains blocked by F11-1, phases 0b/0c/0d and the Stereo Mix design call -- this file removes one gate, not all of them.
This commit is contained in:
@@ -458,17 +458,31 @@ observable in Phase 5 before they gate anything real.
|
|||||||
|
|
||||||
## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE
|
## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE
|
||||||
|
|
||||||
> **🚦 STATUS 2026-07-25: BUILT, RUN, AND THE GATE FAILED.** Results:
|
> **🚦 STATUS 2026-07-26: GATE PASSED on run 2.** Results:
|
||||||
> `docs/screenshare-audio-exclusion-phase5-results.md`. The machinery is correct and needs no
|
> `docs/screenshare-audio-exclusion-phase5-results.md`. All 13 rows completed, the eligible
|
||||||
> rework — **it found the defect on its first live run**, which is the phase working exactly as
|
> half of every row is non-empty, and O5 is re-measured on the fixed graph (worst recompute
|
||||||
> designed. What failed is the observer beneath it (v3.5 §6.7). **Phase 6 does not start.** The
|
> 67 µs; readiness 1–2 ms with 18 binds). Three rows carry recorded substitutions (8, 9, 13)
|
||||||
> matrix re-runs after phase 3r and phase 1's second carrier land; no row was completable
|
> and three findings are recorded as non-blocking.
|
||||||
> under the defect, so none of it carries over. O5's numbers do not carry over either.
|
|
||||||
>
|
>
|
||||||
> Read this before re-running: `PIXELPASS_AUDIO_AUDIT_FILE=… PIXELPASS_AUDIO_AUDIT_AEC=off
|
> **Run 2 found and fixed a third defect of the F2 class, F13-1:** pipewire-pulse's PID was
|
||||||
> pixelpass --audit-audio`. **Every partition row must run with `AEC=off`** — a
|
> unresolvable on this host *permanently*, because stage 1 of the derivation required exactly
|
||||||
|
> one repeated `sec_pid` and **WirePlumber repeats one too** (two Clients, one PID). Key 4's
|
||||||
|
> suppression therefore never fired and every Pulse-emulated node fused into one owner. Fixed
|
||||||
|
> in pixelpass `91c4ded`: probe every distinct `sec_pid` and let `/proc/<pid>/comm` decide.
|
||||||
|
> **The eligible half of row 1 is the only thing that exposed it** — the verdict was
|
||||||
|
> fail-closed and silent.
|
||||||
|
>
|
||||||
|
> ⚠️ **Phase 6 is NOT unblocked by this file alone.** F11-1 is a separate open gate (see §4's
|
||||||
|
> owner-union note and `owner_is_bounded`'s doc), as are phases 0b/0c/0d and the "Stereo Mix"
|
||||||
|
> design call.
|
||||||
|
>
|
||||||
|
> Two things to keep when re-running: **every partition row must run with `AEC=off`** (a
|
||||||
> configured-but-unvalidated AEC shuts the fan-out gate and empties the eligible half of every
|
> configured-but-unvalidated AEC shuts the fan-out gate and empties the eligible half of every
|
||||||
> row, which reads as a failure that is really a harness error.
|
> row, which reads as a failure that is really a harness error), and **start the audit BEFORE
|
||||||
|
> building the fixture**. Fixture-first makes the whole graph arrive as one enumeration burst,
|
||||||
|
> so every node is first tainted while `graph_ready` is false; that partial-graph taint enters
|
||||||
|
> sticky state and the keyless sticky reason then wins over the evidence-derived one, so a row
|
||||||
|
> cannot assert its own key. Read keys at *derivation* (first non-sticky appearance).
|
||||||
|
|
||||||
**Adds no capability. Its entire purpose is to be wrong loudly and safely.**
|
**Adds no capability. Its entire purpose is to be wrong loudly and safely.**
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,403 @@
|
|||||||
# Phase 5 — dry-run audit gate: results
|
# Phase 5 — dry-run audit gate: results
|
||||||
|
|
||||||
**Status: 🚦 GATE FAILED. Phase 6 does not start.** Two defects found, one of them
|
**Status: 🟢 GATE PASSED (run 2, 2026-07-26). All 13 §5.1 rows completed; the
|
||||||
fatal to the whole mechanism. Both go to the design doc as **round 8** per impl
|
eligible half of every row is non-empty. O5 re-measured on the fixed graph and
|
||||||
plan §5.3.
|
stays closed.** One new defect was found and fixed during the run (F13-1); three
|
||||||
|
findings are recorded as non-blocking, and three rows carry recorded
|
||||||
|
substitutions. Phase 6 is unblocked **by this file**, but see "What still blocks
|
||||||
|
phase 6" — F11-1 is a separate gate and is still open.
|
||||||
|
|
||||||
- **Run date:** 2026-07-25
|
- **Run date:** 2026-07-26 (run 1: 2026-07-25, gate FAILED — see history below)
|
||||||
- **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
|
- **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
|
||||||
- **Audit build:** pixelpass branch `phase5-dry-run-audit`, release profile
|
- **Audit build:** pixelpass `main` @ `91c4ded`, release profile
|
||||||
- **Ambient load during the runs:** FINAL FANTASY XIV playing audio (`client.id`
|
- **peerspeak build:** `main` @ `b68fca6` (phase 1 merged)
|
||||||
88, pid 14651), Arctis 1 Wireless as an active sink
|
- **Ambient load:** Firefox playing audio throughout (a live, uncontrived
|
||||||
|
candidate); Sunshine running (pid 3838); Arctis 1 Wireless as active sink
|
||||||
The audit itself worked exactly as designed: it observed the live graph, ran
|
- **Graph size:** 14 Nodes, 4 Devices, 57 Ports, 4 Links, 24 Clients
|
||||||
phases 2–4 on every registry event, created no links, and reported a complete
|
|
||||||
eligible/excluded partition with stable reason codes. **It found the defects on
|
|
||||||
the first live run.** That is the phase doing its job — §5's argument was that a
|
|
||||||
fixture proves the code matches my model of PipeWire while only a live run proves
|
|
||||||
my model matches PipeWire, and my model was wrong.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties
|
## What changed since run 1
|
||||||
|
|
||||||
**The phase-3 adapter reads eight node properties that the PipeWire registry
|
Run 1 failed on two defects, both fixed before this run:
|
||||||
never announces.** They are parsed off `obj.props` in the registry `global`
|
|
||||||
callback (`pixelpass/src/host/observer/adapter.rs`), where they are silently
|
|
||||||
absent, so every one of them is permanently `None`/`false`.
|
|
||||||
|
|
||||||
### Measured
|
- **F1** (fatal): the registry `global` event delivers only a filtered subset of
|
||||||
|
node properties, so eight properties the engine depends on were permanently
|
||||||
|
absent. Fixed by design round 8 / **phase 3r** — bind every Node and Device
|
||||||
|
and read properties from `info`.
|
||||||
|
- **F2**: a machine-wide over-exclusion cascade downstream of F1.
|
||||||
|
|
||||||
The complete set of keys the registry announces for a `Node` global on this host
|
Both are gone: the baseline run (no fixture at all) reports **1 candidate,
|
||||||
(union over every node, via `pw-cli ls Node`):
|
eligible, empty taint set**.
|
||||||
|
|
||||||
|
### 🔴 F13-1 — FOUND AND FIXED DURING THIS RUN
|
||||||
|
|
||||||
|
**Row 1 failed on its first attempt, and the cause was a third defect of exactly
|
||||||
|
the F2 class from a new source: pipewire-pulse's PID was unresolvable on this
|
||||||
|
host, permanently.**
|
||||||
|
|
||||||
|
`pulse_pid::candidate` returned the single `pipewire.sec.pid` shared by two or
|
||||||
|
more Clients, on the stated reasoning that "native PipeWire clients carry their
|
||||||
|
own distinct PID; only the Pulse shim repeats one value". Measured: **WirePlumber
|
||||||
|
repeats one too.** It holds two Clients — `WirePlumber` and
|
||||||
|
`WirePlumber [export]` — both `sec_pid` 1747. Two values repeated (1747 and
|
||||||
|
pipewire-pulse's 2528), the rule called that ambiguous, and returned `None`.
|
||||||
|
|
||||||
|
With the daemon PID unknown, `owner::keys_of`'s documented fail-closed asymmetry
|
||||||
|
takes over: key 4's suppression never fires, every Pulse-emulated node fuses into
|
||||||
|
one owner, and the cascade follows. Row 1's observed failure:
|
||||||
|
|
||||||
|
```
|
||||||
|
ELIGIBLE (1): r1_plain_app
|
||||||
|
EXCLUDED: Firefox tainted-owner-bridge key=application.process.id
|
||||||
|
r1_c_play tainted-owner-bridge <- the CLEAN control half
|
||||||
|
TAINT: ... + both sound cards, all three sunshine sinks, sunshine itself
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule was wrong in **both** directions, so the prefilter was removed rather
|
||||||
|
than patched:
|
||||||
|
|
||||||
|
- **False ambiguity** — any second process holding two Clients defeats it.
|
||||||
|
WirePlumber always does, so this was permanent, not a corner case.
|
||||||
|
- **False absence** — a session where pipewire-pulse holds exactly one Client
|
||||||
|
(one Pulse app running) repeats nothing, so the candidate is missed and the
|
||||||
|
same cascade follows.
|
||||||
|
|
||||||
|
`comm` was always the authoritative check; repetition was a heuristic standing in
|
||||||
|
front of it, and it was a guess about other processes' Client counts. Fixed in
|
||||||
|
pixelpass `91c4ded`: `candidates()` lists every distinct `sec_pid`, `resolve()`
|
||||||
|
picks the unique one whose `/proc/<pid>/comm` is exactly `pipewire-pulse`, and
|
||||||
|
several matches still fail closed (a single `Option<u32>` cannot suppress two
|
||||||
|
daemons — recorded, not approximated). The adapter probes only PIDs *entering*
|
||||||
|
the candidate set, and `retain_probed_comms` bounds the map to live PIDs so a PID
|
||||||
|
that leaves and returns is re-probed instead of answered from a stale `comm`.
|
||||||
|
|
||||||
|
**This is the §5.1 exact-partition requirement earning its keep for the second
|
||||||
|
time.** The verdict was fail-closed and silent; only the asserted *eligible* half
|
||||||
|
exposed it. An exclusion-only checklist would have passed this build too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5.1 — the matrix
|
||||||
|
|
||||||
|
Every row ran with `PIXELPASS_AUDIO_AUDIT_AEC=off` except row 12. Every row ran
|
||||||
|
in its **own** audit process, so nothing carries over (sticky taint is
|
||||||
|
per-process state).
|
||||||
|
|
||||||
|
⚠️ **Methodology change from run 1, and it is load-bearing.** Run 1 built each
|
||||||
|
fixture *before* starting the audit. On this host the entire graph then arrives
|
||||||
|
as one enumeration burst (~122 events in 1–2 ms), so every node is first tainted
|
||||||
|
while `graph_ready` is still false, that partial-graph taint is recorded into
|
||||||
|
sticky state, and on the single ready record the sticky pass raises
|
||||||
|
`TaintedOwnerBridge { key: None }` before the evidence pass can name a key —
|
||||||
|
`raise` will not replace a same-rank reason. Verdicts were still correct but rows
|
||||||
|
could not assert their key. This run starts the audit first, waits for readiness,
|
||||||
|
then builds the fixture, so taint is derived from real topology *changes* against
|
||||||
|
a ready graph — which is also the dynamic path §6.3 cares about. Keys are read at
|
||||||
|
**derivation** (first non-sticky appearance), not from the final record.
|
||||||
|
|
||||||
|
| # | scenario | status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | null-sink + loopback forwarder, owner bridge | ✅ **pass** (after F13-1 fixed) |
|
||||||
|
| 1b | Sunshine's topology (opportunistic, non-gating) | 🟡 observed, nothing to exclude — see below |
|
||||||
|
| 2 | gst split clients, tainted input | ✅ **pass**, key 4 named at derivation |
|
||||||
|
| 3 | two Pulse modules, one tainted | ✅ **pass** |
|
||||||
|
| 4 | peerspeak native call playback | ✅ **pass** — real tagging site |
|
||||||
|
| 5 | peerspeak-spawned mpv | ✅ **pass** — real tagging site, hand-launched mpv eligible |
|
||||||
|
| 6 | peerspeak notification sound | ✅ **pass** — real tagging site |
|
||||||
|
| 7 | second host's capture sink + forwarder | ✅ **pass**, eligible half non-empty |
|
||||||
|
| 8 | EasyEffects | 🟡 **pass with substitution** — echo-cancel stood in |
|
||||||
|
| 9 | Firefox three cases | ✅ **pass** (cases 2–3 via gst; see substitution) |
|
||||||
|
| 10 | sticky taint across teardown | ✅ **pass**, all four phases incl. retirement |
|
||||||
|
| 11 | recycled serial / index / link-group | ✅ **pass**, and provably non-vacuous |
|
||||||
|
| 12 | AEC loaded → unloaded → Revoked | ✅ **pass** |
|
||||||
|
| 13 | `Audio/Duplex` device | 🟡 **pass with synthetic node** — over-taint confirmed |
|
||||||
|
|
||||||
|
### Row 1 — owner bridge, key named
|
||||||
|
|
||||||
|
```
|
||||||
|
ELIGIBLE (3): Firefox · r1_c_play · r1_plain_app
|
||||||
|
EXCLUDED (2): peerspeak_owned_call_4242 peerspeak-owned
|
||||||
|
r1_t_play tainted-owner-bridge key=node.link-group
|
||||||
|
TAINT (5): the tagged producer, r1_t_src, r1_t_cap, r1_t_play, r1_t_dest
|
||||||
|
```
|
||||||
|
|
||||||
|
The clean half is an **identically shaped** forwarder — same module type, same
|
||||||
|
monitor-read, same re-emit — differing only in whether anything tainted feeds it.
|
||||||
|
`r1_c_play` eligible is the assertion an exclude-everything build cannot satisfy.
|
||||||
|
The key is `node.link-group`, a strong key, not a link walk.
|
||||||
|
|
||||||
|
### Row 2 — GStreamer split clients, key 4
|
||||||
|
|
||||||
|
Measured props confirm the shape is the real refutation: `r2_gst_tainted_src`
|
||||||
|
(client 188) and `r2_gst_tainted_sink` (client 191) are **different Clients** of
|
||||||
|
**one process**, pid 235628, with no `link-group` and no `pulse.module.id`. So
|
||||||
|
`application.process.id` is the only key that can relate them.
|
||||||
|
|
||||||
|
Derivation record (seq 209): `r2_gst_tainted_sink` → `tainted-owner-bridge`,
|
||||||
|
**`owner_key=application.process.id`**. `r2_gst_clean_sink`, reading an untainted
|
||||||
|
monitor in a second process, is eligible.
|
||||||
|
|
||||||
|
### Rows 4–6 — peerspeak's own paths, through the real call sites
|
||||||
|
|
||||||
|
Driven by peerspeak's phase-1 live gate tests (`--ignored`), i.e. the real
|
||||||
|
tagging sites, not a hand-rolled env: "emission alone proves only that peerspeak
|
||||||
|
talks, not that pixelpass listens" (impl plan §3).
|
||||||
|
|
||||||
|
| node | verdict |
|
||||||
|
| --- | --- |
|
||||||
|
| `peerspeak_owned_call_238172` | EXCLUDED `peerspeak-owned` |
|
||||||
|
| `peerspeak_owned_mpv_238196` | EXCLUDED `peerspeak-owned` |
|
||||||
|
| `peerspeak_owned_notify_238231` | EXCLUDED `peerspeak-owned` |
|
||||||
|
| `peerspeak_owned_clip_238249` | EXCLUDED `peerspeak-owned` (bonus — chat clips) |
|
||||||
|
| `mpv` (launched by hand, untagged) | **ELIGIBLE** |
|
||||||
|
|
||||||
|
This is the cross-repo contract closed end to end on live nodes.
|
||||||
|
|
||||||
|
### Row 9 — the over-exclusion promise
|
||||||
|
|
||||||
|
```
|
||||||
|
ELIGIBLE: Firefox (music only) · r9_mic_out (captures an untainted real device)
|
||||||
|
EXCLUDED: r9_mon_out tainted-owner-bridge key=application.process.id
|
||||||
|
```
|
||||||
|
|
||||||
|
`r9_mic_out` is the row that defends §6.1.1: an app that captures a real
|
||||||
|
`session_device` source and also plays audio stays shareable. The device source
|
||||||
|
itself never entered the taint set.
|
||||||
|
|
||||||
|
### Row 10 — the full sticky lifecycle
|
||||||
|
|
||||||
|
| phase | topology | verdict |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | tainted producer + forwarder | `r10_play_out` EXCLUDED, key `node.link-group` |
|
||||||
|
| B | **tagged producer killed**, forwarder lives | **still EXCLUDED** (sticky) — current topology alone no longer justifies it |
|
||||||
|
| C | forwarder owner replaced, tainted sink kept | fresh forwarder EXCLUDED — correct: a sink that received call audio is still a hazard while it lives |
|
||||||
|
| D | **every** tainted object torn down, then restart | taint set **empty** at 16.3 s; `r10_new_out` **ELIGIBLE** at 20.3 s |
|
||||||
|
|
||||||
|
Phase B proves stickiness works; phase D proves it is not permanent. Phase C is
|
||||||
|
worth keeping in mind when reading any future report: partial teardown legitimately
|
||||||
|
does *not* retire taint, and that is easy to mistake for over-exclusion.
|
||||||
|
|
||||||
|
### Row 11 — recycled identifiers, provably non-vacuous
|
||||||
|
|
||||||
|
| generation | `node.link-group` | global id (`r11_src`) | `object.serial` (`r11_play`) | pulse module |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 (tainted) | `loopback-2528-14` | 168 | 4702 | 536870919 |
|
||||||
|
| 2 (after teardown) | **`loopback-2528-14`** | **168** | 4746 | 536870920 |
|
||||||
|
|
||||||
|
The `node.link-group` came back **byte-identical** — and it is the very key that
|
||||||
|
carried the taint in generation 1 — and the global id was reused. Generation 2's
|
||||||
|
`r11_play` is **ELIGIBLE** with an empty taint set. `object.serial` correctly did
|
||||||
|
not recycle, which is why the model keys everything by it.
|
||||||
|
|
||||||
|
### Row 12 — AEC lifecycle
|
||||||
|
|
||||||
|
| stage | `aec_state` | `fan_out_permitted` | candidates |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| module live, configured | `validated` | `true` | Firefox + `r12_plain_app` ELIGIBLE; `echo-cancel-playback` EXCLUDED `aec-identity` |
|
||||||
|
| module unloaded | `revoked` | `false` (`gate_reason=aec-revoked`) | every candidate EXCLUDED `aec-revoked` |
|
||||||
|
|
||||||
|
All **four** link-group siblings (`sink`, `source`, `capture`, `playback`) carry
|
||||||
|
`aec-identity`; only `echo-cancel-playback` is a candidate, so it is the only one
|
||||||
|
in the excluded partition. Ordinary apps staying eligible *while validated* is
|
||||||
|
what makes "the gate is open" observable rather than inferred.
|
||||||
|
|
||||||
|
### Row 13 — `Audio/Duplex` over-taint (known accepted)
|
||||||
|
|
||||||
|
No real duplex device exists on this host, so one was synthesised by overriding
|
||||||
|
`media.class=Audio/Duplex` on a null sink. Its playback side was tainted and its
|
||||||
|
capture-side consumer was dragged down with it (`r13_dup_play` EXCLUDED), with
|
||||||
|
the eligible half intact. **Fixture limit, stated plainly:** on a null sink the
|
||||||
|
capture side *is* the monitor, so this cannot separate the duplex smear from the
|
||||||
|
ordinary sink→monitor edge. The accepted over-taint is confirmed as *behaviour*;
|
||||||
|
a real duplex device is still the only way to isolate the mechanism.
|
||||||
|
|
||||||
|
### Row 1b — Sunshine (opportunistic, non-gating)
|
||||||
|
|
||||||
|
Sunshine ran throughout. Its three null sinks stayed SUSPENDED and it read the
|
||||||
|
**hardware** monitor instead, exactly as §5.3 warned. It appears consistently and
|
||||||
|
correctly as `sunshine` / `tainted-upstream` whenever the monitor it reads is
|
||||||
|
tainted (rows 8, 12, o5). It has **no re-emitting output leg** — it sends over
|
||||||
|
the network — so it is never a candidate and there is nothing to exclude. Recorded
|
||||||
|
as observed; the "if a re-emitting leg exists" clause did not apply. A real
|
||||||
|
third-party forwarder sample remains owed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5.2 — O5 re-measured
|
||||||
|
|
||||||
|
The run-1 numbers do not carry over: they were measured on the graph F1 degraded,
|
||||||
|
and phase 3r adds a bind plus an `info` round-trip **per node**, which is new I/O
|
||||||
|
that run never exercised.
|
||||||
|
|
||||||
|
Per-run, across all 13 rows (`recompute` in µs):
|
||||||
|
|
||||||
|
| run | events | ev/s | max | mean | emit max | busy fraction | ready@ms |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| baseline | 123 | 21.4 | 20 | 3 | 6 | 0.0001 | 1 |
|
||||||
|
| o5 (churn) | 407 | 44.0 | 32 | 10 | 9 | 0.0006 | 1 |
|
||||||
|
| row01 | 219 | 41.7 | 53 | 10 | 9 | 0.0006 | 1 |
|
||||||
|
| row02 | 241 | 45.9 | **67** | 11 | 10 | 0.0006 | 1 |
|
||||||
|
| row03 | 206 | 48.5 | 54 | 8 | 8 | 0.0005 | 1 |
|
||||||
|
| row0456 | 185 | 20.0 | 38 | 7 | 10 | 0.0002 | 2 |
|
||||||
|
| row07 | 184 | 43.3 | 40 | 7 | 9 | 0.0004 | 1 |
|
||||||
|
| row08 | 172 | 32.6 | 44 | 6 | 7 | 0.0003 | 2 |
|
||||||
|
| row09 | 224 | 30.9 | 52 | 9 | 9 | 0.0004 | 1 |
|
||||||
|
| row10 | 332 | 14.3 | 41 | 11 | 15 | 0.0002 | 1 |
|
||||||
|
| row11 | 298 | 24.1 | 41 | 9 | 11 | 0.0003 | 1 |
|
||||||
|
| row12 | 188 | 25.9 | 39 | 7 | 8 | 0.0003 | 1 |
|
||||||
|
| row13 | 193 | 36.8 | 43 | 8 | 7 | 0.0004 | 1 |
|
||||||
|
|
||||||
|
The dedicated churn run (five load/unload cycles of null-sink + loopback, the
|
||||||
|
same shape as run 1's measurement):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"kind":"metrics","graph_events":407,"tick_events":37,"emitted_records":407,
|
||||||
|
"span_us":9249639,"graph_events_per_sec":44.0,
|
||||||
|
"recompute_max_us":32,"recompute_mean_us":10,
|
||||||
|
"recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us",
|
||||||
|
"recompute_distribution":[["<50us",444]],
|
||||||
|
"emit_max_us":9,"emit_mean_us":1,
|
||||||
|
"busy_us":5240,"busy_fraction":0.0006,
|
||||||
|
"queued_events":292,"queue_threshold_us":100}
|
||||||
|
```
|
||||||
|
|
||||||
|
**O5 stays closed on the real graph.** Worst recompute across every run is
|
||||||
|
**67 µs**; every single recompute in the churn run finished under 50 µs, against
|
||||||
|
a 44 Hz event rate under churn heavier than a desktop produces at rest. The
|
||||||
|
observer thread spent **0.06 %** of wall time working. Node binding roughly
|
||||||
|
doubled the per-event cost (run 1: 15 µs max / 4 µs mean; now 32 µs / 10 µs on
|
||||||
|
the same churn shape) and that is the honest cost of the F1 fix — it buys three
|
||||||
|
orders of magnitude of remaining headroom, not one.
|
||||||
|
|
||||||
|
**Readiness with node binds: 1–2 ms**, with ~122 enumeration events and 18 binds
|
||||||
|
(14 Nodes + 4 Devices), against the 2000 ms budget. `queued_events` is high
|
||||||
|
(292) for the same benign reason as run 1: PipeWire delivers enumeration and
|
||||||
|
teardown in bursts, and a 32 µs recompute drains a burst faster than it forms.
|
||||||
|
`busy_fraction` is the number to trust.
|
||||||
|
|
||||||
|
⚠️ **The readiness budget still has no calibration argument.** 1–2 ms against
|
||||||
|
2000 ms is three orders of magnitude of slack on *this* host with 18 binds; it is
|
||||||
|
not an argument about a host with a large USB interface, many virtual devices, or
|
||||||
|
a cold cache. Carried forward as open, unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings recorded, not blocking
|
||||||
|
|
||||||
|
### R2-1 — the audit's `sticky` flag is nearly always true, so it says little
|
||||||
|
|
||||||
|
As emitted, `sticky` means "this node is in the remembered set", which
|
||||||
|
`seed_sticky` populates for any node whose current reason the sticky pass agrees
|
||||||
|
with — i.e. essentially every currently-tainted node. It does **not** mean
|
||||||
|
"excluded *only* because remembered", which is what its doc comment implies and
|
||||||
|
what a reader diagnosing "why is this still excluded?" wants.
|
||||||
|
|
||||||
|
The information exists: round 9 already computes a second, **evidence-only** pass
|
||||||
|
(that is the whole provenance mechanism). Emitting "excluded by memory alone"
|
||||||
|
would make row 10 phase B assertable from a single record instead of from a
|
||||||
|
sequence. Not fixed here — it is a reporting change to a merged phase in the
|
||||||
|
middle of a gate run. Row 10 was asserted behaviourally instead, which is
|
||||||
|
stronger anyway.
|
||||||
|
|
||||||
|
### R2-2 — a bridge key is lost when a leg reappears under a new serial
|
||||||
|
|
||||||
|
Row 2 named `application.process.id` at derivation (seq 209), then gst re-created
|
||||||
|
that node; the sticky owner re-seeded the new serial through `reason_for`, whose
|
||||||
|
documented fallback is `TaintedOwnerBridge { key: None }`, and `raise` will not
|
||||||
|
replace a same-rank reason with a better-informed one. The verdict is unaffected;
|
||||||
|
only the diagnosis degrades. The fallback is honest when the owner has no live
|
||||||
|
tainted receiver, and stale when it does — which is the case worth improving.
|
||||||
|
|
||||||
|
### R2-3 — `owner_key` had to be added to the record to run row 1 at all
|
||||||
|
|
||||||
|
Row 1 asserts "reason = owner bridge, **naming the key**", and the record could
|
||||||
|
not express it: `Reason::code` collapses `TaintedOwnerBridge { key }` to one
|
||||||
|
string. `OwnerKey::code` already documented itself as ending up in the phase 5
|
||||||
|
audit output; it was simply never wired to it. Added in pixelpass `d462754`
|
||||||
|
(read-only, diagnostic-only, mutation-verified test). Worth noting as a gate-spec
|
||||||
|
lesson: the row could not have been asserted from any previous build's output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Substitutions, stated so they are not mistaken for passes
|
||||||
|
|
||||||
|
| row | asked for | used instead | why |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 8 | EasyEffects | `module-echo-cancel` with `AEC=off` | EasyEffects makes itself the default sink on start and the user had live audio playing. `module-filter-chain` cannot stand in either — it is a PipeWire module, so `pactl load-module` answers "No such entity" (measured). The stand-in produces the same shape (four nodes, one `node.link-group`) and exercises `foreign-echo-cancel` (decision D3), a reason code no other row reaches. |
|
||||||
|
| 9 | Firefox's mic + monitor capture | `gst-launch` pipelines | Firefox's mic and monitor-capture paths need interactive GUI permission grants. Firefox is present live as case 1 in every row. Case 2 captures the motherboard's **analog input**, not the headset mic the user is wearing — identical to the engine (both `session_device` sources), and nothing of the user is recorded. |
|
||||||
|
| 13 | a real `Audio/Duplex` device | synthetic `media.class` override | None on this host. See row 13 above for what the fixture cannot show. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What still blocks phase 6
|
||||||
|
|
||||||
|
This file passing removes **one** of the two gates. Still outstanding:
|
||||||
|
|
||||||
|
1. **🔴 F11-1 — the owner-key union can *reduce* taint.** Unchanged and still
|
||||||
|
open; it was to be decided *with* this matrix data. The data now exists: the
|
||||||
|
suppression path is live and correct (F13-1 fixed), and rows 2/9 show key 4
|
||||||
|
doing real work, which means the interaction F11-1 describes is reachable in
|
||||||
|
principle. The rule to implement is written in `owner_is_bounded`'s doc in
|
||||||
|
pixelpass `src/host/taint/owner.rs`, sharpened by Codex round 12: "resolved"
|
||||||
|
must mean **an unambiguous Client yielding `Some(pipewire.sec.pid)` before
|
||||||
|
pipewire-pulse suppression**. A five-case Client matrix (absent · ambiguous ·
|
||||||
|
unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse) is owed.
|
||||||
|
2. **Hardware playback-to-capture paths ("Stereo Mix")** defeat `session_device`
|
||||||
|
and are a real echo path — needs ALSA control inspection; user design call owed.
|
||||||
|
3. **Phases 0b / 0c / 0d** are untouched and all precede phase 6.
|
||||||
|
4. **The readiness budget calibration argument** (above).
|
||||||
|
5. **Owed samples:** a real third-party forwarder (row 1b), EasyEffects (row 8),
|
||||||
|
a real `Audio/Duplex` device (row 13).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reproducing this run
|
||||||
|
|
||||||
|
Scripts live in the session scratchpad (not committed — they hard-code paths):
|
||||||
|
one per row, plus `lib.sh`, `summarize.py` and `keys.py`. The shape of every row:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
audit_start out.jsonl off # start FIRST, wait for graph_ready
|
||||||
|
... build fixture ... # taint arrives as topology CHANGES
|
||||||
|
audit_stop # SIGTERM: flushes the O5 summary
|
||||||
|
python3 summarize.py out.jsonl # final partition + derivations + metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
env PIXELPASS_AUDIO_AUDIT_FILE=/path/out.jsonl PIXELPASS_AUDIO_AUDIT_AEC=off \
|
||||||
|
./target/release/pixelpass --audit-audio
|
||||||
|
```
|
||||||
|
|
||||||
|
Rig notes that cost time:
|
||||||
|
|
||||||
|
- A tagged producer: `env PIPEWIRE_ALSA='{ "peerspeak.owned": "1", "node.name":
|
||||||
|
"peerspeak_owned_call_4242", "target.object": "<sink>" }' aplay -c 2 -r 48000
|
||||||
|
-f S16_LE -t raw -d 30 /dev/zero`. Both carriers land, and `target.object`
|
||||||
|
routes it.
|
||||||
|
- ⚠️ `pactl load-module module-echo-cancel --help` **loads the module** with
|
||||||
|
`--help` as its argument instead of printing help. It was loaded accidentally
|
||||||
|
during this session and unloaded again; check `pactl list short modules` after
|
||||||
|
any such probe.
|
||||||
|
- ⚠️ `pkill -f <pattern>` matches the harness's own shell command line and kills
|
||||||
|
the script. Use `pkill -x` or an exact pid.
|
||||||
|
- ⚠️ Under `set -e`, `kill` on an already-exited pid aborts the row before its
|
||||||
|
modules are unloaded; and `timeout` exiting 124 is *success* for the audit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## History — run 1 (2026-07-25): GATE FAILED
|
||||||
|
|
||||||
|
Kept because the reasoning is still the record of why the observation boundary
|
||||||
|
was redesigned.
|
||||||
|
|
||||||
|
### F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties
|
||||||
|
|
||||||
|
The phase-3 adapter read eight node properties the registry never announces.
|
||||||
|
Parsed off `obj.props` in the registry `global` callback, they were silently
|
||||||
|
absent, so every one was permanently `None`/`false`.
|
||||||
|
|
||||||
|
The complete set the registry announces for a `Node` on this host:
|
||||||
|
|
||||||
```
|
```
|
||||||
application.name client.api client.id device.id factory.id media.class
|
application.name client.api client.id device.id factory.id media.class
|
||||||
@@ -37,246 +405,51 @@ node.description node.name node.nick object.path object.serial
|
|||||||
priority.driver priority.session
|
priority.driver priority.session
|
||||||
```
|
```
|
||||||
|
|
||||||
Against what the adapter tries to read:
|
| property | announced? | what died without it |
|
||||||
|
|
||||||
| property | announced? | what dies without it |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `object.serial` | ✅ | — |
|
| `object.serial`, `node.name`, `media.class`, `client.id`, `device.id` | ✅ | — |
|
||||||
| `node.name` | ✅ | — |
|
| **`peerspeak.owned`** | ❌ | **the primary taint root (all of phase 1)** |
|
||||||
| `media.class` | ✅ | — |
|
|
||||||
| `client.id` | ✅ | — |
|
|
||||||
| `device.id` | ✅ | — |
|
|
||||||
| **`peerspeak.owned`** | ❌ | **the primary taint root (v3.4 §5.1, all of phase 1)** |
|
|
||||||
| **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** |
|
| **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** |
|
||||||
| **`node.link-group`** | ❌ | the link-group owner key (echo-cancel, EasyEffects, loopback siblings) |
|
| **`node.link-group`** | ❌ | the link-group owner key |
|
||||||
| **`application.process.id`** | ❌ | the process owner key (GStreamer split clients, §5.1 row 2) |
|
| **`application.process.id`** | ❌ | the process owner key |
|
||||||
| **`node.passthrough`** | ❌ | the passthrough local exclusion (a second link corrupts an encoded stream) |
|
| **`node.passthrough`** | ❌ | the passthrough local exclusion |
|
||||||
| **`device.api`** | ❌ | `session_device` classification |
|
| **`device.api`**, **`factory.name`**, **`alsa.driver_name`** | ❌ | `session_device` classification |
|
||||||
| **`factory.name`** | ❌ | `session_device` classification — the discriminator itself |
|
|
||||||
| **`alsa.driver_name`** | ❌ | `session_device` classification (the `snd_aloop` denylist) |
|
|
||||||
|
|
||||||
Ports and Links are also affected, one materially:
|
Ports lost `port.exclusive`; Links and Clients were fine — notably
|
||||||
|
`pipewire.sec.pid` **is** announced, so pulse-PID derivation was reachable.
|
||||||
|
|
||||||
| object | announced | missing |
|
Demonstrated end to end: a null sink carrying `peerspeak.owned=true` whose
|
||||||
| --- | --- | --- |
|
monitor a `module-loopback` re-emitted was reported **eligible** with an **empty
|
||||||
| Port | `node.id`, `object.serial`, `port.direction`, `port.monitor`, `port.physical`, `port.terminal`, `port.group`, `port.alias`, `port.name`, `port.id`, `audio.channel`, `format.dsp` | **`port.exclusive`** — the `port-exclusive` local exclusion never fires |
|
taint set**. In phase 6 that is an echo.
|
||||||
| Link | `object.serial`, `link.output.node`, `link.input.node`, `link.output.port`, `link.input.port`, `client.id`, `factory.id` | nothing the engine needs |
|
|
||||||
| Client | `object.serial`, **`pipewire.sec.pid`**, `application.name`, `module.id`, `pipewire.access`, `pipewire.protocol`, `pipewire.sec.{uid,gid,socket}` | nothing the engine needs |
|
|
||||||
|
|
||||||
**Links and Clients are fine.** Notably the pulse-PID derivation (v3.4 §6.1.2)
|
The fix became design round 8 (v3.5 §6.7) and phase 3r: bind each Node and read
|
||||||
works: `pipewire.sec.pid` is announced. Also notable: the Link endpoint props are
|
props off its `info`, exactly how `pw-dump` obtains them. `factory.id` is not a
|
||||||
*always* present, which confirms the phase-3 exit-gate worry that the
|
shortcut (`factory.id=19` resolves to `factory.name = "adapter"`), and
|
||||||
bind-`LinkInfoRef` fallback is dead code in practice — it is correctness
|
`device.api` is on the *Device* global.
|
||||||
insurance, never exercised on this host.
|
|
||||||
|
|
||||||
### Demonstrated end to end
|
### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
|
||||||
|
|
||||||
A null sink carrying `peerspeak.owned=true`, its monitor read by a
|
|
||||||
`module-loopback` whose playback leg is a fan-out candidate — the exact shape the
|
|
||||||
tag exists to exclude:
|
|
||||||
|
|
||||||
```
|
|
||||||
pactl load-module module-null-sink sink_name=ppgate_src \
|
|
||||||
sink_properties="peerspeak.owned=true"
|
|
||||||
pactl load-module module-loopback source=ppgate_src.monitor sink=ppgate_dest \
|
|
||||||
source_output_properties=node.name=ppgate_cap \
|
|
||||||
sink_input_properties=node.name=ppgate_play
|
|
||||||
```
|
|
||||||
|
|
||||||
Audit verdict:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"audit","graph_ready":true,"epoch":"complete","aec_state":"not-configured",
|
|
||||||
"fan_out_permitted":true,
|
|
||||||
"candidates":[{"serial":280,"name":"FINAL FANTASY XIV","eligible":true,"sticky":false},
|
|
||||||
{"serial":309,"name":"ppgate_play","eligible":true,"sticky":false}],
|
|
||||||
"eligible_count":2,"excluded_count":0,"taint":[]}
|
|
||||||
```
|
|
||||||
|
|
||||||
`ppgate_play` **eligible**, and the `taint` set **empty** — the tagged sink was
|
|
||||||
not even recognised as a root. In phase 6 this is an echo: peerspeak's own call
|
|
||||||
playback carries `peerspeak.owned` and would be fanned straight into the share.
|
|
||||||
|
|
||||||
The AEC path fails in the other direction. With
|
|
||||||
`PIXELPASS_AUDIO_AUDIT_AEC=pulse-module:536870918` (a real live module index):
|
|
||||||
|
|
||||||
```
|
|
||||||
aec_state = failed fan_out_permitted = false gate_reason = aec-failed
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct behaviour given its inputs — `pulse.module.id` never arrives, so the
|
|
||||||
identity can never be observed and the validator times out fail-closed — but it
|
|
||||||
means **§5.1 row 12 cannot be run as written**, and that with a real AEC
|
|
||||||
configured phase 6 would refuse to share any audio at all.
|
|
||||||
|
|
||||||
### The fix (for round 8)
|
|
||||||
|
|
||||||
The full property set *is* reachable: **bind each Node global and read the props
|
|
||||||
off its `info` event**, which is exactly how `pw-dump` obtains them. Verified on
|
|
||||||
the same objects that were missing them from the registry:
|
|
||||||
|
|
||||||
```
|
|
||||||
alsa_output.usb-SteelSeries… factory.name = 'api.alsa.pcm.sink'
|
|
||||||
device.api = 'alsa'
|
|
||||||
alsa.driver_name = 'snd_usb_audio'
|
|
||||||
ppgate_src peerspeak.owned = True
|
|
||||||
pulse.module.id = 536870917
|
|
||||||
ppgate_play pulse.module.id = 536870918
|
|
||||||
node.link-group = 'loopback-2528-13'
|
|
||||||
FINAL FANTASY XIV application.process.id = 14651
|
|
||||||
```
|
|
||||||
|
|
||||||
Two notes for whoever designs that change:
|
|
||||||
|
|
||||||
- **The pattern already exists.** Phase 3 built exactly this for Links (bind →
|
|
||||||
`LinkInfoRef` → `LinkEndpointsResolved`, "the optimisation is the props, the
|
|
||||||
bind is the correctness path"). Nodes need the same, but as the *only* path
|
|
||||||
rather than a fallback, and the readiness epoch must hold an obligation per
|
|
||||||
unbound node — which the model already supports (`withheld` / `pending_links`).
|
|
||||||
- **`factory.id` is not a shortcut.** The Factory global for `factory.id=19`
|
|
||||||
(which every ALSA node claims) resolves to `factory.name = "adapter"`, not
|
|
||||||
`api.alsa.pcm.sink`. The node's own `factory.name` is a different property and
|
|
||||||
binding is the only way to it.
|
|
||||||
|
|
||||||
Also relevant: **`device.api` is announced on the *Device* global** even though it
|
|
||||||
is absent from the Node. That is the phase-3 review's owed fix ("read the ALSA
|
|
||||||
driver from the backing Device global, authoritative") — now not merely better
|
|
||||||
but load-bearing, though `factory.name` and `alsa.driver_name` are absent from
|
|
||||||
the Device global too, so node binding is still required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
|
|
||||||
|
|
||||||
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is*
|
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is*
|
||||||
announced) is the only taint root that still fires. Running §5.1 row 7 —
|
announced) was the only surviving taint root. Row 7 then excluded every
|
||||||
a capture sink plus a controlled forwarder reading its monitor:
|
`Stream/Output/Audio` on the machine: with no strong owner keys, every tainted
|
||||||
|
capture stream was an **unbounded tainted reader**, tripping phase 2's
|
||||||
|
fail-closed backstop, while WirePlumber's shared `client.id = 42` fused the
|
||||||
|
device layer into one owner.
|
||||||
|
|
||||||
```
|
Net live behaviour: exclude everything, always, as soon as pixelpass's own
|
||||||
candidates:
|
capture sink existed. Fail-closed, so silence rather than echo — but entirely
|
||||||
FINAL FANTASY XIV | eligible: false | reason: unresolved-owner
|
non-functional, and non-functional in a way that would have looked like "working
|
||||||
ppgate7_play | eligible: false | reason: tainted-owner-bridge
|
safely" to any test that asserted only exclusions.
|
||||||
taint:
|
|
||||||
Midi-Bridge | tainted-owner-bridge
|
|
||||||
bluez_midi.server | tainted-owner-bridge
|
|
||||||
alsa_output.pci-0000_03_00.1.hdmi-stereo-… | tainted-owner-bridge
|
|
||||||
alsa_output.usb-SteelSeries_…-analog-stereo | tainted-upstream
|
|
||||||
alsa_input.usb-SteelSeries_…-mono-fallback | tainted-owner-bridge
|
|
||||||
alsa_output.pci-0000_10_00.6.analog-stereo | tainted-owner-bridge
|
|
||||||
alsa_input.pci-0000_10_00.6.analog-stereo | tainted-owner-bridge
|
|
||||||
FINAL FANTASY XIV | unresolved-owner
|
|
||||||
ppgate_dest | tainted-upstream
|
|
||||||
pixelpass_capture_ppgate7 | pixelpass-owned
|
|
||||||
ppgate7_play | tainted-owner-bridge
|
|
||||||
ppgate7_cap | tainted-upstream
|
|
||||||
```
|
|
||||||
|
|
||||||
Row 7's own assertion held — `ppgate7_play` is excluded via the owner bridge, so
|
### What run 1's machinery got right
|
||||||
the cycle-prevention mechanism works. But the row **fails the §5.1 exact-partition
|
|
||||||
requirement**, because the eligible half is empty: FFXIV should have been
|
|
||||||
eligible and was not.
|
|
||||||
|
|
||||||
The mechanism: with `node.link-group`, `application.process.id` and
|
None of this needed revisiting:
|
||||||
`pulse.module.id` all absent, no node has a *strong* owner key — `client.id` is
|
|
||||||
explicitly not one (v3.4 §6.1.3). So every tainted capture stream is an
|
|
||||||
**unbounded tainted reader**, which trips phase 2's documented fail-closed
|
|
||||||
backstop (`taint/mod.rs`, `an_unbounded_tainted_reader_excludes_every_output`)
|
|
||||||
and excludes every `Stream/Output/Audio` on the machine. Every device node
|
|
||||||
separately keeps its coarse keys (`session_device` is universally false, also from
|
|
||||||
F1) and they all share WirePlumber's `client.id = 42`, which fuses them into a
|
|
||||||
single owner and spreads the taint across the whole device layer.
|
|
||||||
|
|
||||||
So the engine's *net* live behaviour today is: exclude everything, always, as soon
|
|
||||||
as pixelpass's own capture sink exists. Fail-closed, so silence rather than echo —
|
|
||||||
but the feature is entirely non-functional, and it is non-functional in a way that
|
|
||||||
would have looked like "working safely" to any test that only asserted exclusions.
|
|
||||||
|
|
||||||
**This is the §5.1 argument vindicated in the most direct possible way.** The
|
|
||||||
current build *is* the degenerate exclude-everything implementation the plan
|
|
||||||
warned about, and it is the eligible half of the partition — asserted, per §5.1 —
|
|
||||||
that caught it. An exclusion-only checklist would have passed this build.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §5.2 — O5 measurements
|
|
||||||
|
|
||||||
Recorded under deliberate churn: five load/unload cycles of
|
|
||||||
`module-null-sink` + `module-loopback`, 6.5 s wall.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"metrics","graph_events":308,"tick_events":26,"emitted_records":308,
|
|
||||||
"span_us":6499634,"graph_events_per_sec":47.39,
|
|
||||||
"recompute_max_us":15,"recompute_mean_us":4,
|
|
||||||
"recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us",
|
|
||||||
"recompute_distribution":[["<50us",334]],
|
|
||||||
"emit_max_us":12,"emit_mean_us":2,"emit_distribution":[["<50us",308]],
|
|
||||||
"busy_us":2331,"busy_fraction":0.0004,
|
|
||||||
"queued_events":198,"queue_threshold_us":100}
|
|
||||||
```
|
|
||||||
|
|
||||||
**O5 is closed: full recompute per graph event has roughly four orders of
|
|
||||||
magnitude of headroom.** Every one of 334 recomputes finished in under 50 µs, the
|
|
||||||
worst at 15 µs, against a 47 Hz event rate under churn far heavier than a desktop
|
|
||||||
produces at rest. The observer thread spent 0.04 % of wall time working.
|
|
||||||
|
|
||||||
`queued_events: 198` looks alarming and is not: PipeWire delivers enumeration and
|
|
||||||
teardown as back-to-back bursts, so most events do begin within 100 µs of the
|
|
||||||
previous one completing. With a 15 µs worst-case recompute the backlog drains
|
|
||||||
faster than it forms. `busy_fraction` is the number to trust here — it needs no
|
|
||||||
inference, and it is 0.0004.
|
|
||||||
|
|
||||||
**Caveat, and it is a real one.** These numbers were measured on the *degraded*
|
|
||||||
graph F1 produces. The recompute cost is over the same node and link count so the
|
|
||||||
taint-engine figure is representative, but the F1 fix adds a bind and an `info`
|
|
||||||
round-trip **per node**, which is new I/O this run did not measure at all. O5
|
|
||||||
should be re-measured after round 8 rather than inherited from here.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Matrix status (§5.1)
|
|
||||||
|
|
||||||
| # | scenario | status |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | null-sink + loopback forwarder, owner bridge | ⛔ blocked by F1 — needs a taint root (`peerspeak.owned`) |
|
|
||||||
| 1b | Sunshine's topology (opportunistic, non-gating) | not attempted |
|
|
||||||
| 2 | gst split clients, tainted input | ⛔ blocked by F1 — needs `application.process.id` |
|
|
||||||
| 3 | two Pulse modules, one tainted | ⛔ blocked by F1 |
|
|
||||||
| 4–6 | peerspeak playback / mpv / notification | ⛔ blocked by F1 — all three are `peerspeak.owned` tags |
|
|
||||||
| 7 | second host's capture sink + forwarder | 🟠 mechanism verified, **partition fails** (F2) |
|
|
||||||
| 8 | EasyEffects | ⛔ blocked by F1 — needs `node.link-group` |
|
|
||||||
| 9 | Firefox three cases | ⛔ blocked by F2 (everything excluded) |
|
|
||||||
| 10 | sticky taint across teardown | ⛔ blocked by F1 |
|
|
||||||
| 11 | recycled serial / index / link-group | ⛔ blocked by F1 |
|
|
||||||
| 12 | AEC loaded → unloaded → Revoked | ⛔ blocked by F1 — `pulse.module.id` never arrives; validator goes `failed` |
|
|
||||||
| 13 | `Audio/Duplex` device | not attempted (none present on this host) |
|
|
||||||
|
|
||||||
**No row can be completed until F1 is fixed.** The matrix is not re-runnable in a
|
|
||||||
meaningful sense before then — every row's eligible half is empty for the same
|
|
||||||
reason.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What the audit machinery got right
|
|
||||||
|
|
||||||
Worth recording, because none of it needs revisiting in round 8:
|
|
||||||
|
|
||||||
- Running the recompute **inline on the observer thread**, once per applied
|
- Running the recompute **inline on the observer thread**, once per applied
|
||||||
registry event, upholds phase 4's no-coalescing contract and put the cost
|
registry event, upheld phase 4's no-coalescing contract and put the cost where
|
||||||
exactly where O5 could measure it.
|
O5 could measure it.
|
||||||
- The **complete-partition record** is what caught F2. A record of only the
|
- The **complete-partition record** is what caught F2 — and, in run 2, F13-1.
|
||||||
interesting nodes would have shown row 7 passing.
|
- **Reason codes survived the trip** and were immediately diagnostic.
|
||||||
- **Reason codes survived the trip** and were immediately diagnostic:
|
- The **`peerspeak.owned` / `pulse.module.id` fixtures were right**: the engine
|
||||||
`unresolved-owner` on FFXIV named the backstop, not a symptom, and pointed
|
does the correct thing when handed correct properties. Both failures were at
|
||||||
straight at the missing strong keys.
|
the observation boundary, which is where phase 5 was designed to look.
|
||||||
- The **`peerspeak.owned` / `pulse.module.id` fixtures were right** — phase 2's
|
|
||||||
engine does the correct thing when handed correct properties. The defect is
|
|
||||||
entirely at the observation boundary, which is where phase 5 was designed to
|
|
||||||
look.
|
|
||||||
|
|
||||||
## Next
|
|
||||||
|
|
||||||
1. **Design round 8** on F1: node binding in the observer, readiness obligations
|
|
||||||
per unbound node, and where `session_device` reads its inputs from.
|
|
||||||
2. Re-run this matrix in full afterwards. Rows 4–6 additionally need peerspeak
|
|
||||||
running; rows 8, 9 and 1b need EasyEffects, Firefox and Sunshine respectively.
|
|
||||||
3. Re-measure O5 with node binding in place.
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# Design v3: whole-desktop screen-share audio without self-echo
|
# 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
|
**Status:** 🟢 **v3.7 — round 10: the §5.1 matrix PASSED in full and the architecture is
|
||||||
audit run of the *fixed* observer.** v3.4's architecture is still unchanged and converged.
|
unchanged for the third consecutive measured round.** Round 8 revised the **observation
|
||||||
Round 8 revised the **observation boundary** (§6.7); round 9 revises what stickiness is
|
boundary** (§6.7), round 9 revised what stickiness may remember (§6.8), and round 10 deletes
|
||||||
allowed to remember (new §6.8). Both were found by running code, not by reading it.
|
the pipewire-pulse PID **derivation heuristic** (§6.1.2) after measuring that WirePlumber
|
||||||
**Date:** 2026-07-25 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
|
repeats a `sec_pid` too — which had switched key 4's suppression off permanently. All three
|
||||||
v3.4 r7 · v3.5 r8 · v3.6 r9)
|
were found by running code, not by reading it, and all three were at the *observation*
|
||||||
|
boundary rather than in the design.
|
||||||
|
**Date:** 2026-07-26 (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 · v3.7 r10)
|
||||||
**Origin:** Joe's suggestion — "whitelist all audio except audio coming from peerspeak."
|
**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`),
|
**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.
|
playback tagging + AEC-identity export + teardown-ordering invariants in peerspeak.
|
||||||
@@ -502,11 +505,47 @@ derive this itself; it cannot assume a value, and peerspeak can supply only a *h
|
|||||||
- Read `pipewire.sec.pid` from the **Client** objects of Pulse-emulated streams. Measured:
|
- 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
|
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`).
|
node's own `application.process.id` differs (Firefox `11114`, sunshine `4119`).
|
||||||
- Require a **single consistent** value across those clients, and validate it by reading
|
- ~~Require a **single consistent** value across those clients~~ and validate it by reading
|
||||||
`/proc/<pid>/comm` (or cmdline) and confirming it is `pipewire-pulse`.
|
`/proc/<pid>/comm` (or cmdline) and confirming it is `pipewire-pulse`.
|
||||||
- "This PID owns implausibly many unrelated streams" is a **diagnostic**, never
|
- "This PID owns implausibly many unrelated streams" is a **diagnostic**, never
|
||||||
correctness logic.
|
correctness logic.
|
||||||
|
|
||||||
|
> #### 🔴 Round 10 (MEASURED, phase-5 run 2): "a repeated `sec_pid`" does not identify pulse
|
||||||
|
>
|
||||||
|
> The struck rule above was implemented as *the single `sec_pid` shared by two or more
|
||||||
|
> Clients*, on the reasoning that native clients each carry their own distinct PID so only the
|
||||||
|
> Pulse shim repeats a value. **Measured on this host: WirePlumber repeats one too** — it holds
|
||||||
|
> two Clients, `WirePlumber` and `WirePlumber [export]`, both `sec_pid` 1747. Two values
|
||||||
|
> repeated, "single consistent" was unsatisfiable, and the derivation returned `None`
|
||||||
|
> **permanently, on a stock desktop**.
|
||||||
|
>
|
||||||
|
> The consequence was not a missing optimisation. With the daemon PID unknown the key-4
|
||||||
|
> exception never fires, every Pulse-emulated node fuses into one owner, and the result is the
|
||||||
|
> machine-wide over-exclusion cascade of phase 5's F2 — reached again from a new cause, and
|
||||||
|
> caught again only by the §5.1 requirement to assert the **eligible** half of a row.
|
||||||
|
>
|
||||||
|
> The rule failed in both directions, so the repetition test is **deleted** rather than
|
||||||
|
> tightened:
|
||||||
|
>
|
||||||
|
> - **False ambiguity** — any second process holding two Clients defeats it, and WirePlumber
|
||||||
|
> always does.
|
||||||
|
> - **False absence** — a session in which pipewire-pulse holds exactly one Client (one Pulse
|
||||||
|
> app running) repeats nothing at all, so the candidate is never even considered.
|
||||||
|
>
|
||||||
|
> `comm` was always the authoritative check; repetition was a heuristic standing in front of it,
|
||||||
|
> and what it actually encoded was an assumption about *other* processes' Client counts.
|
||||||
|
> **The rule is now: every distinct `pipewire.sec.pid` is a candidate; the daemon is the unique
|
||||||
|
> one whose `/proc/<pid>/comm` is exactly `pipewire-pulse`.** Zero matches ⇒ `None` (nothing we
|
||||||
|
> can prove to suppress). **Several** matches ⇒ also `None`: two live pipewire-pulse daemons (a
|
||||||
|
> nested or sandboxed session) cannot both be suppressed by a single `Option<u32>`, and failing
|
||||||
|
> closed there lands on the over-exclusion side, consistent with the failure-mode paragraph
|
||||||
|
> below. Suppressing a *set* of daemon PIDs is the real answer if a multi-daemon host ever turns
|
||||||
|
> up; it is out of v1 and recorded rather than silently approximated.
|
||||||
|
>
|
||||||
|
> The lesson generalises past this key: **a property of the objects we are trying to identify is
|
||||||
|
> evidence; a property of everyone else's object count is a guess.** The `/proc` read was already
|
||||||
|
> there and already authoritative — the heuristic in front of it only added a way to be wrong.
|
||||||
|
|
||||||
Failure modes: if pixelpass fails to identify the real pipewire-pulse PID, the result is
|
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
|
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
|
result is over-exclusion **for that app** — safe *only* because unresolved ancestry is
|
||||||
@@ -1143,9 +1182,28 @@ Both reviewers agree on all seven. Recorded as decided; reopen only with new evi
|
|||||||
- **D7 — no materially simpler design exists** that still meets Joe's ask. The available
|
- **D7 — no materially simpler design exists** that still meets Joe's ask. The available
|
||||||
simplification is to *narrow v1 scope*, not to change architecture. ✅
|
simplification is to *narrow v1 scope*, not to change architecture. ✅
|
||||||
|
|
||||||
## 14. Readiness — 🟠 v3.6 (round 9): architecture converged; observation boundary and sticky provenance REVISED.
|
## 14. Readiness — 🟢 v3.7 (round 10): the §5.1 matrix PASSED; architecture unchanged.
|
||||||
|
|
||||||
**Round 9 (2026-07-25, same day).** Phase 3r shipped §6.7 and the audit was re-run
|
**Round 10 (2026-07-26).** The phase-5 matrix ran in full and **passed all 13 rows with a
|
||||||
|
non-empty eligible half in every one** — results in
|
||||||
|
`screenshare-audio-exclusion-phase5-results.md`. It also found a third measured defect on
|
||||||
|
first contact, and once again the failure was fail-closed and *silent*, exposed only by the
|
||||||
|
requirement to assert what must remain **eligible**: §6.1.2's pulse-PID derivation returned
|
||||||
|
`None` permanently on this host, so key 4 fused every Pulse-emulated node into one owner.
|
||||||
|
|
||||||
|
| | verdict |
|
||||||
|
| --- | --- |
|
||||||
|
| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged, three times vindicated** |
|
||||||
|
| §6.1.2 | **revised** — the derivation heuristic is deleted; `comm` alone decides |
|
||||||
|
| §5.1 matrix | **PASSED** — 13/13, incl. the full sticky lifecycle (row 10) and provable identifier recycling (row 11) |
|
||||||
|
| O5 | **closed on the real graph** — worst recompute 67 µs, churn mean 10 µs, busy fraction 0.0006 |
|
||||||
|
| Phase 5 | **machinery unchanged and correct** — three real defects caught on first contact with the live graph, none of them in the engine |
|
||||||
|
| Phase 6 | **still blocked** — by F11-1, phases 0b/0c/0d, and the "Stereo Mix" design call, *not* by this matrix |
|
||||||
|
|
||||||
|
Three rows passed with recorded substitutions (8 EasyEffects, 9 Firefox's own mic/monitor
|
||||||
|
paths, 13 a real `Audio/Duplex` device) and the third-party samples stay owed.
|
||||||
|
|
||||||
|
**Round 9 (2026-07-25).** Phase 3r shipped §6.7 and the audit was re-run
|
||||||
immediately; it found a *second* measured defect within minutes — a permanent sticky taint
|
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
|
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
|
right and the instrumentation was wrong**, and only running the code against a live daemon
|
||||||
@@ -1205,7 +1263,10 @@ How the blockers closed:
|
|||||||
| **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_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** | `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** | 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 |
|
| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; 1–2 ms observed on this host with 18 binds (phase-5 run 2) |
|
||||||
|
| **10** | **the pulse-PID derivation required a *single* repeated `sec_pid`; WirePlumber repeats one too, so it returned `None` permanently and key 4's suppression never fired (measured, phase-5 run 2)** | **fixed** — §6.1.2 round-10 box: probe every distinct `sec_pid`, let `/proc/<pid>/comm` decide |
|
||||||
|
| **10** | the audit's `sticky` flag means "is in the remembered set", so it is true for nearly every tainted node and does not answer "excluded only because remembered" | **OPEN — reporting only**; the evidence-only pass §6.8 already computes what is needed |
|
||||||
|
| **10** | a bridge's named key is lost when a leg reappears under a new serial (sticky `reason_for` falls back to keyless, and `raise` will not replace a same-rank reason) | **OPEN — reporting only**; verdict unaffected |
|
||||||
|
|
||||||
### v1 scope — agreed
|
### v1 scope — agreed
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user