16 Commits
Author SHA1 Message Date
mollusk ca3122b92f feat(windows): add viewer-only PixelPass milestone 2026-08-22 20:42:23 -04:00
mollusk 2f00df758d chore: update h2 for security advisory 2026-08-22 14:52:11 -04:00
mollusk ce909afc4b fix: clear resolved audio exclusion statuses 2026-08-22 02:41:26 -04:00
mollusk 360d7112e6 test(host): harden Phase 9 live rig 2026-08-22 00:39:38 -04:00
mollusk 98bb78f9cf test(host): add Phase 9 correlation rig 2026-08-21 22:24:07 -04:00
mollusk 792f2bd55a feat(cli): publish desktop audio exclusion 2026-08-21 21:53:54 -04:00
mollusk e027bc65e5 test(host): qualify Phase 6 AEC leak path 2026-08-21 20:59:08 -04:00
mollusk 7b118272b0 test(host): close Row 9 fanout partition 2026-08-21 19:55:41 -04:00
mollusk 956534f63c fix(host): close unsupported fanout gates 2026-08-21 19:45:18 -04:00
mollusk 6be07ef706 fix(host): close desktop audio failure gates 2026-08-21 17:34:44 -04:00
mollusk d09ee9b02f feat(host): recover desktop audio fanout after sink replacement 2026-08-21 17:00:30 -04:00
mollusk 5a65f50c4b feat(host): emit desktop audio exclusion status events 2026-08-21 16:31:11 -04:00
mollusk 98cde2c19b feat(host): fan out desktop audio through owned links 2026-08-21 16:12:52 -04:00
mollusk 781defcd84 feat(host): build desktop audio exclusion foundation 2026-08-21 15:39:07 -04:00
mollusk 5d3da8b006 fix(host): contain capture owners and fail closed
Bound the libpipewire router shutdown without detaching its OS handle, contain GStreamer and pactl children in parent-bound process groups, and make ownership failures terminal through the capture supervisor.\n\nAdd focused lifecycle tests plus a serialized live router teardown gate.
2026-08-15 15:30:43 -04:00
mollusk 70820cf903 build(deps): refresh audited AppImage inputs
Update the vulnerable PixelPass lockfile entries and document the exact Rust and Pulse development requirements for AppImage builds.
2026-08-14 18:05:00 -04:00
40 changed files with 8711 additions and 638 deletions
Generated
+22 -31
View File
@@ -160,7 +160,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -171,7 +171,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1625,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1910,7 +1910,7 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows-link 0.2.1",
"windows-link 0.1.3",
"windows-result 0.4.1",
]
@@ -2092,9 +2092,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -3557,7 +3557,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4015,7 +4015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -4250,7 +4250,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64",
"indexmap",
"quick-xml 0.41.0",
"quick-xml",
"serde",
"time",
]
@@ -4452,15 +4452,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -4737,7 +4728,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4795,7 +4786,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4887,7 +4878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5203,7 +5194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5391,7 +5382,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5800,7 +5791,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6211,12 +6202,12 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.10"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.39.4",
"quick-xml",
"quote",
]
@@ -6254,15 +6245,15 @@ dependencies = [
[[package]]
name = "webbrowser"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f"
dependencies = [
"core-foundation 0.10.1",
"jni 0.22.4",
"log",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
"web-sys",
@@ -6433,7 +6424,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+9 -4
View File
@@ -40,9 +40,17 @@ anyhow = "1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
nix = { version = "0.30", features = ["signal", "process"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# The host/capture stack is intentionally Linux-only. Keeping it out of the
# Windows dependency graph lets the same binary provide the transport/viewer
# half without trying to cross-link PipeWire, PulseAudio, Wayland, or X11.
nix = { version = "0.30", features = ["signal", "process"] }
directories = "5"
ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
pipewire = "0.9"
@@ -57,9 +65,6 @@ pipewire = "0.9"
# RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0.
libpulse-binding = "2.30"
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false }
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
ureq = { version = "3", default-features = false, features = ["rustls"] }
toml = "1"
+68 -11
View File
@@ -1,6 +1,7 @@
# pixelpass
P2P screen sharing CLI for Linux. Single binary, hole-punched over
P2P screen sharing CLI. Linux can host or view; the first Windows milestone
can view a Linux-hosted share. A single binary, hole-punched over
[iroh](https://www.iroh.computer/) — no port forwarding, no signup, no
server-side accounts. Hardware-encoded H.264 + AAC audio, viewed in
mpv or VLC.
@@ -21,13 +22,17 @@ Working:
- VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable
GPUs should work), with a software x264 fallback via `--no-hwencode`
- Audio capture of the default sink's monitor, with optional per-app
routing (`--app <name>`)
routing (`--app <name>`) and a guarded whole-desktop mode for parent
integrations (`--audio-mode=desktop-excluding`)
- `--repair` cleanup of orphaned PipeWire state left by a crashed host
- `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
viewer player, relay reachability) — see [Diagnostics](#diagnostics)
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
- Headless mode for scripts (`pixelpass <ticket>`)
- Headless Windows 10 viewer: consumes the same ticket and JSON event protocol,
tunnels the stream to localhost, and works with PeerSpeak's external VLC/mpv
launcher
- Multi-viewer fanout (default 2, configurable via `--max-viewers`;
shared gst pipeline, one broadcast channel per host)
- First-run upstream bandwidth pre-flight, persisted to
@@ -38,6 +43,9 @@ Working:
derives quality from the bandwidth pre-flight
Not yet built (deferred, not blocking):
- Windows hosting/capture, including desktop capture, WASAPI system audio, and
PeerSpeak voice exclusion. The current Windows binary is deliberately
viewer-only and reports Linux host-audio capabilities as unavailable.
- Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the
whole root canvas; single-monitor cropping needs xrandr region coords
- `use-damage=true` CPU optimization for the X11 capture path
@@ -102,6 +110,8 @@ the capture machinery is untouched by it. On a build without the feature,
## Requirements
### Linux host or viewer
- Linux (Wayland or X11; the backend is autodetected)
- A VAAPI-capable GPU and the right driver:
- AMD: `libva-mesa-driver`
@@ -123,6 +133,13 @@ the capture machinery is untouched by it. On a build without the feature,
mpv ships its own decoder stack and doesn't share either dependency.
- PipeWire (for screencast portal + audio capture)
### Windows viewer
- Windows 10 build 19045 or newer
- VLC or mpv on `PATH`; VLC is available as `VideoLAN.VLC` through `winget`
- A share ticket from a PixelPass host (hosting remains Linux-only in this
milestone)
On Arch / CachyOS / EndeavourOS:
```sh
@@ -183,6 +200,23 @@ windowing stack). Build it with:
cargo build --release --features gui
```
### Windows viewer
Cross-build the headless viewer with MinGW; the Linux-only capture dependencies
are excluded from this target:
```sh
rustup target add x86_64-pc-windows-gnu
# Install the MinGW-w64 compiler using your distro's package manager.
cargo build --release --target x86_64-pc-windows-gnu
```
The result is
`target/x86_64-pc-windows-gnu/release/pixelpass.exe`. Validate it on Windows
with `pixelpass.exe --doctor`, then pass a ticket directly or let PeerSpeak
drive it with `--output json`. See [Windows support](docs/WINDOWS.md) for the
support boundary and smoke-test gates.
## How it works
```
@@ -316,12 +350,34 @@ matches a built-in overrides that built-in.
## Audio
By default pixelpass captures the default sink's monitor — the viewer
hears whatever the host hears. `--app <name>` narrows that to a single
application: pixelpass creates a per-PID null-sink and uses libpipewire to
reroute matching `Stream/Output/Audio` nodes (by `application.name`) into
it, so the viewer hears just that app instead of the whole desktop. In the
interactive menu you can pick the app from a list of what's currently
playing.
hears whatever the host hears. This is also available explicitly as
`--audio-mode=desktop-shared`.
`--app <name>` narrows capture to a single application: pixelpass creates a
per-PID null-sink and uses libpipewire to reroute matching
`Stream/Output/Audio` nodes (by `application.name`) into it, so the viewer
hears just that app instead of the whole desktop. In the interactive menu
you can pick the app from a list of what's currently playing.
Parent integrations can select guarded whole-desktop capture with
`--audio-mode=desktop-excluding`. This copies eligible playback streams into
a connection-owned capture sink while excluding PeerSpeak-tagged playback,
the exact configured echo-canceller, unsafe ancestry, and unsupported stream
formats. The mode requires an explicit AEC state so a missing integration
argument cannot silently mean "no AEC":
```sh
pixelpass --host --audio-mode=desktop-excluding --aec=off
pixelpass --host --audio-mode=desktop-excluding --aec=pulse-module:536870919
```
Integrations should use `pixelpass --capabilities`, not scrape `--help`. Its
versioned response advertises strict per-app audio and desktop exclusion as
independent capabilities:
```json
{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}
```
Microphone capture is intentionally out of scope — pixelpass is a
screen-share tool meant to be paired with a dedicated voice app (Mumble,
@@ -407,9 +463,10 @@ each take precedence over the chosen preset's value for that field.
either one breaks playback (the first kills the demuxer, the second
kills the H.264 decoder). pixelpass warns at player-launch time if
either plugin isn't on disk. mpv doesn't share these dependencies.
- **Audio echo** if the host plays the stream through speakers and
captures system audio — expected, the mic / monitor picks up the
playback. Headphones bypass it.
- **Audio echo in `desktop-shared` mode** if the host plays the stream through
speakers while capturing system audio. The guarded `desktop-excluding`
mode requires a cooperating parent integration to tag its playback and
supply the active AEC identity.
- **Late joiners see ~2 s of garbage** before the next keyframe lets
their decoder lock. Expected behavior, not a bug.
- **VAAPI driver must be package-tracked**, not an orphaned `.so` on
+102
View File
@@ -0,0 +1,102 @@
# Windows support
## Current milestone
PixelPass on Windows is a **viewer**, not a screen-share host. It preserves the
same viewer-side architecture used on Linux:
1. parse an iroh endpoint ticket;
2. connect to the Linux host over the existing `pixelpass/0` ALPN;
3. expose the tunneled MPEG-TS stream on a random loopback HTTP port;
4. emit `{"event":"connected","url":"http://127.0.0.1:..."}` when
`--output json` is enabled;
5. let PeerSpeak launch VLC/mpv, or launch one from PixelPass's interactive
viewer prompt.
No codec, container, ticket, ALPN, or JSON protocol fork was introduced for
Windows.
## Support matrix
| Capability | Linux | Windows 10 |
| --- | --- | --- |
| View a share | Yes | Yes |
| Headless `--output json` viewer | Yes | Yes |
| Interactive viewer/player launch | Yes | Yes |
| Host Wayland/X11 capture | Yes | No |
| Host desktop/system audio | PipeWire/Pulse | No |
| PeerSpeak voice exclusion | Yes | No |
| PixelPass GUI | Yes (feature build) | No |
Unsupported host operations fail with an explicit viewer-only error. On
Windows, `--capabilities` reports both host-audio capability flags as `false`,
so parent integrations cannot mistake this milestone for full hosting parity.
## Build
From Linux with Rust 1.95+ and MinGW-w64 installed:
```sh
rustup target add x86_64-pc-windows-gnu
cargo build --release --target x86_64-pc-windows-gnu
```
The artifact is:
```text
target/x86_64-pc-windows-gnu/release/pixelpass.exe
```
The Windows target does not compile or link PipeWire, PulseAudio, Wayland, X11,
or the Linux GUI stack. Keep the build headless; `--gui` is intentionally
rejected on Windows.
## Validation gates
Before bundling a Windows binary with PeerSpeak:
```sh
cargo fmt -- --check
cargo clippy --target x86_64-pc-windows-gnu -- -D warnings
cargo test -- --test-threads=1
cargo build --release --target x86_64-pc-windows-gnu
```
Then on an actual Windows 10 build 19045 VM:
1. verify the transferred SHA-256;
2. run `pixelpass.exe --version` and `pixelpass.exe --capabilities`;
3. run `pixelpass.exe --doctor` with VLC or mpv installed;
4. start a real Linux host, pass its fresh ticket to the Windows executable
with `--output json`, and verify the `connected` event;
5. open the emitted loopback URL in the player and confirm moving video and
audio;
6. stop the player/viewer and confirm both sides terminate cleanly.
### Verified baseline
On 2026-08-22 this gate passed on Windows 10 Enterprise Evaluation build 19045
against a Wayland Linux host using software x264 at the Low preset. The Windows
viewer emitted a loopback URL, VLC 3.0.23 rendered the live desktop, closing VLC
terminated the viewer, and the host emitted `viewer_left` followed by
`capture: stopped`. The final release artifact was 13,165,056 bytes with
SHA-256:
```text
3eabd9f0dcd8565136a5c87a79470eceedd426cea5708904d7492a6fa37b3c49
```
The run deliberately declined Windows Defender's one-off public-network allow
prompt; relay viewing succeeded without it. A packaged application should own
an explicit, idempotent firewall rule for the final installed path instead of
depending on that prompt.
## Next Windows phases
Windows hosting should be added behind a native capture boundary rather than by
weakening the Linux implementation:
1. desktop/window video capture and H.264 encode;
2. WASAPI system-audio capture;
3. PeerSpeak-owned voice/AEC exclusion with a fail-closed contract;
4. dependency packaging, firewall rules, and installer upgrade coverage.
+3 -2
View File
@@ -47,9 +47,10 @@ distrobox create --yes --image ubuntu:24.04 --name pixelpass-build
distrobox enter pixelpass-build -- sudo apt-get update
distrobox enter pixelpass-build -- sudo apt-get install -y \
build-essential cmake clang libclang-dev pkg-config \
libpipewire-0.3-dev libspa-0.2-dev curl ca-certificates file
# Install rustup inside the box (edition 2024 needs rustc >= 1.85), then:
libpipewire-0.3-dev libspa-0.2-dev libpulse-dev curl ca-certificates file
rustup toolchain install 1.97.1 --profile default
distrobox enter pixelpass-build -- env \
PATH="$HOME/.rustup/toolchains/1.97.1-x86_64-unknown-linux-gnu/bin:$PATH" \
CARGO_TARGET_DIR=~/.cache/pixelpass-ubuntu/target \
./packaging/appimage/build-appimage.sh
```
+67
View File
@@ -0,0 +1,67 @@
//! Versioned machine-readable feature discovery for parent integrations.
//!
//! PeerSpeak may execute an older PixelPass from `PATH`, so recognizing a CLI
//! token in `--help` cannot be the primary protocol. This response advertises
//! strict per-app audio and desktop audio exclusion independently; neither can
//! accidentally imply the other.
use anyhow::{Context, Result};
use serde::Serialize;
use std::io::Write;
const CAPABILITY_SCHEMA_VERSION: u32 = 1;
#[derive(Serialize)]
struct CapabilityResponse {
schema_version: u32,
capabilities: CapabilitySet,
}
#[derive(Serialize)]
struct CapabilitySet {
strict_app_audio: bool,
desktop_audio_exclusion: bool,
}
fn response() -> CapabilityResponse {
CapabilityResponse {
schema_version: CAPABILITY_SCHEMA_VERSION,
capabilities: CapabilitySet {
// These are host-side audio features. The Windows milestone is a
// viewer only, so advertising either one there would falsely
// imply that its Linux capture/audio stack is available.
strict_app_audio: cfg!(target_os = "linux"),
desktop_audio_exclusion: cfg!(target_os = "linux"),
},
}
}
fn write_response(mut writer: impl Write) -> Result<()> {
serde_json::to_writer(&mut writer, &response()).context("serialize capability response")?;
writeln!(writer).context("write capability response")
}
pub fn run() -> Result<()> {
write_response(std::io::stdout().lock())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_one_wire_shape_is_exact_and_capabilities_are_independent() {
let mut output = Vec::new();
write_response(&mut output).expect("serialize the capability golden");
let expected = if cfg!(target_os = "linux") {
br#"{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}
"#
.as_slice()
} else {
br#"{"schema_version":1,"capabilities":{"strict_app_audio":false,"desktop_audio_exclusion":false}}
"#
.as_slice()
};
assert_eq!(output, expected);
}
}
+293 -5
View File
@@ -1,15 +1,20 @@
#![cfg_attr(target_os = "windows", allow(dead_code))]
use anyhow::{Result, bail};
use clap::{Parser, ValueEnum};
use crate::common::aec::{AecConfig, parse_aec_arg};
#[derive(Parser, Debug)]
#[command(
name = "pixelpass",
version,
about = "P2P screen sharing over iroh",
long_about = "Run with no arguments for an interactive Host/View menu. \
long_about = "Run with no arguments for the platform's interactive mode. \
Pass a ticket positionally to skip the menu and view headlessly."
)]
pub struct Cli {
/// iroh ticket. If present, runs as viewer. If absent, runs as host.
/// iroh ticket. If present, runs as viewer. If absent, enters interactive mode.
pub ticket: Option<String>,
// ── host options ──────────────────────────────────────────────────
@@ -38,6 +43,38 @@ pub struct Cli {
#[arg(long)]
pub strict_audio: bool,
/// Select whole-desktop audio behavior. `desktop-shared` captures the
/// default output mix. `desktop-excluding` captures system audio while
/// excluding PeerSpeak-owned playback and the configured AEC identity.
#[arg(
long,
value_enum,
value_name = "MODE",
requires = "host",
conflicts_with_all = ["app", "internal_desktop_excluding"]
)]
pub audio_mode: Option<AudioModeArg>,
/// Echo-canceller identity for `--audio-mode=desktop-excluding`.
/// PeerSpeak passes `off` when no AEC is active, or the exact Pulse module
/// index returned by `pactl load-module` as `pulse-module:<idx>`.
#[arg(
long,
value_name = "off|pulse-module:<idx>",
requires = "host",
value_parser = parse_aec_cli
)]
pub aec: Option<AecConfig>,
/// Internal phase-0d trigger retained for deterministic compatibility and
/// mutation tests. Production integrations use `--audio-mode`.
#[arg(
long,
hide = true,
conflicts_with_all = ["app", "audio_mode"]
)]
pub internal_desktop_excluding: bool,
/// Override display server autodetection.
#[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>,
@@ -101,6 +138,10 @@ pub struct Cli {
#[arg(long, short)]
pub verbose: bool,
/// Print the versioned machine-readable capability response, then exit.
#[arg(long)]
pub capabilities: bool,
/// Clean up orphaned PipeWire state from a crashed host run, then exit.
#[arg(long)]
pub repair: bool,
@@ -156,6 +197,14 @@ pub enum OutputFormat {
Json,
}
/// Public values for the whole-desktop audio selector. These names are the
/// Phase-7 cross-repository CLI contract consumed by PeerSpeak.
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum AudioModeArg {
DesktopShared,
DesktopExcluding,
}
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
/// tuple — resolution is a quality-per-bitrate knob, so the three only make
/// sense together. `Auto` has no fixed tuple; it picks one of the others from
@@ -174,6 +223,34 @@ pub enum Quality {
Auto,
}
/// Internal input to the typed audio-capture planner. The public `AudioModeArg`
/// is resolved to this type once, at the CLI boundary.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum CaptureMode {
#[default]
Legacy,
DesktopExcluding,
}
impl CaptureMode {
fn validate_inputs(self, app: Option<&str>, legacy_null_sink: bool) -> Result<()> {
if self != Self::DesktopExcluding {
return Ok(());
}
if app.is_some() {
bail!(
"desktop-excluding audio conflicts with --app; refusing to fall back to legacy per-app routing"
);
}
if legacy_null_sink {
bail!(
"desktop-excluding audio conflicts with PIXELPASS_AUDIO_VIA_NULL_SINK; refusing to load the legacy default-monitor loopback"
);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct HostOpts {
pub window: bool,
@@ -194,6 +271,16 @@ pub struct HostOpts {
pub no_hwencode: bool,
pub max_viewers: Option<u32>,
pub interactive: bool,
/// Resolved public or test-only selector.
pub(crate) capture_mode: CaptureMode,
/// Explicit AEC state for desktop-excluding fan-out. Legacy invocations
/// resolve to `Off`; public desktop-excluding invocations must spell this
/// on argv so absence can never masquerade as "no AEC".
pub(crate) aec: AecConfig,
/// Snapshot the legacy dogfood override during CLI resolution. Capture is
/// lazy, so reading the process environment later would let it change modes
/// between ticket creation and the first viewer.
pub(crate) legacy_null_sink: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>,
}
@@ -207,8 +294,37 @@ pub struct ViewerOpts {
}
impl Cli {
pub fn into_host_opts(self, interactive: bool) -> HostOpts {
HostOpts {
pub fn into_host_opts(self, interactive: bool) -> Result<HostOpts> {
let legacy_null_sink = std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
self.into_host_opts_with_legacy_override(interactive, legacy_null_sink)
}
fn into_host_opts_with_legacy_override(
self,
interactive: bool,
legacy_null_sink: bool,
) -> Result<HostOpts> {
let public_desktop_excluding = self.audio_mode == Some(AudioModeArg::DesktopExcluding);
let capture_mode = match self.audio_mode {
Some(AudioModeArg::DesktopExcluding) => CaptureMode::DesktopExcluding,
Some(AudioModeArg::DesktopShared) => CaptureMode::Legacy,
None if self.internal_desktop_excluding => CaptureMode::DesktopExcluding,
None => CaptureMode::Legacy,
};
capture_mode.validate_inputs(self.app.as_deref(), legacy_null_sink)?;
let aec = match (capture_mode, self.aec) {
(CaptureMode::DesktopExcluding, Some(aec)) => aec,
(CaptureMode::DesktopExcluding, None) if public_desktop_excluding => {
bail!("--audio-mode=desktop-excluding requires --aec=off|pulse-module:<idx>");
}
(CaptureMode::DesktopExcluding, None) => AecConfig::Off,
(CaptureMode::Legacy, Some(_)) => {
bail!("--aec requires --audio-mode=desktop-excluding");
}
(CaptureMode::Legacy, None) => AecConfig::Off,
};
Ok(HostOpts {
window: self.window,
app: self.app,
strict_audio: self.strict_audio,
@@ -222,8 +338,11 @@ impl Cli {
no_hwencode: self.no_hwencode,
max_viewers: self.max_viewers,
interactive,
capture_mode,
aec,
legacy_null_sink,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
}
})
}
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
@@ -234,3 +353,172 @@ impl Cli {
}
}
}
fn parse_aec_cli(value: &str) -> std::result::Result<AecConfig, String> {
parse_aec_arg(value).map_err(|_| {
format!(
"invalid AEC identity {value:?}; expected `off` or `pulse-module:<unsigned decimal>`"
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn phase_0d_trigger_is_hidden_from_help() {
let help = Cli::command().render_long_help().to_string();
assert!(!help.contains("internal-desktop-excluding"));
}
#[test]
fn phase_0d_trigger_conflicts_with_app_during_clap_parsing() {
let error = Cli::try_parse_from([
"pixelpass",
"--host",
"--internal-desktop-excluding",
"--app",
"Firefox",
])
.expect_err("clap must reject the hidden mode combined with --app");
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn phase_0d_trigger_conflicts_with_legacy_env_override_during_option_resolution() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--internal-desktop-excluding"])
.expect("hidden trigger parses");
let error = cli
.into_host_opts_with_legacy_override(false, true)
.expect_err("legacy override must be rejected before host startup");
assert!(error.to_string().contains("PIXELPASS_AUDIO_VIA_NULL_SINK"));
}
#[test]
fn phase_0d_trigger_reaches_the_internal_host_mode() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--internal-desktop-excluding"])
.expect("hidden trigger parses");
let opts = cli
.into_host_opts_with_legacy_override(false, false)
.expect("non-conflicting hidden mode resolves");
assert_eq!(opts.capture_mode, CaptureMode::DesktopExcluding);
assert_eq!(opts.aec, AecConfig::Off);
assert!(!opts.legacy_null_sink);
}
#[test]
fn phase_7_public_contract_is_visible_in_help() {
let help = Cli::command().render_long_help().to_string();
assert!(help.contains("--audio-mode <MODE>"));
assert!(help.contains("desktop-shared"));
assert!(help.contains("desktop-excluding"));
assert!(help.contains("--aec <off|pulse-module:<idx>>"));
assert!(help.contains("--capabilities"));
}
#[test]
fn public_desktop_modes_resolve_to_the_typed_planner() {
let shared = Cli::try_parse_from(["pixelpass", "--host", "--audio-mode=desktop-shared"])
.expect("public shared mode parses")
.into_host_opts_with_legacy_override(false, false)
.expect("public shared mode resolves");
assert_eq!(shared.capture_mode, CaptureMode::Legacy);
assert_eq!(shared.aec, AecConfig::Off);
let excluding = Cli::try_parse_from([
"pixelpass",
"--host",
"--audio-mode=desktop-excluding",
"--aec=pulse-module:536870919",
])
.expect("public excluding mode parses")
.into_host_opts_with_legacy_override(false, false)
.expect("public excluding mode resolves");
assert_eq!(excluding.capture_mode, CaptureMode::DesktopExcluding);
assert_eq!(excluding.aec, AecConfig::PulseModule(536_870_919));
}
#[test]
fn public_desktop_excluding_requires_explicit_aec_state() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--audio-mode=desktop-excluding"])
.expect("mode token parses before semantic validation");
let error = cli
.into_host_opts_with_legacy_override(false, false)
.expect_err("missing AEC state must fail the public excluding mode");
assert!(error.to_string().contains("requires --aec"));
}
#[test]
fn public_audio_protocol_flags_require_host_mode() {
for args in [
["pixelpass", "--audio-mode=desktop-shared"],
["pixelpass", "--aec=off"],
] {
let error = Cli::try_parse_from(args)
.expect_err("host-only audio protocol flag parsed without --host");
assert_eq!(
error.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
}
}
#[test]
fn aec_is_rejected_outside_desktop_excluding_and_malformed_at_parse_time() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--aec=off"])
.expect("AEC token parses before mode validation");
assert!(
cli.into_host_opts_with_legacy_override(false, false)
.expect_err("legacy capture must not silently ignore an AEC identity")
.to_string()
.contains("requires --audio-mode=desktop-excluding")
);
let malformed = Cli::try_parse_from([
"pixelpass",
"--host",
"--audio-mode=desktop-excluding",
"--aec=module:7",
])
.expect_err("the public CLI must use the Phase-4 exact grammar");
assert_eq!(malformed.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn old_peerspeak_host_argv_golden_is_byte_compatible_without_aec() {
let whole_desktop = Cli::try_parse_from(["pixelpass", "--host", "--output", "json"])
.expect("new PixelPass must accept old whole-desktop argv unchanged")
.into_host_opts_with_legacy_override(false, false)
.expect("old whole-desktop argv resolves without new flags");
assert_eq!(whole_desktop.capture_mode, CaptureMode::Legacy);
assert_eq!(whole_desktop.aec, AecConfig::Off);
assert!(whole_desktop.app.is_none());
// Exact argv emitted by PeerSpeak before the Phase-8 integration.
let cli = Cli::try_parse_from([
"pixelpass",
"--host",
"--output",
"json",
"--app=Firefox",
"--strict-audio",
])
.expect("new PixelPass must accept old PeerSpeak argv unchanged");
assert!(cli.host);
assert_eq!(cli.output, Some(OutputFormat::Json));
assert_eq!(cli.app.as_deref(), Some("Firefox"));
assert!(cli.strict_audio);
assert!(cli.audio_mode.is_none());
assert!(cli.aec.is_none());
let opts = cli
.into_host_opts_with_legacy_override(false, false)
.expect("old PeerSpeak argv resolves without new flags");
assert_eq!(opts.capture_mode, CaptureMode::Legacy);
assert_eq!(opts.aec, AecConfig::Off);
assert_eq!(opts.app.as_deref(), Some("Firefox"));
assert!(opts.strict_audio);
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Platform-neutral parsing for PixelPass's host audio/AEC command-line contract.
//!
//! Hosting currently remains Linux-only, but the CLI is shared by the Windows
//! viewer build so unsupported host invocations can be parsed and rejected with
//! a direct platform message instead of looking like unknown arguments.
/// The parsed `--aec=off|pulse-module:<idx>` argument (decision D5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecConfig {
/// `--aec=off` — PeerSpeak's AEC is not in play. This is distinct from an
/// absent argument; the CLI decides when explicit state is required.
Off,
/// Validate this live Pulse module index before trusting it. The index is
/// compared as `u64`, never `u32`.
PulseModule(u64),
}
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge:
/// a malformed identity must never silently become "no AEC".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecParseError {
Empty,
UnknownForm,
MissingIndex,
InvalidIndex,
}
/// Parse one exact `off` or `pulse-module:<bare u64 decimal>` value.
///
/// The grammar deliberately rejects signs, whitespace, non-decimal digits,
/// and overflow while accepting indices beyond `u32::MAX`. PeerSpeak produces
/// this machine argument from `pactl load-module`, so widening the grammar is
/// less safe than requiring its canonical unsigned decimal.
pub fn parse_aec_arg(value: &str) -> Result<AecConfig, AecParseError> {
if value.is_empty() {
return Err(AecParseError::Empty);
}
if value == "off" {
return Ok(AecConfig::Off);
}
if let Some(index) = value.strip_prefix("pulse-module:") {
if index.is_empty() {
return Err(AecParseError::MissingIndex);
}
if !index.bytes().all(|b| b.is_ascii_digit()) {
return Err(AecParseError::InvalidIndex);
}
return index
.parse::<u64>()
.map(AecConfig::PulseModule)
.map_err(|_| AecParseError::InvalidIndex);
}
Err(AecParseError::UnknownForm)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_cross_platform_cli_contract() {
assert_eq!(parse_aec_arg("off"), Ok(AecConfig::Off));
assert_eq!(
parse_aec_arg("pulse-module:536870919"),
Ok(AecConfig::PulseModule(536_870_919))
);
assert_eq!(
parse_aec_arg(&format!("pulse-module:{}", u64::MAX)),
Ok(AecConfig::PulseModule(u64::MAX))
);
}
#[test]
fn rejects_noncanonical_or_incomplete_values() {
assert_eq!(parse_aec_arg(""), Err(AecParseError::Empty));
assert_eq!(
parse_aec_arg("pulse-module:"),
Err(AecParseError::MissingIndex)
);
assert_eq!(
parse_aec_arg("pulse-module:+7"),
Err(AecParseError::InvalidIndex)
);
assert_eq!(parse_aec_arg("on"), Err(AecParseError::UnknownForm));
}
}
+1 -1
View File
@@ -10,5 +10,5 @@ pub const ALPN: &[u8] = b"pixelpass/0";
/// without their accept loops colliding, and so a control dial never lands on a
/// bare video host (which doesn't speak this protocol). GUI-only, like the rest
/// of the friends stack.
#[cfg(feature = "gui")]
#[cfg(all(feature = "gui", target_os = "linux"))]
pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0";
+188
View File
@@ -0,0 +1,188 @@
//! Linux child-process containment for capture-side helpers.
//!
//! PixelPass owns `gst-launch-1.0` and the short-lived `pactl load-module`
//! workers. They must not outlive a host that is killed or fail-stops: GStreamer
//! can retain a portal/PipeWire capture resource, and a late pactl mutation can
//! race teardown. Each child therefore gets both:
//!
//! - `PR_SET_PDEATHSIG(SIGKILL)`, with the standard parent-race check; and
//! - its own process group, so explicit teardown reaches descendants as well as
//! the direct child.
//!
//! This is intentionally the inverse of [`super::process`], whose viewer
//! players are user-facing detached processes and are meant to survive their
//! launcher.
use nix::libc;
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
use std::io;
use std::os::unix::process::CommandExt;
use std::process::{Child, Command};
/// Install parent-death and process-group containment on `command`.
///
/// The closure runs between `fork` and `exec`, so it contains only direct
/// async-signal-safe syscalls. A failure aborts the spawn rather than launching
/// an uncontained graph-mutating child.
fn configure(command: &mut Command) {
let expected_parent = std::process::id() as libc::pid_t;
// SAFETY: `setpgid`, `prctl`, `getppid`, and `_exit` are direct syscalls and
// are async-signal-safe in the post-fork child. No allocation, logging, or
// locking occurs in the closure.
unsafe {
command.pre_exec(move || {
if libc::setpgid(0, 0) == -1 {
return Err(io::Error::last_os_error());
}
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 {
return Err(io::Error::last_os_error());
}
// The parent may have died after fork but before PR_SET_PDEATHSIG
// was installed. Checking after the prctl closes that window: a
// later death delivers SIGKILL, an earlier one is handled here.
if libc::getppid() != expected_parent {
libc::_exit(127);
}
Ok(())
});
}
}
/// Spawn a contained blocking child.
pub fn spawn(command: &mut Command) -> io::Result<Child> {
configure(command);
command.spawn()
}
/// Spawn a contained Tokio child.
pub fn spawn_tokio(command: &mut tokio::process::Command) -> io::Result<tokio::process::Child> {
configure(command.as_std_mut());
command.spawn()
}
/// Signal the process group created by [`configure`].
pub fn signal_group(leader_pid: u32, signal: Signal) -> nix::Result<()> {
killpg(Pid::from_raw(leader_pid as i32), signal)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::process::Stdio;
use std::time::{Duration, Instant};
const PDEATH_HELPER: &str = "PIXELPASS_TEST_PDEATH_HELPER";
fn process_is_running(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false;
};
// comm is parenthesized and may contain spaces; the state byte follows
// the final `) `. A zombie holds no resources and only awaits init's
// reap, so it is not a surviving capture child for this gate.
stat.rsplit_once(") ")
.and_then(|(_, rest)| rest.as_bytes().first().copied())
.is_some_and(|state| state != b'Z' && state != b'X')
}
#[test]
fn contained_child_has_its_own_process_group() {
let mut command = Command::new("sleep");
command.arg("30");
let mut child = spawn(&mut command).expect("spawn contained child");
let pid = child.id();
// SAFETY: getpgid is a read-only syscall for the live child we own.
let pgid = unsafe { libc::getpgid(pid as libc::pid_t) };
assert_eq!(pgid, pid as libc::pid_t);
signal_group(pid, Signal::SIGKILL).expect("kill contained group");
child.wait().expect("reap contained child");
}
#[test]
fn process_group_signal_reaches_descendants() {
let mut command = Command::new("sh");
command
.args(["-c", "sleep 30 & child=$!; echo $child; wait"])
.stdout(Stdio::piped());
let mut leader = spawn(&mut command).expect("spawn group leader");
let mut line = String::new();
use std::io::BufRead;
std::io::BufReader::new(leader.stdout.take().expect("piped stdout"))
.read_line(&mut line)
.expect("read descendant pid");
let descendant: u32 = line.trim().parse().expect("numeric descendant pid");
assert!(process_is_running(descendant));
signal_group(leader.id(), Signal::SIGKILL).expect("kill whole group");
leader.wait().expect("reap group leader");
let deadline = Instant::now() + Duration::from_secs(2);
while process_is_running(descendant) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!process_is_running(descendant),
"a descendant must not survive explicit group teardown"
);
}
/// Subprocess-only half of the parent-death gate. The outer test launches
/// this exact test in a disposable harness process; when that process exits,
/// the contained sleep must receive SIGKILL.
#[test]
fn pdeathsig_helper() {
if std::env::var_os(PDEATH_HELPER).is_none() {
return;
}
let mut command = Command::new("sleep");
command
.arg("30")
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = spawn(&mut command).expect("spawn pdeath child");
println!("PIXELPASS_CONTAINED_PID={}", child.id());
std::io::stdout().flush().expect("flush child pid");
// std::process::Child has no kill-on-drop behavior. Returning lets the
// helper harness exit while the contained process is still live.
drop(child);
}
#[test]
fn parent_death_kills_the_contained_child() {
let helper = std::env::current_exe().expect("test executable path");
let output = Command::new(helper)
.args([
"--exact",
"common::contained::tests::pdeathsig_helper",
"--nocapture",
])
.env(PDEATH_HELPER, "1")
.output()
.expect("run pdeath helper harness");
assert!(
output.status.success(),
"helper failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let pid: u32 = stdout
.lines()
.find_map(|line| line.strip_prefix("PIXELPASS_CONTAINED_PID="))
.expect("helper printed contained pid")
.parse()
.expect("numeric contained pid");
let deadline = Instant::now() + Duration::from_secs(2);
while process_is_running(pid) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!process_is_running(pid),
"PR_SET_PDEATHSIG must stop the child when its PixelPass parent exits"
);
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
/// Bind the **control-plane** endpoint with the machine's persistent identity
/// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its
/// `EndpointId` is the stable id friends know you by.
#[cfg(feature = "gui")]
#[cfg(all(feature = "gui", target_os = "linux"))]
pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> {
let secret_key = super::identity::load_or_create()?;
bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await
+10 -3
View File
@@ -1,17 +1,24 @@
pub mod aec;
pub mod alpn;
#[cfg(target_os = "linux")]
pub mod bandwidth;
#[cfg(target_os = "linux")]
pub mod config;
#[cfg(target_os = "linux")]
pub mod contained;
// The friends stack (persistent identity + control plane) is GUI-only — a
// headless CLI host runs no presence service — so it's gated with the feature
// that pulls the rest of the GUI, keeping the headless build lean.
#[cfg(feature = "gui")]
#[cfg(all(feature = "gui", target_os = "linux"))]
pub mod control;
#[cfg(target_os = "linux")]
pub mod deps;
#[cfg(target_os = "linux")]
pub mod display;
pub mod endpoint;
#[cfg(feature = "gui")]
#[cfg(all(feature = "gui", target_os = "linux"))]
pub mod friends;
#[cfg(feature = "gui")]
#[cfg(all(feature = "gui", target_os = "linux"))]
pub mod identity;
pub mod output;
pub mod process;
+151
View File
@@ -10,6 +10,8 @@
//! `--gui` front-end re-execs this binary as `pixelpass --host --output json`
//! and parses these lines to drive its window.
#![cfg_attr(target_os = "windows", allow(dead_code))]
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -17,6 +19,14 @@ use serde::Serialize;
static JSON_ENABLED: AtomicBool = AtomicBool::new(false);
/// First wire version for the desktop-audio-exclusion status family.
///
/// Existing event tags predate explicit versioning. These events are consumed
/// across the PixelPass/PeerSpeak process boundary and are landing before the
/// PeerSpeak parser, so their version is carried on every record rather than
/// inferred from either binary's package version.
pub(crate) const AUDIO_EXCLUSION_EVENT_VERSION: u8 = 1;
/// Turn JSON event output on. Called once at startup from `--output json`.
pub fn set_json(enabled: bool) {
JSON_ENABLED.store(enabled, Ordering::Relaxed);
@@ -63,6 +73,24 @@ pub enum Event<'a> {
/// stream went away. Under `--strict-audio`, `lost` means viewers currently
/// hear silence; without it, viewers fall back to whole-desktop audio.
AppAudio { state: AppAudioState },
/// One otherwise-eligible playback stream could not be linked safely.
StreamUnsupported {
version: u8,
stream_serial: u64,
reason: &'a str,
},
/// A previously reported per-stream exclusion no longer applies because
/// the stream became capturable or disappeared from the live graph.
StreamStatusCleared { version: u8, stream_serial: u64 },
/// The configured AEC identity never appeared before its validation
/// deadline. Fan-out remains fail-closed.
AecFailed { version: u8, module_index: u64 },
/// A previously validated AEC identity disappeared. Every owned fan-out
/// link is revoked before this transition is reported.
AecRevoked { version: u8, module_index: u64 },
/// An echo-cancel group other than the configured PeerSpeak instance is
/// present and excluded from fan-out.
ForeignAecWarning { version: u8, link_group: &'a str },
}
#[derive(Serialize)]
@@ -79,6 +107,65 @@ pub enum AppAudioState {
Lost,
}
/// Owned form of the audio-exclusion status family. The PipeWire observer can
/// enqueue this through an unbounded sender without borrowing its snapshot;
/// a Tokio-side forwarder then converts it to the public JSON [`Event`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum AudioExclusionEvent {
StreamUnsupported {
stream_serial: u64,
reason: &'static str,
},
StreamStatusCleared {
stream_serial: u64,
},
AecFailed {
module_index: u64,
},
AecRevoked {
module_index: u64,
},
ForeignAecWarning {
link_group: String,
},
}
impl AudioExclusionEvent {
fn as_event(&self) -> Event<'_> {
match self {
Self::StreamUnsupported {
stream_serial,
reason,
} => Event::StreamUnsupported {
version: AUDIO_EXCLUSION_EVENT_VERSION,
stream_serial: *stream_serial,
reason,
},
Self::StreamStatusCleared { stream_serial } => Event::StreamStatusCleared {
version: AUDIO_EXCLUSION_EVENT_VERSION,
stream_serial: *stream_serial,
},
Self::AecFailed { module_index } => Event::AecFailed {
version: AUDIO_EXCLUSION_EVENT_VERSION,
module_index: *module_index,
},
Self::AecRevoked { module_index } => Event::AecRevoked {
version: AUDIO_EXCLUSION_EVENT_VERSION,
module_index: *module_index,
},
Self::ForeignAecWarning { link_group } => Event::ForeignAecWarning {
version: AUDIO_EXCLUSION_EVENT_VERSION,
link_group,
},
}
}
/// Emit this owned status record through the stable stdout protocol.
pub(crate) fn emit(&self) {
emit(self.as_event());
}
}
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
/// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching.
@@ -118,4 +205,68 @@ mod tests {
.unwrap();
assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#);
}
#[test]
fn audio_exclusion_event_wire_shapes_are_versioned_and_exact() {
let unsupported = serde_json::to_string(
&AudioExclusionEvent::StreamUnsupported {
stream_serial: 4_294_967_297,
reason: "link-creation-failed",
}
.as_event(),
)
.unwrap();
assert_eq!(
unsupported,
r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967297,"reason":"link-creation-failed"}"#
);
let cleared = serde_json::to_string(
&AudioExclusionEvent::StreamStatusCleared {
stream_serial: 4_294_967_297,
}
.as_event(),
)
.unwrap();
assert_eq!(
cleared,
r#"{"event":"stream_status_cleared","version":1,"stream_serial":4294967297}"#
);
let failed = serde_json::to_string(
&AudioExclusionEvent::AecFailed {
module_index: 536_870_919,
}
.as_event(),
)
.unwrap();
assert_eq!(
failed,
r#"{"event":"aec_failed","version":1,"module_index":536870919}"#
);
let revoked = serde_json::to_string(
&AudioExclusionEvent::AecRevoked {
module_index: 536_870_919,
}
.as_event(),
)
.unwrap();
assert_eq!(
revoked,
r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#
);
let warning = serde_json::to_string(
&AudioExclusionEvent::ForeignAecWarning {
link_group: "echo-cancel-9999-13".to_string(),
}
.as_event(),
)
.unwrap();
assert_eq!(
warning,
r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#
);
}
}
+24 -4
View File
@@ -1,12 +1,15 @@
use std::io;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};
/// Spawn a child process fully detached from this process group.
/// Spawn a player detached from PixelPass's process group and console.
///
/// The child gets its own session via `setsid(2)` and null stdio, so it
/// survives the parent exiting and doesn't take a SIGKILL cascade when
/// pixelpass dies.
/// On Unix the child gets its own session via `setsid(2)`. On Windows it gets a
/// new process group with no console window. Both paths use null stdio, so the
/// player can survive PixelPass exiting without holding its terminal open.
///
/// A detached reaper thread `wait()`s the child so it doesn't linger as a
/// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches
@@ -18,6 +21,7 @@ use std::process::{Command, Stdio};
/// `fork(2)` followed by non-trivial work in this multithreaded process is
/// unsound — the reaper thread is the safe equivalent.)
pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
#[cfg(unix)]
let child = unsafe {
Command::new(prog)
.args(args)
@@ -30,9 +34,25 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
})
.spawn()?
};
#[cfg(windows)]
let child = Command::new(prog)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
// Keep a console player out of PeerSpeak's process group and prevent a
// stray console window. GUI players ignore CREATE_NO_WINDOW and still
// show their normal window.
.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW)
.spawn()?;
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
Ok(())
}
#[cfg(windows)]
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+3
View File
@@ -1,10 +1,13 @@
#[cfg(unix)]
use anyhow::{Context, Result};
#[cfg(unix)]
use tokio::signal::unix::{Signal, SignalKind};
use tokio_util::sync::CancellationToken;
/// A stream of SIGTERMs, for the callers that need to shut down cleanly when
/// something other than a human at a terminal asks them to (`timeout`, a test
/// harness, a service manager). Ctrl-c alone covers only the interactive case.
#[cfg(unix)]
pub fn terminate_stream() -> Result<Signal> {
tokio::signal::unix::signal(SignalKind::terminate())
.context("could not install a SIGTERM handler")
+9 -72
View File
@@ -46,84 +46,13 @@
//! adapter; this module consumes the already-parsed
//! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps).
#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases.
#[cfg(test)]
mod tests;
pub use crate::common::aec::{AecConfig, AecParseError, parse_aec_arg};
use crate::host::observer::Millis;
use crate::host::taint::snapshot::GraphSnapshot;
/// The parsed `--aec=off|pulse-module:<idx>` argument (decision D5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecConfig {
/// `--aec=off` — peerspeak's AEC is not in play, so there is nothing to
/// exclude and fan-out proceeds with no AEC identity. Not the same as an
/// *absent* argument (that default is the caller's; see [`parse_aec_arg`]).
Off,
/// `--aec=pulse-module:<idx>` — validate this live module index before
/// trusting it. The index is compared as `u64`, never `u32` (v3.4 §5.2).
PulseModule(u64),
}
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge —
/// there is no fail-closed *default* index, because a wrong index would exclude
/// the wrong node (or nothing), so a malformed value must not silently become
/// "no AEC".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecParseError {
/// The value was empty.
Empty,
/// Not `off` and not `pulse-module:...`.
UnknownForm,
/// `pulse-module:` with nothing after the colon.
MissingIndex,
/// The index was not a bare `u64` decimal (sign, whitespace, non-digit, or
/// `> u64::MAX`).
InvalidIndex,
}
/// Parse one `--aec` value. `off` and `pulse-module:<idx>` are the only forms.
///
/// The index accepts values `> u32::MAX` (v3.4 §5.2: `pulse.module.id` sits
/// next to the `object.serial` u32-truncation bug, so it is only ever compared
/// as `u64`) and requires a **bare decimal** — stricter than Rust's [`u64`]
/// parser, which also accepts a leading `+`. Rejected: any sign, surrounding or
/// interior whitespace, non-decimal digits, and overflow. Matching is exact and
/// case-sensitive: the argument is machine-generated by peerspeak from
/// `EchoCancelGuard::module_index`, not typed by a user.
///
/// ⚠️ **Producer contract** (Codex phase-4 review, finding 5): because the
/// grammar is narrower than Rust's parser, peerspeak must emit a bare decimal.
/// `pactl load-module` returns an unsigned decimal, so the stored index is
/// already canonical and no reachable value is rejected; if peerspeak ever
/// changes how it formats the index it must canonicalize (`value.to_string()`),
/// not widen this parser — the narrow grammar is the point.
pub fn parse_aec_arg(value: &str) -> Result<AecConfig, AecParseError> {
if value.is_empty() {
return Err(AecParseError::Empty);
}
if value == "off" {
return Ok(AecConfig::Off);
}
if let Some(index) = value.strip_prefix("pulse-module:") {
if index.is_empty() {
return Err(AecParseError::MissingIndex);
}
// A bare decimal only: reject a leading sign (Rust's `u64` parser
// accepts `+7`), interior/surrounding whitespace, and any non-digit,
// before letting the parser catch overflow. Leading zeros are harmless.
if !index.bytes().all(|b| b.is_ascii_digit()) {
return Err(AecParseError::InvalidIndex);
}
return index
.parse::<u64>()
.map(AecConfig::PulseModule)
.map_err(|_| AecParseError::InvalidIndex);
}
Err(AecParseError::UnknownForm)
}
/// The validation epoch (v3.4 §5.3, verbatim).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecState {
@@ -195,6 +124,14 @@ impl AecValidator {
self.state
}
/// The configured module index regardless of validation state. Status
/// reporting and foreign-AEC detection need to name the intended identity
/// without treating it as trusted for taint; only
/// [`Self::validated_module_id`] grants that trust.
pub fn configured_module_id(&self) -> Option<u64> {
self.target
}
/// The validated index to place in
/// [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx) —
/// `Some` **only** in [`AecState::Validated`]. `None` everywhere else,
+180 -358
View File
@@ -1,21 +1,18 @@
//! Per-app audio routing.
//! Connection-owned capture-sink and per-app audio routing.
//!
//! Two cooperating layers:
//!
//! - **Null-sink + loopback** (pactl shell-out): a per-PID null-sink
//! `pixelpass_capture_<pid>` plus a `module-loopback` that mirrors the
//! default sink's monitor into it. gst captures from the null-sink's
//! monitor, so the viewer hears whatever the user hears — by default.
//! - **Native graph actor** (libpipewire on a dedicated OS thread): owns a
//! non-lingering per-PID sink named `pixelpass_capture_<pid>`. When
//! [`HostOpts::app`] is set, the same actor finds matching
//! `Stream/Output/Audio` nodes and writes `target.object` so WirePlumber
//! reroutes them to that sink. The sink disappears with the actor's PipeWire
//! connection, including after SIGKILL.
//!
//! - **Per-stream rerouting** (libpipewire on a dedicated OS thread):
//! when [`HostOpts::app`] is set, a [`StreamRouter`] subscribes to the
//! PipeWire registry, finds `Stream/Output/Audio` nodes whose
//! `application.name` matches the filter, and writes
//! `target.object` to the "default" metadata so WirePlumber reroutes
//! them to our null-sink. Once at least one stream is actually routed,
//! the loopback is unloaded — otherwise the viewer would hear the
//! filtered audio twice (once via the routed stream, once via the
//! default-sink monitor loopback).
//! - **Pulse loopbacks** (bounded pactl shell-outs): by default one loopback
//! mirrors the default sink's monitor into the native capture sink. Once at
//! least one selected app stream is routed, that loopback is unloaded so the
//! viewer does not hear the app twice.
//!
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
//! the chosen app off the sharer's speakers into the null-sink, so without
@@ -26,21 +23,20 @@
//! the first routed stream (after the default-sink loopback is gone, so the
//! two never coexist and feed back) and unloaded when the app stops.
//!
//! pactl is the right tool for the one-shot null-sink/loopback graph
//! mutations. libpipewire is dragged in only when per-stream filtering
//! is requested, because that needs registry-event subscription.
//! Shutdown quiesces route writes, unloads every dependent Pulse loopback, and
//! only then releases the actor connection and native sink.
use anyhow::{Context, Result, bail};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::io::{self, Read};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::rc::Rc;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use crate::cli::HostOpts;
use crate::common::contained;
use crate::host::graph::{AudioGraphOwner, CaptureSinkSpec, GraphEvent, QuiesceOutcome};
use crate::host::health;
use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome};
use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
@@ -55,10 +51,10 @@ use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
const PACTL_BUDGET: Duration = Duration::from_secs(5);
const PACTL_REAP_BUDGET: Duration = Duration::from_secs(1);
/// Owns the pactl-loaded modules plus, when filtering is active, the
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
/// prefer [`Routing::shutdown`] explicitly, which is the only path that can
/// reconcile a load whose outcome was never observed.
/// Owns the native graph actor plus its pactl-loaded dependent modules. Drop
/// unloads modules as a backstop; prefer [`Routing::shutdown`] explicitly,
/// which is the only path that can reconcile a load whose outcome was never
/// observed and acknowledge route restoration.
pub struct Routing {
/// Every module this host has loaded, is loading, or must ask the server
/// about. Shared with the event task, which loads and unloads the two
@@ -69,39 +65,42 @@ pub struct Routing {
/// exactly like no load at all and its module was left behind.
ledger: Arc<ModuleLedger>,
sink_name: String,
stream_router: Option<StreamRouter>,
graph_owner: Option<AudioGraphOwner>,
event_task: Option<tokio::task::JoinHandle<()>>,
health: health::Reporter,
}
impl Routing {
/// Create the per-PID null-sink + loopback. If `opts.app` is set,
/// also spawn the libpipewire thread that reroutes matching streams.
pub async fn start(opts: &HostOpts) -> Result<Self> {
/// Create the per-PID native sink and graph actor, plus the default-monitor
/// loopback when the selected routing mode permits it.
pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> {
let pid = std::process::id();
let sink_name = repair_plan::sink_name_for(pid);
let ledger = ModuleLedger::new();
// Construct the owner before the first mutation. Any error or cancellation
// below now drops a real `Routing`, whose backstop closes, quiesces,
// reconciles, and unloads this ledger. Previously the owner did not exist
// until both initial modules had loaded, so constructor failure leaked
// everything loaded up to that point.
// Construct Routing before either ownership layer mutates the graph. Any
// error or cancellation below drops a real owner whose backstop closes,
// reconciles, and unloads the ledger before releasing the native sink.
let mut routing = Self {
ledger: Arc::clone(&ledger),
sink_name: sink_name.clone(),
stream_router: None,
graph_owner: None,
event_task: None,
health: health.clone(),
};
// Every module this host loads carries an ownership token, minted per
// load, so `--repair` can tell whose pid the name refers to instead of
// assuming the number means the same thing everywhere. Without it a repair
// run in another pid namespace can unload a live host's audio; see
// `repair::plan::OwnerToken`. That same per-load nonce is what lets
// reconciliation identify a module whose load was interrupted before its
// index was ever read.
load_module(&ledger, Shape::LegacyCaptureSink, pid)
.await
.context("failed to load module-null-sink")?;
// S4: the capture sink is a native, non-lingering PipeWire object owned
// by this actor connection, not a module owned by pipewire-pulse. The
// actor also absorbs the per-app router so every sink-owning mode has one
// graph lifetime and one readiness handshake.
let (graph_owner, mut event_rx, _identity_rx) = AudioGraphOwner::start(
opts.app.clone(),
CaptureSinkSpec::for_pid(pid),
health.clone(),
)
.await
.context("failed to start the connection-owned audio graph")?;
debug_assert_eq!(graph_owner.identity().name, sink_name);
routing.graph_owner = Some(graph_owner);
// In strict per-app mode we never mirror the default sink: the viewer
// must hear *only* the chosen app, never the whole desktop (which would
@@ -112,27 +111,31 @@ impl Routing {
// 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible.
let strict_app = opts.app.is_some() && opts.strict_audio;
if !strict_app {
load_module(&ledger, Shape::LoopbackIntoCapture, pid)
.await
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?;
if !strict_app
&& let Err(error) = load_module(&ledger, Shape::LoopbackIntoCapture, pid).await
{
// Once the actor exists, an ordinary constructor error gets a full
// async teardown rather than falling through the narrower
// synchronous Drop backstop and quarantining a responsive thread.
routing.shutdown().await;
return Err(error)
.context("failed to load module-loopback (connection-owned sink cleaned up)");
}
tracing::info!(
strict_app,
%sink_name,
"audio routing: null-sink ready (loopback skipped in strict app mode)"
"audio routing: connection-owned sink ready (loopback skipped in strict app mode)"
);
if let Some(app) = &opts.app {
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
if opts.app.is_some() {
let ledger_for_task = Arc::clone(&ledger);
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await {
match ev {
Event::FirstRoutedStream => {
GraphEvent::FirstRoutedStream => {
tracing::info!(
"audio routing: first stream routed → unloading default-sink loopback"
);
@@ -159,7 +162,7 @@ impl Routing {
state: AppAudioState::Routed,
});
}
Event::LastRoutedStreamGone => {
GraphEvent::LastRoutedStreamGone => {
// Routed app exited/paused mid-session. Notify the
// front-end either way; the recovery differs by mode.
output::emit(output::Event::AppAudio {
@@ -199,7 +202,6 @@ impl Routing {
}
}
});
routing.stream_router = Some(router);
routing.event_task = Some(event_task);
}
@@ -220,10 +222,10 @@ impl Routing {
&self.sink_name
}
/// Stop the stream router and the event task, settle anything the ledger is
/// unsure about, then unload every module in shape order — the loopbacks
/// before the sink they reference, because PipeWire can leave zombie links if
/// a sink is destroyed with active inputs.
/// Quiesce graph mutations, stop the event task, settle anything the ledger
/// is unsure about, unload every dependent loopback, then release the native
/// sink. PipeWire can leave zombie links if a sink is destroyed with active
/// inputs, so that final ordering is load-bearing.
///
/// The event task is **awaited, not merely aborted**. Aborting and walking
/// away is what left orphans behind: the task's load is an await point now,
@@ -233,29 +235,42 @@ impl Routing {
pub async fn shutdown(mut self) {
// Closing is synchronous and happens first: after this point the event
// task cannot register another mutation even if it receives one last
// router event while shutdown is in progress.
// graph event while shutdown is in progress.
self.ledger.close();
if let Some(router) = self.stream_router.take() {
// ⚠️ Still an unbounded join: a wedged PipeWire thread parks this
// task indefinitely. That is the pre-existing defect S3b exists for.
// Nothing here makes it worse, and the ledger is what will make
// bounding it safe when it lands.
router.shutdown();
}
let graph_quiesced = if let Some(graph_owner) = self.graph_owner.as_mut() {
graph_owner.quiesce().await == QuiesceOutcome::Confirmed
} else {
true
};
if let Some(mut task) = self.event_task.take() {
// The router's exit drops the event senders, so the task normally
// ends by itself. Abort is the fallback, and it is awaited through
// `&mut JoinHandle` so the future is genuinely dropped — and with it
// any in-flight permit — before reconciliation reads the ledger.
// Dropping the handle instead would *detach* the task, which is how a
// load could still land after teardown believed it was finished.
if tokio::time::timeout(PACTL_BUDGET, &mut task).await.is_err() {
tracing::warn!(
"audio routing: the event task did not finish within {PACTL_BUDGET:?}; \
cancelling it"
);
if !graph_quiesced {
// An unresponsive graph actor may still own its event sender.
// Cancel and await the task before ledger reconciliation.
task.abort();
let _ = task.await;
} else {
// Quiesce closes the event sender while retaining the native
// sink. Abort is the fallback and is awaited through `&mut
// JoinHandle`, so any in-flight affine permit is dropped before
// reconciliation reads the ledger.
match tokio::time::timeout(PACTL_BUDGET, &mut task).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
self.health
.poison(format!("audio routing event task failed: {e}"));
}
Err(_) => {
tracing::warn!(
"audio routing: the event task did not finish within {PACTL_BUDGET:?}; \
cancelling it"
);
self.health.poison(format!(
"audio routing event task did not stop within {PACTL_BUDGET:?}"
));
task.abort();
let _ = task.await;
}
}
}
}
@@ -269,15 +284,29 @@ impl Routing {
.await
{
tracing::warn!("audio routing: module-operation wait task failed: {e}");
self.health
.poison(format!("audio module-operation wait task failed: {e}"));
}
cleanup_modules(&self.ledger).await;
// Loopbacks are gone before the actor connection is released. This is
// the S4 ordering invariant: dependent Pulse modules never outlive the
// native sink they reference during an ordinary shutdown.
if let Some(graph_owner) = self.graph_owner.take()
&& !graph_owner.shutdown().await
{
tracing::warn!("audio routing: AudioGraphOwner shutdown was not confirmed");
}
if !self.ledger.is_clean() {
tracing::warn!(
settled = self.ledger.is_settled(),
"audio routing: some audio modules could not be removed safely; \
`pixelpass --repair` will clean up anything left behind"
);
self.health.poison(
"audio routing teardown left module ownership unresolved; repair is required",
);
}
}
}
@@ -291,20 +320,24 @@ impl Drop for Routing {
/// After a completed `shutdown` the ledger holds nothing and this does nothing.
fn drop(&mut self) {
self.ledger.close();
if let Some(router) = self.stream_router.take() {
router.shutdown();
}
if let Some(task) = self.event_task.take() {
task.abort();
}
self.ledger.close_and_wait();
cleanup_modules_blocking(&self.ledger);
// Keep the actor/sink alive until dependent modules have been handled,
// even on this synchronous error/unwind backstop.
if let Some(graph_owner) = self.graph_owner.take() {
drop(graph_owner);
}
if !self.ledger.is_clean() {
tracing::warn!(
settled = self.ledger.is_settled(),
"audio routing: torn down with modules that could not be removed safely; \
run `pixelpass --repair` to clean up anything left behind"
);
self.health
.poison("audio routing Drop left module ownership unresolved; repair is required");
}
}
}
@@ -635,7 +668,7 @@ impl Drop for ReapedChild {
if self.reaped {
return;
}
let _ = self.child.kill();
self.kill_group();
match self.reap_within(PACTL_REAP_BUDGET) {
Ok(Some(_)) => {}
Ok(None) => tracing::warn!(
@@ -647,6 +680,12 @@ impl Drop for ReapedChild {
}
impl ReapedChild {
fn kill_group(&mut self) {
let _ = contained::signal_group(self.child.id(), nix::sys::signal::Signal::SIGKILL);
// Backstop in case the group disappeared between lookup and signal.
let _ = self.child.kill();
}
fn reap_within(&mut self, budget: Duration) -> io::Result<Option<ExitStatus>> {
let deadline = Instant::now() + budget;
loop {
@@ -665,7 +704,7 @@ impl ReapedChild {
fn bounded_output(command: &mut Command, budget: Duration) -> io::Result<BoundedOutput> {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = ReapedChild {
child: command.spawn()?,
child: contained::spawn(command)?,
reaped: false,
};
let stdout = child
@@ -700,7 +739,7 @@ fn bounded_output(command: &mut Command, budget: Duration) -> io::Result<Bounded
break (status, false);
}
if Instant::now() >= deadline {
let _ = child.child.kill();
child.kill_group();
let Some(status) = child.reap_within(PACTL_REAP_BUDGET)? else {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
@@ -779,210 +818,6 @@ fn cleanup_modules_blocking(ledger: &Arc<ModuleLedger>) {
}
}
// ──────────────────────────────────────────────────────────────────────
// Per-stream routing (libpipewire thread)
// ──────────────────────────────────────────────────────────────────────
/// Command from tokio → libpipewire thread.
enum Cmd {
/// Clear `target.object` for everything we routed, then quit the
/// MainLoop so the thread joins.
Shutdown,
}
/// Event from libpipewire thread → tokio. The pair drives loopback
/// oscillation: unload on `FirstRoutedStream`, re-load on
/// `LastRoutedStreamGone`. Both fire on count-transitions (0→N and N→0
/// respectively), not on every change.
enum Event {
/// At least one stream is now routed to our sink. Receiver unloads
/// the default-sink loopback so the filtered audio isn't doubled.
FirstRoutedStream,
/// The last routed stream just disappeared (app closed, paused,
/// switched output). Receiver re-loads the default-sink loopback so
/// the viewer doesn't go silent.
LastRoutedStreamGone,
}
/// Handle to the libpipewire stream-router thread.
pub struct StreamRouter {
cmd_tx: pipewire::channel::Sender<Cmd>,
thread: Option<JoinHandle<()>>,
}
impl StreamRouter {
/// Spawn the libpipewire thread. Returns the router handle and the
/// event receiver tokio side polls.
fn spawn(
filter_name: String,
sink_name: String,
) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver<Event>)> {
let (cmd_tx, cmd_rx) = pipewire::channel::channel::<Cmd>();
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
let thread = std::thread::Builder::new()
.name("pixelpass-pw-router".to_string())
.spawn(move || {
if let Err(e) = run_router(filter_name, sink_name, cmd_rx, event_tx) {
tracing::warn!("audio routing: libpipewire thread exited with error: {e:#}");
}
})
.context("failed to spawn libpipewire router thread")?;
Ok((
Self {
cmd_tx,
thread: Some(thread),
},
event_rx,
))
}
fn shutdown(mut self) {
// Best-effort: if the send fails the thread is already gone.
let _ = self.cmd_tx.send(Cmd::Shutdown);
if let Some(t) = self.thread.take()
&& let Err(e) = t.join()
{
tracing::warn!("audio routing: pw thread join failed: {e:?}");
}
}
}
/// Body of the libpipewire thread. Owns MainLoop, registry listener, and
/// all PipeWire proxies for the duration of the routing session.
fn run_router(
filter_name: String,
sink_name: String,
cmd_rx: pipewire::channel::Receiver<Cmd>,
event_tx: tokio::sync::mpsc::UnboundedSender<Event>,
) -> Result<()> {
use pipewire::{self as pw, types::ObjectType};
let main_loop =
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?;
let context =
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?;
let core = context
.connect_rc(None)
.context("pw core connect failed (is the daemon running?)")?;
let registry = core.get_registry_rc().context("pw get_registry failed")?;
let state = Rc::new(RefCell::new(RouterState {
sink_serial: None,
default_metadata: None,
routed_node_ids: Vec::new(),
pending: Vec::new(),
}));
// Cmd handler: clear metadata for routed streams, then quit.
let main_loop_for_cmd = main_loop.clone();
let state_for_cmd = Rc::clone(&state);
let _cmd_recv = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
Cmd::Shutdown => {
let s = state_for_cmd.borrow();
if let Some(meta) = &s.default_metadata {
for &nid in &s.routed_node_ids {
meta.set_property(nid, "target.object", None, None);
}
if !s.routed_node_ids.is_empty() {
tracing::info!(
n = s.routed_node_ids.len(),
"audio routing: cleared target.object on routed streams before quitting"
);
}
}
main_loop_for_cmd.quit();
}
});
let filter_lower = filter_name.to_ascii_lowercase();
let sink_name_owned = sink_name.clone();
let registry_weak = registry.downgrade();
let state_for_reg = Rc::clone(&state);
let event_tx_for_reg = event_tx.clone();
let state_for_remove = Rc::clone(&state);
let event_tx_for_remove = event_tx.clone();
let _reg_listener = registry
.add_listener_local()
.global(move |obj| {
let Some(reg) = registry_weak.upgrade() else {
return;
};
match obj.type_ {
ObjectType::Node => {
let Some(props) = obj.props.as_ref() else {
return;
};
if props.get("node.name") == Some(sink_name_owned.as_str()) {
match props.get("object.serial").and_then(parse_object_serial) {
Some(serial) => {
state_for_reg.borrow_mut().sink_serial = Some(serial);
tracing::info!(serial, "audio routing: pixelpass sink registered");
try_flush(&state_for_reg, &event_tx_for_reg);
}
// Never silently: without a serial `try_flush` can
// never route anything, so the whole app-filter mode
// is dead and the only symptom is missing audio.
None => tracing::warn!(
node_id = obj.id,
serial = props.get("object.serial").unwrap_or("<absent>"),
"audio routing: pixelpass sink has no usable object.serial; \
stream rerouting disabled"
),
}
return;
}
if props.get("media.class") != Some("Stream/Output/Audio") {
return;
}
let Some(app) = props.get("application.name") else {
return;
};
if !app.eq_ignore_ascii_case(&filter_lower) {
return;
}
tracing::info!(
node_id = obj.id,
%app,
"audio routing: matched stream, queued for route"
);
state_for_reg.borrow_mut().pending.push(obj.id);
try_flush(&state_for_reg, &event_tx_for_reg);
}
ObjectType::Metadata => {
let Some(props) = obj.props.as_ref() else {
return;
};
if props.get("metadata.name") != Some("default") {
return;
}
let metadata: pw::metadata::Metadata = match reg.bind(obj) {
Ok(m) => m,
Err(e) => {
tracing::warn!("audio routing: bind default metadata failed: {e}");
return;
}
};
state_for_reg.borrow_mut().default_metadata = Some(metadata);
tracing::info!("audio routing: default metadata bound");
try_flush(&state_for_reg, &event_tx_for_reg);
}
_ => {}
}
})
.global_remove(move |id| {
handle_global_remove(&state_for_remove, &event_tx_for_remove, id);
})
.register();
tracing::info!(filter = %filter_name, "audio routing: pw thread running");
main_loop.run();
tracing::info!("audio routing: pw thread exiting");
Ok(())
}
/// Parse a PipeWire `object.serial` property value.
///
/// `object.serial` is a **64-bit** monotonically-increasing counter
@@ -1003,80 +838,14 @@ pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
raw.parse::<u64>().ok()
}
struct RouterState {
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
/// the `u32` node ids in `routed_node_ids` / `pending`.
sink_serial: Option<u64>,
default_metadata: Option<pipewire::metadata::Metadata>,
routed_node_ids: Vec<u32>,
pending: Vec<u32>,
}
/// Drop the vanished node from `routed_node_ids` and `pending`. If it
/// was the last routed stream, emit `LastRoutedStreamGone` so the
/// tokio side restores the default-sink loopback.
fn handle_global_remove(
state: &Rc<RefCell<RouterState>>,
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
id: u32,
) {
let mut s = state.borrow_mut();
let was_routed = !s.routed_node_ids.is_empty();
s.routed_node_ids.retain(|&x| x != id);
s.pending.retain(|&x| x != id);
if was_routed && s.routed_node_ids.is_empty() {
tracing::info!(
node_id = id,
"audio routing: last routed stream disappeared"
);
let _ = event_tx.send(Event::LastRoutedStreamGone);
}
}
/// Drain pending streams to the sink, but only once both prerequisites
/// (sink serial known + default metadata bound) are in place. Emits
/// `FirstRoutedStream` when routed count crosses 0→N (so it fires
/// each time the count comes back up from zero, not just the first
/// time — pairs with `LastRoutedStreamGone` to oscillate the loopback).
fn try_flush(
state: &Rc<RefCell<RouterState>>,
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
) {
let mut s = state.borrow_mut();
let Some(serial) = s.sink_serial else { return };
if s.default_metadata.is_none() {
return;
}
if s.pending.is_empty() {
return;
}
let was_empty = s.routed_node_ids.is_empty();
let serial_str = serial.to_string();
let pending = std::mem::take(&mut s.pending);
if let Some(meta) = &s.default_metadata {
for nid in &pending {
meta.set_property(*nid, "target.object", Some("Spa:Id"), Some(&serial_str));
tracing::info!(
node_id = *nid,
sink_serial = serial,
"audio routing: stream routed to pixelpass sink"
);
}
}
s.routed_node_ids.extend(pending);
if was_empty && !s.routed_node_ids.is_empty() {
let _ = event_tx.send(Event::FirstRoutedStream);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::ledger::SlotState;
use crate::repair::plan::{ModuleObservation, classify};
/// Whole-desktop routing: no app filter, so no PipeWire thread and no event
/// task — just the null-sink and its default-sink loopback.
/// Whole-desktop routing: the graph actor owns the native sink, while the
/// ledger owns only its default-monitor loopback.
fn whole_desktop_opts() -> HostOpts {
HostOpts {
window: false,
@@ -1090,10 +859,30 @@ mod tests {
no_hwencode: false,
max_viewers: None,
interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false,
relay: None,
}
}
#[test]
fn bounded_module_worker_uses_the_contained_spawn_path() {
let mut command = Command::new("sh");
command.args([
"-c",
"read pid comm state ppid pgrp rest < /proc/self/stat; printf '%s %s' \"$pid\" \"$pgrp\"",
]);
let output = bounded_output(&mut command, Duration::from_secs(1))
.expect("run contained module-worker fixture");
assert!(output.status.success());
let ids = String::from_utf8(output.stdout).expect("ascii pid/pgid");
let mut ids = ids.split_whitespace();
let pid = ids.next().expect("child pid");
let pgid = ids.next().expect("child process group");
assert_eq!(pid, pgid, "module worker must lead its own process group");
}
/// The module table exactly as `--repair` observes it.
fn module_snapshot() -> Vec<(u32, String, String)> {
let mut session =
@@ -1148,7 +937,8 @@ mod tests {
#[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"]
async fn live_teardown_leaves_the_module_table_as_it_found_it() {
let before = module_snapshot();
let routing = Routing::start(&whole_desktop_opts())
let (health, _) = health::channel();
let routing = Routing::start(&whole_desktop_opts(), health)
.await
.expect("routing starts");
@@ -1159,8 +949,8 @@ mod tests {
.collect();
assert_eq!(
ours.len(),
2,
"the null-sink and its default-sink loopback must both be loaded"
1,
"only the default-monitor loopback is a Pulse module; the sink is native"
);
for (id, name, args) in ours {
let fp = classify(&ModuleObservation::new(*id, name, args))
@@ -1180,6 +970,38 @@ mod tests {
);
}
/// Exercise the real graph-actor command path with app routing enabled. The
/// deliberately unmatched filter avoids moving an unrelated live stream.
#[tokio::test]
#[ignore = "uses the real Pulse/PipeWire graph; run with --ignored --test-threads=1"]
async fn live_audio_graph_owner_stops_within_its_policy_budget() {
let before = module_snapshot();
let mut opts = whole_desktop_opts();
opts.app = Some("__pixelpass_s3b_no_matching_application__".to_string());
opts.strict_audio = true;
let (health, _) = health::channel();
let routing = Routing::start(&opts, health.clone())
.await
.expect("per-app routing starts");
// Let the OS thread reach its normal running phase so this covers the
// tighter steady-state budget rather than only startup containment.
tokio::time::sleep(Duration::from_millis(100)).await;
tokio::time::timeout(Duration::from_secs(10), routing.shutdown())
.await
.expect("actor and graph teardown stay globally bounded");
assert!(
health.fault().is_none(),
"an observed shutdown and successful join must remain healthy"
);
assert_eq!(
module_snapshot(),
before,
"per-app teardown must leave the module table byte-identical"
);
}
/// The orphan race, staged against a real server: a load cancelled while
/// `pactl` is in flight must still be findable and removable.
///
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -8,18 +8,20 @@ use anyhow::Result;
use crate::cli::HostOpts;
use crate::common::display::DisplayServer;
use crate::host::health;
use crate::host::pipeline::CaptureHandle;
use crate::host::quality::EffectiveQuality;
use crate::host::{wayland, x11};
pub async fn spawn(
pub(super) async fn spawn(
display: DisplayServer,
opts: &HostOpts,
quality: &EffectiveQuality,
health: health::Reporter,
) -> Result<CaptureHandle> {
match display {
DisplayServer::Wayland => wayland::start(opts, quality).await,
DisplayServer::X11 => x11::start(opts, quality).await,
DisplayServer::Wayland => wayland::start(opts, quality, health).await,
DisplayServer::X11 => x11::start(opts, quality, health).await,
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
}
}
+1699
View File
File diff suppressed because it is too large Load Diff
+1861
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
//! Terminal health shared by capture components and the host supervisor.
//!
//! A capture-side ownership failure is not recoverable inside the same host
//! process: a wedged PipeWire owner or an unaccounted Pulse module can collide
//! with the next capture. Health is therefore one-way (`Healthy -> Poisoned`)
//! and first-fault-wins so a later, less precise teardown symptom cannot erase
//! the original cause.
use std::sync::Arc;
use tokio::sync::watch;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum State {
Healthy,
Poisoned(Arc<str>),
}
#[derive(Clone)]
pub(super) struct Reporter {
tx: watch::Sender<State>,
}
pub(super) struct Monitor {
rx: watch::Receiver<State>,
}
pub(super) fn channel() -> (Reporter, Monitor) {
let (tx, rx) = watch::channel(State::Healthy);
(Reporter { tx }, Monitor { rx })
}
impl Reporter {
/// Poison the host exactly once. Returns true for the first fault.
pub(super) fn poison(&self, reason: impl Into<Arc<str>>) -> bool {
let reason = reason.into();
self.tx.send_if_modified(move |state| {
if matches!(state, State::Healthy) {
*state = State::Poisoned(reason);
true
} else {
false
}
})
}
#[cfg(test)]
pub(super) fn fault(&self) -> Option<Arc<str>> {
match &*self.tx.borrow() {
State::Healthy => None,
State::Poisoned(reason) => Some(Arc::clone(reason)),
}
}
}
impl Monitor {
pub(super) fn fault(&self) -> Option<Arc<str>> {
match &*self.rx.borrow() {
State::Healthy => None,
State::Poisoned(reason) => Some(Arc::clone(reason)),
}
}
pub(super) async fn changed(&mut self) {
// The supervisor owns a Reporter for the whole run, so closure is not a
// normal state. Treat it like a wake and let `fault()` decide.
let _ = self.rx.changed().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn poison_is_terminal_and_first_fault_wins() {
let (reporter, mut monitor) = channel();
assert!(reporter.poison("first"));
monitor.changed().await;
assert_eq!(monitor.fault().as_deref(), Some("first"));
assert!(!reporter.poison("second"));
assert_eq!(reporter.fault().as_deref(), Some("first"));
}
}
+114 -3
View File
@@ -1,9 +1,14 @@
pub mod aec;
pub mod audio;
mod audio_plan;
pub mod audit;
mod capture;
mod fanout;
mod graph;
mod health;
pub mod ledger;
mod observer;
mod owned_thread;
mod pipeline;
mod quality;
mod serve;
@@ -127,12 +132,15 @@ pub async fn run(opts: HostOpts) -> Result<()> {
});
let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
let (capture_health, capture_health_monitor) = health::channel();
let supervisor = tokio::spawn(supervise(
opts.clone(),
quality,
display,
resolution.value,
sup_rx,
(capture_health, capture_health_monitor),
cancel.clone(),
));
// Command channel for the GUI front-end: read `kick <endpoint-id>` lines
@@ -289,14 +297,46 @@ async fn supervise(
display: DisplayServer,
max_viewers: u32,
mut rx: mpsc::Receiver<SupervisorMsg>,
capture_health: (health::Reporter, health::Monitor),
host_cancel: CancellationToken,
) {
let (capture_health, mut capture_health_monitor) = capture_health;
let mut handle: Option<CaptureHandle> = None;
// Active viewers, keyed by endpoint id, holding each one's kill switch.
// The count is just `viewers.len()`. (A given endpoint connecting twice is
// a non-case here: each viewer process uses a fresh ephemeral identity.)
let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
while let Some(msg) = rx.recv().await {
loop {
// Poison is terminal for this host process. A surviving wedged owner or
// an unaccounted graph mutation can collide with a later capture, so do
// not detach it and keep advertising the ticket. Cancelling the host
// closes the endpoint; PeerSpeak's S2 EOF path then clears presence.
if let Some(reason) = capture_health_monitor.fault() {
tracing::error!(%reason, "capture supervisor poisoned — stopping host");
host_cancel.cancel();
for cancel in viewers.values() {
cancel.cancel();
}
if let Some(h) = handle.take() {
h.shutdown().await;
output::emit(output::Event::Capture {
state: output::CaptureState::Stopped,
});
}
break;
}
let msg = tokio::select! {
// Health wins a simultaneous race with another viewer request: a
// poisoned host must not begin one more capture.
biased;
_ = capture_health_monitor.changed() => continue,
msg = rx.recv() => msg,
};
let Some(msg) = msg else {
break;
};
match msg {
SupervisorMsg::AddViewer { id, cancel, reply } => {
let count = viewers.len() as u32;
@@ -310,8 +350,37 @@ async fn supervise(
if handle.is_none() {
tracing::info!("first viewer arriving — spawning capture");
match capture::spawn(display, &opts, &quality).await {
let spawned = tokio::select! {
// A subsystem can fail while the portal/GStreamer setup
// future is still running. Do not wait for setup to
// return and then admit one viewer to an already-poisoned
// capture.
biased;
_ = capture_health_monitor.changed() => {
let reason = capture_health_monitor
.fault()
.unwrap_or_else(|| "capture health channel changed unexpectedly".into());
let _ = reply.send(Err(format!(
"capture ownership failed during startup: {reason}"
)));
continue;
}
result = capture::spawn(
display,
&opts,
&quality,
capture_health.clone(),
) => result,
};
match spawned {
Ok(h) => {
if let Some(reason) = capture_health_monitor.fault() {
h.shutdown().await;
let _ = reply.send(Err(format!(
"capture ownership failed during startup: {reason}"
)));
continue;
}
handle = Some(h);
output::emit(output::Event::Capture {
state: output::CaptureState::Started,
@@ -498,7 +567,9 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app {
if opts.capture_mode == crate::cli::CaptureMode::DesktopExcluding {
bits.push("desktop-excluding-audio".to_string());
} else if let Some(app) = &opts.app {
if opts.strict_audio {
bits.push(format!("app-audio={app} (strict)"));
} else {
@@ -528,6 +599,9 @@ mod tests {
no_hwencode: false,
max_viewers: None,
interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false,
relay: None,
}
}
@@ -551,6 +625,13 @@ mod tests {
capture_summary(&opts(None, true)),
"fullscreen + system-audio"
);
let mut desktop_excluding = opts(None, false);
desktop_excluding.capture_mode = crate::cli::CaptureMode::DesktopExcluding;
assert_eq!(
capture_summary(&desktop_excluding),
"fullscreen + desktop-excluding-audio"
);
}
#[test]
@@ -568,4 +649,34 @@ mod tests {
assert_eq!(initial_app_audio_state(&opts(None, true)), None);
assert_eq!(initial_app_audio_state(&opts(None, false)), None);
}
#[tokio::test]
async fn supervisor_health_poison_cancels_the_host_with_command_channel_open() {
let opts = opts(None, false);
let quality = quality::resolve(&opts, 1);
let (tx, rx) = mpsc::channel(1);
let (health, monitor) = health::channel();
let cancel = CancellationToken::new();
let supervisor = tokio::spawn(supervise(
opts,
quality,
DisplayServer::Unknown,
1,
rx,
(health.clone(), monitor),
cancel.clone(),
));
assert!(health.poison("test ownership fault"));
tokio::time::timeout(Duration::from_secs(1), supervisor)
.await
.expect("poisoned supervisor must stop promptly")
.expect("supervisor task must not panic");
assert!(cancel.is_cancelled());
// The sender deliberately stayed open until the supervisor exited. If
// the health arm were removed, the task above would still be blocked on
// `rx.recv()` and the timeout would fail.
drop(tx);
}
}
+506 -34
View File
@@ -1,19 +1,26 @@
//! PipeWire I/O adapter for the pure registry observer.
//! PipeWire I/O adapter for the registry observer and Phase-6 link owner.
//!
//! This module owns a read-only PipeWire main-loop thread, translates registry
//! callbacks into [`RegEvent`]s, and publishes the latest [`Projection`] for
//! consumers running outside the PipeWire thread.
//! The default entry points are read-only: they translate registry callbacks
//! into [`RegEvent`]s and publish the latest [`Projection`]. The explicit
//! mutating entry point additionally owns retained non-lingering fan-out Link
//! proxies on that same ordered main-loop thread. The Phase-5 audit can only
//! receive the read-only trait and therefore cannot reach the mutator.
use super::classify::{DeviceClaim, DeviceProps};
use super::{
EventKind, LinkEndpoints, NodeObservation, Outcome, Projection, RegEvent, RegistryModel,
};
use crate::host::audio::parse_object_serial;
use crate::host::fanout::{
DesiredLink, LinkMutation, ManagedLinkState, MutationProjectionSink, revalidated_links,
};
use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
StreamFormat,
};
use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE};
use anyhow::{Context, Result};
use pipewire::proxy::ProxyT;
use pipewire::{self as pw, types::ObjectType};
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
@@ -25,6 +32,16 @@ use std::time::{Duration, Instant};
const READINESS_TIMEOUT_MILLIS: u64 = 2_000;
const TICK_INTERVAL: Duration = Duration::from_millis(250);
fn recoverable_core_error(result: i32) -> bool {
// A global can disappear after registry enumeration but before (or while)
// this observer's bound proxy finishes an operation. PipeWire reports that
// ordinary churn as asynchronous -ENOENT on the Core. The model's removal
// event and serial revalidation still fail closed, so terminating the
// mutation owner here would make sink recreation impossible without
// adding safety. Other Core failures remain fatal.
result == -(nix::errno::Errno::ENOENT as i32)
}
/// A consumer that sees **every** projection, one per applied registry event,
/// on the observer thread.
///
@@ -49,10 +66,31 @@ pub trait ProjectionSink: Send {
/// Tokio-side access to the observer's most recent coherent projection.
pub struct RegistryObserverHandle {
latest: Arc<Mutex<Option<Projection>>>,
shutdown_tx: pw::channel::Sender<()>,
command_tx: pw::channel::Sender<ObserverCommand>,
thread: Option<JoinHandle<()>>,
}
enum ObserverCommand {
ReplaceCaptureSink(Serial),
Shutdown,
}
/// Cloneable Tokio-side capability for the one mutation-controller command
/// needed during capture-sink recreation. The serial is consumed on the
/// observer's PipeWire thread; callers never receive or reuse a global id.
#[derive(Clone)]
pub(in crate::host) struct FanoutControl {
command_tx: pw::channel::Sender<ObserverCommand>,
}
impl FanoutControl {
pub(in crate::host) fn replace_capture_sink(&self, capture_sink: Serial) -> bool {
self.command_tx
.send(ObserverCommand::ReplaceCaptureSink(capture_sink))
.is_ok()
}
}
impl RegistryObserverHandle {
/// Spawn the read-only PipeWire registry observer.
pub fn spawn() -> Result<Self> {
@@ -65,23 +103,41 @@ impl RegistryObserverHandle {
/// exits, which is what lets a sink emit a final summary on shutdown without
/// the caller arranging one.
pub fn spawn_with_sink(sink: Option<Box<dyn ProjectionSink>>) -> Result<Self> {
Self::spawn_with_consumer(ObserverConsumer::ReadOnly(sink))
}
/// Spawn the observer with the explicit Phase-6 mutation capability.
/// This is intentionally a different entry point from `spawn_with_sink`:
/// the Phase-5 audit's trait has no path to a PipeWire mutator.
pub(in crate::host) fn spawn_with_mutation_sink(
sink: Box<dyn MutationProjectionSink>,
health: crate::host::health::Reporter,
) -> Result<Self> {
Self::spawn_with_consumer(ObserverConsumer::Mutating { sink, health })
}
fn spawn_with_consumer(consumer: ObserverConsumer) -> Result<Self> {
let mutation_health = consumer.mutation_health();
let latest = Arc::new(Mutex::new(None));
let latest_for_thread = Arc::clone(&latest);
let (shutdown_tx, shutdown_rx) = pw::channel::channel::<()>();
let (command_tx, command_rx) = pw::channel::channel::<ObserverCommand>();
let thread = std::thread::Builder::new()
.name("pixelpass-pw-observer".to_string())
.spawn(move || {
if let Err(e) = run_observer(latest_for_thread, shutdown_rx, sink) {
if let Err(e) = run_observer(latest_for_thread, command_rx, consumer) {
tracing::warn!(
"registry observer: libpipewire thread exited with error: {e:#}"
);
if let Some(health) = mutation_health {
health.poison(format!("audio fan-out observer failed: {e:#}"));
}
}
})
.context("failed to spawn libpipewire registry observer thread")?;
Ok(Self {
latest,
shutdown_tx,
command_tx,
thread: Some(thread),
})
}
@@ -94,11 +150,34 @@ impl RegistryObserverHandle {
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub(in crate::host) fn fanout_control(&self) -> FanoutControl {
FanoutControl {
command_tx: self.command_tx.clone(),
}
}
}
enum ObserverConsumer {
ReadOnly(Option<Box<dyn ProjectionSink>>),
Mutating {
sink: Box<dyn MutationProjectionSink>,
health: crate::host::health::Reporter,
},
}
impl ObserverConsumer {
fn mutation_health(&self) -> Option<crate::host::health::Reporter> {
match self {
Self::ReadOnly(_) => None,
Self::Mutating { health, .. } => Some(health.clone()),
}
}
}
impl Drop for RegistryObserverHandle {
fn drop(&mut self) {
let _ = self.shutdown_tx.send(());
let _ = self.command_tx.send(ObserverCommand::Shutdown);
if let Some(thread) = self.thread.take()
&& let Err(e) = thread.join()
{
@@ -110,7 +189,7 @@ impl Drop for RegistryObserverHandle {
enum BoundProxy {
Node {
_listener: pw::node::NodeListener,
_proxy: pw::node::Node,
_proxy: Rc<pw::node::Node>,
},
Device {
_listener: pw::device::DeviceListener,
@@ -127,6 +206,161 @@ struct LiveGlobal {
bound_proxy: Option<BoundProxy>,
}
struct OwnedFanoutLink {
// Both listeners must unhook callbacks before the proxy is dropped.
_info_listener: pw::link::LinkListener,
_proxy_listener: pw::proxy::ProxyListener,
_proxy: pw::link::Link,
}
struct PipeWireLinkMutation {
core: pw::core::CoreRc,
owned: BTreeMap<DesiredLink, OwnedFanoutLink>,
states: Rc<RefCell<BTreeMap<DesiredLink, ManagedLinkState>>>,
}
impl PipeWireLinkMutation {
fn new(core: pw::core::CoreRc) -> Self {
Self {
core,
owned: BTreeMap::new(),
states: Rc::new(RefCell::new(BTreeMap::new())),
}
}
fn create_link(&mut self, desired: DesiredLink) -> Result<OwnedFanoutLink> {
let output_node = desired.output.node_id.0.to_string();
let output_port = desired.output.port_id.0.to_string();
let input_node = desired.input.node_id.0.to_string();
let input_port = desired.input.port_id.0.to_string();
let mut props = pw::properties::properties! {
// Load-bearing: dropping the retained proxy or losing this
// connection removes the server-side link.
"object.linger" => "false"
};
props.insert("link.output.node", output_node.as_str());
props.insert("link.output.port", output_port.as_str());
props.insert("link.input.node", input_node.as_str());
props.insert("link.input.port", input_port.as_str());
let link = self
.core
.create_object::<pw::link::Link>("link-factory", &props)
.context("PipeWire link-factory rejected a fan-out link")?;
self.states
.borrow_mut()
.insert(desired, ManagedLinkState::Pending);
let weak_states = Rc::downgrade(&self.states);
let info_listener = link
.add_listener_local()
.info(move |info| {
let Some(states) = weak_states.upgrade() else {
return;
};
let state = match info.state() {
pw::link::LinkState::Active => ManagedLinkState::Active,
pw::link::LinkState::Error(error) => {
tracing::warn!(%error, "audio fan-out: owned link entered error state");
ManagedLinkState::Failed
}
_ => ManagedLinkState::Pending,
};
states.borrow_mut().insert(desired, state);
})
.register();
let weak_states = Rc::downgrade(&self.states);
let weak_states_for_error = Rc::downgrade(&self.states);
let proxy_listener = link
.upcast_ref()
.add_listener_local()
.removed(move || {
if let Some(states) = weak_states.upgrade() {
states
.borrow_mut()
.insert(desired, ManagedLinkState::Failed);
}
})
.error(move |seq, res, message| {
tracing::warn!(seq, result = res, %message, "audio fan-out: link proxy error");
if let Some(states) = weak_states_for_error.upgrade() {
states
.borrow_mut()
.insert(desired, ManagedLinkState::Failed);
}
})
.register();
Ok(OwnedFanoutLink {
_info_listener: info_listener,
_proxy_listener: proxy_listener,
_proxy: link,
})
}
}
impl LinkMutation for PipeWireLinkMutation {
fn reconcile(
&mut self,
snapshot: &crate::host::taint::snapshot::GraphSnapshot,
desired: &BTreeSet<DesiredLink>,
) -> BTreeMap<DesiredLink, ManagedLinkState> {
// Revoke before creating. Revalidation applies to retained proxies as
// well as new requests: a stale serial-guarded intent is no longer
// authority merely because it already reached `owned`.
let current = revalidated_links(snapshot, desired);
self.owned.retain(|link, _| current.contains(link));
self.states
.borrow_mut()
.retain(|link, _| current.contains(link));
// A Link that reached Error stays failed until the graph changes
// enough to remove this exact serial-guarded intent. Retrying the same
// broken request on every 250 ms observer tick would hot-loop.
let failed: Vec<DesiredLink> = self
.owned
.keys()
.copied()
.filter(|link| self.states.borrow().get(link) == Some(&ManagedLinkState::Failed))
.collect();
for link in failed {
self.owned.remove(&link);
}
for &link in &current {
if self.owned.contains_key(&link) || self.states.borrow().contains_key(&link) {
continue;
}
match self.create_link(link) {
Ok(owned) => {
self.owned.insert(link, owned);
}
Err(error) => {
tracing::warn!(error = %error, "audio fan-out: could not create link");
self.states
.borrow_mut()
.insert(link, ManagedLinkState::Failed);
}
}
}
let states = self.states.borrow();
desired
.iter()
.map(|link| {
(
*link,
states
.get(link)
.copied()
.unwrap_or(ManagedLinkState::Failed),
)
})
.collect()
}
}
struct ObserverState {
model: RegistryModel,
latest: Arc<Mutex<Option<Projection>>>,
@@ -134,7 +368,8 @@ struct ObserverState {
/// it are read from `/proc`.
last_candidates: BTreeSet<u32>,
live_globals: BTreeMap<GlobalId, VecDeque<LiveGlobal>>,
sink: Option<Box<dyn ProjectionSink>>,
consumer: ObserverConsumer,
link_mutation: PipeWireLinkMutation,
started_at: Instant,
}
@@ -144,7 +379,8 @@ impl ObserverState {
/// `now` are the same clock, not two that drift.
fn new(
latest: Arc<Mutex<Option<Projection>>>,
sink: Option<Box<dyn ProjectionSink>>,
consumer: ObserverConsumer,
link_mutation: PipeWireLinkMutation,
started_at: Instant,
) -> Self {
Self {
@@ -152,7 +388,8 @@ impl ObserverState {
latest,
last_candidates: BTreeSet::new(),
live_globals: BTreeMap::new(),
sink,
consumer,
link_mutation,
started_at,
}
}
@@ -202,9 +439,15 @@ impl ObserverState {
fn publish(&mut self, kind: EventKind) {
let projection = self.model.project();
if let Some(sink) = self.sink.as_mut() {
let now_us = u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX);
sink.on_projection(&projection, kind, now_us);
let now_us = u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX);
match &mut self.consumer {
ObserverConsumer::ReadOnly(Some(sink)) => {
sink.on_projection(&projection, kind, now_us);
}
ObserverConsumer::ReadOnly(None) => {}
ObserverConsumer::Mutating { sink, .. } => {
sink.on_projection(&projection, kind, now_us, &mut self.link_mutation);
}
}
// Published after the sink has seen it, so the projection is moved
// rather than cloned — the snapshot is the largest thing the observer
@@ -215,6 +458,14 @@ impl ObserverState {
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(projection);
}
fn replace_capture_sink(&mut self, capture_sink: Serial) {
let projection = self.model.project();
let now_us = u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX);
if let ObserverConsumer::Mutating { sink, .. } = &mut self.consumer {
sink.replace_capture_sink(capture_sink, &projection, now_us, &mut self.link_mutation);
}
}
/// Record the global's id and apply its add event as one step, so the
/// bound-proxy FIFO stays provably lockstep with the model's own `live_ids`
/// index. Recording only on *applied* adds (never on unknown object types
@@ -280,8 +531,8 @@ impl ObserverState {
fn run_observer(
latest: Arc<Mutex<Option<Projection>>>,
shutdown_rx: pw::channel::Receiver<()>,
sink: Option<Box<dyn ProjectionSink>>,
command_rx: pw::channel::Receiver<ObserverCommand>,
consumer: ObserverConsumer,
) -> Result<()> {
let started_at = Instant::now();
let main_loop =
@@ -292,16 +543,36 @@ fn run_observer(
.connect_rc(None)
.context("pw core connect failed (is the daemon running?)")?;
let registry = core.get_registry_rc().context("pw get_registry failed")?;
let state = Rc::new(RefCell::new(ObserverState::new(latest, sink, started_at)));
let mutation_health = consumer.mutation_health();
let link_mutation = PipeWireLinkMutation::new(core.clone());
let state = Rc::new(RefCell::new(ObserverState::new(
latest,
consumer,
link_mutation,
started_at,
)));
let main_loop_for_shutdown = main_loop.clone();
let _shutdown_receiver = shutdown_rx.attach(main_loop.loop_(), move |()| {
main_loop_for_shutdown.quit();
let shutdown_observed = Rc::new(Cell::new(false));
let shutdown_for_receiver = Rc::clone(&shutdown_observed);
let main_loop_for_command = main_loop.clone();
let state_for_command = Rc::clone(&state);
let _command_receiver = command_rx.attach(main_loop.loop_(), move |command| match command {
ObserverCommand::ReplaceCaptureSink(capture_sink) => {
state_for_command
.borrow_mut()
.replace_capture_sink(capture_sink);
}
ObserverCommand::Shutdown => {
shutdown_for_receiver.set(true);
main_loop_for_command.quit();
}
});
let pending_sync = Rc::new(Cell::new(None));
let pending_sync_for_done = Rc::clone(&pending_sync);
let state_for_done = Rc::clone(&state);
let main_loop_for_error = main_loop.clone();
let mutation_health_for_error = mutation_health.clone();
let _core_listener = core
.add_listener_local()
.done(move |id, seq| {
@@ -310,7 +581,7 @@ fn run_observer(
state_for_done.borrow_mut().apply(RegEvent::ServerSynced);
}
})
.error(|id, seq, res, message| {
.error(move |id, seq, res, message| {
tracing::warn!(
id,
seq,
@@ -318,6 +589,20 @@ fn run_observer(
%message,
"registry observer: PipeWire core error"
);
if recoverable_core_error(res) {
tracing::info!(
id,
seq,
result = res,
%message,
"registry observer: stale resource disappeared during graph churn"
);
} else if let Some(health) = &mutation_health_for_error {
health.poison(format!(
"audio fan-out PipeWire core error {res}: {message}"
));
main_loop_for_error.quit();
}
})
.register();
@@ -364,11 +649,15 @@ fn run_observer(
return;
}
};
let node = Rc::new(node);
// This bit only recognizes the initial callback for the
// change-mask fast path. Admission vs update remains
// entirely the model's decision.
let first_info = Cell::new(true);
let format_subscribed = Cell::new(false);
let node_for_info = Rc::downgrade(&node);
let state_for_info = Rc::downgrade(&state_for_global);
let state_for_format = Rc::downgrade(&state_for_global);
let listener = node
.add_listener_local()
.info(move |info| {
@@ -376,15 +665,65 @@ fn run_observer(
return;
};
let first = first_info.replace(false);
if !first
&& !info.change_mask().contains(pw::node::NodeChangeMask::PROPS)
{
let props_changed = first
|| info.change_mask().contains(pw::node::NodeChangeMask::PROPS);
let params_changed = first
|| info
.change_mask()
.contains(pw::node::NodeChangeMask::PARAMS);
if !props_changed && !params_changed {
return;
}
if let Some(state) = state_for_info.upgrade() {
let observation = node_observation_from_props(props);
if props_changed && let Some(state) = state_for_info.upgrade() {
state.borrow_mut().apply(RegEvent::NodeInfo {
serial,
observation: node_observation_from_props(props),
observation: observation.clone(),
});
}
if !observation.role.is_candidate() {
return;
}
let format_readable = info.params().iter().any(|param| {
param.id() == pw::spa::param::ParamType::Format
&& param.flags().contains(pw::spa::param::ParamInfoFlags::READ)
});
if !format_readable {
if params_changed && let Some(state) = state_for_info.upgrade() {
state.borrow_mut().apply(RegEvent::NodeFormat {
serial,
format: StreamFormat::Unknown,
});
}
return;
}
if !format_subscribed.replace(true)
&& let Some(node) = node_for_info.upgrade()
{
// Subscription covers later renegotiation;
// enumeration supplies the current configured
// format. Only candidates advertising a
// readable Format are queried, so ordinary
// driver Nodes cannot turn their expected
// ENOENT/EIO replies into host health faults.
node.subscribe_params(&[pw::spa::param::ParamType::Format]);
node.enum_params(
0,
Some(pw::spa::param::ParamType::Format),
0,
u32::MAX,
);
}
})
.param(move |_seq, id, _index, _next, param| {
if id != pw::spa::param::ParamType::Format {
return;
}
if let Some(state) = state_for_format.upgrade() {
state.borrow_mut().apply(RegEvent::NodeFormat {
serial,
format: stream_format_from_pod(param),
});
}
})
@@ -443,6 +782,7 @@ fn run_observer(
id,
node,
direction,
channel: props.get("audio.channel").map(str::to_string),
exclusive: truthy(props.get("port.exclusive")),
monitor: truthy(props.get("port.monitor")),
}),
@@ -642,6 +982,11 @@ fn run_observer(
tracing::info!("registry observer: pw thread running");
main_loop.run();
tracing::info!("registry observer: pw thread exiting");
if let Some(health) = mutation_health
&& !shutdown_observed.get()
{
health.poison("audio fan-out observer exited without a shutdown request");
}
Ok(())
}
@@ -668,6 +1013,63 @@ fn truthy(value: Option<&str>) -> bool {
value.is_some_and(|value| value != "false" && value != "0")
}
/// Classify the configured SPA Format without depending on producer-specific
/// property aliases. `Unknown` is the safe result for an absent or malformed
/// param; the taint engine refuses that stream until a usable format arrives.
fn stream_format_from_pod(param: Option<&pw::spa::pod::Pod>) -> StreamFormat {
let Some(param) = param else {
return StreamFormat::Unknown;
};
let Ok((media_type, media_subtype)) = pw::spa::param::format_utils::parse_format(param) else {
tracing::warn!("registry observer: could not parse Node Format media type");
return StreamFormat::Unknown;
};
let audio_format = if media_type == pw::spa::param::format::MediaType::Audio
&& media_subtype == pw::spa::param::format::MediaSubtype::Raw
{
let mut raw = pw::spa::param::audio::AudioInfoRaw::new();
match raw.parse(param) {
Ok(_) => Some(raw.format()),
Err(error) => {
tracing::warn!(
?error,
"registry observer: could not parse raw audio Format"
);
None
}
}
} else {
None
};
classify_stream_format(media_type, media_subtype, audio_format)
}
fn classify_stream_format(
media_type: pw::spa::param::format::MediaType,
media_subtype: pw::spa::param::format::MediaSubtype,
audio_format: Option<pw::spa::param::audio::AudioFormat>,
) -> StreamFormat {
use pw::spa::param::audio::AudioFormat;
use pw::spa::param::format::{MediaSubtype, MediaType};
if media_type != MediaType::Audio {
return StreamFormat::Unknown;
}
match media_subtype {
MediaSubtype::Unknown => StreamFormat::Unknown,
MediaSubtype::Iec958 => StreamFormat::Iec958,
MediaSubtype::Raw => match audio_format {
Some(AudioFormat::Encoded) => StreamFormat::Encoded,
Some(AudioFormat::Unknown) | None => StreamFormat::Unknown,
Some(_) => StreamFormat::Raw,
},
// Any configured non-raw audio subtype is encoded. Keeping this
// conservative also covers codecs newer than this libspa binding.
_ => StreamFormat::Encoded,
}
}
/// The ownership carrier is matched **exactly**, not leniently (round 10,
/// R10-4).
///
@@ -689,6 +1091,10 @@ fn peerspeak_owned(value: Option<&str>) -> bool {
}
fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation {
let device_id = props
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId);
NodeObservation {
name: props.get("node.name").map(str::to_string),
role: MediaRole::parse(props.get("media.class")),
@@ -710,13 +1116,12 @@ fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObs
.get("application.process.id")
.and_then(|value| value.parse::<u32>().ok()),
passthrough: truthy(props.get("node.passthrough")),
stream_format: StreamFormat::Unknown,
device_id,
session_device: false,
},
device_claim: DeviceClaim {
device_id: props
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
device_id,
device_api: props.get("device.api").map(str::to_string),
factory_name: props.get("factory.name").map(str::to_string),
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
@@ -757,6 +1162,60 @@ mod tests {
use super::*;
use std::process::Command;
#[test]
fn only_stale_resource_core_errors_are_recoverable() {
assert!(recoverable_core_error(-(nix::errno::Errno::ENOENT as i32)));
assert!(!recoverable_core_error(-(nix::errno::Errno::EPIPE as i32)));
assert!(!recoverable_core_error(0));
}
#[test]
fn configured_format_classifier_separates_raw_encoded_and_iec958() {
use pw::spa::param::audio::AudioFormat;
use pw::spa::param::format::{MediaSubtype, MediaType};
assert_eq!(
classify_stream_format(
MediaType::Audio,
MediaSubtype::Raw,
Some(AudioFormat::F32LE),
),
StreamFormat::Raw
);
assert_eq!(
classify_stream_format(MediaType::Audio, MediaSubtype::Mp3, None),
StreamFormat::Encoded
);
assert_eq!(
classify_stream_format(
MediaType::Audio,
MediaSubtype::Raw,
Some(AudioFormat::Encoded),
),
StreamFormat::Encoded
);
assert_eq!(
classify_stream_format(MediaType::Audio, MediaSubtype::Iec958, None),
StreamFormat::Iec958
);
assert_eq!(
classify_stream_format(MediaType::Video, MediaSubtype::Raw, None),
StreamFormat::Unknown
);
assert_eq!(
classify_stream_format(
MediaType::Audio,
MediaSubtype::Raw,
Some(AudioFormat::Unknown),
),
StreamFormat::Unknown
);
assert_eq!(
classify_stream_format(MediaType::Audio, MediaSubtype::Unknown, None),
StreamFormat::Unknown
);
}
/// **R10-4.** The ownership carrier is matched exactly; the lenient
/// [`truthy`] spelling is wrong for it.
///
@@ -832,6 +1291,7 @@ mod tests {
props.insert("media.class", "Stream/Output/Audio");
props.insert("node.name", "probe");
props.insert("client.id", "42");
props.insert("device.id", "77");
if let Some(value) = value {
props.insert(PEERSPEAK_OWNED_PROP, value);
}
@@ -846,6 +1306,8 @@ mod tests {
assert_eq!(observation.role, MediaRole::StreamOutput);
assert_eq!(observation.name.as_deref(), Some("probe"));
assert_eq!(observation.props.client_id, Some(GlobalId(42)));
assert_eq!(observation.props.device_id, Some(GlobalId(77)));
assert_eq!(observation.device_claim.device_id, Some(GlobalId(77)));
}
}
@@ -1168,9 +1630,19 @@ mod tests {
.is_some_and(|name| name.contains("alsa"))
|| matches!(node.role, MediaRole::Sink | MediaRole::Source))
});
let session_device = session_device.expect(
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device",
);
assert!(
session_device.is_some(),
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device"
session_device.props.device_id.is_some(),
"a live session device must retain the device.id used by the hardware bridge"
);
assert!(
projection
.snapshot
.ports()
.any(|port| port.channel.is_some()),
"at least one live audio port must retain audio.channel for Phase-6 pairing"
);
assert!(projection.graph_ready);
+37 -2
View File
@@ -88,7 +88,7 @@ mod tests;
use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
PortSnapshot, Serial,
PortSnapshot, Serial, StreamFormat,
};
use classify::{Classification, DeviceClaim, DeviceProps};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
@@ -145,6 +145,13 @@ pub enum RegEvent {
serial: Serial,
observation: NodeObservation,
},
/// The Node's configured `SPA_PARAM_Format`. This is independent of
/// [`RegEvent::NodeInfo`]: property and parameter callbacks have separate
/// lifetimes, and either may change without the other.
NodeFormat {
serial: Serial,
format: StreamFormat,
},
/// A Port global appeared.
PortAdded(PortSnapshot),
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
@@ -305,6 +312,10 @@ struct NodeEntry {
/// `None` while the bind is outstanding — withheld from the snapshot and
/// an outstanding readiness obligation (v3.5 §6.7 decision 3).
obs: Option<NodeObservation>,
/// `Unknown` until the bound Node returns its configured Format param.
/// Unknown streams remain projected but are refused locally, so one idle
/// app cannot hold the entire graph's readiness epoch open.
format: StreamFormat,
}
/// A live Device: its global id plus its bound properties once they arrive.
@@ -427,7 +438,14 @@ impl RegistryModel {
match event {
RegEvent::NodeAdded { serial, id } => {
self.push_id(id, Slot::Node(serial));
self.nodes.insert(serial, NodeEntry { id, obs: None });
self.nodes.insert(
serial,
NodeEntry {
id,
obs: None,
format: StreamFormat::Unknown,
},
);
// A node awaiting its bind is a fresh obligation, so this can
// only ever *hold* readiness, never complete it — but the
// re-check is cheap and keeps the invariant local.
@@ -438,6 +456,7 @@ impl RegistryModel {
serial,
observation,
} => self.on_node_info(serial, observation),
RegEvent::NodeFormat { serial, format } => self.on_node_format(serial, format),
RegEvent::PortAdded(port) => {
self.push_id(port.id, Slot::Port(port.serial));
self.ports.insert(port.serial, port);
@@ -517,6 +536,21 @@ impl RegistryModel {
Outcome::Applied
}
fn on_node_format(&mut self, serial: Serial, format: StreamFormat) -> Outcome {
let Some(entry) = self.nodes.get_mut(&serial) else {
tracing::debug!(
serial = serial.0,
"observer: node format for an unknown node"
);
return Outcome::Suppressed;
};
if entry.format == format {
return Outcome::Suppressed;
}
entry.format = format;
Outcome::Applied
}
fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome {
let Some(entry) = self.devices.get_mut(&serial) else {
tracing::debug!(
@@ -737,6 +771,7 @@ impl RegistryModel {
};
let mut props = obs.props.clone();
props.session_device = session_device;
props.stream_format = entry.format;
Some(NodeSnapshot {
serial,
id: entry.id,
+54 -1
View File
@@ -15,6 +15,7 @@ use super::pulse_pid;
use super::*;
use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, IdLookup, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
StreamFormat,
};
// ---- builders -------------------------------------------------------------
@@ -74,10 +75,14 @@ fn device_with(api: Option<&str>, driver: Option<&str>) -> DeviceProps {
}
fn obs(name: &str, role: MediaRole, claim: DeviceClaim) -> NodeObservation {
let device_id = claim.device_id;
NodeObservation {
name: Some(name.to_string()),
role,
props: NodeProps::default(),
props: NodeProps {
device_id,
..NodeProps::default()
},
device_claim: claim,
}
}
@@ -148,6 +153,7 @@ fn port(serial: u64, id: u32, node_id: u32, dir: PortDirection) -> RegEvent {
id: gid(id),
node: gid(node_id),
direction: dir,
channel: None,
exclusive: false,
monitor: false,
})
@@ -652,6 +658,46 @@ fn model_adds_all_four_object_types() {
assert_eq!(snap.links().count(), 1);
}
#[test]
fn model_projects_configured_node_format_independently_of_props() {
let mut m = model();
add_stream_out(&mut m, 100, 50);
assert_eq!(
m.project()
.snapshot
.node(ser(100))
.unwrap()
.props
.stream_format,
StreamFormat::Unknown
);
assert_eq!(
m.apply(RegEvent::NodeFormat {
serial: ser(100),
format: StreamFormat::Encoded,
}),
Outcome::Applied
);
assert_eq!(
m.project()
.snapshot
.node(ser(100))
.unwrap()
.props
.stream_format,
StreamFormat::Encoded
);
assert_eq!(
m.apply(RegEvent::NodeFormat {
serial: ser(100),
format: StreamFormat::Encoded,
}),
Outcome::Suppressed,
"an unchanged param must not inflate the graph event stream"
);
}
#[test]
fn model_removes_all_four_object_types() {
let mut m = model();
@@ -911,6 +957,13 @@ fn model_readiness_does_not_release_with_obligation_outstanding() {
});
assert_eq!(m.readiness(), Readiness::Complete);
assert!(m.graph_ready());
let projected = m.project();
let device_node = projected
.snapshot
.node(ser(100))
.expect("resolved device node is projected");
assert!(device_node.props.session_device);
assert_eq!(device_node.props.device_id, Some(gid(42)));
}
#[test]
+170
View File
@@ -0,0 +1,170 @@
//! Cancellation-safe ownership for blocking subsystem threads.
//!
//! `std::thread::JoinHandle` has no timed join. Moving it into
//! `spawn_blocking` only moves the problem: cancelling the async waiter detaches
//! the blocking task and loses the only handle. Instead this owner polls
//! `is_finished()` while retaining the handle, joins only after completion, and
//! quarantines an unfinished handle on timeout or Drop. Quarantine is
//! process-lifetime ownership, not recovery; the shared health channel makes
//! the supervisor terminate the poisoned host.
use super::health::Reporter;
use std::sync::{Mutex, OnceLock};
use std::thread::JoinHandle;
use std::time::Duration;
static QUARANTINED: OnceLock<Mutex<Vec<JoinHandle<()>>>> = OnceLock::new();
fn quarantine(handle: JoinHandle<()>) {
let mut handles = QUARANTINED
.get_or_init(|| Mutex::new(Vec::new()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
// Reap anything that happened to finish since the previous quarantine;
// every still-running handle remains owned until process exit.
let old = std::mem::take(&mut *handles);
for old_handle in old {
if old_handle.is_finished() {
let _ = old_handle.join();
} else {
handles.push(old_handle);
}
}
handles.push(handle);
}
pub(super) struct OwnedThread {
name: &'static str,
handle: Option<JoinHandle<()>>,
health: Reporter,
}
impl OwnedThread {
pub(super) fn new(name: &'static str, handle: JoinHandle<()>, health: Reporter) -> Self {
Self {
name,
handle: Some(handle),
health,
}
}
/// Wait up to `budget`, retaining the OS handle across every await.
///
/// Returns true only when the thread was joined successfully. Timeout and
/// panic poison the process; a timeout also moves the still-running handle
/// into process-lifetime quarantine.
pub(super) async fn join_within(&mut self, budget: Duration) -> bool {
let deadline = tokio::time::Instant::now() + budget;
loop {
let Some(handle) = self.handle.as_ref() else {
return true;
};
if handle.is_finished() {
let handle = self.handle.take().expect("checked as present");
return match handle.join() {
Ok(()) => true,
Err(_) => {
self.health
.poison(format!("{} panicked during shutdown", self.name));
false
}
};
}
if tokio::time::Instant::now() >= deadline {
self.health.poison(format!(
"{} did not stop within {:?}; its thread handle is quarantined",
self.name, budget
));
quarantine(self.handle.take().expect("checked as present"));
return false;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
impl Drop for OwnedThread {
fn drop(&mut self) {
let Some(handle) = self.handle.take() else {
return;
};
if handle.is_finished() {
if handle.join().is_err() {
self.health
.poison(format!("{} panicked before it was joined", self.name));
}
return;
}
self.health.poison(format!(
"{} was dropped before it stopped; its thread handle is quarantined",
self.name
));
quarantine(handle);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::health;
use std::sync::mpsc;
fn reap_finished_quarantine() {
let Some(handles) = QUARANTINED.get() else {
return;
};
let mut handles = handles
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let old = std::mem::take(&mut *handles);
for handle in old {
if handle.is_finished() {
let _ = handle.join();
} else {
handles.push(handle);
}
}
}
#[tokio::test]
async fn completed_thread_is_joined_without_poison() {
let (health, _) = health::channel();
let handle = std::thread::spawn(|| {});
let mut owner = OwnedThread::new("test worker", handle, health.clone());
assert!(owner.join_within(Duration::from_secs(1)).await);
assert!(health.fault().is_none());
}
#[tokio::test]
async fn timeout_poisons_and_quarantines_instead_of_detaching() {
let (release_tx, release_rx) = mpsc::channel();
let (health, _) = health::channel();
let handle = std::thread::spawn(move || {
let _ = release_rx.recv();
});
let mut owner = OwnedThread::new("wedged worker", handle, health.clone());
assert!(!owner.join_within(Duration::from_millis(20)).await);
assert!(health.fault().is_some());
drop(owner);
release_tx.send(()).expect("release quarantined worker");
std::thread::sleep(Duration::from_millis(20));
reap_finished_quarantine();
}
#[test]
fn dropping_an_unfinished_owner_poisons_and_quarantines() {
let (release_tx, release_rx) = mpsc::channel();
let (health, _) = health::channel();
let handle = std::thread::spawn(move || {
let _ = release_rx.recv();
});
drop(OwnedThread::new("cancelled worker", handle, health.clone()));
assert!(health.fault().is_some());
release_tx.send(()).expect("release quarantined worker");
std::thread::sleep(Duration::from_millis(20));
reap_finished_quarantine();
}
}
+252 -79
View File
@@ -5,23 +5,123 @@
//! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared
//! and lives here. Backends call [`spawn`] with just their source-element args.
use anyhow::{Context, Result, bail};
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use anyhow::{Context, Result};
use nix::sys::signal::Signal;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::process::{Child, Command};
use tokio::time::timeout;
use super::audio::Routing;
use super::audio_plan::CapturePlan;
use super::health;
use super::quality::EffectiveQuality;
use super::serve::Serve;
use crate::cli::HostOpts;
use crate::common::contained;
pub struct CaptureHandle {
gst: Option<Child>,
audio: Option<Routing>,
const GST_TERM_BUDGET: Duration = Duration::from_secs(1);
const GST_KILL_BUDGET: Duration = Duration::from_secs(1);
/// Owns the contained GStreamer process from spawn through confirmed reap.
///
/// Keeping this guard alive during `Serve::bind` closes the old constructor
/// leak: any error after spawn kills the whole process group, not merely the
/// direct child. An unconfirmed Drop poisons the host so the supervisor cannot
/// start another capture on top of a possibly-live portal/PipeWire owner.
struct CaptureProcess {
child: Child,
leader_pid: u32,
reaped: bool,
health: health::Reporter,
}
impl CaptureProcess {
fn new(child: Child, health: health::Reporter) -> Result<Self> {
let leader_pid = child
.id()
.context("gst-launch-1.0 exited before its process id was recorded")?;
Ok(Self {
child,
leader_pid,
reaped: false,
health,
})
}
fn take_stdout(&mut self) -> Option<tokio::process::ChildStdout> {
self.child.stdout.take()
}
async fn shutdown(&mut self) {
// Reap an already-dead child before addressing its process group. A
// zombie still reserves its pid, but after `try_wait` succeeds that pid
// may be reused; returning here avoids ever signalling a new group that
// inherited the old numeric id.
match self.child.try_wait() {
Ok(Some(_)) => {
self.reaped = true;
self.health
.poison("gst-launch-1.0 exited before PixelPass began capture shutdown");
return;
}
Ok(None) => {}
Err(e) => tracing::warn!(
"capture: could not inspect gst before SIGTERM ({e}); continuing teardown"
),
}
let _ = contained::signal_group(self.leader_pid, Signal::SIGTERM);
match timeout(GST_TERM_BUDGET, self.child.wait()).await {
Ok(Ok(_)) => {
self.reaped = true;
return;
}
Ok(Err(e)) => tracing::warn!(
"capture: gst wait after SIGTERM failed ({e}); escalating to SIGKILL"
),
Err(_) => tracing::warn!(
"capture: gst did not exit within {GST_TERM_BUDGET:?}; escalating to SIGKILL"
),
}
let _ = contained::signal_group(self.leader_pid, Signal::SIGKILL);
let _ = self.child.start_kill();
match timeout(GST_KILL_BUDGET, self.child.wait()).await {
Ok(Ok(_)) => self.reaped = true,
Ok(Err(e)) => {
self.health.poison(format!(
"gst-launch-1.0 could not be reaped after SIGKILL: {e}"
));
}
Err(_) => {
self.health.poison(format!(
"gst-launch-1.0 did not exit within {GST_KILL_BUDGET:?} after SIGKILL"
));
}
}
}
}
impl Drop for CaptureProcess {
fn drop(&mut self) {
if self.reaped {
return;
}
let _ = contained::signal_group(self.leader_pid, Signal::SIGKILL);
let _ = self.child.start_kill();
self.health.poison(
"gst-launch-1.0 was dropped before a confirmed reap; its process group was killed",
);
}
}
pub(super) struct CaptureHandle {
gst: Option<CaptureProcess>,
audio: Option<CapturePlan>,
serve: Option<Serve>,
stopping: Arc<AtomicBool>,
}
impl CaptureHandle {
@@ -32,22 +132,18 @@ impl CaptureHandle {
.local_port()
}
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL,
/// Graceful teardown: SIGTERM the gst process group, give it ~1s to exit,
/// then SIGKILL and a second bounded reap,
/// unload audio routing (if any), then tear down the serve layer.
/// The serve reader will see EOF on gst stdout and exit on its own;
/// serve.shutdown() is the backstop.
pub async fn shutdown(mut self) {
if let Some(child) = self.gst.as_mut()
&& let Some(pid) = child.id()
{
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
self.stopping.store(true, Ordering::Release);
if let Some(mut gst) = self.gst.take() {
gst.shutdown().await;
}
if let Some(child) = self.gst.as_mut() {
let _ = timeout(Duration::from_millis(1000), child.wait()).await;
let _ = child.start_kill();
}
if let Some(audio) = self.audio.take() {
audio.shutdown().await;
if let Some(audio_plan) = self.audio.take() {
audio_plan.shutdown().await;
}
if let Some(serve) = self.serve.take() {
serve.shutdown().await;
@@ -57,10 +153,9 @@ impl CaptureHandle {
impl Drop for CaptureHandle {
fn drop(&mut self) {
if let Some(child) = self.gst.as_mut() {
let _ = child.start_kill();
}
// Routing's and Serve's own Drop impls handle the rest.
self.stopping.store(true, Ordering::Release);
// CaptureProcess kills the whole process group and poisons the host;
// the typed plan's inner owner and Serve handle their own Drop layers.
}
}
@@ -73,74 +168,51 @@ impl Drop for CaptureHandle {
/// `after_spawn` runs once, immediately after the gst child is launched —
/// Wayland uses it to `close` the pipewire fd it leaked into the child; X11
/// passes a no-op.
pub async fn spawn(
pub(super) async fn spawn(
opts: &HostOpts,
quality: &EffectiveQuality,
source_dims: Option<(u32, u32)>,
source_args: Vec<String>,
health: health::Reporter,
after_spawn: impl FnOnce(),
) -> Result<CaptureHandle> {
let (audio_routing, audio_device) = setup_audio(opts).await?;
let args = build_args(&source_args, &audio_device, opts, quality, source_dims);
let audio_plan = CapturePlan::start(opts, health.clone()).await?;
let args = build_args(&source_args, &audio_plan, opts, quality, source_dims);
let mut gst_cmd = Command::new("gst-launch-1.0");
gst_cmd
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
.stderr(Stdio::inherit())
.kill_on_drop(true);
if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() {
gst_cmd.env("GST_DEBUG", "3");
}
let mut gst = gst_cmd.spawn().context("failed to spawn gst-launch-1.0")?;
let gst = contained::spawn_tokio(&mut gst_cmd).context("failed to spawn gst-launch-1.0")?;
let mut gst = CaptureProcess::new(gst, health.clone())?;
// Backend-specific post-spawn cleanup (Wayland closes its leaked pw fd here,
// once gst has inherited its own copy).
after_spawn();
let gst_stdout = gst
.stdout
.take()
.take_stdout()
.context("gst-launch-1.0 stdout pipe unavailable")?;
// Hand stdout to the serve layer, which binds the localhost HTTP listener
// and runs the broadcast fanout. No demux/remux, no codec assumptions.
let serve = Serve::bind(gst_stdout).await?;
let stopping = Arc::new(AtomicBool::new(false));
let serve = Serve::bind(gst_stdout, health.clone(), Arc::clone(&stopping)).await?;
Ok(CaptureHandle {
gst: Some(gst),
audio: audio_routing,
audio: Some(audio_plan),
serve: Some(serve),
stopping,
})
}
/// Decide whether per-app audio routing is active and produce the `device=…`
/// argument for `pulsesrc`. Routing activates when either `--app` is set
/// (per-stream rerouting to a per-PID null-sink) or `PIXELPASS_AUDIO_VIA_NULL_SINK=1`
/// is set (no app filter — captures everything via the null-sink, used for
/// dogfooding the loopback path). Otherwise we capture the default sink's
/// monitor (system audio out), not the default source (the mic).
async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
let routing_requested =
opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
let audio_routing = if routing_requested {
Some(
Routing::start(opts)
.await
.context("audio routing setup failed")?,
)
} else {
None
};
let audio_device = if let Some(r) = &audio_routing {
format!("device={}.monitor", r.sink_name())
} else {
let default = default_audio_monitor().await?;
format!("device={default}")
};
Ok((audio_routing, audio_device))
}
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
/// video branch (caller's `source` → videorate cap → optional downscale →
/// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.).
@@ -150,7 +222,7 @@ async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
/// wants I420).
fn build_args(
source: &[String],
audio_device: &str,
audio_plan: &CapturePlan,
opts: &HostOpts,
quality: &EffectiveQuality,
source_dims: Option<(u32, u32)>,
@@ -304,7 +376,7 @@ fn build_args(
// not the default source (which is the mic).
args.extend([
"pulsesrc".into(),
audio_device.to_string(),
audio_plan.gst_device_arg(),
"do-timestamp=true".into(),
"!".into(),
"queue".into(),
@@ -326,26 +398,127 @@ fn build_args(
args
}
async fn default_audio_monitor() -> Result<String> {
let output = Command::new("pactl")
.arg("get-default-sink")
.output()
.await
.context(
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
)?;
if !output.status.success() {
bail!(
"pactl get-default-sink failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{CaptureMode, Quality};
use std::time::{Duration, Instant};
fn legacy_opts() -> HostOpts {
HostOpts {
window: false,
app: None,
strict_audio: false,
display_server: None,
quality: Quality::Source,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
capture_mode: CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false,
relay: None,
}
}
#[test]
fn legacy_desktop_audio_tail_is_byte_identical() {
let opts = legacy_opts();
let quality = super::super::quality::resolve(&opts, 1);
let plan = CapturePlan::legacy_fixture("alsa_output.fixture.monitor");
let args = build_args(&["ximagesrc".to_string()], &plan, &opts, &quality, None);
let audio_start = args
.iter()
.position(|arg| arg == "pulsesrc")
.expect("pipeline has an audio branch");
assert_eq!(
&args[audio_start..],
[
"pulsesrc",
"device=alsa_output.fixture.monitor",
"do-timestamp=true",
"!",
"queue",
"!",
"audioconvert",
"!",
"audioresample",
"!",
"audio/x-raw,rate=48000,channels=2",
"!",
"avenc_aac",
"bitrate=128000",
"!",
"aacparse",
"!",
"mux.",
]
);
}
let sink = String::from_utf8(output.stdout)
.context("default sink name was not UTF-8")?
.trim()
.to_string();
if sink.is_empty() {
bail!("pactl get-default-sink returned no name (is a sound server running?)");
fn process_is_running(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false;
};
stat.rsplit_once(") ")
.and_then(|(_, rest)| rest.as_bytes().first().copied())
.is_some_and(|state| state != b'Z' && state != b'X')
}
fn sleeping_capture(health: health::Reporter) -> CaptureProcess {
let mut command = Command::new("sleep");
command.arg("30").kill_on_drop(true);
let child = contained::spawn_tokio(&mut command).expect("spawn contained fixture");
CaptureProcess::new(child, health).expect("capture process guard")
}
#[tokio::test]
async fn graceful_capture_shutdown_confirms_reap_without_poison() {
let (health, _) = health::channel();
let mut capture = sleeping_capture(health.clone());
capture.shutdown().await;
assert!(health.fault().is_none());
}
#[tokio::test]
async fn an_already_exited_capture_is_not_misreported_as_a_clean_shutdown() {
let (health, _) = health::channel();
let mut command = Command::new("true");
command.kill_on_drop(true);
let child = contained::spawn_tokio(&mut command).expect("spawn short contained fixture");
let mut capture = CaptureProcess::new(child, health.clone()).expect("capture guard");
let deadline = Instant::now() + Duration::from_secs(1);
while process_is_running(capture.leader_pid) && Instant::now() < deadline {
tokio::task::yield_now().await;
}
assert!(
!process_is_running(capture.leader_pid),
"short fixture must exit before shutdown begins"
);
capture.shutdown().await;
assert_eq!(
health.fault().as_deref(),
Some("gst-launch-1.0 exited before PixelPass began capture shutdown")
);
}
#[tokio::test]
async fn dropping_live_capture_kills_its_group_and_poisons() {
let (health, _) = health::channel();
let capture = sleeping_capture(health.clone());
let pid = capture.leader_pid;
drop(capture);
assert!(health.fault().is_some());
let deadline = Instant::now() + Duration::from_secs(2);
while process_is_running(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!process_is_running(pid));
}
Ok(format!("{sink}.monitor"))
}
+3
View File
@@ -214,6 +214,9 @@ mod tests {
no_hwencode: false,
max_viewers,
interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false,
relay: None,
}
}
+55 -3
View File
@@ -10,6 +10,7 @@
use anyhow::{Context, Result, bail};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
@@ -18,6 +19,8 @@ use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use tokio::time::{Instant, sleep};
use super::health;
/// Broadcast-channel capacity in chunks. Each chunk is up to 64 KiB from
/// the capture child's stdout, so 16 chunks ≈ 1 MiB ≈ ~2 s of buffered
/// jitter at typical bitrates. A viewer that falls behind by more than
@@ -40,14 +43,18 @@ impl Serve {
/// Bind a localhost listener on a random port, set up the broadcast
/// fanout, and spawn the reader + accept-loop tasks. The provided
/// `stdout` is assumed to produce MPEG-TS bytes.
pub async fn bind(stdout: ChildStdout) -> Result<Self> {
pub(super) async fn bind(
stdout: ChildStdout,
health: health::Reporter,
stopping: Arc<AtomicBool>,
) -> Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.context("could not bind local capture HTTP listener")?;
let port = listener.local_addr()?.port();
let (tx, _) = broadcast::channel::<Arc<Vec<u8>>>(FANOUT_CAPACITY);
let reader = tokio::spawn(pump_to_broadcast(stdout, tx.clone()));
let reader = tokio::spawn(pump_to_broadcast(stdout, tx.clone(), health, stopping));
let server = tokio::spawn(run_accept_loop(listener, tx));
Ok(Self {
@@ -105,12 +112,20 @@ pub async fn connect_to_capture(port: u16, max_wait: Duration) -> Result<TcpStre
/// current subscribers. `broadcast::send` returns Err when there are no
/// receivers; we ignore it so the capture child isn't backpressured
/// waiting for a viewer.
async fn pump_to_broadcast(mut stdout: ChildStdout, tx: broadcast::Sender<Arc<Vec<u8>>>) {
async fn pump_to_broadcast(
mut stdout: impl tokio::io::AsyncRead + Unpin,
tx: broadcast::Sender<Arc<Vec<u8>>>,
health: health::Reporter,
stopping: Arc<AtomicBool>,
) {
let mut buf = vec![0u8; READ_CHUNK];
loop {
match stdout.read(&mut buf).await {
Ok(0) => {
tracing::info!("capture stdout EOF — fanout reader exiting");
if !stopping.load(Ordering::Acquire) {
health.poison("GStreamer capture stdout closed unexpectedly");
}
return;
}
Ok(n) => {
@@ -119,6 +134,9 @@ async fn pump_to_broadcast(mut stdout: ChildStdout, tx: broadcast::Sender<Arc<Ve
}
Err(e) => {
tracing::warn!("capture stdout read error: {e}");
if !stopping.load(Ordering::Acquire) {
health.poison(format!("GStreamer capture stdout failed: {e}"));
}
return;
}
}
@@ -192,3 +210,37 @@ async fn drain_http_request(sock: &mut TcpStream) -> bool {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unexpected_capture_eof_poisons_the_host() {
let (reader, writer) = tokio::io::duplex(16);
let (tx, _) = broadcast::channel(FANOUT_CAPACITY);
let (health, _) = health::channel();
let stopping = Arc::new(AtomicBool::new(false));
drop(writer);
pump_to_broadcast(reader, tx, health.clone(), stopping).await;
assert_eq!(
health.fault().as_deref(),
Some("GStreamer capture stdout closed unexpectedly")
);
}
#[tokio::test]
async fn expected_capture_eof_during_shutdown_stays_healthy() {
let (reader, writer) = tokio::io::duplex(16);
let (tx, _) = broadcast::channel(FANOUT_CAPACITY);
let (health, _) = health::channel();
let stopping = Arc::new(AtomicBool::new(true));
drop(writer);
pump_to_broadcast(reader, tx, health.clone(), stopping).await;
assert!(health.fault().is_none());
}
}
+27 -2
View File
@@ -155,7 +155,17 @@ impl Graph {
/// A device node as the session manager creates it: no strong key,
/// WirePlumber's client and PID — shared with every other device — and
/// a `device.id`, which is what marks it as session-manager-exported.
/// Each call models a distinct physical device; use [`Self::device_node_on`]
/// when two terminals belong to the same card.
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
let device_id = self.id();
self.device_node_on(name, role, device_id)
}
/// A passive terminal exported by a particular physical Device. Sink
/// and source nodes given the same id model the hidden playback-to-capture
/// path that an ALSA/USB device may expose outside PipeWire's Link graph.
pub fn device_node_on(&mut self, name: &str, role: MediaRole, device_id: GlobalId) -> NodeRef {
let session = match self.session_client {
Some(id) => id,
None => {
@@ -164,7 +174,7 @@ impl Graph {
id
}
};
self.node(name, role, device(session, SESSION_PID))
self.node(name, role, device(session, SESSION_PID, device_id))
}
/// A node that *belongs to* a Device but is not a passive device node —
@@ -247,6 +257,18 @@ impl Graph {
}
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
self.port_on_channel(node, direction, exclusive, None);
}
/// A port with the channel identity Phase 6 uses for deterministic link
/// pairing. Returns its snapshot-local id for exact plan assertions.
pub fn port_on_channel(
&mut self,
node: NodeRef,
direction: PortDirection,
exclusive: bool,
channel: Option<&str>,
) -> GlobalId {
let serial = self.serial();
let id = self.id();
self.ports.push(PortSnapshot {
@@ -254,9 +276,11 @@ impl Graph {
id,
node: node.id,
direction,
channel: channel.map(str::to_string),
exclusive,
monitor: false,
});
id
}
/// A signal edge: audio flows `from → to`.
@@ -386,10 +410,11 @@ pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
/// here is deliberately *more* pessimistic than reality — it hands the
/// engine a second coarse key it could fuse devices on, so a test that
/// passes here also passes against the real props.
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
pub fn device(session_client: GlobalId, session_pid: u32, device_id: GlobalId) -> NodeProps {
NodeProps {
client_id: Some(session_client),
process_id: Some(session_pid),
device_id: Some(device_id),
session_device: true,
..NodeProps::default()
}
+76 -9
View File
@@ -34,7 +34,12 @@
//! *is* a real Link whose output node is the sink node itself (measured).
//! A port-granular walk would need a synthetic edge; a node-granular one
//! does not.
//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See
//! 3. **Hardware-device bridges** — a passive sink can feed a passive source
//! on the same physical Device through a mixer/loopback path that PipeWire
//! does not expose as a Link. The observer positively classifies both
//! terminals and retains their shared `device.id`; the walk conservatively
//! adds `sink → source` for that one Device.
//! 4. **Owner bridges** — the intra-process hop the graph cannot see. See
//! [`owner`]; this is the hard one.
//!
//! ## Stickiness
@@ -196,9 +201,15 @@ pub enum Reason {
/// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local
/// to the node; does not propagate.
PortExclusive,
/// An encoded/passthrough stream — a second link would corrupt it.
/// No usable configured Format param has arrived. Fan-out cannot prove a
/// second link is safe. Local to the node; does not propagate.
FormatUnknown,
/// An encoded stream — a second raw-audio link would refuse or corrupt.
/// Local to the node; does not propagate.
Passthrough,
Encoded,
/// An IEC958/S/PDIF passthrough stream. Local to the node; does not
/// propagate.
Iec958Passthrough,
}
impl Reason {
@@ -214,10 +225,25 @@ impl Reason {
Self::UnresolvedOwner => "unresolved-owner",
Self::GraphNotReady => "graph-not-ready",
Self::PortExclusive => "port-exclusive",
Self::Passthrough => "passthrough",
Self::FormatUnknown => "format-unknown",
Self::Encoded => "encoded",
Self::Iec958Passthrough => "iec958-passthrough",
}
}
/// A clean, otherwise-eligible stream whose known format/port shape this
/// fan-out mode cannot link safely. These reasons are user-visible
/// `stream_unsupported` statuses; taint roots and observation gating are
/// intentional exclusions, not failures. `FormatUnknown` is deliberately
/// omitted because the initial Node-info callback can precede the Format
/// reply; reporting that transient would produce a false warning.
pub(super) fn reports_stream_unsupported(self) -> bool {
matches!(
self,
Self::PortExclusive | Self::Encoded | Self::Iec958Passthrough
)
}
/// Lower wins. A node can acquire taint several ways in one recompute
/// and the reported reason must not depend on traversal order, or the
/// audit output is unstable and the fixture tests are flaky. Explicit
@@ -236,7 +262,9 @@ impl Reason {
// because it is only consulted for untainted candidates.
Self::GraphNotReady => 8,
Self::PortExclusive => 9,
Self::Passthrough => 10,
Self::FormatUnknown => 10,
Self::Encoded => 11,
Self::Iec958Passthrough => 12,
}
}
@@ -784,6 +812,40 @@ fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Se
_ => {}
}
}
// A physical sound device may route playback back into capture in its
// own mixer/firmware without publishing a PipeWire Link (HDA "Stereo
// Mix", USB loopback channels, vendor DSPs). Control-name inspection is
// neither portable nor proof of absence, so v1 fails closed: once a
// passive hardware sink is tainted, passive capture terminals exported
// by the same Device are downstream too.
//
// Both guards are load-bearing. `session_device` limits this to the
// observer's positive hardware-terminal allowlist, so an app-associated
// filter cannot invent a bridge. `device_id` limits it to one physical
// Device, so the shared WirePlumber client does not fuse every card.
let hardware_outputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
.nodes()
.filter(|node| {
node.props.session_device && matches!(node.role, MediaRole::Sink | MediaRole::Duplex)
})
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
.collect();
let hardware_inputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
.nodes()
.filter(|node| {
node.props.session_device && matches!(node.role, MediaRole::Source | MediaRole::Duplex)
})
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
.collect();
for (from, output_device) in hardware_outputs {
for &(to, input_device) in &hardware_inputs {
if from != to && output_device == input_device {
edges.entry(from).or_default().push(to);
receivers.insert(to);
}
}
}
for targets in edges.values_mut() {
targets.sort_unstable();
targets.dedup();
@@ -1018,13 +1080,18 @@ fn build_decisions(
/// clean. These do not propagate — an exclusive-port stream is unlinkable,
/// not hazardous.
fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option<Reason> {
if node.props.passthrough {
return Some(Reason::Passthrough);
}
if snapshot.ports_of(node.id).any(|port| port.exclusive) {
return Some(Reason::PortExclusive);
}
None
if node.props.passthrough {
return Some(Reason::Iec958Passthrough);
}
match node.props.stream_format {
snapshot::StreamFormat::Raw => None,
snapshot::StreamFormat::Unknown => Some(Reason::FormatUnknown),
snapshot::StreamFormat::Encoded => Some(Reason::Encoded),
snapshot::StreamFormat::Iec958 => Some(Reason::Iec958Passthrough),
}
}
/// Sticky bookkeeping for the next recompute: every tainted owner, with
+37 -2
View File
@@ -52,6 +52,25 @@ pub enum MediaRole {
Other,
}
/// The configured format of an application playback stream.
///
/// The production observer reads this from the Node's `SPA_PARAM_Format`.
/// Fixtures default to [`Self::Raw`] because almost every graph fixture models
/// ordinary PCM playback; the observer explicitly uses [`Self::Unknown`]
/// until PipeWire supplies a format. Unknown is fail-closed at eligibility.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StreamFormat {
/// Ordinary PCM audio, safe to fan out subject to the other predicates.
#[default]
Raw,
/// No usable configured format has been observed yet.
Unknown,
/// Encoded audio other than IEC958 passthrough.
Encoded,
/// IEC958/S/PDIF passthrough.
Iec958,
}
impl MediaRole {
pub fn parse(media_class: Option<&str>) -> Self {
match media_class {
@@ -136,9 +155,22 @@ pub struct NodeProps {
/// module-created streams this is pipewire-pulse's own PID, which is
/// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists.
pub process_id: Option<u32>,
/// The stream negotiated an encoded/passthrough format; a second link
/// would refuse or corrupt it (v3.4 §6.2).
/// `node.passthrough`; an explicit producer/session-manager refusal flag.
/// Kept separate from [`Self::stream_format`] so the two Phase-6 refusal
/// predicates cannot accidentally mask one another.
pub passthrough: bool,
/// The Node's configured `SPA_PARAM_Format`, classified at the observer
/// boundary. Encoded and IEC958 streams are distinct refusal predicates.
pub stream_format: StreamFormat,
/// `device.id` — the snapshot-local PipeWire Device this node belongs
/// to. This is retained separately from [`Self::session_device`]: the
/// latter says the node is a positively-classified passive hardware
/// terminal, while this id lets the taint walk relate the playback and
/// capture terminals exported by that *same* device.
///
/// Like every [`GlobalId`], this is valid only within this snapshot. It
/// must never enter sticky identity or survive a recompute.
pub device_id: Option<GlobalId>,
/// This node is a **passive device node exported by the session
/// manager** — a real sound card's sink or source, not something that
/// forwards audio.
@@ -219,6 +251,9 @@ pub struct PortSnapshot {
/// Owning node, by snapshot-local id.
pub node: GlobalId,
pub direction: PortDirection,
/// `audio.channel` (for example `FL`, `FR`, `MONO`). Phase 6 pairs
/// ports by channel, never by global-id or enumeration order.
pub channel: Option<String>,
/// `port.exclusive` — fan-out will be refused (v3.4 §6.2).
pub exclusive: bool,
/// `port.monitor`. Recorded for phase 6 link creation; taint does not
+84 -2
View File
@@ -16,7 +16,7 @@ use std::collections::BTreeSet;
use super::fixture::{Graph, NodeRef, PULSE_PID, app};
use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key};
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial};
use super::snapshot::{GlobalId, MediaRole, NodeProps, PortDirection, Serial};
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
fn ctx() -> ExclusionCtx {
@@ -176,6 +176,88 @@ fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() {
assert_tainted(&decisions, sink, "tainted-upstream");
}
// ──────────────────────────────────────────────────────────────────────
// Hidden hardware playback-to-capture paths — same Device only
// ─────────────────────────────────────────────────────────────────────
#[test]
fn same_hardware_device_closes_an_unpublished_playback_to_capture_hop() {
let mut graph = Graph::new();
let card = GlobalId(700);
let sink = graph.device_node_on("card-playback", MediaRole::Sink, card);
let source = graph.device_node_on("card-capture", MediaRole::Source, card);
let call = graph.peerspeak_node("peerspeak-call", 7);
let music = graph.app_node("music", MediaRole::StreamOutput, 8);
let recorder_in = graph.app_node("recorder-in", MediaRole::StreamInput, 9);
let recorder_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 9);
graph.link(call, sink);
graph.link(music, sink);
// There is deliberately no sink → source Link: the hardware bridge is
// the route being modeled.
graph.link(source, recorder_in);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("music", music)],
&[
("call", call, "peerspeak-owned"),
("recorder-out", recorder_out, "tainted-owner-bridge"),
],
);
assert_tainted(&decisions, sink, "tainted-upstream");
assert_tainted(&decisions, source, "tainted-upstream");
assert_tainted(&decisions, recorder_in, "tainted-upstream");
}
#[test]
fn different_hardware_devices_do_not_invent_a_capture_path() {
let mut graph = Graph::new();
let sink = graph.device_node_on("speaker", MediaRole::Sink, GlobalId(700));
let source = graph.device_node_on("usb-mic", MediaRole::Source, GlobalId(701));
let call = graph.peerspeak_node("peerspeak-call", 7);
let recorder_in = graph.app_node("recorder-in", MediaRole::StreamInput, 9);
let recorder_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 9);
graph.link(call, sink);
graph.link(source, recorder_in);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("recorder-out", recorder_out)],
&[("call", call, "peerspeak-owned")],
);
assert_untainted(&decisions, source);
assert_untainted(&decisions, recorder_in);
}
#[test]
fn same_device_microphone_use_is_intentionally_over_excluded() {
// The hardware's private mixer/firmware path is not observable in the
// PipeWire graph. If an app captures the same device receiving the call,
// v1 cannot prove that its capture is clean, so its playback is excluded.
let mut graph = Graph::new();
let card = GlobalId(700);
let sink = graph.device_node_on("headset-output", MediaRole::Sink, card);
let mic = graph.device_node_on("headset-mic", MediaRole::Source, card);
let call = graph.peerspeak_node("peerspeak-call", 7);
let firefox_in = graph.app_node("firefox-mic", MediaRole::StreamInput, 11_114);
let firefox_out = graph.app_node("firefox-audio", MediaRole::StreamOutput, 11_114);
graph.link(call, sink);
graph.link(mic, firefox_in);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("firefox-out", firefox_out, "tainted-owner-bridge"),
],
);
}
/// Each ownership carrier must work **alone** (v3.5 §5.1).
///
/// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value
@@ -517,7 +599,7 @@ fn port_exclusive_and_passthrough_are_local_exclusions() {
&[("ok", ok)],
&[
("exclusive", exclusive, "port-exclusive"),
("passthrough", passthrough, "passthrough"),
("passthrough", passthrough, "iec958-passthrough"),
],
);
// Neither is hazardous — an unlinkable stream must not taint anything.
+7 -1
View File
@@ -14,11 +14,16 @@ use ashpd::{
use nix::fcntl::{FcntlArg, FdFlag, fcntl};
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
use super::health;
use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality;
use crate::cli::HostOpts;
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
pub(super) async fn start(
opts: &HostOpts,
quality: &EffectiveQuality,
health: health::Reporter,
) -> Result<CaptureHandle> {
// 1. Negotiate the screencast session with the portal.
let proxy = Screencast::new()
.await
@@ -81,6 +86,7 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
quality,
Some((w as u32, h as u32)),
source_args,
health,
move || {
// Parent no longer needs the pipewire fd — gst inherited its own copy.
drop(pw_fd);
+7 -2
View File
@@ -10,11 +10,16 @@ use tokio::process::Command;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::ConnectionExt;
use super::health;
use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality;
use crate::cli::HostOpts;
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
pub(super) async fn start(
opts: &HostOpts,
quality: &EffectiveQuality,
health: health::Reporter,
) -> Result<CaptureHandle> {
let xid = if opts.window {
Some(pick_window().await?)
} else {
@@ -60,7 +65,7 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
}
// X11 has no leaked fd to clean up, so the post-spawn hook is a no-op.
pipeline::spawn(opts, quality, source_dims, source_args, || {}).await
pipeline::spawn(opts, quality, source_dims, source_args, health, || {}).await
}
/// Run `xwininfo` and let the user click the window they want to share, then
+1 -1
View File
@@ -30,7 +30,7 @@ pub async fn run(cli: Cli) -> Result<()> {
if cli.quality.is_none() {
cli.quality = Some(pick_quality(&theme)?);
}
host::run(cli.into_host_opts(true)).await
host::run(cli.into_host_opts(true)?).await
}
_ => {
let ticket = prompt_ticket(&theme)?;
+69 -2
View File
@@ -1,10 +1,21 @@
mod capabilities;
mod cli;
mod common;
#[cfg(target_os = "linux")]
mod doctor;
#[cfg(feature = "gui")]
#[cfg(target_os = "windows")]
#[path = "windows/doctor.rs"]
mod doctor;
#[cfg(all(feature = "gui", target_os = "linux"))]
mod gui;
#[cfg(target_os = "linux")]
mod host;
#[cfg(target_os = "linux")]
mod interactive;
#[cfg(target_os = "windows")]
#[path = "windows/interactive.rs"]
mod interactive;
#[cfg(target_os = "linux")]
mod repair;
mod viewer;
@@ -19,6 +30,15 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
init_tracing(cli.verbose);
run(cli).await
}
#[cfg(target_os = "linux")]
async fn run(cli: Cli) -> Result<()> {
if cli.capabilities {
return capabilities::run();
}
if matches!(cli.output, Some(cli::OutputFormat::Json)) {
common::output::set_json(true);
}
@@ -70,7 +90,7 @@ async fn main() -> Result<()> {
screen, a ticket views someone else's."
);
}
return host::run(cli.into_host_opts(false)).await;
return host::run(cli.into_host_opts(false)?).await;
}
match cli.ticket.as_deref() {
@@ -87,6 +107,53 @@ async fn main() -> Result<()> {
}
}
#[cfg(target_os = "windows")]
async fn run(cli: Cli) -> Result<()> {
if cli.capabilities {
return capabilities::run();
}
if matches!(cli.output, Some(cli::OutputFormat::Json)) {
common::output::set_json(true);
}
if cli.gui {
anyhow::bail!(
"the Windows PixelPass milestone is viewer-only and headless; use it through \
PeerSpeak or pass a ticket directly"
);
}
if cli.doctor {
let relay = common::endpoint::relay_override(cli.relay.as_deref());
return doctor::run(relay).await;
}
if cli.host {
anyhow::bail!(
"hosting a screen share is not available in this Windows PixelPass milestone; \
this build can view shares hosted by Linux"
);
}
if cli.repair || cli.audit_audio || cli.reconfigure {
anyhow::bail!("this operation belongs to PixelPass's Linux host/capture stack");
}
match cli.ticket.as_deref() {
Some(s) => {
let ticket: EndpointTicket = s.parse().map_err(|e| {
anyhow::anyhow!(
"argument doesn't look like a pixelpass ticket ({e}).\n\
Run with no arguments for the viewer prompt, or pass a ticket to view."
)
})?;
viewer::run(ticket, cli.into_viewer_opts(false)).await
}
None => interactive::run(cli).await,
}
}
fn init_tracing(verbose: bool) {
let default = if verbose {
"pixelpass=trace,iroh=info"
+76
View File
@@ -0,0 +1,76 @@
//! Viewer-only diagnostics for the first Windows PixelPass milestone.
use anyhow::{Result, bail};
use std::path::PathBuf;
use std::time::Duration;
use crate::common::endpoint;
pub async fn run(relay: Option<String>) -> Result<()> {
eprintln!();
eprintln!("PixelPass doctor — Windows viewer");
eprintln!("─────────────────────────────────");
eprintln!("· pixelpass : {}", env!("CARGO_PKG_VERSION"));
eprintln!("· hosting : unavailable in this viewer-only milestone");
let player = find_program("mpv")
.map(|path| ("mpv", path))
.or_else(|| find_program("vlc").map(|path| ("vlc", path)));
match &player {
Some((name, path)) => eprintln!("✓ player : {name} ({})", path.display()),
None => eprintln!("✗ player : neither mpv nor VLC was found"),
}
let relay_ok = match endpoint::bind(relay.as_deref()).await {
Ok(ep) => {
let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
.await
.is_ok();
let has_relay = ep.addr().addrs.iter().any(|addr| addr.is_relay());
ep.close().await;
if online && has_relay {
eprintln!("✓ network : relay reachable");
} else if online {
eprintln!("✗ network : endpoint online, but no relay address is available");
} else {
eprintln!("✗ network : no relay reached within 8 seconds");
}
online && has_relay
}
Err(error) => {
eprintln!("✗ network : could not bind an iroh endpoint ({error:#})");
false
}
};
eprintln!();
if player.is_none() || !relay_ok {
bail!("Windows viewer prerequisites are incomplete");
}
eprintln!("Ready to view a PixelPass share hosted by Linux.");
Ok(())
}
fn find_program(name: &str) -> Option<PathBuf> {
let file = format!("{name}.exe");
if let Some(path) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path) {
let candidate = dir.join(&file);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_program_is_reported_without_panicking() {
assert!(find_program("pixelpass-definitely-not-installed").is_none());
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Minimal interactive wrapper for the Windows viewer milestone.
use anyhow::Result;
use dialoguer::{Input, Select, theme::ColorfulTheme};
use iroh_tickets::endpoint::EndpointTicket;
use std::str::FromStr;
use crate::cli::Cli;
use crate::viewer;
pub async fn run(cli: Cli) -> Result<()> {
eprintln!();
eprintln!("Welcome to PixelPass for Windows (viewer milestone).");
eprintln!("This build can watch a share hosted by PixelPass on Linux.");
eprintln!();
let theme = ColorfulTheme::default();
let ticket = prompt_ticket(&theme)?;
viewer::run(ticket, cli.into_viewer_opts(true)).await
}
fn prompt_ticket(theme: &ColorfulTheme) -> Result<EndpointTicket> {
loop {
let raw: String = Input::with_theme(theme)
.with_prompt("Paste the share code you received")
.interact_text()?;
match EndpointTicket::from_str(raw.trim()) {
Ok(ticket) => return Ok(ticket),
Err(_) => eprintln!("That doesn't look like a share code. Try again."),
}
}
}
#[derive(Clone, Copy)]
pub enum Player {
Mpv,
Vlc,
}
impl Player {
pub fn spawn(self, url: &str) -> std::io::Result<()> {
match self {
Self::Mpv => crate::common::process::spawn_detached(
"mpv",
&[
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
url,
],
),
Self::Vlc => crate::common::process::spawn_detached(
"vlc",
&["--network-caching=200", "--live-caching=200", url],
),
}
}
}
pub fn prompt_player() -> Result<Player> {
let theme = ColorfulTheme::default();
let choice = Select::with_theme(&theme)
.with_prompt("Open stream with")
.items(["mpv (recommended)", "VLC"])
.default(0)
.interact()?;
Ok(if choice == 0 {
Player::Mpv
} else {
Player::Vlc
})
}