phase 1: tag every audio node peerspeak owns, on both carriers

Zero behaviour change. This is what makes the screenshare exclusion
engine able to see us at all (plan §5.1, impl plan §3): pixelpass must
refuse to fan out our own playback, and until now it had no way to
recognise it.

Two carriers, matched by pixelpass as a union — `peerspeak.owned=1`
and a `node.name` prefix `peerspeak_owned_<role>_<pid>`. Round 8 added
the second after the phase-5 audit found a node property is invisible
to the PipeWire registry `global` event and recoverable only by
binding the node; the prefix is announced directly. A union is also
the fail-closed direction: a missed tag leaks call audio into a share,
a spurious one only over-excludes.

Three tagging sites, all three verified live on this host:
  - native call playback  → props on the stream dict
  - screenshare mpv/VLC   → PULSE_PROP + PIPEWIRE_PROPS on the child
  - notification chimes   → same, on pw-play/paplay

The literals are a cross-repo wire contract, so they appear once here
as named constants and are pinned in a fixture committed byte-identical
in both repos (tests/fixtures/ownership-tag-contract.txt). The contract
test is black-box: it builds a real child `Command` and reads back the
environment it would carry, rather than testing our own formatter.

Three live `#[ignore]`d exit-gate tests drive the real call sites and
poll `pw-dump` for the resulting node — the plan requires the tag be
shown landing on a live node, not just in the env. All three
mutation-verified (drop either carrier, or the role, and the matching
gate fails).

Measured while verifying: mpv, VLC, pw-play and paplay all honour
`node.name` from those env vars. The native stream set neither
`application.name` nor a description, so a mixer fell back to
`node.name` — which the tag turns into an internal identifier. Added
an explicit `node.description = "PeerSpeak"` there, which keeps the
plan's rule (the prefix must not reach `node.description`) while
preserving its intent: mixers stay readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 19:19:25 -04:00
co-authored by Claude Opus 5
parent 6773a3882b
commit bcf1343a55
6 changed files with 601 additions and 6 deletions
+75
View File
@@ -1,3 +1,4 @@
use crate::audio::ownership;
use crate::audio::{AudioBackend, AudioError};
use pipewire as pw;
use pw::{properties::properties, spa};
@@ -371,6 +372,11 @@ fn run_playback(
mainloop_clone.quit();
});
// Ownership tag, both carriers (`crate::audio::ownership`, plan §5.1).
// This is the node that carries the far end's voice, so it is the single
// most important thing for pixelpass to refuse to fan out: sharing it
// would send the call back to the person already speaking on it.
let owned_node_name = ownership::owned_node_name(ownership::NATIVE_PLAYBACK_ROLE);
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
@@ -379,6 +385,19 @@ fn run_playback(
// buffer — the real fix is the explicit Buffers param below — but it
// expresses the intended quantum for any node that honours it.
*pw::keys::NODE_LATENCY => "1024/48000",
ownership::OWNED_PROP_KEY => ownership::OWNED_PROP_VALUE,
// Set explicitly rather than relying on the stream name passed to
// `StreamBox::new` below: props win over that name, and this one has
// to be exact.
*pw::keys::NODE_NAME => owned_node_name.as_str(),
// Measured: this stream sets neither `application.name` nor a
// description, so a mixer falls back to `node.name` — which the line
// above just turned into an internal identifier. The plan's rule is
// that the ownership prefix must not reach `node.description`; a
// human label there is what keeps that rule's *intent* (mixers stay
// readable) true for our own stream, exactly as mpv's own
// description does for the spawned players.
*pw::keys::NODE_DESCRIPTION => "PeerSpeak",
};
if let Some(target) = target_node {
props.insert("node.target", target);
@@ -637,6 +656,62 @@ mod tests {
use std::time::Duration;
use std::{sync::mpsc, thread};
/// Phase-1 exit gate, native-playback half (impl plan §3): the stream
/// that carries the far end's voice appears on the graph with **both**
/// ownership carriers, and still with the `Communication` media role.
///
/// The third and most important of the three tagged paths — this is the
/// node whose audio, if fanned out, would send the call back to whoever
/// is speaking on it.
///
/// Feeds silence, so the gate is inaudible. Live: needs PipeWire and
/// `pw-dump`. `cargo test --lib -- --ignored native_playback`
#[test]
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
fn native_playback_node_carries_both_ownership_carriers() {
use crate::audio::ownership::{self, live_test};
use crate::audio::{AudioBackend, PLAYBACK_TARGET_SAMPLES};
let backend = super::PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
backend
.start_playback(rx, None, ring_fill.clone())
.expect("playback starts");
// Keep the ring fed so the node stays live for the whole poll; the
// stream is created on connect, but a starved one is not a fair test
// of what a real call looks like on the graph.
let feeder = thread::spawn(move || {
let silence = vec![0i16; 960 * 2];
for _ in 0..300 {
if ring_fill.load(Ordering::Relaxed) < PLAYBACK_TARGET_SAMPLES
&& tx.send(silence.clone()).is_err()
{
return;
}
thread::sleep(Duration::from_millis(20));
}
});
let prefix = live_test::expected_prefix(ownership::NATIVE_PLAYBACK_ROLE);
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
let _ = backend.stop();
let _ = feeder.join();
let (name, owned) =
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
assert!(
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
"{name}"
);
assert_eq!(
owned.as_deref(),
Some(ownership::OWNED_PROP_VALUE),
"carrier 1 must be on the live node, not just carrier 2"
);
}
#[test]
fn requested_in_range_is_honored() {
// The graph's requested quantum is produced verbatim when it fits.