Files
peerspeak/docs/screenshare-audio-exclusion-phase5-results.md
T
molluskandClaude Opus 5 692ad677d2 docs: record F11-1 closed — boundedness needs a resolved Client
Design v3.7 §6.1.1 gains the round-13 box (the rule, the ordering that is
load-bearing in both directions, and why bridging deliberately still uses the
full union); the phase-5 results file records the close with the measurement
the deferral was waiting for; the impl plan's phase-6 gate note drops F11-1.

pixelpass c78eb2d is the implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 04:46:25 -04:00

24 KiB
Raw Blame History

Phase 5 — dry-run audit gate: results

Status: 🟢 GATE PASSED (run 2, 2026-07-26). All 13 §5.1 rows completed; the eligible half of every row is non-empty. O5 re-measured on the fixed graph and 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, and F11-1 — the other gate — was closed with this data on 2026-07-26 (see "What still blocks phase 6").

  • 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
  • Audit build: pixelpass main @ 91c4ded, release profile
  • peerspeak build: main @ b68fca6 (phase 1 merged)
  • Ambient load: Firefox playing audio throughout (a live, uncontrived candidate); Sunshine running (pid 3838); Arctis 1 Wireless as active sink
  • Graph size: 14 Nodes, 4 Devices, 57 Ports, 4 Links, 24 Clients

What changed since run 1

Run 1 failed on two defects, both fixed before this run:

  • 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.

Both are gone: the baseline run (no fixture at all) reports 1 candidate, 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 12 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 23 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_sinktainted-owner-bridge, owner_key=application.process.id. r2_gst_clean_sink, reading an untainted monitor in a second process, is eligible.

Rows 46 — 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):

{"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: 12 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. 12 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. F11-1, the other, is now closed. Still outstanding:

  1. Hardware playback-to-capture paths ("Stereo Mix") defeat session_device and are a real echo path — needs ALSA control inspection; user design call owed.
  2. Phases 0b / 0c / 0d are untouched and all precede phase 6.
  3. The readiness budget calibration argument (above).
  4. Owed samples: a real third-party forwarder (row 1b), EasyEffects (row 8), a real Audio/Duplex device (row 13).

F11-1 — closed 2026-07-26, with this matrix's data

The rule now implemented (pixelpass c78eb2d, §6.1.2's round-13 box): key 4 bounds an owner only when the node's Client resolves — an unambiguous Client yielding Some(pipewire.sec.pid), read before pipewire-pulse suppression — so a node can no longer bound itself, and escape propagate_unresolved_owner's sweep, with an application.process.id it invented. Bridging still uses the full union.

Codex's round-12 sharpening was the decisive part: "resolved" must mean a sec_pid, not "a unique Client object exists", and the unique-but-pid-less row is the only one that tells the two apart. All five Client cases are unit tests (absent · ambiguous · unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse), plus the recorded three-step leak path end to end. Mutation-verified: dropping the provenance test fails four of the six rows and leaves the two no-over-exclusion rows green.

The cost question the deferral was waiting on, measured on this host: the before- and after-binaries audited the same live graph simultaneously (both are read-only observers) — tagged producer into the default sink, parec on its monitor as a live tainted reader so the sweep was genuinely armed, Firefox + aplay + pacat as bystanders. 181 records each, the same 14 distinct decision states, none exclusive to either side, no unresolved-owner on either, eligible half non-empty throughout. O5 unmoved (identical p50 15 µs and busy fraction 0.0012). Every real app here is native or Pulse-emulated and both resolve; sweeping all 18 live nodes, the only unresolved-Client ones were Dummy-Driver and Freewheel-Driver, which carry no pid key to lose.


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:

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
node.description  node.name   node.nick  object.path  object.serial
priority.driver   priority.session
property announced? what died without it
object.serial, node.name, media.class, client.id, device.id
peerspeak.owned the primary taint root (all of phase 1)
pulse.module.id AEC identity exclusion + phase 4 validation
node.link-group the link-group owner key
application.process.id the process owner key
node.passthrough the passthrough local exclusion
device.api, factory.name, alsa.driver_name session_device classification

Ports lost port.exclusive; Links and Clients were fine — notably pipewire.sec.pid is announced, so pulse-PID derivation was reachable.

Demonstrated end to end: a null sink carrying peerspeak.owned=true whose monitor a module-loopback re-emitted was reported eligible with an empty taint set. In phase 6 that is an echo.

The fix became design round 8 (v3.5 §6.7) and phase 3r: bind each Node and read props off its info, exactly how pw-dump obtains them. factory.id is not a shortcut (factory.id=19 resolves to factory.name = "adapter"), and device.api is on the Device global.

F2 🟠 Machine-wide over-exclusion cascade, downstream of F1

With F1 in force, pixelpass_capture_* (matched on node.name, which is announced) was the only surviving taint root. Row 7 then excluded every 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 capture sink existed. Fail-closed, so silence rather than echo — but entirely non-functional, and non-functional in a way that would have looked like "working safely" to any test that asserted only exclusions.

What run 1's machinery got right

None of this needed revisiting:

  • Running the recompute inline on the observer thread, once per applied registry event, upheld phase 4's no-coalescing contract and put the cost where O5 could measure it.
  • The complete-partition record is what caught F2 — and, in run 2, F13-1.
  • Reason codes survived the trip and were immediately diagnostic.
  • The peerspeak.owned / pulse.module.id fixtures were right: the engine does the correct thing when handed correct properties. Both failures were at the observation boundary, which is where phase 5 was designed to look.