Files
peerspeak/docs/screenshare-audio-exclusion-phase5-results.md
T
molluskandClaude Opus 5 297f4397a7 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>
2026-07-25 15:43:23 -04:00

14 KiB
Raw Blame History

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 24 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:

{"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 → LinkInfoRefLinkEndpointsResolved, "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.

{"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
46 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 46 additionally need peerspeak running; rows 8, 9 and 1b need EasyEffects, Firefox and Sunshine respectively.
  3. Re-measure O5 with node binding in place.