diff --git a/src/audio/clip_player.rs b/src/audio/clip_player.rs index 6ee3ba4..fcd0492 100644 --- a/src/audio/clip_player.rs +++ b/src/audio/clip_player.rs @@ -328,4 +328,54 @@ mod tests { assert_eq!(seek_target(-1.0, total), Duration::ZERO); assert_eq!(seek_target(2.0, total), total); } + + /// **The fourth playback path's exit gate (round 10, R10-2).** Drives a + /// real [`ClipPlayer`] — the same object the app uses for chat clips, peer + /// music and the local playlist — and asserts the node it puts on the + /// graph carries both ownership carriers. + /// + /// This path was untagged through all of phase 1, which is a real echo: + /// B broadcasts music, A tunes in, A shares their desktop, B hears their + /// own track. It was missed because phase 1 worked from the impl plan's + /// list of three playback sites and that list was incomplete — so this + /// gate drives the *player*, not the tagging helper. + /// + /// ⚠️ **Run alone**: it sets a process-wide environment variable, which is + /// only sound single-threaded. In production `main` does this before + /// anything is spawned; a test binary has no such guarantee, hence + /// `--test-threads=1`. + /// + /// `cargo test --lib -- --ignored --test-threads=1 clip_player_node` + #[test] + #[ignore = "live: requires a running PipeWire daemon and pw-dump; run with --test-threads=1"] + fn clip_player_node_carries_both_ownership_carriers() { + use crate::audio::ownership::{self, live_test}; + + // SAFETY: `--test-threads=1` is documented above and in the ignore + // reason; this is the same call `main` makes, exercised for real + // rather than reimplemented, so the gate cannot pass against a + // formatter that production never uses. + unsafe { ownership::tag_this_process_alsa_audio() }; + + let (player, _status) = ClipPlayer::new(1.0); + // Six seconds of silence: long enough for the poll, inaudible. + player.play([0u8; 32], live_test::silent_wav(6)); + + let prefix = live_test::expected_prefix(ownership::CLIP_ROLE); + let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5)); + player.stop(); + + let (name, owned) = found.unwrap_or_else(|| { + panic!("no live clip-player 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" + ); + } } diff --git a/src/audio/ownership.rs b/src/audio/ownership.rs index 90df079..740132e 100644 --- a/src/audio/ownership.rs +++ b/src/audio/ownership.rs @@ -60,6 +60,46 @@ pub const PULSE_PROP_ENV: &str = "PULSE_PROP"; /// (`pw-play`, mpv on `--ao=pipewire`) receives extra stream properties. pub const PIPEWIRE_PROPS_ENV: &str = "PIPEWIRE_PROPS"; +/// The environment variable read **only by PipeWire's ALSA plugin**, and so +/// the one that reaches audio played through ALSA — which on Linux is how +/// `rodio`/`cpal` reach the graph (round 10, R10-2). +/// +/// This is the *in-process* carrier: unlike [`PULSE_PROP_ENV`] and +/// [`PIPEWIRE_PROPS_ENV`], which are set per-child on a [`Command`], this one +/// is set process-wide in `main` because the audio it tags is played by a +/// library inside this process, with no fork to hang properties on. +/// +/// **Measured 2026-07-25**, with `PIPEWIRE_PROPS` and `PULSE_PROP` explicitly +/// unset, to establish that it is surgical enough to set process-wide: +/// +/// - `aplay` (ALSA plugin) ⇒ node carries **both** carriers. ✅ +/// - `pw-play` (PipeWire-native) ⇒ `node.name=pw-play`, `peerspeak.owned` +/// absent. ✅ So our own native call-playback and capture streams, which go +/// through `pipewire_impl`, are **not** affected by this variable; they keep +/// being tagged explicitly at their own site. +/// - `arecord` (ALSA plugin, capture) ⇒ **is** tagged, on a +/// `Stream/Input/Audio`. ⚠️ Not surgical in the *role* dimension. Harmless +/// only because pixelpass honours the carriers on `Stream/Output/Audio` +/// alone (R10-1) — which is why R10-1 lands first. Nothing in peerspeak +/// captures via ALSA on Linux today (the Linux backend is native PipeWire; +/// `cpal` is the Windows backend), so this is a guard against a future +/// caller, not a live condition. +pub const PIPEWIRE_ALSA_ENV: &str = "PIPEWIRE_ALSA"; + +/// Role of in-process audio played through `rodio`: received chat clips, peer +/// music, and locally chosen playlist tracks (round 10, R10-2). +/// +/// **All three are tagged, deliberately**, including the local playlist. The +/// case for exempting locally chosen music is that the user picked it and may +/// want it shared; the case against is stronger. A local track played from the +/// playlist is *already being broadcast to peers over the call* on the same +/// keypress (`app::play_local_track` → `broadcast_track`), so sharing it a +/// second time through the screen-share sends the far end two copies of the +/// same audio, offset by the two paths' differing latency. That is not a lost +/// feature, it is a defect. A user who wants music in the share can play it in +/// any other application, which peerspeak never tags. +pub const CLIP_ROLE: &str = "clip"; + /// Role of the native call-playback stream — the node carrying the far end's /// voice. pub const NATIVE_PLAYBACK_ROLE: &str = "call"; @@ -135,6 +175,46 @@ pub fn tag_child(command: &mut Command, role: &str) { command.env(PIPEWIRE_PROPS_ENV, pipewire_props_value(&node_name)); } +/// Tag the audio this process plays **through ALSA** — in practice everything +/// `rodio` plays, which is [`crate::audio::clip_player::ClipPlayer`]: received +/// chat clips, peer music, and local playlist tracks. +/// +/// `ClipPlayer` opens a `rodio` default sink, which on Linux reaches the graph +/// through PipeWire's ALSA plugin. It is a genuine fourth playback path and it +/// was untagged until round 10, which is a real echo: B broadcasts music, A +/// tunes in, A shares their desktop, and B hears their own track played back +/// at them. Found by Codex's phase-1 review; confirmed live as +/// `alsa_playback.peerspeak-…` with no ownership properties at all. +/// +/// # Why an environment variable and not a property on the stream +/// +/// `rodio` exposes no way to set PipeWire node properties — it is an ALSA +/// consumer several layers down. The alternative fix is to rebuild `ClipPlayer` +/// on peerspeak's own PipeWire backend, which is a large change for an +/// identical outcome. [`PIPEWIRE_ALSA_ENV`] reaches exactly the ALSA path and +/// nothing else (measured — see the constant). +/// +/// # Must be called before any thread starts +/// +/// `std::env::set_var` is `unsafe` in edition 2024 because a concurrent +/// `getenv` in another thread is a data race. The only safe moment is the top +/// of `main`, before anything is spawned; that is also correct on the merits, +/// since the plugin reads the variable when a stream is opened. +/// +/// Inherited values are **replaced**. Unlike the `Command` carriers (R10-5), +/// there is nothing to preserve: no user sets `PIPEWIRE_ALSA` to route +/// peerspeak's own clip playback, and honouring one would defeat the tag. +/// +/// # Safety +/// +/// The caller must guarantee no other thread exists in this process. +pub unsafe fn tag_this_process_alsa_audio() { + let node_name = owned_node_name(CLIP_ROLE); + // SAFETY: the caller's obligation, discharged by calling this at the top + // of `main` before any thread is spawned. + unsafe { std::env::set_var(PIPEWIRE_ALSA_ENV, pipewire_props_value(&node_name)) }; +} + /// Live-test support for the phase-1 exit gate, shared by the two modules /// that spawn tagged players (`notify`, `screenshare`). /// @@ -353,6 +433,72 @@ mod tests { ); } + /// R10-2's in-process carrier, checked as a *value*, not by setting the + /// variable: mutating the process environment from a test races every + /// other test in the binary. That the value reaches `PIPEWIRE_ALSA` at all + /// is what the live gate below proves, and only a live gate can. + #[test] + fn the_alsa_carrier_value_carries_both_carriers() { + let name = owned_node_name(CLIP_ROLE); + let value = pipewire_props_value(&name); + + assert_eq!(PIPEWIRE_ALSA_ENV, "PIPEWIRE_ALSA"); + assert!( + value.contains(&format!( + "\"{}\" = \"{}\"", + fixture_get("prop_key"), + fixture_get("prop_value") + )), + "carrier 1: {value}" + ); + assert!( + value.contains(&format!("\"node.name\" = \"{name}\"")), + "carrier 2: {value}" + ); + assert!(name.starts_with(&fixture_get("node_name_prefix"))); + // The role is a distinct token, so an audit can tell clip audio from + // the call stream and from a spawned player. + assert!(name.contains("_clip_"), "role token present: {name}"); + } + + /// The in-process carrier only works if `main` actually sets it, and the + /// live gate cannot prove that half: a test binary has no `main`, so it + /// must call the tagger itself. That leaves exactly one way to regress — + /// delete the line from `main` — and this is the cheapest thing that + /// catches it. + /// + /// A source-text assertion is crude. It is also the *only* check available + /// short of driving the real GUI binary, and phase 1's miss was precisely + /// a call site nobody verified existed. + #[test] + fn main_tags_this_process_before_anything_starts() { + const MAIN: &str = include_str!("../main.rs"); + + // ⚠️ Comments are stripped first, and that is not tidiness. The first + // version of this test searched the raw source and **passed against a + // `main` with the call deleted**, because the explanatory comment + // above it still named the function. A gate satisfiable by two sources + // gates neither (the phase-3r lesson); here the prose was the second + // source. Caught by mutation, which is the only reason it is not still + // green and worthless. + let code: String = MAIN + .lines() + .map(|line| line.split("//").next().unwrap_or("")) + .collect::>() + .join("\n"); + + let call = code + .find("tag_this_process_alsa_audio()") + .expect("main must call tag_this_process_alsa_audio (round 10, R10-2)"); + let gui = code + .find("run_gui") + .expect("main runs the GUI; this test's ordering check assumes it"); + assert!( + call < gui, + "the tag must be set before the GUI starts any thread" + ); + } + #[test] fn every_generated_name_matches_the_prefix_pixelpass_looks_for() { for role in ["call", "mpv", "vlc", "notify"] { diff --git a/src/main.rs b/src/main.rs index 9a42670..270523c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,22 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Tag the audio we play through ALSA (rodio's `ClipPlayer`: chat clips, + // peer music, local playlist tracks) so the screen-share exclusion engine + // can recognise it as ours and refuse to fan it back to the far end. + // + // First statement in the program, and that is load-bearing: this sets an + // environment variable, which is only sound while the process is still + // single-threaded, and PipeWire's ALSA plugin reads it when a stream is + // opened. See `audio::ownership::tag_this_process_alsa_audio`. + // + // SAFETY: nothing has been spawned yet, so no thread can be reading the + // environment concurrently. + #[cfg(target_os = "linux")] + unsafe { + peerspeak::audio::ownership::tag_this_process_alsa_audio() + }; + if let Err(e) = peerspeak::app::run_gui() { eprintln!("Error running GUI: {:?}", e); }