diff --git a/docs/screenshare-audio-exclusion-phase5-results.md b/docs/screenshare-audio-exclusion-phase5-results.md new file mode 100644 index 0000000..4d4884d --- /dev/null +++ b/docs/screenshare-audio-exclusion-phase5-results.md @@ -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.