19 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
mollusk eaea188e05 chore: enable automatic Nix development shell 2026-08-11 11:16:29 -04:00
mollusk 15d13742f5 Merge 0c step 2 S3a: cancellation-safe Pulse module ledger 2026-08-10 03:56:20 -04:00
mollusk 6f3e26a78c fix(audio): make module teardown cancellation-safe 2026-08-10 03:43:31 -04:00
42 changed files with 9684 additions and 869 deletions
+1
View File
@@ -0,0 +1 @@
use flake
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,
+599 -495
View File
File diff suppressed because it is too large Load Diff
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"));
}
}
+553 -94
View File
@@ -33,12 +33,13 @@
//! # Why the permit is affine
//!
//! [`LoadPermit`] is not `Clone`, is consumed by value to settle, and its [`Drop`]
//! marks the slot [`Reconcile::Load`] when it was never settled. That is what makes
//! the guarantee structural rather than a discipline: a cancelled task drops its
//! locals, so an aborted load *cannot* silently forget a module the server may
//! already have created. Two permitted loads for one slot cannot both commit,
//! because [`ModuleLedger::begin_load`] issues a permit only for a `Vacant` slot
//! and every other state — including the ambiguous one — refuses.
//! marks the slot [`Reconcile::Load`] when it was never settled. The permit moves
//! into a registered blocking worker before any await can detach that work; either
//! the worker settles it, or dropping the worker marks the question ambiguous.
//! Teardown waits for all such permits before reconciling. Two permitted loads for
//! one slot cannot both commit, because [`ModuleLedger::begin_load`] issues a permit
//! only for a `Vacant` slot and every other state — including the ambiguous one —
//! refuses.
//!
//! # Why an ambiguous slot blocks the next load
//!
@@ -50,8 +51,8 @@
//! proceed until the question is answered is the whole point.
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use anyhow::{Context as _, Result};
@@ -99,7 +100,7 @@ pub enum SlotState {
Loaded { fp: Fingerprint },
/// An unload is in flight. The fingerprint is retained deliberately: an unload
/// that times out must not leave the module unrecorded.
Unloading { fp: Fingerprint },
Unloading { fp: Fingerprint, permit: u64 },
/// The slot's real state is unknown and must be resolved against the server.
Ambiguous(Reconcile),
/// Resolution found something we refuse to act on. Terminal.
@@ -129,6 +130,20 @@ pub enum LedgerError {
Poisoned { shape: Shape, reason: String },
/// `pactl` reported `PA_INVALID_INDEX` where an index was expected.
InvalidIndex { shape: Shape },
/// Teardown has begun, so event-driven work may no longer start.
Closed { shape: Shape },
/// The two inverse loopbacks must never coexist.
Incompatible {
shape: Shape,
occupied: Shape,
state: &'static str,
},
/// The capture sink cannot be removed while a loopback may still reference it.
Referenced {
shape: Shape,
dependency: Shape,
state: &'static str,
},
}
impl std::fmt::Display for LedgerError {
@@ -145,6 +160,31 @@ impl std::fmt::Display for LedgerError {
"pactl reported PA_INVALID_INDEX for the {} module",
shape.label()
),
LedgerError::Closed { shape } => write!(
f,
"the module ledger is closing; refusing new work on the {} slot",
shape.label()
),
LedgerError::Incompatible {
shape,
occupied,
state,
} => write!(
f,
"cannot load the {} while the incompatible {} slot is {state}",
shape.label(),
occupied.label()
),
LedgerError::Referenced {
shape,
dependency,
state,
} => write!(
f,
"cannot unload the {} while the dependent {} slot is {state}",
shape.label(),
dependency.label()
),
}
}
}
@@ -180,6 +220,59 @@ pub enum Resolution {
pub struct ModuleLedger {
slots: Mutex<BTreeMap<Shape, SlotState>>,
next_permit: AtomicU64,
closed: AtomicBool,
operations: OperationCoordinator,
}
/// Registers module mutations before they can be detached from their caller.
///
/// `spawn_blocking` work continues when the awaiting future is cancelled. The
/// count is therefore registered synchronously, while the slot lock is held, and
/// is released only by the affine permit's `Drop`. Teardown closes the ledger and
/// waits on this condition variable before it asks the server what exists.
struct OperationCoordinator {
active: Mutex<usize>,
idle: Condvar,
server: Mutex<()>,
}
impl OperationCoordinator {
fn new() -> Self {
Self {
active: Mutex::new(0),
idle: Condvar::new(),
server: Mutex::new(()),
}
}
fn register(&self) {
let mut active = self.active.lock().unwrap_or_else(|e| e.into_inner());
*active = active
.checked_add(1)
.expect("module operation count overflow");
}
fn finish(&self) {
let mut active = self.active.lock().unwrap_or_else(|e| e.into_inner());
*active = active
.checked_sub(1)
.expect("module operation count underflow");
if *active == 0 {
self.idle.notify_all();
}
}
fn wait_idle(&self) {
let mut active = self.active.lock().unwrap_or_else(|e| e.into_inner());
while *active != 0 {
active = self.idle.wait(active).unwrap_or_else(|e| e.into_inner());
}
}
fn run<T>(&self, operation: impl FnOnce() -> T) -> T {
let _serial = self.server.lock().unwrap_or_else(|e| e.into_inner());
operation()
}
}
impl ModuleLedger {
@@ -187,9 +280,36 @@ impl ModuleLedger {
Arc::new(Self {
slots: Mutex::new(BTreeMap::new()),
next_permit: AtomicU64::new(1),
closed: AtomicBool::new(false),
operations: OperationCoordinator::new(),
})
}
/// Stop event-driven mutations and wait until every already-registered load
/// or unload has settled its affine permit.
///
/// The slots-lock hand-off closes the only race that matters: an operation
/// that saw `closed == false` has registered itself before this method can
/// pass the hand-off and begin waiting.
pub(super) fn close(&self) {
self.closed.store(true, Ordering::SeqCst);
drop(self.slots.lock().unwrap_or_else(|e| e.into_inner()));
}
pub(super) fn wait_for_operations(&self) {
self.operations.wait_idle();
}
pub(super) fn close_and_wait(&self) {
self.close();
self.wait_for_operations();
}
/// Serialize one server mutation or observation with every other such action.
pub(super) fn with_server_operation<T>(&self, operation: impl FnOnce() -> T) -> T {
self.operations.run(operation)
}
/// The current state of one slot. Absent keys read as [`SlotState::Vacant`].
///
/// Test-only: production code never needs to look a slot up, because every
@@ -219,6 +339,9 @@ impl ModuleLedger {
token: OwnerToken,
) -> Result<LoadPermit, LedgerError> {
let mut slots = self.slots.lock().unwrap();
if self.closed.load(Ordering::SeqCst) {
return Err(LedgerError::Closed { shape });
}
let current = slots.get(&shape).cloned().unwrap_or(SlotState::Vacant);
match current {
SlotState::Vacant => {}
@@ -232,7 +355,18 @@ impl ModuleLedger {
});
}
}
if let Some(occupied) = inverse_loopback(shape) {
let state = slots.get(&occupied).cloned().unwrap_or(SlotState::Vacant);
if !matches!(state, SlotState::Vacant) {
return Err(LedgerError::Incompatible {
shape,
occupied,
state: state.label(),
});
}
}
let permit = self.next_permit.fetch_add(1, Ordering::Relaxed);
self.operations.register();
slots.insert(
shape,
SlotState::Loading {
@@ -253,36 +387,76 @@ impl ModuleLedger {
/// Move a `Loaded` slot to `Unloading` and hand back what to unload.
///
/// `None` for any other state: there is nothing to unload, or the slot is not
/// in a condition to be acted on.
pub fn begin_unload(&self, shape: Shape) -> Option<Fingerprint> {
let mut slots = self.slots.lock().unwrap();
let SlotState::Loaded { fp } = slots.get(&shape).cloned()? else {
return None;
};
slots.insert(shape, SlotState::Unloading { fp: fp.clone() });
Some(fp)
/// `Ok(None)` means confirmed vacancy. Busy, poisoned, closed, and still-
/// referenced states are errors so callers cannot mistake uncertainty for
/// absence and load an inverse loopback or destroy the capture sink.
pub fn begin_unload(
self: &Arc<Self>,
shape: Shape,
) -> Result<Option<UnloadPermit>, LedgerError> {
self.begin_unload_inner(shape, false)
}
/// Record how an unload turned out. An uncertain one becomes ambiguous rather
/// than being assumed done — the module is not forgotten either way.
pub fn finish_unload(&self, shape: Shape, outcome: UnloadOutcome) {
/// Teardown-only form of [`ModuleLedger::begin_unload`]. New event work is
/// closed by then, but cleanup must still be able to remove tracked modules.
pub(super) fn begin_cleanup_unload(
self: &Arc<Self>,
shape: Shape,
) -> Result<Option<UnloadPermit>, LedgerError> {
self.begin_unload_inner(shape, true)
}
fn begin_unload_inner(
self: &Arc<Self>,
shape: Shape,
cleanup: bool,
) -> Result<Option<UnloadPermit>, LedgerError> {
let mut slots = self.slots.lock().unwrap();
let Some(SlotState::Unloading { fp }) = slots.get(&shape).cloned() else {
return;
};
let next = match outcome {
UnloadOutcome::Confirmed => SlotState::Vacant,
UnloadOutcome::Uncertain(why) => {
tracing::warn!(
shape = fp.shape.label(),
module = fp.id,
"audio ledger: unload outcome uncertain ({why}); slot needs reconciling"
);
SlotState::Ambiguous(Reconcile::Unload { fp })
if !cleanup && self.closed.load(Ordering::SeqCst) {
return Err(LedgerError::Closed { shape });
}
let current = slots.get(&shape).cloned().unwrap_or(SlotState::Vacant);
let fp = match current {
SlotState::Vacant => return Ok(None),
SlotState::Loaded { fp } => fp,
SlotState::Poisoned { reason } => {
return Err(LedgerError::Poisoned { shape, reason });
}
other => {
return Err(LedgerError::Busy {
shape,
state: other.label(),
});
}
};
slots.insert(shape, next);
if shape == Shape::LegacyCaptureSink {
for dependency in [Shape::LoopbackIntoCapture, Shape::LoopbackOutOfCapture] {
let state = slots.get(&dependency).cloned().unwrap_or(SlotState::Vacant);
if !matches!(state, SlotState::Vacant) {
return Err(LedgerError::Referenced {
shape,
dependency,
state: state.label(),
});
}
}
}
let permit = self.next_permit.fetch_add(1, Ordering::Relaxed);
self.operations.register();
slots.insert(
shape,
SlotState::Unloading {
fp: fp.clone(),
permit,
},
);
Ok(Some(UnloadPermit {
ledger: Arc::clone(self),
shape,
fp,
permit,
settled: false,
}))
}
/// Every slot currently holding a module, in [`Shape`] declaration order —
@@ -313,14 +487,28 @@ impl ModuleLedger {
.collect()
}
/// Is every slot in a state we can explain? False while anything is ambiguous
/// or poisoned.
/// Is every slot in a state we can explain? False while an operation is in
/// flight, its outcome is ambiguous, or a conflict poisoned the slot.
pub fn is_settled(&self) -> bool {
self.slots
.lock()
.unwrap()
.values()
.all(|state| !matches!(state, SlotState::Ambiguous(_) | SlotState::Poisoned { .. }))
.all(|state| matches!(state, SlotState::Vacant | SlotState::Loaded { .. }))
}
/// Has teardown removed every module rather than merely accounted for it?
pub fn is_clean(&self) -> bool {
self.slots
.lock()
.unwrap()
.values()
.all(|state| matches!(state, SlotState::Vacant))
}
/// A stable snapshot used to stop cleanup when another pass made no progress.
pub(super) fn snapshot(&self) -> BTreeMap<Shape, SlotState> {
self.slots.lock().unwrap().clone()
}
/// Apply a reconciliation result to an ambiguous slot.
@@ -359,6 +547,14 @@ impl ModuleLedger {
}
}
fn inverse_loopback(shape: Shape) -> Option<Shape> {
match shape {
Shape::LoopbackIntoCapture => Some(Shape::LoopbackOutOfCapture),
Shape::LoopbackOutOfCapture => Some(Shape::LoopbackIntoCapture),
Shape::LegacyCaptureSink => None,
}
}
/// Permission to perform exactly one load, which must be settled by value.
///
/// Dropping it unsettled is not an error — it is the *reporting* path for a
@@ -375,6 +571,12 @@ pub struct LoadPermit {
}
impl LoadPermit {
/// Run the server-facing part of this load in the same serialized domain as
/// unload verification and reconciliation.
pub fn with_server_operation<T>(&self, operation: impl FnOnce() -> T) -> T {
self.ledger.with_server_operation(operation)
}
/// Record that the server created the module at `index`.
///
/// Fails on [`PA_INVALID_INDEX`], leaving the permit unsettled so that
@@ -427,29 +629,106 @@ impl LoadPermit {
impl Drop for LoadPermit {
fn drop(&mut self) {
if self.settled {
if !self.settled {
let mut slots = self.ledger.slots.lock().unwrap();
// Only claim the slot if it is still *our* load. Anything else already
// moved past this permit.
if let Some(SlotState::Loading { permit, .. }) = slots.get(&self.shape)
&& *permit == self.permit
{
tracing::warn!(
shape = self.shape.label(),
"audio ledger: a load was cancelled before its outcome was known; \
the slot needs reconciling"
);
slots.insert(
self.shape,
SlotState::Ambiguous(Reconcile::Load {
shape: self.shape,
pid: self.pid,
token: self.token.clone(),
}),
);
}
}
self.ledger.operations.finish();
}
}
/// Permission to perform exactly one unload, which must be settled by value.
///
/// Like [`LoadPermit`], this is affine. Cancellation drops it unsettled, retaining
/// the full fingerprint as a reconciliation question instead of leaving the slot
/// stuck in an invisible `Unloading` state.
#[must_use = "an unsettled permit marks the unload ambiguous when dropped"]
pub struct UnloadPermit {
ledger: Arc<ModuleLedger>,
shape: Shape,
fp: Fingerprint,
permit: u64,
settled: bool,
}
impl UnloadPermit {
pub fn fingerprint(&self) -> &Fingerprint {
&self.fp
}
pub fn with_server_operation<T>(&self, operation: impl FnOnce() -> T) -> T {
self.ledger.with_server_operation(operation)
}
/// Record how the server operation ended. An uncertain answer retains the
/// fingerprint and makes the next action reconcile it before doing anything.
pub fn finish(mut self, outcome: UnloadOutcome) {
let mut slots = self.ledger.slots.lock().unwrap();
let Some(SlotState::Unloading { fp, permit }) = slots.get(&self.shape).cloned() else {
self.settled = true;
return;
};
if permit != self.permit {
self.settled = true;
return;
}
let mut slots = self.ledger.slots.lock().unwrap();
// Only claim the slot if it is still *our* load. Anything else already
// moved past this permit.
if let Some(SlotState::Loading { permit, .. }) = slots.get(&self.shape)
&& *permit == self.permit
{
tracing::warn!(
shape = self.shape.label(),
"audio ledger: a load was cancelled before its outcome was known; \
the slot needs reconciling"
);
slots.insert(
self.shape,
SlotState::Ambiguous(Reconcile::Load {
shape: self.shape,
pid: self.pid,
token: self.token.clone(),
}),
);
let next = match outcome {
UnloadOutcome::Confirmed => SlotState::Vacant,
UnloadOutcome::Uncertain(why) => {
tracing::warn!(
shape = fp.shape.label(),
module = fp.id,
"audio ledger: unload outcome uncertain ({why}); slot needs reconciling"
);
SlotState::Ambiguous(Reconcile::Unload { fp })
}
};
slots.insert(self.shape, next);
drop(slots);
self.settled = true;
}
}
impl Drop for UnloadPermit {
fn drop(&mut self) {
if !self.settled {
let mut slots = self.ledger.slots.lock().unwrap();
if let Some(SlotState::Unloading { permit, .. }) = slots.get(&self.shape)
&& *permit == self.permit
{
tracing::warn!(
shape = self.shape.label(),
module = self.fp.id,
"audio ledger: an unload was cancelled before its outcome was known; \
the slot needs reconciling"
);
slots.insert(
self.shape,
SlotState::Ambiguous(Reconcile::Unload {
fp: self.fp.clone(),
}),
);
}
}
self.ledger.operations.finish();
}
}
@@ -509,26 +788,35 @@ pub fn resolve(reconcile: &Reconcile, observations: &[ModuleObservation]) -> Res
/// the life of a share. Reconciliation is rare and off the hot path, so paying a
/// connection for it costs nothing that matters.
pub async fn reconcile_pending(ledger: &Arc<ModuleLedger>) -> Result<()> {
let pending = ledger.pending();
if pending.is_empty() {
return Ok(());
}
tracing::info!(
n = pending.len(),
"audio ledger: reconciling unresolved module slots against the server"
);
let observations = tokio::task::spawn_blocking(|| -> Result<Vec<ModuleObservation>> {
let mut session = PulseSession::connect()?;
session.list_modules()
})
.await
.context("the Pulse listing task failed to run")?
.context("could not list Pulse modules to reconcile the audio ledger")?;
let ledger = Arc::clone(ledger);
tokio::task::spawn_blocking(move || reconcile_pending_blocking(&ledger))
.await
.context("the Pulse reconciliation task failed to run")?
}
for (shape, reconcile) in pending {
ledger.apply(shape, resolve(&reconcile, &observations));
}
Ok(())
/// Synchronous reconciliation for [`Routing::drop`](crate::host::audio::Routing).
/// The caller closes and quiesces the ledger first; serialization here also makes
/// ordinary async reconciliation wait behind any server operation already running.
pub(super) fn reconcile_pending_blocking(ledger: &Arc<ModuleLedger>) -> Result<()> {
ledger.with_server_operation(|| {
let pending = ledger.pending();
if pending.is_empty() {
return Ok(());
}
tracing::info!(
n = pending.len(),
"audio ledger: reconciling unresolved module slots against the server"
);
let mut session = PulseSession::connect()?;
let observations = session
.list_modules()
.context("could not list Pulse modules to reconcile the audio ledger")?;
for (shape, reconcile) in pending {
ledger.apply(shape, resolve(&reconcile, &observations));
}
Ok(())
})
}
#[cfg(test)]
@@ -684,14 +972,12 @@ mod tests {
.expect("a vacant slot issues a permit")
.commit(3)
.expect("3 is a real index");
assert_eq!(
ledger.begin_unload(Shape::LoopbackIntoCapture),
Some(fp.clone())
);
ledger.finish_unload(
Shape::LoopbackIntoCapture,
UnloadOutcome::Uncertain("pactl was killed".to_string()),
);
let permit = ledger
.begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the loaded slot issues a permit");
assert_eq!(permit.fingerprint(), &fp);
permit.finish(UnloadOutcome::Uncertain("pactl was killed".to_string()));
assert_eq!(
ledger.state(Shape::LoopbackIntoCapture),
SlotState::Ambiguous(Reconcile::Unload { fp }),
@@ -707,23 +993,194 @@ mod tests {
.expect("a vacant slot issues a permit")
.commit(3)
.expect("3 is a real index");
ledger.begin_unload(Shape::LoopbackIntoCapture);
ledger.finish_unload(Shape::LoopbackIntoCapture, UnloadOutcome::Confirmed);
ledger
.begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the loaded slot issues a permit")
.finish(UnloadOutcome::Confirmed);
assert_eq!(ledger.state(Shape::LoopbackIntoCapture), SlotState::Vacant);
assert!(ledger.is_settled());
assert!(ledger.is_clean());
}
#[test]
fn begin_unload_only_acts_on_a_loaded_slot() {
fn dropping_an_unload_permit_marks_the_slot_ambiguous() {
let ledger = ModuleLedger::new();
assert_eq!(ledger.begin_unload(Shape::LegacyCaptureSink), None);
let fp = ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
.expect("a vacant slot issues a permit")
.commit(3)
.expect("3 is a real index");
let permit = ledger
.begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the loaded slot issues a permit");
assert!(matches!(
ledger.state(Shape::LoopbackIntoCapture),
SlotState::Unloading { .. }
));
assert!(!ledger.is_settled(), "in-flight unloads are not settled");
drop(permit);
assert_eq!(
ledger.state(Shape::LoopbackIntoCapture),
SlotState::Ambiguous(Reconcile::Unload { fp })
);
}
#[test]
fn inverse_loopbacks_cannot_coexist_or_cross_an_unknown_boundary() {
let ledger = ModuleLedger::new();
ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
.expect("the first loopback may load")
.commit(3)
.expect("3 is a real index");
assert_eq!(
ledger
.begin_load(Shape::LoopbackOutOfCapture, 42, token(8))
.err(),
Some(LedgerError::Incompatible {
shape: Shape::LoopbackOutOfCapture,
occupied: Shape::LoopbackIntoCapture,
state: "loaded"
})
);
let unload = ledger
.begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the loaded slot issues a permit");
drop(unload);
assert_eq!(
ledger
.begin_load(Shape::LoopbackOutOfCapture, 42, token(9))
.err(),
Some(LedgerError::Incompatible {
shape: Shape::LoopbackOutOfCapture,
occupied: Shape::LoopbackIntoCapture,
state: "ambiguous"
}),
"an unconfirmed absence must block the inverse loopback"
);
}
#[test]
fn capture_sink_unload_waits_for_every_dependent_slot_to_be_vacant() {
let ledger = ModuleLedger::new();
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(1))
.expect("the sink may load")
.commit(1)
.expect("1 is a real index");
ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(2))
.expect("the mirror may load")
.commit(2)
.expect("2 is a real index");
assert_eq!(
ledger.begin_unload(Shape::LegacyCaptureSink).err(),
Some(LedgerError::Referenced {
shape: Shape::LegacyCaptureSink,
dependency: Shape::LoopbackIntoCapture,
state: "loaded"
})
);
ledger
.begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the mirror issues an unload permit")
.finish(UnloadOutcome::Confirmed);
assert!(
ledger
.begin_unload(Shape::LegacyCaptureSink)
.expect("the sink is no longer referenced")
.is_some()
);
}
#[test]
fn closing_waits_for_a_previously_registered_operation() {
use std::sync::mpsc;
use std::time::Duration;
let ledger = ModuleLedger::new();
let permit = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("the operation registers before it can be detached");
let (entered_tx, entered_rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
let waiter_ledger = Arc::clone(&ledger);
let waiter = std::thread::spawn(move || {
entered_tx.send(()).unwrap();
waiter_ledger.close_and_wait();
done_tx.send(()).unwrap();
});
entered_rx.recv().unwrap();
assert!(
matches!(
done_rx.recv_timeout(Duration::from_millis(30)),
Err(mpsc::RecvTimeoutError::Timeout)
),
"the close barrier must not pass a live affine permit"
);
permit.abandon();
done_rx
.recv_timeout(Duration::from_secs(1))
.expect("settling the operation releases teardown");
waiter.join().unwrap();
assert!(ledger.is_clean());
}
#[test]
fn a_closed_ledger_refuses_event_work_but_allows_cleanup() {
let ledger = ModuleLedger::new();
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("the sink may load before close")
.commit(3)
.expect("3 is a real index");
ledger.close_and_wait();
assert_eq!(
ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(8))
.err(),
Some(LedgerError::Closed {
shape: Shape::LoopbackIntoCapture
})
);
assert_eq!(
ledger.begin_unload(Shape::LegacyCaptureSink).err(),
Some(LedgerError::Closed {
shape: Shape::LegacyCaptureSink
})
);
assert!(
ledger
.begin_cleanup_unload(Shape::LegacyCaptureSink)
.expect("teardown retains its cleanup authority")
.is_some()
);
}
#[test]
fn begin_unload_distinguishes_vacant_from_busy() {
let ledger = ModuleLedger::new();
assert!(
ledger
.begin_unload(Shape::LegacyCaptureSink)
.expect("vacancy is not an error")
.is_none()
);
let _permit = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit");
assert_eq!(
ledger.begin_unload(Shape::LegacyCaptureSink),
None,
"a load in flight has no id to unload yet"
ledger.begin_unload(Shape::LegacyCaptureSink).err(),
Some(LedgerError::Busy {
shape: Shape::LegacyCaptureSink,
state: "loading"
}),
"an in-flight load must not look like a confirmed vacancy"
);
}
@@ -864,13 +1321,12 @@ mod tests {
}
#[test]
fn loaded_lists_modules_in_shape_declaration_order() {
fn loaded_lists_a_loopback_before_the_capture_sink() {
// Declaration order is unload order: the loopbacks that reference the
// capture sink must come before the sink itself.
let ledger = ModuleLedger::new();
for (shape, id) in [
(Shape::LegacyCaptureSink, 1),
(Shape::LoopbackIntoCapture, 2),
(Shape::LoopbackOutOfCapture, 3),
] {
ledger
@@ -880,7 +1336,10 @@ mod tests {
.expect("a real index");
}
let order: Vec<Shape> = ledger.loaded().into_iter().map(|fp| fp.shape).collect();
assert_eq!(order, crate::repair::plan::ALL_SHAPES.to_vec());
assert_eq!(
order,
vec![Shape::LoopbackOutOfCapture, Shape::LegacyCaptureSink]
);
assert_eq!(
order.last(),
Some(&Shape::LegacyCaptureSink),
+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
})
}