Compare commits
19
Commits
fa792b9927
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca3122b92f | ||
|
|
2f00df758d | ||
|
|
ce909afc4b | ||
|
|
360d7112e6 | ||
|
|
98bb78f9cf | ||
|
|
792f2bd55a | ||
|
|
e027bc65e5 | ||
|
|
7b118272b0 | ||
|
|
956534f63c | ||
|
|
6be07ef706 | ||
|
|
d09ee9b02f | ||
|
|
5a65f50c4b | ||
|
|
98cde2c19b | ||
|
|
781defcd84 | ||
|
|
5d3da8b006 | ||
|
|
70820cf903 | ||
|
|
eaea188e05 | ||
|
|
15d13742f5 | ||
|
|
6f3e26a78c |
Generated
+22
-31
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -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
File diff suppressed because it is too large
Load Diff
+1861
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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 ¤t {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user