docs: phase 5 dry-run audit results — GATE FAILED, two findings
Impl plan §5's required results file. Phase 6 does not start. F1 (fatal): the PipeWire registry `global` event delivers only a filtered subset of node properties, and eight of the properties the phase-3 adapter reads are not among them — peerspeak.owned, pulse.module.id, node.link-group, application.process.id, node.passthrough, device.api, factory.name, alsa.driver_name (plus port.exclusive on Ports). They are silently absent, so the primary taint root never fires, the AEC identity can never validate, and session_device is universally false. Measured on PipeWire 1.6.8 / WirePlumber 0.5.15, with the full announced key set for all five object types recorded. Links and Clients are unaffected; pulse-PID derivation works. F2: with F1 in force no node has a strong owner key, so any tainted capture stream is an unbounded tainted reader and phase 2's fail-closed backstop excludes every Stream/Output/Audio on the machine. Fail-closed, so silence rather than echo — but entirely non-functional, and non-functional in a way an exclusion-only checklist would have scored as passing. The eligible half of the §5.1 partition is what caught it, exactly as the plan argued it would. The fix direction is measured and recorded: binding each Node and reading its info props recovers every missing property, which is the pattern phase 3 already built for Links. factory.id is not a shortcut — it resolves to "adapter", not api.alsa.pcm.sink. O5 is closed with ~4 orders of magnitude of headroom: 308 graph events in 6.5s under churn, every recompute under 50us (max 15us), busy fraction 0.0004. Caveat recorded — measured on the degraded graph, and the F1 fix adds per-node bind I/O this run did not measure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
# Phase 5 — dry-run audit gate: results
|
||||
|
||||
**Status: 🚦 GATE FAILED. Phase 6 does not start.** Two defects found, one of them
|
||||
fatal to the whole mechanism. Both go to the design doc as **round 8** per impl
|
||||
plan §5.3.
|
||||
|
||||
- **Run date:** 2026-07-25
|
||||
- **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
|
||||
- **Audit build:** pixelpass branch `phase5-dry-run-audit`, release profile
|
||||
- **Ambient load during the runs:** FINAL FANTASY XIV playing audio (`client.id`
|
||||
88, pid 14651), Arctis 1 Wireless as an active sink
|
||||
|
||||
The audit itself worked exactly as designed: it observed the live graph, ran
|
||||
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
|
||||
|
||||
**The phase-3 adapter reads eight node properties that the PipeWire registry
|
||||
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
|
||||
|
||||
The complete set of keys the registry announces for a `Node` global on this host
|
||||
(union over every node, via `pw-cli ls Node`):
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Against what the adapter tries to read:
|
||||
|
||||
| property | announced? | what dies without it |
|
||||
| --- | --- | --- |
|
||||
| `object.serial` | ✅ | — |
|
||||
| `node.name` | ✅ | — |
|
||||
| `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** |
|
||||
| **`node.link-group`** | ❌ | the link-group owner key (echo-cancel, EasyEffects, loopback siblings) |
|
||||
| **`application.process.id`** | ❌ | the process owner key (GStreamer split clients, §5.1 row 2) |
|
||||
| **`node.passthrough`** | ❌ | the passthrough local exclusion (a second link corrupts an encoded stream) |
|
||||
| **`device.api`** | ❌ | `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:
|
||||
|
||||
| object | announced | missing |
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
| 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)
|
||||
works: `pipewire.sec.pid` is announced. Also notable: the Link endpoint props are
|
||||
*always* present, which confirms the phase-3 exit-gate worry that the
|
||||
bind-`LinkInfoRef` fallback is dead code in practice — it is correctness
|
||||
insurance, never exercised on this host.
|
||||
|
||||
### Demonstrated end to end
|
||||
|
||||
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*
|
||||
announced) is the only taint root that still fires. Running §5.1 row 7 —
|
||||
a capture sink plus a controlled forwarder reading its monitor:
|
||||
|
||||
```
|
||||
candidates:
|
||||
FINAL FANTASY XIV | eligible: false | reason: unresolved-owner
|
||||
ppgate7_play | eligible: false | reason: tainted-owner-bridge
|
||||
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
|
||||
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
|
||||
`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
|
||||
registry event, upholds phase 4's no-coalescing contract and put the cost
|
||||
exactly where O5 could measure it.
|
||||
- The **complete-partition record** is what caught F2. A record of only the
|
||||
interesting nodes would have shown row 7 passing.
|
||||
- **Reason codes survived the trip** and were immediately diagnostic:
|
||||
`unresolved-owner` on FFXIV named the backstop, not a symptom, and pointed
|
||||
straight at the missing strong keys.
|
||||
- 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.
|
||||
Reference in New Issue
Block a user