Compare commits
75
Commits
3d7b01c8a2
..
v0.6.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f100981ad0 | ||
|
|
c2d82acf82 | ||
|
|
0823f6173f | ||
|
|
ff2533c95d | ||
|
|
140e4e7f73 | ||
|
|
db8459fd03 | ||
|
|
260815154f | ||
|
|
2c2b861516 | ||
|
|
ba96e59db0 | ||
|
|
d72271bd2e | ||
|
|
bf6d0e47b5 | ||
|
|
46dc5902d6 | ||
|
|
a1df62ce21 | ||
|
|
30a460420a | ||
|
|
61960eb76f | ||
|
|
3d1f114fd8 | ||
|
|
9e52acf9d3 | ||
|
|
9ba42c4cda | ||
|
|
f46b2cacc7 | ||
|
|
d023621eee | ||
|
|
59da73c013 | ||
|
|
0e395e5c0c | ||
|
|
774922c6a9 | ||
|
|
63c246d976 | ||
|
|
52d842160d | ||
|
|
e043810eb0 | ||
|
|
5b80a1a010 | ||
|
|
b9803f93fb | ||
|
|
3df2378831 | ||
|
|
81c230a09c | ||
|
|
be3740f5f9 | ||
|
|
0d836d14c2 | ||
|
|
76c1a13e11 | ||
|
|
aa0515af1c | ||
|
|
9f06741b99 | ||
|
|
3aa768af52 | ||
|
|
1cfa932fbe | ||
|
|
d8b8fd79cf | ||
|
|
92a64465a4 | ||
|
|
6ba763774d | ||
|
|
692ad677d2 | ||
|
|
bf908adbf0 | ||
|
|
b68fca689e | ||
|
|
c82ef07464 | ||
|
|
9eab6c118d | ||
|
|
21ba633825 | ||
|
|
45b1b97dd8 | ||
|
|
ae2e9de523 | ||
|
|
985c63806b | ||
|
|
6fc55a286d | ||
|
|
d63db68318 | ||
|
|
e7923a1b5c | ||
|
|
b5569fe2c6 | ||
|
|
503f78153b | ||
|
|
d40385f85c | ||
|
|
bcf1343a55 | ||
|
|
6773a3882b | ||
|
|
1cd19b355f | ||
|
|
297f4397a7 | ||
|
|
283d938b79 | ||
|
|
fd72e6f018 | ||
|
|
8768cd242c | ||
|
|
da72541e18 | ||
|
|
8610ab2eb6 | ||
|
|
100117085d | ||
|
|
cab6bafce5 | ||
|
|
10203e1edb | ||
|
|
88ad5a0807 | ||
|
|
0588d92537 | ||
|
|
b4a4c00711 | ||
|
|
8c4f4a0b8b | ||
|
|
76c4f68bb3 | ||
|
|
c427231858 | ||
|
|
4bfc18463b | ||
|
|
26d66007de |
@@ -11,3 +11,9 @@
|
||||
# the .iss script and .ico are the tracked sources.
|
||||
/packaging/windows/peerspeak.exe
|
||||
/packaging/windows/output/
|
||||
|
||||
# Nix: the symlink `nix build` drops, and direnv's local cache. flake.nix and
|
||||
# flake.lock ARE tracked — the lock is what pins the toolchain.
|
||||
/result
|
||||
/result-*
|
||||
/.direnv/
|
||||
|
||||
@@ -4,6 +4,90 @@ All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.7] — 2026-08-22
|
||||
|
||||
### Changed
|
||||
- **All system audio is now the safe screen-share choice.** On supported
|
||||
PixelPass builds, the single **All system audio** row shares ordinary desktop
|
||||
sound while excluding PeerSpeak's own call playback. There is no longer a
|
||||
second, technical-sounding desktop-audio choice to understand before sharing.
|
||||
|
||||
### Fixed
|
||||
- **Sharing desktop audio no longer sends the call back to its listeners.**
|
||||
PeerSpeak marks every playback path it owns, passes the active echo-canceller
|
||||
identity to PixelPass, and uses a fail-closed PipeWire fan-out so voice,
|
||||
watched shares, and notification sounds cannot enter the screen-share mix.
|
||||
- **Screen-share audio failures are visible without becoming unsafe.** Missing,
|
||||
unsupported, or newly unsafe streams stay out of the capture and produce a
|
||||
useful warning; warnings clear again when the condition or share ends.
|
||||
- **Stopped and failed shares clean up promptly.** Remote viewers and their
|
||||
players are reaped when a stream ends, and leaving a call tears down the
|
||||
PixelPass host, capture graph, and viewer processes instead of leaving a
|
||||
stale sharing state or warning behind.
|
||||
|
||||
### Packaging
|
||||
- **The Linux AppImage bundles the matching PixelPass helper.** The one-file
|
||||
build therefore carries the desktop-audio exclusion capability it advertises
|
||||
rather than depending on an unrelated system PixelPass version.
|
||||
|
||||
### Compatibility
|
||||
- **Wire-compatible with 0.6.6.** This release changes local screen-share audio
|
||||
routing and lifecycle behavior; no PeerSpeak audio, friends, files, or gossip
|
||||
protocol version changed.
|
||||
|
||||
[0.6.7]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.7
|
||||
|
||||
## [0.6.6] — 2026-07-19
|
||||
|
||||
### Fixed
|
||||
- **A screen share that falls behind now catches back up.** On a lossy
|
||||
connection (satellite links are the worst case) the share could settle several
|
||||
seconds behind the host and simply stay there for the rest of the call. The
|
||||
viewer now notices a deep buffer and plays imperceptibly fast until it is back
|
||||
at the live edge — the audio stays in tune and in sync while it does. This
|
||||
replaces the previous attempt at the problem, which measurement showed did not
|
||||
help. Applies to the Low latency setting; Smooth intentionally keeps its
|
||||
larger buffer.
|
||||
|
||||
### Changed
|
||||
- **Low latency now keeps a tighter viewer buffer.** The screen-share cache
|
||||
setting is a size in megabytes, which at a given bitrate quietly decides how
|
||||
many *seconds* behind a viewer can drift — a 2 MB buffer turned out to hold
|
||||
about six seconds of a typical share. Low latency now caps that buffer at 1 MB
|
||||
regardless of the setting, which halved how far behind a share fell on a bad
|
||||
connection before anything else kicked in. Smooth still honors the value you
|
||||
choose, since a deep buffer is the point of that mode.
|
||||
|
||||
[0.6.6]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.6
|
||||
|
||||
## [0.6.5] — 2026-07-19
|
||||
|
||||
### Added
|
||||
- **Chat message sounds.** Successful outgoing messages and admitted incoming
|
||||
messages now have distinct notification chimes, each with its own enable
|
||||
toggle and optional custom WAV path in Notifications settings.
|
||||
- **Contact presence sounds.** The home-screen contacts list now announces a
|
||||
contact becoming online or offline. Initial online contacts are announced;
|
||||
initial offline results stay silent. Both events have independent toggles and
|
||||
optional custom WAV paths.
|
||||
- **Notification sound browser.** Every notification event now has a native
|
||||
Browse button for choosing a custom WAV instead of typing its path manually.
|
||||
|
||||
### Changed
|
||||
- **Tidier per-participant audio controls.** The equalizer bands and noise gate
|
||||
for each participant now live behind an **"Advanced audio"** foldout instead
|
||||
of being expanded all the time, so a call with several people no longer fills
|
||||
the panel with sliders. The controls themselves are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **Low-latency screen sharing stays near the live edge again.** mpv's
|
||||
timestamp pacing could let stale frames accumulate across the reliable
|
||||
PixelPass transport until a share was 7–10 seconds behind. Low-latency mode
|
||||
now presents decoded frames immediately; Smooth mode retains timestamp pacing
|
||||
when keeping shared-video audio and video synchronized matters more.
|
||||
|
||||
[0.6.5]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.5
|
||||
|
||||
## [0.6.4] — 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+4
-3
@@ -2262,9 +2262,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.6.4"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4883,6 +4883,7 @@ dependencies = [
|
||||
"image",
|
||||
"iroh",
|
||||
"iroh-gossip",
|
||||
"libc",
|
||||
"opus",
|
||||
"pipewire",
|
||||
"rand 0.10.1",
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.6.4"
|
||||
version = "0.6.7"
|
||||
edition = "2024"
|
||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
license = "MIT"
|
||||
@@ -109,3 +109,10 @@ windows-sys = { version = "0.61", features = [
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
# Unix-only. Used for exactly one thing: sending SIGINT to our own
|
||||
# pixelpass child so it can run its cleanup before we resort to SIGKILL
|
||||
# (src/core/teardown.rs). Already in the tree via alsa/cpal/tokio, so
|
||||
# declaring it directly adds no new code to the build.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.186"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -76,6 +76,14 @@ CHIMES = {
|
||||
"mic-toggle.wav": [(E5, 0.08)],
|
||||
# Reconnect gave up: disappointing low two-note fall.
|
||||
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
|
||||
# Our chat message entered the room: a tiny bright acknowledgement.
|
||||
"chat-sent.wav": [(1046.50, 0.06)],
|
||||
# A peer message arrived: a soft two-note lift, distinct but unobtrusive.
|
||||
"chat-received.wav": [(E5, 0.07), (G5, 0.11)],
|
||||
# A saved contact came online: a light, higher two-note arrival.
|
||||
"contact-online.wav": [(E5, 0.09), (880.00, 0.18)],
|
||||
# A saved contact went offline: the same tonal family falling away.
|
||||
"contact-offline.wav": [(E5, 0.09), (440.00, 0.18)],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -137,6 +137,35 @@ covers internals). When you ship a feature, add it here.
|
||||
|
||||
---
|
||||
|
||||
## Known bugs
|
||||
|
||||
Defects found by code review, not yet fixed.
|
||||
|
||||
1. **Adaptive playout delay never shrinks back in real conversation**
|
||||
(`src/core/jitter.rs`) — `target_delay` grows +1 per disruption up to
|
||||
`MAX_DELAY_FRAMES` (12 frames = 240 ms) but only shrinks after
|
||||
`CLEAN_RUN_TO_SHRINK` = 250 consecutive cleanly-played frames, i.e. **5 s of
|
||||
unbroken audio**. `clean_run` is reset in five places; two of them fire on
|
||||
every natural pause in speech: the benign-underrun branch (`jitter.rs:201`,
|
||||
talker went quiet) and the subsequent re-prime (`jitter.rs:181`). Because the
|
||||
sender skips transmitting entirely while the noise gate is closed
|
||||
(`src/core/mod.rs:2041`), a pause between sentences *always* underruns the
|
||||
receiver and zeroes the clean run — twice.
|
||||
|
||||
Net effect: the controller is a one-way ratchet. A single burst of jitter
|
||||
early in a call pins up to 240 ms of extra playout latency for the rest of
|
||||
the session, because no conversational speaker talks for 5 continuous
|
||||
seconds without the gate closing. The AIMD "decrease" half is effectively
|
||||
unreachable under the workload the app is built for.
|
||||
|
||||
Likely fix: let `clean_run` survive a benign idle→re-prime transition rather
|
||||
than resetting it. Silence is not evidence the link is bad, so it should not
|
||||
count against the clean run. Distinguish "talker stopped" (benign) from
|
||||
"playout broke" (real) at `jitter.rs:195-202`.
|
||||
|
||||
Found 2026-07-31 by code review. Not yet reproduced in a live call — pairs
|
||||
with field-test debt item 5 below.
|
||||
|
||||
## Known field-test debt (the 🧪 rows above, collected)
|
||||
|
||||
Re-run on a real desktop ↔ dopedart call before calling these done:
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# PeerSpeak 0.6.7 release prep
|
||||
|
||||
Prepared: 2026-08-22
|
||||
|
||||
Planned work day: 2026-08-23 (confirm the actual date before updating the changelog)
|
||||
|
||||
Release target: GitButter release page with a verified Linux x86_64 AppImage
|
||||
|
||||
This is a checklist, not release authorization. Pushing `main`, creating or
|
||||
pushing a tag, creating the GitButter release, and uploading assets remain
|
||||
explicit approval gates.
|
||||
|
||||
## Starting checkpoint
|
||||
|
||||
Recheck every value live tomorrow; these are the known-good handoff values from
|
||||
2026-08-22.
|
||||
|
||||
- PeerSpeak: `main` at `0823f617`, 17 commits ahead of `origin/main`.
|
||||
- PixelPass: `main` at `ce909afc`, 14 commits ahead of `origin/main`.
|
||||
- Latest published PeerSpeak tag: `v0.6.6`.
|
||||
- Current Cargo/Windows version: `0.6.6`.
|
||||
- Current field-test build:
|
||||
`packaging/appimage/peerspeak-0.6.6-unofficial-20260822-ps0823f617-ppce909afc-fieldtest-x86_64.AppImage`
|
||||
- Current field-test SHA-256:
|
||||
`f094a665dbd719929d6def32c8fd3d4943e4b88b6290e371e57b127c4e91ec45`.
|
||||
- The same build was staged for Lindsay at
|
||||
`/home/lindsay/Downloads/peerspeak-0.6.6-unofficial-20260822-ps0823f617-ppce909afc-fieldtest-x86_64.AppImage`.
|
||||
- Preserve the existing user-owned `docs/FEATURES.md` modification and
|
||||
untracked `.codex/` directory. Do not include either in release-prep commits
|
||||
unless the user explicitly puts them in scope.
|
||||
|
||||
The current one-row audio-picker build passed local AppImage artifact checks,
|
||||
but it still needs the final two-machine field pass. Earlier two-machine passes
|
||||
proved that desktop audio was heard, call voices were not echoed, and no warning
|
||||
was visible. They also exposed the now-fixed stale viewer/warning behavior. Do
|
||||
not substitute those earlier passes for testing the current build.
|
||||
|
||||
## Version decision
|
||||
|
||||
The expected release is **0.6.7**.
|
||||
|
||||
`VERSIONING.md` defines pre-1.0 MINOR bumps as breaking wire-protocol changes,
|
||||
not as a measure of feature size. The diff from `v0.6.6` through `0823f617` is a
|
||||
large screen-share implementation and local UI/lifecycle change, but it does
|
||||
not change `src/protocol.rs` or bump an audio, friends, files, or gossip protocol
|
||||
version. Under the repository's policy, that makes this a PATCH release.
|
||||
|
||||
Use `0.7.0` only if tomorrow's final source review finds or adds an actual
|
||||
incompatible wire change. If that happens, identify and bump the affected
|
||||
Layer-2 protocol constant/domain in the same change and plan a coordinated
|
||||
upgrade for every peer.
|
||||
|
||||
## Critical build-label rule
|
||||
|
||||
The first/home screen visibly identifies the binary as:
|
||||
|
||||
```text
|
||||
PeerSpeak v<package version> (<8-character commit hash>)
|
||||
```
|
||||
|
||||
The same label also appears in Settings. The two pieces come from different
|
||||
places:
|
||||
|
||||
- `Cargo.toml` supplies the version through `CARGO_PKG_VERSION` and must be
|
||||
bumped from `0.6.6` to `0.6.7`.
|
||||
- `build.rs` runs `git rev-parse --short=8 HEAD` and embeds the result as
|
||||
`PEERSPEAK_GIT_SHORT`. **Do not edit or bump the hash by hand.**
|
||||
|
||||
Consequences for the release order:
|
||||
|
||||
1. Make and commit all source, version, changelog, and field-evidence changes.
|
||||
2. Build the final AppImage from that clean final commit.
|
||||
3. On first launch, verify the title screen says exactly
|
||||
`PeerSpeak v0.6.7 (<final HEAD short hash>)`.
|
||||
4. Verify the displayed hash equals `git rev-parse --short=8 HEAD`.
|
||||
5. Tag that exact commit as `v0.6.7`.
|
||||
|
||||
If any commit is added after an AppImage is built—even a field-evidence or
|
||||
release-note commit—the embedded hash is now old. Rebuild and revalidate the
|
||||
AppImage. Never publish an artifact whose visible hash differs from the tag
|
||||
target.
|
||||
|
||||
## Tomorrow's runbook
|
||||
|
||||
### 1. Re-establish live state
|
||||
|
||||
From `/home/mollusk/git/butter/peerspeak`:
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git log -1 --oneline --decorate
|
||||
git -C ../pixelpass status --short --branch
|
||||
git -C ../pixelpass log -1 --oneline --decorate
|
||||
git fetch --prune --tags origin
|
||||
git ls-remote --heads --tags origin
|
||||
df -h / /mnt/superjar
|
||||
pgrep -af 'peerspeak|pixelpass|mpv|gst-launch' || true
|
||||
```
|
||||
|
||||
Confirm that:
|
||||
|
||||
- the checkpoint commits above are still the intended source;
|
||||
- neither repository has unexpected changes;
|
||||
- the existing `docs/FEATURES.md` and `.codex/` state is preserved;
|
||||
- no stale call/share processes are running;
|
||||
- `v0.6.7` does not already exist locally, remotely, or on GitButter;
|
||||
- there is enough space for one-job release builds and extracted AppImages.
|
||||
|
||||
### 2. Complete the current-build two-machine field test
|
||||
|
||||
Use the stamped `0823f617` AppImage on this machine and Lindsay's staged copy.
|
||||
Start with PeerSpeak and any old screen-share helper/player processes closed.
|
||||
|
||||
Run both directions, with Lindsay keeping software encode enabled where needed:
|
||||
|
||||
1. Mollusk hosts; Lindsay views.
|
||||
2. Lindsay hosts; Mollusk views.
|
||||
3. In each direction select the sole visible **All system audio** row.
|
||||
4. Play desktop audio after the share begins, including starting a new audio
|
||||
stream/application during the share.
|
||||
5. Talk from both machines while the desktop audio plays.
|
||||
6. Stop sharing while the voice call remains active.
|
||||
7. Start one more share, then have both peers leave the call.
|
||||
|
||||
Record all of these results explicitly:
|
||||
|
||||
- the picker has one desktop-audio row, not duplicate legacy/safe choices;
|
||||
- desktop audio is heard by the viewer;
|
||||
- neither person's call voice loops back through the shared audio;
|
||||
- no transient or persistent missing-output-port warning appears;
|
||||
- stopping a share clears the viewer and warning promptly;
|
||||
- the viewer PixelPass child is reaped within roughly 500 ms after the remote
|
||||
stream ends;
|
||||
- leaving the call clears the share and leaves no PeerSpeak-owned PixelPass,
|
||||
player, GStreamer, capture, or echo-cancellation residue.
|
||||
|
||||
If any row fails, stop release preparation. Save the exact visible text and
|
||||
relevant logs, fix the defect, run the focused regression gates, commit the
|
||||
fix, rebuild, and repeat the matrix.
|
||||
|
||||
### 3. Prepare the 0.6.7 source
|
||||
|
||||
After the current build passes:
|
||||
|
||||
- Change `[package].version` in `Cargo.toml` to `0.6.7`.
|
||||
- Refresh the root PeerSpeak package entry in `Cargo.lock`; inspect the diff and
|
||||
make sure it changes only as intended.
|
||||
- Change `MyAppVersion` in `packaging/windows/peerspeak.iss` to `0.6.7`, even
|
||||
though tomorrow's planned public asset is AppImage-only. This prevents the
|
||||
next Windows installer from silently retaining `0.6.6`.
|
||||
- Move the relevant `[Unreleased]` material into a dated `0.6.7` section in
|
||||
`CHANGELOG.md` and add the `v0.6.7` comparison/release link.
|
||||
- State that the release is wire-compatible with `0.6.6`; do not claim a
|
||||
protocol bump.
|
||||
- Summarize user-visible behavior, especially:
|
||||
- **All system audio** now shares desktop sound without feeding PeerSpeak's
|
||||
own call audio back to listeners;
|
||||
- the safe path is the single normal desktop-audio choice;
|
||||
- unsafe or incomplete audio-routing states fail closed and surface a useful
|
||||
warning;
|
||||
- viewer/share cleanup is prompt when a stream stops or a peer leaves;
|
||||
- the Linux AppImage bundles the matching PixelPass helper.
|
||||
- Record the completed two-machine evidence in
|
||||
`docs/screenshare-audio-exclusion-impl-plan.md`.
|
||||
|
||||
Commit the scoped version/changelog/evidence work. Keep unrelated user changes
|
||||
out of the commit.
|
||||
|
||||
### 4. Run source gates on the final commit
|
||||
|
||||
Use the pinned Rust toolchain and one build job if disk or linker pressure is
|
||||
tight:
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
CARGO_BUILD_JOBS=1 cargo clippy --locked --all-targets -- -D warnings
|
||||
CARGO_BUILD_JOBS=1 cargo test --locked --all-targets
|
||||
CARGO_BUILD_JOBS=1 cargo test --locked --doc
|
||||
cargo deny --locked check
|
||||
cargo audit
|
||||
git diff --check
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
The commands mirror the repository CI, with `--locked` added to compilation
|
||||
and test gates after the intentional lockfile refresh. A gate that cannot run
|
||||
must be recorded as unverified; do not silently treat it as passed.
|
||||
|
||||
Also verify the bundled PixelPass source is still exactly the intended clean
|
||||
commit and run its release diagnostics before packaging:
|
||||
|
||||
```sh
|
||||
git -C ../pixelpass status --short --branch
|
||||
git -C ../pixelpass rev-parse --short=8 HEAD
|
||||
cargo run --manifest-path ../pixelpass/Cargo.toml --locked -- --doctor
|
||||
```
|
||||
|
||||
### 5. Build the final AppImage
|
||||
|
||||
Build from the clean, committed PeerSpeak release source and clean PixelPass
|
||||
source in the Ubuntu 24.04 distrobox described in `packaging/appimage/README.md`.
|
||||
Keep build caches on `/mnt/superjar` and use one build job if space remains
|
||||
tight. A representative invocation is:
|
||||
|
||||
```sh
|
||||
distrobox enter peerspeak-appimage -- env \
|
||||
PATH="$HOME/.rustup/toolchains/1.97.1-x86_64-unknown-linux-gnu/bin:$PATH" \
|
||||
SYSTEM_DEPS_LIBSPA_INCLUDE=/run/host/usr/include/spa-0.2 \
|
||||
SYSTEM_DEPS_LIBPIPEWIRE_INCLUDE=/run/host/usr/include/pipewire-0.3:/run/host/usr/include/spa-0.2 \
|
||||
CARGO_BUILD_JOBS=1 \
|
||||
PEERSPEAK_APPIMAGE_CACHE=/mnt/superjar/peerspeak-appimage-cache \
|
||||
./packaging/appimage/build-appimage.sh
|
||||
```
|
||||
|
||||
The official output should be
|
||||
`packaging/appimage/peerspeak-0.6.7-x86_64.AppImage`. Do not overwrite the
|
||||
stamped 0.6.6 field-test artifact until the release is complete and verified.
|
||||
|
||||
### 6. Validate the exact final artifact
|
||||
|
||||
At minimum:
|
||||
|
||||
- record `sha256sum` and create a matching `.sha256` sidecar;
|
||||
- run `--appimage-extract` into an isolated scratch directory;
|
||||
- inspect `ldd` for both bundled `usr/bin/peerspeak` and `usr/bin/pixelpass` and
|
||||
require no `not found` entries;
|
||||
- inspect RUNPATH/RPATH and confirm the bundle has not captured the host's
|
||||
graphics, PulseAudio, or GStreamer stack contrary to the thin-AppImage policy;
|
||||
- run the bundled `pixelpass --capabilities` and confirm desktop-audio exclusion
|
||||
support is advertised;
|
||||
- run the bundled `pixelpass --doctor` with a fresh temporary GStreamer registry;
|
||||
- launch the AppImage with an isolated config/data directory for a GUI smoke;
|
||||
- verify the first/home screen reads
|
||||
`PeerSpeak v0.6.7 (<final PeerSpeak HEAD short hash>)`;
|
||||
- verify the same version/hash appears in Settings;
|
||||
- confirm the bundled PixelPass binary corresponds to `ce909afc` (or the exact
|
||||
newer commit deliberately selected tomorrow).
|
||||
|
||||
Because the final version/evidence commit changes the embedded hash relative to
|
||||
the current field-test artifact, do one short two-machine confirmation using
|
||||
the final AppImage: connect, share **All system audio**, confirm desktop sound
|
||||
and no voice echo/warning, stop the share, and leave cleanly.
|
||||
|
||||
### 7. Publication gate
|
||||
|
||||
Before any external write, present the final facts to the user:
|
||||
|
||||
- final PeerSpeak full and short commit;
|
||||
- bundled PixelPass full and short commit;
|
||||
- `v0.6.7` proposed tag target;
|
||||
- AppImage filename, size, and SHA-256;
|
||||
- title-screen version/hash observed;
|
||||
- source, artifact, and two-machine gate results;
|
||||
- exact release notes/assets to publish.
|
||||
|
||||
Then wait for explicit approval to publish.
|
||||
|
||||
After approval only:
|
||||
|
||||
1. Push `main` without force.
|
||||
2. Confirm remote `main` resolves to the tested final commit.
|
||||
3. Create an annotated `v0.6.7` tag on that exact commit and push it.
|
||||
4. Create the `v0.6.7` GitButter release page from the finalized changelog text.
|
||||
5. Upload `peerspeak-0.6.7-x86_64.AppImage` and its `.sha256` sidecar.
|
||||
6. Do not paste or store a GitButter token in the repository, documentation,
|
||||
shell history, or release artifacts.
|
||||
|
||||
The planned release scope is the Linux x86_64 AppImage only. Do not imply that
|
||||
a Windows installer, Debian package, or separate PixelPass release exists
|
||||
unless those artifacts are deliberately added and independently verified.
|
||||
|
||||
### 8. Verify the public release
|
||||
|
||||
Publication is not complete until it is independently read back:
|
||||
|
||||
- confirm GitButter shows the correct release title, tag, notes, and both assets;
|
||||
- confirm the remote tag and remote `main` point to the expected commit;
|
||||
- download the public AppImage to a fresh temporary path;
|
||||
- verify its SHA-256 against the published sidecar and the local final hash;
|
||||
- extract or run the downloaded copy and repeat the version/hash and bundled
|
||||
PixelPass diagnostic checks;
|
||||
- save the final release URL and verification result in the implementation plan
|
||||
or handoff.
|
||||
|
||||
## Failure and recovery rules
|
||||
|
||||
- A failed gate means no tag and no release—not a waiver.
|
||||
- Never force-push or move a published tag as an automatic recovery step.
|
||||
- If the tag is pushed but release creation/upload fails, stop and report the
|
||||
exact remote state before changing anything.
|
||||
- Keep failed or candidate artifacts clearly stamped so they cannot be mistaken
|
||||
for the final asset.
|
||||
- Remove only exact, rebuildable scratch/extraction directories when reclaiming
|
||||
disk space; preserve source, user changes, final artifacts, hashes, and field
|
||||
evidence.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,529 @@
|
||||
# Phase 5 — dry-run audit gate: results
|
||||
|
||||
**Status: 🟢 GATE PASSED (run 2, 2026-07-26). All 13 §5.1 rows completed; the
|
||||
eligible half of every row is non-empty. O5 re-measured on the fixed graph and
|
||||
stays closed.** One new defect was found and fixed during the run (F13-1); three
|
||||
findings are recorded as non-blocking, and three rows carry recorded
|
||||
substitutions. Phase 6 is unblocked **by this file**, and F11-1 — the other gate —
|
||||
was closed with this data on 2026-07-26 (see "What still blocks phase 6").
|
||||
|
||||
- **Run date:** 2026-07-26 (run 1: 2026-07-25, gate FAILED — see history below)
|
||||
- **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
|
||||
- **Audit build:** pixelpass `main` @ `91c4ded`, release profile
|
||||
- **peerspeak build:** `main` @ `b68fca6` (phase 1 merged)
|
||||
- **Ambient load:** Firefox playing audio throughout (a live, uncontrived
|
||||
candidate); Sunshine running (pid 3838); Arctis 1 Wireless as active sink
|
||||
- **Graph size:** 14 Nodes, 4 Devices, 57 Ports, 4 Links, 24 Clients
|
||||
|
||||
---
|
||||
|
||||
## Addendum — pre-Phase-6 hardware/readiness gate (2026-08-21)
|
||||
|
||||
This addendum does not rewrite the historical 2026-07-26 matrix. Design v3.8 §6.9 adds one
|
||||
conservative edge the old engine did not have: tainted playback into a positively classified
|
||||
hardware sink taints passive capture nodes carrying the same snapshot-local `device.id`. It
|
||||
also closes the readiness-budget calibration that round 9 left open.
|
||||
|
||||
### Hardware path — targeted live exact partition
|
||||
|
||||
The audit started first and reached readiness. Controlled modules then created three named
|
||||
candidates:
|
||||
|
||||
| candidate | expected | observed |
|
||||
| --- | --- | --- |
|
||||
| tagged playback into ALC897 | excluded root | `peerspeak-owned` |
|
||||
| capture/re-emitter reading the ALC897 source | excluded through hidden same-device hop | `tainted-owner-bridge` |
|
||||
| identical capture/re-emitter reading the Arctis source | eligible; different Device | eligible, no reason |
|
||||
|
||||
The settled record was `graph_ready=true`, epoch `complete`. This revises row 9 for every
|
||||
future full matrix: music-only and a microphone on a **different Device** remain eligible;
|
||||
a microphone on the **same Device receiving tainted playback** and a tainted-monitor capture
|
||||
are excluded. This is deliberate fail-closed over-exclusion because a private hardware or
|
||||
firmware loopback is not observable as a PipeWire Link.
|
||||
|
||||
The host's ALC897 had no `Stereo Mix` capture-source item: `Input Source` offered Rear Mic,
|
||||
Front Mic and Line. Its separate `Loopback Mixing` control was disabled. Runtime safety does
|
||||
not depend on either spelling; USB/vendor loopbacks need the same rule.
|
||||
|
||||
### Readiness calibration — retain the 2 s sticky deadline
|
||||
|
||||
Each measurement used a fresh observer process and its emitted monotonic `at_ms` readiness
|
||||
timestamp:
|
||||
|
||||
| arm | runs | min | p50 | p95 | max | timed out |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| ambient live graph | 30 | 4 ms | 5 ms | 10 ms | 11 ms | 0 |
|
||||
| 24 temporary null sinks + 24 loopbacks | 30 | 109 ms | 111 ms | 113 ms | 114 ms | 0 |
|
||||
| 250 null-sink create/remove cycles concurrent with starts | 20 | 4 ms | 5 ms | 6 ms | 8 ms | 0 |
|
||||
|
||||
The inflated maximum is 17.5x below the 2 s deadline. All temporary modules were unloaded by
|
||||
the exact ids returned from `pactl`; no test modules remained, and the configured default
|
||||
sink/source were unchanged.
|
||||
|
||||
### Regression gates
|
||||
|
||||
- focused taint engine: 83 passed;
|
||||
- observer: 65 pure passed, then all 3 serialized live PipeWire tests passed;
|
||||
- complete non-GUI PixelPass suite: 313 passed, 0 failed, 9 ignored;
|
||||
- disk before the first build: 20 GiB free; after tests and Clippy: 17 GiB free.
|
||||
|
||||
**Verdict:** both former pre-Phase-6 design blockers are closed. S4/S5/0d, round 11, and the
|
||||
first pure Phase 6 channel-planning prerequisite are committed locally in PixelPass `781defc`;
|
||||
the first fan-out mutation remains the current work.
|
||||
|
||||
---
|
||||
|
||||
## What changed since run 1
|
||||
|
||||
Run 1 failed on two defects, both fixed before this run:
|
||||
|
||||
- **F1** (fatal): the registry `global` event delivers only a filtered subset of
|
||||
node properties, so eight properties the engine depends on were permanently
|
||||
absent. Fixed by design round 8 / **phase 3r** — bind every Node and Device
|
||||
and read properties from `info`.
|
||||
- **F2**: a machine-wide over-exclusion cascade downstream of F1.
|
||||
|
||||
Both are gone: the baseline run (no fixture at all) reports **1 candidate,
|
||||
eligible, empty taint set**.
|
||||
|
||||
### 🔴 F13-1 — FOUND AND FIXED DURING THIS RUN
|
||||
|
||||
**Row 1 failed on its first attempt, and the cause was a third defect of exactly
|
||||
the F2 class from a new source: pipewire-pulse's PID was unresolvable on this
|
||||
host, permanently.**
|
||||
|
||||
`pulse_pid::candidate` returned the single `pipewire.sec.pid` shared by two or
|
||||
more Clients, on the stated reasoning that "native PipeWire clients carry their
|
||||
own distinct PID; only the Pulse shim repeats one value". Measured: **WirePlumber
|
||||
repeats one too.** It holds two Clients — `WirePlumber` and
|
||||
`WirePlumber [export]` — both `sec_pid` 1747. Two values repeated (1747 and
|
||||
pipewire-pulse's 2528), the rule called that ambiguous, and returned `None`.
|
||||
|
||||
With the daemon PID unknown, `owner::keys_of`'s documented fail-closed asymmetry
|
||||
takes over: key 4's suppression never fires, every Pulse-emulated node fuses into
|
||||
one owner, and the cascade follows. Row 1's observed failure:
|
||||
|
||||
```
|
||||
ELIGIBLE (1): r1_plain_app
|
||||
EXCLUDED: Firefox tainted-owner-bridge key=application.process.id
|
||||
r1_c_play tainted-owner-bridge <- the CLEAN control half
|
||||
TAINT: ... + both sound cards, all three sunshine sinks, sunshine itself
|
||||
```
|
||||
|
||||
The rule was wrong in **both** directions, so the prefilter was removed rather
|
||||
than patched:
|
||||
|
||||
- **False ambiguity** — any second process holding two Clients defeats it.
|
||||
WirePlumber always does, so this was permanent, not a corner case.
|
||||
- **False absence** — a session where pipewire-pulse holds exactly one Client
|
||||
(one Pulse app running) repeats nothing, so the candidate is missed and the
|
||||
same cascade follows.
|
||||
|
||||
`comm` was always the authoritative check; repetition was a heuristic standing in
|
||||
front of it, and it was a guess about other processes' Client counts. Fixed in
|
||||
pixelpass `91c4ded`: `candidates()` lists every distinct `sec_pid`, `resolve()`
|
||||
picks the unique one whose `/proc/<pid>/comm` is exactly `pipewire-pulse`, and
|
||||
several matches still fail closed (a single `Option<u32>` cannot suppress two
|
||||
daemons — recorded, not approximated). The adapter probes only PIDs *entering*
|
||||
the candidate set, and `retain_probed_comms` bounds the map to live PIDs so a PID
|
||||
that leaves and returns is re-probed instead of answered from a stale `comm`.
|
||||
|
||||
**This is the §5.1 exact-partition requirement earning its keep for the second
|
||||
time.** The verdict was fail-closed and silent; only the asserted *eligible* half
|
||||
exposed it. An exclusion-only checklist would have passed this build too.
|
||||
|
||||
---
|
||||
|
||||
## §5.1 — the matrix
|
||||
|
||||
Every row ran with `PIXELPASS_AUDIO_AUDIT_AEC=off` except row 12. Every row ran
|
||||
in its **own** audit process, so nothing carries over (sticky taint is
|
||||
per-process state).
|
||||
|
||||
⚠️ **Methodology change from run 1, and it is load-bearing.** Run 1 built each
|
||||
fixture *before* starting the audit. On this host the entire graph then arrives
|
||||
as one enumeration burst (~122 events in 1–2 ms), so every node is first tainted
|
||||
while `graph_ready` is still false, that partial-graph taint is recorded into
|
||||
sticky state, and on the single ready record the sticky pass raises
|
||||
`TaintedOwnerBridge { key: None }` before the evidence pass can name a key —
|
||||
`raise` will not replace a same-rank reason. Verdicts were still correct but rows
|
||||
could not assert their key. This run starts the audit first, waits for readiness,
|
||||
then builds the fixture, so taint is derived from real topology *changes* against
|
||||
a ready graph — which is also the dynamic path §6.3 cares about. Keys are read at
|
||||
**derivation** (first non-sticky appearance), not from the final record.
|
||||
|
||||
| # | scenario | status |
|
||||
| --- | --- | --- |
|
||||
| 1 | null-sink + loopback forwarder, owner bridge | ✅ **pass** (after F13-1 fixed) |
|
||||
| 1b | Sunshine's topology (opportunistic, non-gating) | 🟡 observed, nothing to exclude — see below |
|
||||
| 2 | gst split clients, tainted input | ✅ **pass**, key 4 named at derivation |
|
||||
| 3 | two Pulse modules, one tainted | ✅ **pass** |
|
||||
| 4 | peerspeak native call playback | ✅ **pass** — real tagging site |
|
||||
| 5 | peerspeak-spawned mpv | ✅ **pass** — real tagging site, hand-launched mpv eligible |
|
||||
| 6 | peerspeak notification sound | ✅ **pass** — real tagging site |
|
||||
| 7 | second host's capture sink + forwarder | ✅ **pass**, eligible half non-empty |
|
||||
| 8 | EasyEffects | 🟡 **pass with substitution** — echo-cancel stood in |
|
||||
| 9 | Firefox three cases | ✅ **pass** (cases 2–3 via gst; see substitution) |
|
||||
| 10 | sticky taint across teardown | ✅ **pass**, all four phases incl. retirement |
|
||||
| 11 | recycled serial / index / link-group | ✅ **pass**, and provably non-vacuous |
|
||||
| 12 | AEC loaded → unloaded → Revoked | ✅ **pass** |
|
||||
| 13 | `Audio/Duplex` device | 🟡 **pass with synthetic node** — over-taint confirmed |
|
||||
|
||||
### Row 1 — owner bridge, key named
|
||||
|
||||
```
|
||||
ELIGIBLE (3): Firefox · r1_c_play · r1_plain_app
|
||||
EXCLUDED (2): peerspeak_owned_call_4242 peerspeak-owned
|
||||
r1_t_play tainted-owner-bridge key=node.link-group
|
||||
TAINT (5): the tagged producer, r1_t_src, r1_t_cap, r1_t_play, r1_t_dest
|
||||
```
|
||||
|
||||
The clean half is an **identically shaped** forwarder — same module type, same
|
||||
monitor-read, same re-emit — differing only in whether anything tainted feeds it.
|
||||
`r1_c_play` eligible is the assertion an exclude-everything build cannot satisfy.
|
||||
The key is `node.link-group`, a strong key, not a link walk.
|
||||
|
||||
### Row 2 — GStreamer split clients, key 4
|
||||
|
||||
Measured props confirm the shape is the real refutation: `r2_gst_tainted_src`
|
||||
(client 188) and `r2_gst_tainted_sink` (client 191) are **different Clients** of
|
||||
**one process**, pid 235628, with no `link-group` and no `pulse.module.id`. So
|
||||
`application.process.id` is the only key that can relate them.
|
||||
|
||||
Derivation record (seq 209): `r2_gst_tainted_sink` → `tainted-owner-bridge`,
|
||||
**`owner_key=application.process.id`**. `r2_gst_clean_sink`, reading an untainted
|
||||
monitor in a second process, is eligible.
|
||||
|
||||
### Rows 4–6 — peerspeak's own paths, through the real call sites
|
||||
|
||||
Driven by peerspeak's phase-1 live gate tests (`--ignored`), i.e. the real
|
||||
tagging sites, not a hand-rolled env: "emission alone proves only that peerspeak
|
||||
talks, not that pixelpass listens" (impl plan §3).
|
||||
|
||||
| node | verdict |
|
||||
| --- | --- |
|
||||
| `peerspeak_owned_call_238172` | EXCLUDED `peerspeak-owned` |
|
||||
| `peerspeak_owned_mpv_238196` | EXCLUDED `peerspeak-owned` |
|
||||
| `peerspeak_owned_notify_238231` | EXCLUDED `peerspeak-owned` |
|
||||
| `peerspeak_owned_clip_238249` | EXCLUDED `peerspeak-owned` (bonus — chat clips) |
|
||||
| `mpv` (launched by hand, untagged) | **ELIGIBLE** |
|
||||
|
||||
This is the cross-repo contract closed end to end on live nodes.
|
||||
|
||||
### Row 9 — the over-exclusion promise
|
||||
|
||||
```
|
||||
ELIGIBLE: Firefox (music only) · r9_mic_out (captures an untainted real device)
|
||||
EXCLUDED: r9_mon_out tainted-owner-bridge key=application.process.id
|
||||
```
|
||||
|
||||
`r9_mic_out` is the row that defends §6.1.1: an app that captures a real
|
||||
`session_device` source and also plays audio stays shareable. The device source
|
||||
itself never entered the taint set.
|
||||
|
||||
### Row 10 — the full sticky lifecycle
|
||||
|
||||
| phase | topology | verdict |
|
||||
| --- | --- | --- |
|
||||
| A | tainted producer + forwarder | `r10_play_out` EXCLUDED, key `node.link-group` |
|
||||
| B | **tagged producer killed**, forwarder lives | **still EXCLUDED** (sticky) — current topology alone no longer justifies it |
|
||||
| C | forwarder owner replaced, tainted sink kept | fresh forwarder EXCLUDED — correct: a sink that received call audio is still a hazard while it lives |
|
||||
| D | **every** tainted object torn down, then restart | taint set **empty** at 16.3 s; `r10_new_out` **ELIGIBLE** at 20.3 s |
|
||||
|
||||
Phase B proves stickiness works; phase D proves it is not permanent. Phase C is
|
||||
worth keeping in mind when reading any future report: partial teardown legitimately
|
||||
does *not* retire taint, and that is easy to mistake for over-exclusion.
|
||||
|
||||
### Row 11 — recycled identifiers, provably non-vacuous
|
||||
|
||||
| generation | `node.link-group` | global id (`r11_src`) | `object.serial` (`r11_play`) | pulse module |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 (tainted) | `loopback-2528-14` | 168 | 4702 | 536870919 |
|
||||
| 2 (after teardown) | **`loopback-2528-14`** | **168** | 4746 | 536870920 |
|
||||
|
||||
The `node.link-group` came back **byte-identical** — and it is the very key that
|
||||
carried the taint in generation 1 — and the global id was reused. Generation 2's
|
||||
`r11_play` is **ELIGIBLE** with an empty taint set. `object.serial` correctly did
|
||||
not recycle, which is why the model keys everything by it.
|
||||
|
||||
### Row 12 — AEC lifecycle
|
||||
|
||||
| stage | `aec_state` | `fan_out_permitted` | candidates |
|
||||
| --- | --- | --- | --- |
|
||||
| module live, configured | `validated` | `true` | Firefox + `r12_plain_app` ELIGIBLE; `echo-cancel-playback` EXCLUDED `aec-identity` |
|
||||
| module unloaded | `revoked` | `false` (`gate_reason=aec-revoked`) | every candidate EXCLUDED `aec-revoked` |
|
||||
|
||||
All **four** link-group siblings (`sink`, `source`, `capture`, `playback`) carry
|
||||
`aec-identity`; only `echo-cancel-playback` is a candidate, so it is the only one
|
||||
in the excluded partition. Ordinary apps staying eligible *while validated* is
|
||||
what makes "the gate is open" observable rather than inferred.
|
||||
|
||||
### Row 13 — `Audio/Duplex` over-taint (known accepted)
|
||||
|
||||
No real duplex device exists on this host, so one was synthesised by overriding
|
||||
`media.class=Audio/Duplex` on a null sink. Its playback side was tainted and its
|
||||
capture-side consumer was dragged down with it (`r13_dup_play` EXCLUDED), with
|
||||
the eligible half intact. **Fixture limit, stated plainly:** on a null sink the
|
||||
capture side *is* the monitor, so this cannot separate the duplex smear from the
|
||||
ordinary sink→monitor edge. The accepted over-taint is confirmed as *behaviour*;
|
||||
a real duplex device is still the only way to isolate the mechanism.
|
||||
|
||||
### Row 1b — Sunshine (opportunistic, non-gating)
|
||||
|
||||
Sunshine ran throughout. Its three null sinks stayed SUSPENDED and it read the
|
||||
**hardware** monitor instead, exactly as §5.3 warned. It appears consistently and
|
||||
correctly as `sunshine` / `tainted-upstream` whenever the monitor it reads is
|
||||
tainted (rows 8, 12, o5). It has **no re-emitting output leg** — it sends over
|
||||
the network — so it is never a candidate and there is nothing to exclude. Recorded
|
||||
as observed; the "if a re-emitting leg exists" clause did not apply. A real
|
||||
third-party forwarder sample remains owed.
|
||||
|
||||
---
|
||||
|
||||
## §5.2 — O5 re-measured
|
||||
|
||||
The run-1 numbers do not carry over: they were measured on the graph F1 degraded,
|
||||
and phase 3r adds a bind plus an `info` round-trip **per node**, which is new I/O
|
||||
that run never exercised.
|
||||
|
||||
Per-run, across all 13 rows (`recompute` in µs):
|
||||
|
||||
| run | events | ev/s | max | mean | emit max | busy fraction | ready@ms |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| baseline | 123 | 21.4 | 20 | 3 | 6 | 0.0001 | 1 |
|
||||
| o5 (churn) | 407 | 44.0 | 32 | 10 | 9 | 0.0006 | 1 |
|
||||
| row01 | 219 | 41.7 | 53 | 10 | 9 | 0.0006 | 1 |
|
||||
| row02 | 241 | 45.9 | **67** | 11 | 10 | 0.0006 | 1 |
|
||||
| row03 | 206 | 48.5 | 54 | 8 | 8 | 0.0005 | 1 |
|
||||
| row0456 | 185 | 20.0 | 38 | 7 | 10 | 0.0002 | 2 |
|
||||
| row07 | 184 | 43.3 | 40 | 7 | 9 | 0.0004 | 1 |
|
||||
| row08 | 172 | 32.6 | 44 | 6 | 7 | 0.0003 | 2 |
|
||||
| row09 | 224 | 30.9 | 52 | 9 | 9 | 0.0004 | 1 |
|
||||
| row10 | 332 | 14.3 | 41 | 11 | 15 | 0.0002 | 1 |
|
||||
| row11 | 298 | 24.1 | 41 | 9 | 11 | 0.0003 | 1 |
|
||||
| row12 | 188 | 25.9 | 39 | 7 | 8 | 0.0003 | 1 |
|
||||
| row13 | 193 | 36.8 | 43 | 8 | 7 | 0.0004 | 1 |
|
||||
|
||||
The dedicated churn run (five load/unload cycles of null-sink + loopback, the
|
||||
same shape as run 1's measurement):
|
||||
|
||||
```json
|
||||
{"kind":"metrics","graph_events":407,"tick_events":37,"emitted_records":407,
|
||||
"span_us":9249639,"graph_events_per_sec":44.0,
|
||||
"recompute_max_us":32,"recompute_mean_us":10,
|
||||
"recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us",
|
||||
"recompute_distribution":[["<50us",444]],
|
||||
"emit_max_us":9,"emit_mean_us":1,
|
||||
"busy_us":5240,"busy_fraction":0.0006,
|
||||
"queued_events":292,"queue_threshold_us":100}
|
||||
```
|
||||
|
||||
**O5 stays closed on the real graph.** Worst recompute across every run is
|
||||
**67 µs**; every single recompute in the churn run finished under 50 µs, against
|
||||
a 44 Hz event rate under churn heavier than a desktop produces at rest. The
|
||||
observer thread spent **0.06 %** of wall time working. Node binding roughly
|
||||
doubled the per-event cost (run 1: 15 µs max / 4 µs mean; now 32 µs / 10 µs on
|
||||
the same churn shape) and that is the honest cost of the F1 fix — it buys three
|
||||
orders of magnitude of remaining headroom, not one.
|
||||
|
||||
**Readiness with node binds: 1–2 ms**, with ~122 enumeration events and 18 binds
|
||||
(14 Nodes + 4 Devices), against the 2000 ms budget. `queued_events` is high
|
||||
(292) for the same benign reason as run 1: PipeWire delivers enumeration and
|
||||
teardown in bursts, and a 32 µs recompute drains a burst faster than it forms.
|
||||
`busy_fraction` is the number to trust.
|
||||
|
||||
⚠️ **Historical run-2 finding, closed by the 2026-08-21 addendum above:** 1–2 ms
|
||||
against 2000 ms was three orders of magnitude of slack on *this* host with 18
|
||||
binds, but not an argument about enumeration volume or instability. The addendum
|
||||
adds a 48-module graph and concurrent create/remove churn and retains the 2 s
|
||||
budget from that evidence.
|
||||
|
||||
---
|
||||
|
||||
## Findings recorded, not blocking
|
||||
|
||||
### R2-1 — the audit's `sticky` flag is nearly always true, so it says little
|
||||
|
||||
As emitted, `sticky` means "this node is in the remembered set", which
|
||||
`seed_sticky` populates for any node whose current reason the sticky pass agrees
|
||||
with — i.e. essentially every currently-tainted node. It does **not** mean
|
||||
"excluded *only* because remembered", which is what its doc comment implies and
|
||||
what a reader diagnosing "why is this still excluded?" wants.
|
||||
|
||||
The information exists: round 9 already computes a second, **evidence-only** pass
|
||||
(that is the whole provenance mechanism). Emitting "excluded by memory alone"
|
||||
would make row 10 phase B assertable from a single record instead of from a
|
||||
sequence. Not fixed here — it is a reporting change to a merged phase in the
|
||||
middle of a gate run. Row 10 was asserted behaviourally instead, which is
|
||||
stronger anyway.
|
||||
|
||||
### R2-2 — a bridge key is lost when a leg reappears under a new serial
|
||||
|
||||
Row 2 named `application.process.id` at derivation (seq 209), then gst re-created
|
||||
that node; the sticky owner re-seeded the new serial through `reason_for`, whose
|
||||
documented fallback is `TaintedOwnerBridge { key: None }`, and `raise` will not
|
||||
replace a same-rank reason with a better-informed one. The verdict is unaffected;
|
||||
only the diagnosis degrades. The fallback is honest when the owner has no live
|
||||
tainted receiver, and stale when it does — which is the case worth improving.
|
||||
|
||||
### R2-3 — `owner_key` had to be added to the record to run row 1 at all
|
||||
|
||||
Row 1 asserts "reason = owner bridge, **naming the key**", and the record could
|
||||
not express it: `Reason::code` collapses `TaintedOwnerBridge { key }` to one
|
||||
string. `OwnerKey::code` already documented itself as ending up in the phase 5
|
||||
audit output; it was simply never wired to it. Added in pixelpass `d462754`
|
||||
(read-only, diagnostic-only, mutation-verified test). Worth noting as a gate-spec
|
||||
lesson: the row could not have been asserted from any previous build's output.
|
||||
|
||||
---
|
||||
|
||||
## Substitutions, stated so they are not mistaken for passes
|
||||
|
||||
| row | asked for | used instead | why |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 | EasyEffects | `module-echo-cancel` with `AEC=off` | EasyEffects makes itself the default sink on start and the user had live audio playing. `module-filter-chain` cannot stand in either — it is a PipeWire module, so `pactl load-module` answers "No such entity" (measured). The stand-in produces the same shape (four nodes, one `node.link-group`) and exercises `foreign-echo-cancel` (decision D3), a reason code no other row reaches. |
|
||||
| 9 | Firefox's mic + monitor capture | `gst-launch` pipelines | Firefox's mic and monitor-capture paths need interactive GUI permission grants. Firefox is present live as case 1 in every row. Case 2 captures the motherboard's **analog input**, not the headset mic the user is wearing — identical to the engine (both `session_device` sources), and nothing of the user is recorded. |
|
||||
| 13 | a real `Audio/Duplex` device | synthetic `media.class` override | None on this host. See row 13 above for what the fixture cannot show. |
|
||||
|
||||
---
|
||||
|
||||
## What still blocks phase 6
|
||||
|
||||
This file passing removes **one** of the two gates. F11-1, the other, is now
|
||||
closed. Still outstanding:
|
||||
|
||||
1. **Hardware playback-to-capture paths ("Stereo Mix")** defeat `session_device`
|
||||
and are a real echo path — needs ALSA control inspection; user design call owed.
|
||||
2. **Phases 0b / 0c / 0d** are untouched and all precede phase 6.
|
||||
3. **The readiness budget calibration argument** (above).
|
||||
4. **Owed samples:** a real third-party forwarder (row 1b), EasyEffects (row 8),
|
||||
a real `Audio/Duplex` device (row 13).
|
||||
|
||||
### ✅ F11-1 — closed 2026-07-26, with this matrix's data
|
||||
|
||||
The rule now implemented (pixelpass `c78eb2d`, §6.1.2's round-13 box): **key 4 bounds an
|
||||
owner only when the node's Client resolves** — an unambiguous Client yielding
|
||||
`Some(pipewire.sec.pid)`, read *before* pipewire-pulse suppression — so a node can no
|
||||
longer bound itself, and escape `propagate_unresolved_owner`'s sweep, with an
|
||||
`application.process.id` it invented. Bridging still uses the full union.
|
||||
|
||||
Codex's round-12 sharpening was the decisive part: "resolved" must mean a `sec_pid`, not
|
||||
"a unique Client object exists", and the **unique-but-pid-less** row is the only one that
|
||||
tells the two apart. All five Client cases are unit tests (absent · ambiguous ·
|
||||
unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse), plus the recorded
|
||||
three-step leak path end to end. Mutation-verified: dropping the provenance test fails
|
||||
four of the six rows and leaves the two no-over-exclusion rows green.
|
||||
|
||||
**The cost question the deferral was waiting on, measured on this host:** the before- and
|
||||
after-binaries audited the *same* live graph simultaneously (both are read-only observers)
|
||||
— tagged producer into the default sink, `parec` on its monitor as a live tainted reader
|
||||
so the sweep was genuinely armed, Firefox + `aplay` + `pacat` as bystanders. **181 records
|
||||
each, the same 14 distinct decision states, none exclusive to either side, no
|
||||
`unresolved-owner` on either, eligible half non-empty throughout.** O5 unmoved (identical
|
||||
p50 15 µs and busy fraction 0.0012). Every real app here is native or Pulse-emulated and
|
||||
**both resolve**; sweeping all 18 live nodes, the only unresolved-Client ones were
|
||||
`Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose.
|
||||
|
||||
---
|
||||
|
||||
## Reproducing this run
|
||||
|
||||
Scripts live in the session scratchpad (not committed — they hard-code paths):
|
||||
one per row, plus `lib.sh`, `summarize.py` and `keys.py`. The shape of every row:
|
||||
|
||||
```sh
|
||||
audit_start out.jsonl off # start FIRST, wait for graph_ready
|
||||
... build fixture ... # taint arrives as topology CHANGES
|
||||
audit_stop # SIGTERM: flushes the O5 summary
|
||||
python3 summarize.py out.jsonl # final partition + derivations + metrics
|
||||
```
|
||||
|
||||
```
|
||||
env PIXELPASS_AUDIO_AUDIT_FILE=/path/out.jsonl PIXELPASS_AUDIO_AUDIT_AEC=off \
|
||||
./target/release/pixelpass --audit-audio
|
||||
```
|
||||
|
||||
Rig notes that cost time:
|
||||
|
||||
- A tagged producer: `env PIPEWIRE_ALSA='{ "peerspeak.owned": "1", "node.name":
|
||||
"peerspeak_owned_call_4242", "target.object": "<sink>" }' aplay -c 2 -r 48000
|
||||
-f S16_LE -t raw -d 30 /dev/zero`. Both carriers land, and `target.object`
|
||||
routes it.
|
||||
- ⚠️ `pactl load-module module-echo-cancel --help` **loads the module** with
|
||||
`--help` as its argument instead of printing help. It was loaded accidentally
|
||||
during this session and unloaded again; check `pactl list short modules` after
|
||||
any such probe.
|
||||
- ⚠️ `pkill -f <pattern>` matches the harness's own shell command line and kills
|
||||
the script. Use `pkill -x` or an exact pid.
|
||||
- ⚠️ Under `set -e`, `kill` on an already-exited pid aborts the row before its
|
||||
modules are unloaded; and `timeout` exiting 124 is *success* for the audit.
|
||||
|
||||
---
|
||||
|
||||
## History — run 1 (2026-07-25): GATE FAILED
|
||||
|
||||
Kept because the reasoning is still the record of why the observation boundary
|
||||
was redesigned.
|
||||
|
||||
### F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties
|
||||
|
||||
The phase-3 adapter read eight node properties the registry never announces.
|
||||
Parsed off `obj.props` in the registry `global` callback, they were silently
|
||||
absent, so every one was permanently `None`/`false`.
|
||||
|
||||
The complete set the registry announces for a `Node` on this host:
|
||||
|
||||
```
|
||||
application.name client.api client.id device.id factory.id media.class
|
||||
node.description node.name node.nick object.path object.serial
|
||||
priority.driver priority.session
|
||||
```
|
||||
|
||||
| property | announced? | what died without it |
|
||||
| --- | --- | --- |
|
||||
| `object.serial`, `node.name`, `media.class`, `client.id`, `device.id` | ✅ | — |
|
||||
| **`peerspeak.owned`** | ❌ | **the primary taint root (all of phase 1)** |
|
||||
| **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** |
|
||||
| **`node.link-group`** | ❌ | the link-group owner key |
|
||||
| **`application.process.id`** | ❌ | the process owner key |
|
||||
| **`node.passthrough`** | ❌ | the passthrough local exclusion |
|
||||
| **`device.api`**, **`factory.name`**, **`alsa.driver_name`** | ❌ | `session_device` classification |
|
||||
|
||||
Ports lost `port.exclusive`; Links and Clients were fine — notably
|
||||
`pipewire.sec.pid` **is** announced, so pulse-PID derivation was reachable.
|
||||
|
||||
Demonstrated end to end: a null sink carrying `peerspeak.owned=true` whose
|
||||
monitor a `module-loopback` re-emitted was reported **eligible** with an **empty
|
||||
taint set**. In phase 6 that is an echo.
|
||||
|
||||
The fix became design round 8 (v3.5 §6.7) and phase 3r: bind each Node and read
|
||||
props off its `info`, exactly how `pw-dump` obtains them. `factory.id` is not a
|
||||
shortcut (`factory.id=19` resolves to `factory.name = "adapter"`), and
|
||||
`device.api` is on the *Device* global.
|
||||
|
||||
### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
|
||||
|
||||
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is*
|
||||
announced) was the only surviving taint root. Row 7 then excluded every
|
||||
`Stream/Output/Audio` on the machine: with no strong owner keys, every tainted
|
||||
capture stream was an **unbounded tainted reader**, tripping phase 2's
|
||||
fail-closed backstop, while WirePlumber's shared `client.id = 42` fused the
|
||||
device layer into one owner.
|
||||
|
||||
Net live behaviour: exclude everything, always, as soon as pixelpass's own
|
||||
capture sink existed. Fail-closed, so silence rather than echo — but entirely
|
||||
non-functional, and non-functional in a way that would have looked like "working
|
||||
safely" to any test that asserted only exclusions.
|
||||
|
||||
### What run 1's machinery got right
|
||||
|
||||
None of this needed revisiting:
|
||||
|
||||
- Running the recompute **inline on the observer thread**, once per applied
|
||||
registry event, upheld phase 4's no-coalescing contract and put the cost where
|
||||
O5 could measure it.
|
||||
- The **complete-partition record** is what caught F2 — and, in run 2, F13-1.
|
||||
- **Reason codes survived the trip** and were immediately diagnostic.
|
||||
- The **`peerspeak.owned` / `pulse.module.id` fixtures were right**: the engine
|
||||
does the correct thing when handed correct properties. Both failures were at
|
||||
the observation boundary, which is where phase 5 was designed to look.
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785989512,
|
||||
"narHash": "sha256-HFQhkQcl5D1hUNoen3SGHCSFCt2Bg6uP+HgbrnA3InQ=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "445d861c6d31b4af0c79d8d4be2331f762a361d7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786076960,
|
||||
"narHash": "sha256-jfR6OhwurCKn1tREyfOcK/Omxf1Q/DzDDFbnEr1mBLs=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "57a23bfaf4f7017267294b161175db1e32eb1c85",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
description = "PeerSpeak — decentralized P2P voice chat (Rust/iroh/PipeWire/Opus/iced)";
|
||||
|
||||
inputs = {
|
||||
# Pinned to the same channel the hosts run (nixos-config tracks
|
||||
# nixos-26.05), so the libraries this shell links and dlopens are built
|
||||
# against the same release as the PipeWire daemon and Vulkan ICD actually
|
||||
# running on the machine. Floating to unstable here would reintroduce
|
||||
# precisely the client/server version skew the pin exists to prevent.
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
|
||||
# The Rust toolchain is pinned SEPARATELY from the system libraries, and
|
||||
# deliberately so. nixpkgs 26.05 ships rustc 1.95.0, but this crate was
|
||||
# developed and verified against 1.97.1 — close enough to build and pass
|
||||
# every test, but not close enough for clippy, which flags a
|
||||
# `collapsible_match` on 1.95 that 1.97 does not. Taking the compiler from
|
||||
# here decouples "which Rust the project targets" from "which release the
|
||||
# audio stack came from", so a nixpkgs bump can never silently move the
|
||||
# compiler under the lint gate again.
|
||||
#
|
||||
# This is the reproducible alternative to rustup: same exact-version
|
||||
# control, but the choice is recorded in flake.lock, so darp5 or a fresh
|
||||
# clone resolves the identical toolchain instead of whatever rustup happens
|
||||
# to fetch that day.
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ nixpkgs, rust-overlay, ... }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
# Matches what CachyOS shipped (rust 1:1.97.1-1), which is the toolchain
|
||||
# every green result in the handoff was produced with.
|
||||
#
|
||||
# `default` is the rustup "default" profile — rustc, cargo, rust-std,
|
||||
# rustfmt and clippy — so those are NOT listed separately below.
|
||||
#
|
||||
# rust-src and the windows-gnu target exist for win-cross-build.sh, which
|
||||
# needs `-Z build-std=std,panic_abort` for the self-contained .exe. That
|
||||
# script still expects to run in the peerspeak-win distrobox for the
|
||||
# mingw toolchain; carrying the target here just means the Rust half is
|
||||
# already in place if it is ever driven from the host.
|
||||
rustToolchain = pkgs.rust-bin.stable."1.97.1".default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ "x86_64-pc-windows-gnu" ];
|
||||
};
|
||||
|
||||
# Libraries that iced/winit/wgpu open with dlopen at RUNTIME rather than
|
||||
# linking at build time. Because nothing links them, they never land in
|
||||
# the binary's rpath — under `cargo run` the loader finds them only
|
||||
# through LD_LIBRARY_PATH. Leaving them out builds fine and then panics
|
||||
# at window creation, which is a genuinely confusing failure, so they are
|
||||
# listed explicitly instead of discovered the hard way.
|
||||
runtimeLibs = with pkgs; [
|
||||
vulkan-loader # wgpu's Vulkan backend (iced's renderer)
|
||||
libxkbcommon # winit keyboard handling
|
||||
wayland # wayland-sys, dlopen'd on a Wayland session
|
||||
libx11 # x11-dl, dlopen'd on the X11 fallback path
|
||||
libxcursor
|
||||
libxrandr
|
||||
libxi
|
||||
];
|
||||
|
||||
# Screen sharing spawns pixelpass as a CHILD PROCESS, and pixelpass in
|
||||
# turn drives GStreamer as a subprocess. That makes these tools a
|
||||
# dependency of peerspeak's own test suite, not just of pixelpass:
|
||||
# `tests/screenshare_host_fault.rs` starts a real pixelpass host, which
|
||||
# aborts at its preflight if gst-launch-1.0 is missing.
|
||||
#
|
||||
# Deliberately duplicated from pixelpass's flake rather than importing it
|
||||
# as an input. The two projects are mutually optional by design — neither
|
||||
# is a dependency of the other, and the coupling is a runtime subprocess
|
||||
# contract. Making one flake consume the other would quietly reintroduce
|
||||
# exactly the build-level dependency that rule exists to prevent.
|
||||
screenshareTools = with pkgs; [
|
||||
gst_all_1.gstreamer
|
||||
gst_all_1.gst-plugins-base
|
||||
gst_all_1.gst-plugins-good
|
||||
gst_all_1.gst-plugins-bad
|
||||
gst_all_1.gst-plugins-ugly
|
||||
gst_all_1.gst-libav
|
||||
pipewire # pipewiresrc (Wayland capture; ships in this pkg)
|
||||
];
|
||||
in
|
||||
{
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
nativeBuildInputs = [
|
||||
rustToolchain
|
||||
]
|
||||
++ (with pkgs; [
|
||||
# The supply-chain gates .gitea/workflows/ci.yml runs, so the same
|
||||
# checks are reproducible locally before a push. These were `cargo
|
||||
# install`ed on the CachyOS side, which does not carry over — those
|
||||
# binaries link that distro's glibc and will not run here.
|
||||
# cargo-deny reads deny.toml; cargo-audit reads .cargo/audit.toml.
|
||||
cargo-audit
|
||||
cargo-deny
|
||||
# Debian packaging (`cargo deb --no-build`). Note the .deb itself
|
||||
# should still be built inside a Debian/Ubuntu distrobox so the
|
||||
# binary links that distro's glibc — see the packaging notes in
|
||||
# Cargo.toml.
|
||||
cargo-deb
|
||||
|
||||
pkg-config
|
||||
|
||||
# pipewire-sys and libspa-sys generate their bindings with bindgen,
|
||||
# which needs a real libclang present at build time.
|
||||
clang
|
||||
|
||||
# audiopus_sys prefers the system libopus via pkg-config but falls
|
||||
# back to a vendored CMake build; cmake keeps that fallback working
|
||||
# rather than failing obscurely inside a build script.
|
||||
cmake
|
||||
|
||||
# build.rs shells out to `git rev-parse --short=8 HEAD` to stamp
|
||||
# PEERSPEAK_GIT_SHORT into the binary (surfaced in Settings).
|
||||
git
|
||||
])
|
||||
++ screenshareTools
|
||||
++ [
|
||||
pkgs.pulseaudio # `pactl`, used by pixelpass's audio routing
|
||||
pkgs.mpv # the screen-share viewer
|
||||
];
|
||||
|
||||
buildInputs =
|
||||
with pkgs;
|
||||
[
|
||||
alsa-lib # alsa-sys, pulled in by rodio/cpal
|
||||
libopus # audiopus_sys, linked dynamically
|
||||
pipewire # pipewire-sys + libspa-sys: the Linux audio backend
|
||||
]
|
||||
++ runtimeLibs;
|
||||
|
||||
# bindgen finds libclang through this variable specifically — having
|
||||
# clang on PATH is not sufficient.
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||
|
||||
# NixOS keeps every GStreamer plugin in its own store path, so the
|
||||
# gst-launch-1.0 that pixelpass spawns discovers them ONLY through this
|
||||
# search path. Same reasoning as hosts/darp5 and hosts/cazen in
|
||||
# nixos-config.
|
||||
GST_PLUGIN_SYSTEM_PATH_1_0 =
|
||||
pkgs.lib.makeSearchPathOutput "lib" "lib/gstreamer-1.0" screenshareTools;
|
||||
|
||||
# Only greet an interactive shell. shellHook also runs under
|
||||
# `nix develop --command …`, where printing this would interleave the
|
||||
# banner with the command's own output.
|
||||
shellHook = ''
|
||||
if [ -t 1 ]; then
|
||||
echo "peerspeak — rustc $(rustc --version | cut -d' ' -f2) / cargo $(cargo --version | cut -d' ' -f2)"
|
||||
echo " cargo build --release build"
|
||||
echo " cargo test lib tests"
|
||||
echo " cargo clippy --all-targets -- -D warnings lint"
|
||||
echo
|
||||
echo "Screen sharing spawns pixelpass as a child process — it must be"
|
||||
echo "on PATH. Build it from ../pixelpass and add its target/release."
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,10 @@ The AppImage runs on any reasonably current glibc-based distro that has:
|
||||
|
||||
- **A Vulkan-capable GPU + driver** (peerspeak's iced/wgpu renderer). Mesa/RADV
|
||||
on AMD/Intel or the NVIDIA driver all work.
|
||||
- **PipeWire** (with the PulseAudio shim, for `pactl`).
|
||||
- **PipeWire** with the PulseAudio shim, `pactl`, and the host `libpulse.so.0`
|
||||
client library. The Pulse client stack is deliberately not bundled because
|
||||
PixelPass launches host GStreamer tools that must keep using the host's
|
||||
matching multimedia libraries.
|
||||
- For **screen-share only** — pixelpass shells out to these on the host `PATH`;
|
||||
it prints the exact package names for your distro if any are missing:
|
||||
- **GStreamer + plugins** (`gst-launch-1.0`/`gst-inspect-1.0`, base,
|
||||
@@ -58,13 +61,23 @@ distrobox create --yes --image ubuntu:24.04 --name peerspeak-appimage
|
||||
distrobox enter peerspeak-appimage -- sudo apt-get update
|
||||
distrobox enter peerspeak-appimage -- sudo apt-get install -y \
|
||||
build-essential cmake clang libclang-dev pkg-config \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libxcb1-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libpulse-dev libasound2-dev libxcb1-dev \
|
||||
curl ca-certificates file patchelf git
|
||||
rustup toolchain install 1.97.1 --profile default
|
||||
|
||||
# Build (the host's ~/.rustup toolchain is glibc-2.17-baseline, so it runs in the
|
||||
# box; isolated CARGO_TARGET_DIRs keep it off the host target/):
|
||||
# PeerSpeak's ownership validator needs the modern SPA-JSON parser headers it
|
||||
# is tested against. Distrobox exposes the host's headers under /run/host;
|
||||
# pkg-config still links Ubuntu's older ABI-compatible libraries, preserving
|
||||
# the glibc baseline. This checkout's verified host-header version is 1.6.8:
|
||||
test -f /run/host/usr/include/spa-0.2/spa/utils/json-core.h
|
||||
|
||||
# Build with the repository's pinned Rust version (the host's ~/.rustup
|
||||
# toolchain is glibc-2.17-baseline, so it runs in the box; isolated
|
||||
# CARGO_TARGET_DIRs keep it off the host target/):
|
||||
distrobox enter peerspeak-appimage -- env \
|
||||
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" \
|
||||
PATH="$HOME/.rustup/toolchains/1.97.1-x86_64-unknown-linux-gnu/bin:$PATH" \
|
||||
SYSTEM_DEPS_LIBSPA_INCLUDE="/run/host/usr/include/spa-0.2" \
|
||||
SYSTEM_DEPS_LIBPIPEWIRE_INCLUDE="/run/host/usr/include/pipewire-0.3:/run/host/usr/include/spa-0.2" \
|
||||
./packaging/appimage/build-appimage.sh
|
||||
```
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
# X11) is dlopen'd at runtime and is on the AppImage excludelist because it must
|
||||
# match the host driver, and pixelpass's capture/encode tools (gst-launch-1.0,
|
||||
# pactl, mpv) are expected on the host PATH. So the AppImage carries just the two
|
||||
# binaries plus their handful of non-excludelisted libs. The custom AppRun
|
||||
# prepends usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled
|
||||
# pixelpass, while the host's tools stay reachable.
|
||||
# binaries plus their handful of non-excludelisted libs. PulseAudio's client
|
||||
# stack is also excluded: pixelpass shells out to host GStreamer, and letting
|
||||
# those subprocesses inherit Ubuntu's bundled libsndfile/libmpg123 stack can
|
||||
# override incompatible host multimedia libraries. The custom AppRun prepends
|
||||
# usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled pixelpass,
|
||||
# while the host's tools and matching audio stack stay reachable.
|
||||
#
|
||||
# All runtime assets (notification WAVs, avatar presets, window icon, fonts) are
|
||||
# include_bytes!-embedded in the peerspeak binary, so nothing else is bundled.
|
||||
@@ -80,6 +83,7 @@ echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
|
||||
--appdir "$appdir" \
|
||||
-e "$appdir/usr/bin/peerspeak" \
|
||||
-e "$appdir/usr/bin/pixelpass" \
|
||||
--exclude-library 'lib*.so*' \
|
||||
-d "$repo/packaging/peerspeak.desktop" \
|
||||
-i "$repo/assets/icons/peerspeak-256.png" \
|
||||
--icon-filename peerspeak \
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.6.4"
|
||||
#define MyAppVersion "0.6.7"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
+726
-154
File diff suppressed because it is too large
Load Diff
@@ -328,4 +328,54 @@ mod tests {
|
||||
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||
assert_eq!(seek_target(2.0, total), total);
|
||||
}
|
||||
|
||||
/// **The fourth playback path's exit gate (round 10, R10-2).** Drives a
|
||||
/// real [`ClipPlayer`] — the same object the app uses for chat clips, peer
|
||||
/// music and the local playlist — and asserts the node it puts on the
|
||||
/// graph carries both ownership carriers.
|
||||
///
|
||||
/// This path was untagged through all of phase 1, which is a real echo:
|
||||
/// B broadcasts music, A tunes in, A shares their desktop, B hears their
|
||||
/// own track. It was missed because phase 1 worked from the impl plan's
|
||||
/// list of three playback sites and that list was incomplete — so this
|
||||
/// gate drives the *player*, not the tagging helper.
|
||||
///
|
||||
/// ⚠️ **Run alone**: it sets a process-wide environment variable, which is
|
||||
/// only sound single-threaded. In production `main` does this before
|
||||
/// anything is spawned; a test binary has no such guarantee, hence
|
||||
/// `--test-threads=1`.
|
||||
///
|
||||
/// `cargo test --lib -- --ignored --test-threads=1 clip_player_node`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump; run with --test-threads=1"]
|
||||
fn clip_player_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
|
||||
// SAFETY: `--test-threads=1` is documented above and in the ignore
|
||||
// reason; this is the same call `main` makes, exercised for real
|
||||
// rather than reimplemented, so the gate cannot pass against a
|
||||
// formatter that production never uses.
|
||||
unsafe { ownership::tag_this_process_alsa_audio() };
|
||||
|
||||
let (player, _status) = ClipPlayer::new(1.0);
|
||||
// Six seconds of silence: long enough for the poll, inaudible.
|
||||
player.play([0u8; 32], live_test::silent_wav(6));
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::CLIP_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||
player.stop();
|
||||
|
||||
let (name, owned) = found.unwrap_or_else(|| {
|
||||
panic!("no live clip-player node named {prefix:?} appeared within 5s")
|
||||
});
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node, not just carrier 2"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,17 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Owns a loaded `module-echo-cancel` instance; unloads it on drop so the virtual
|
||||
/// nodes never leak past the call that created them.
|
||||
pub struct EchoCancelGuard {
|
||||
module_index: String,
|
||||
module_index: u64,
|
||||
source_name: String,
|
||||
sink_name: String,
|
||||
}
|
||||
|
||||
impl EchoCancelGuard {
|
||||
/// The pactl module identity PixelPass validates in desktop-excluding mode.
|
||||
pub fn module_index(&self) -> u64 {
|
||||
self.module_index
|
||||
}
|
||||
|
||||
pub fn source_name(&self) -> &str {
|
||||
&self.source_name
|
||||
}
|
||||
@@ -52,7 +57,7 @@ impl Drop for EchoCancelGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(&self.module_index)
|
||||
.arg(self.module_index.to_string())
|
||||
.output();
|
||||
crate::log_msg(&format!(
|
||||
"Echo cancel: unloaded module {}",
|
||||
@@ -103,10 +108,10 @@ pub fn enable(
|
||||
));
|
||||
}
|
||||
|
||||
let module_index = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if module_index.parse::<u64>().is_err() {
|
||||
return Err(format!("unexpected pactl output: {module_index:?}"));
|
||||
}
|
||||
let raw_module_index = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let module_index = raw_module_index
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("unexpected pactl output: {raw_module_index:?}"))?;
|
||||
let guard = EchoCancelGuard {
|
||||
module_index,
|
||||
source_name,
|
||||
|
||||
@@ -65,6 +65,10 @@ pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
// The cross-repo ownership tag (plan §5.1). Platform-neutral on purpose: the
|
||||
// carriers only matter on PipeWire, but the literals are a wire contract and
|
||||
// their test must run on every platform so a rename can't pass CI elsewhere.
|
||||
pub mod ownership;
|
||||
pub mod pan;
|
||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
use crate::audio::ownership;
|
||||
use crate::audio::{AudioBackend, AudioError};
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
@@ -371,6 +372,11 @@ fn run_playback(
|
||||
mainloop_clone.quit();
|
||||
});
|
||||
|
||||
// Ownership tag, both carriers (`crate::audio::ownership`, plan §5.1).
|
||||
// This is the node that carries the far end's voice, so it is the single
|
||||
// most important thing for pixelpass to refuse to fan out: sharing it
|
||||
// would send the call back to the person already speaking on it.
|
||||
let owned_node_name = ownership::owned_node_name(ownership::NATIVE_PLAYBACK_ROLE);
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
@@ -379,6 +385,19 @@ fn run_playback(
|
||||
// buffer — the real fix is the explicit Buffers param below — but it
|
||||
// expresses the intended quantum for any node that honours it.
|
||||
*pw::keys::NODE_LATENCY => "1024/48000",
|
||||
ownership::OWNED_PROP_KEY => ownership::OWNED_PROP_VALUE,
|
||||
// Set explicitly rather than relying on the stream name passed to
|
||||
// `StreamBox::new` below: props win over that name, and this one has
|
||||
// to be exact.
|
||||
*pw::keys::NODE_NAME => owned_node_name.as_str(),
|
||||
// Measured: this stream sets neither `application.name` nor a
|
||||
// description, so a mixer falls back to `node.name` — which the line
|
||||
// above just turned into an internal identifier. The plan's rule is
|
||||
// that the ownership prefix must not reach `node.description`; a
|
||||
// human label there is what keeps that rule's *intent* (mixers stay
|
||||
// readable) true for our own stream, exactly as mpv's own
|
||||
// description does for the spawned players.
|
||||
*pw::keys::NODE_DESCRIPTION => "PeerSpeak",
|
||||
};
|
||||
if let Some(target) = target_node {
|
||||
props.insert("node.target", target);
|
||||
@@ -637,6 +656,62 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
use std::{sync::mpsc, thread};
|
||||
|
||||
/// Phase-1 exit gate, native-playback half (impl plan §3): the stream
|
||||
/// that carries the far end's voice appears on the graph with **both**
|
||||
/// ownership carriers, and still with the `Communication` media role.
|
||||
///
|
||||
/// The third and most important of the three tagged paths — this is the
|
||||
/// node whose audio, if fanned out, would send the call back to whoever
|
||||
/// is speaking on it.
|
||||
///
|
||||
/// Feeds silence, so the gate is inaudible. Live: needs PipeWire and
|
||||
/// `pw-dump`. `cargo test --lib -- --ignored native_playback`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||
fn native_playback_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
use crate::audio::{AudioBackend, PLAYBACK_TARGET_SAMPLES};
|
||||
|
||||
let backend = super::PipeWireBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
backend
|
||||
.start_playback(rx, None, ring_fill.clone())
|
||||
.expect("playback starts");
|
||||
|
||||
// Keep the ring fed so the node stays live for the whole poll; the
|
||||
// stream is created on connect, but a starved one is not a fair test
|
||||
// of what a real call looks like on the graph.
|
||||
let feeder = thread::spawn(move || {
|
||||
let silence = vec![0i16; 960 * 2];
|
||||
for _ in 0..300 {
|
||||
if ring_fill.load(Ordering::Relaxed) < PLAYBACK_TARGET_SAMPLES
|
||||
&& tx.send(silence.clone()).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::NATIVE_PLAYBACK_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||
let _ = backend.stop();
|
||||
let _ = feeder.join();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node, not just carrier 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_in_range_is_honored() {
|
||||
// The graph's requested quantum is produced verbatim when it fits.
|
||||
|
||||
@@ -478,6 +478,14 @@ pub struct AppConfig {
|
||||
pub custom_sound_mic_toggle: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_reconnect_failed: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_chat_sent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_chat_received: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_contact_online: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_contact_offline: Option<String>,
|
||||
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
|
||||
/// gates ALL chimes; these let the user silence individual events while the
|
||||
/// master stays on. A chime plays only if the master AND its flag are true.
|
||||
@@ -498,6 +506,14 @@ pub struct AppConfig {
|
||||
pub sound_mic_toggle_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_reconnect_failed_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_chat_sent_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_chat_received_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_contact_online_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_contact_offline_enabled: bool,
|
||||
/// Optional override for the `pixelpass` binary location (screen share).
|
||||
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
||||
#[serde(default)]
|
||||
@@ -601,6 +617,10 @@ impl Default for AppConfig {
|
||||
custom_sound_self_leave: None,
|
||||
custom_sound_mic_toggle: None,
|
||||
custom_sound_reconnect_failed: None,
|
||||
custom_sound_chat_sent: None,
|
||||
custom_sound_chat_received: None,
|
||||
custom_sound_contact_online: None,
|
||||
custom_sound_contact_offline: None,
|
||||
sound_self_join_enabled: true,
|
||||
sound_peer_join_enabled: true,
|
||||
sound_peer_leave_enabled: true,
|
||||
@@ -609,6 +629,10 @@ impl Default for AppConfig {
|
||||
sound_self_leave_enabled: true,
|
||||
sound_mic_toggle_enabled: true,
|
||||
sound_reconnect_failed_enabled: true,
|
||||
sound_chat_sent_enabled: true,
|
||||
sound_chat_received_enabled: true,
|
||||
sound_contact_online_enabled: true,
|
||||
sound_contact_offline_enabled: true,
|
||||
pixelpass_path: None,
|
||||
screen_share: ScreenShareSettings::default(),
|
||||
recents: Vec::new(),
|
||||
@@ -639,6 +663,10 @@ impl AppConfig {
|
||||
Sound::SelfLeave => self.sound_self_leave_enabled,
|
||||
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
||||
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
|
||||
Sound::ChatSent => self.sound_chat_sent_enabled,
|
||||
Sound::ChatReceived => self.sound_chat_received_enabled,
|
||||
Sound::ContactOnline => self.sound_contact_online_enabled,
|
||||
Sound::ContactOffline => self.sound_contact_offline_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +681,10 @@ impl AppConfig {
|
||||
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
|
||||
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
||||
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
|
||||
Sound::ChatSent => self.sound_chat_sent_enabled = enabled,
|
||||
Sound::ChatReceived => self.sound_chat_received_enabled = enabled,
|
||||
Sound::ContactOnline => self.sound_contact_online_enabled = enabled,
|
||||
Sound::ContactOffline => self.sound_contact_offline_enabled = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,6 +972,10 @@ mod tests {
|
||||
assert!(deserialized.custom_sound_self_leave.is_none());
|
||||
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
||||
assert!(deserialized.custom_sound_reconnect_failed.is_none());
|
||||
assert!(deserialized.custom_sound_chat_sent.is_none());
|
||||
assert!(deserialized.custom_sound_chat_received.is_none());
|
||||
assert!(deserialized.custom_sound_contact_online.is_none());
|
||||
assert!(deserialized.custom_sound_contact_offline.is_none());
|
||||
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
|
||||
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
|
||||
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
|
||||
|
||||
+23
-10
@@ -126,17 +126,26 @@ pub enum CoreCommand {
|
||||
ListAudioApps,
|
||||
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
|
||||
/// on our presence so the room can watch. No-op when not in a call.
|
||||
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
||||
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
||||
/// whole desktop audio (the legacy behavior).
|
||||
/// `audio` is typed so legacy whole-desktop, desktop-excluding, and strict
|
||||
/// per-app capture remain distinct across the UI/core boundary.
|
||||
StartScreenShare {
|
||||
audio_app: Option<String>,
|
||||
audio: crate::screenshare::ShareAudioSelection,
|
||||
settings: ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
},
|
||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||
/// ticket. No-op when not sharing.
|
||||
StopScreenShare,
|
||||
/// **Core-internal.** The running pixelpass host's stdout ended — the
|
||||
/// process died (or its event stream broke), so the share identified by
|
||||
/// `generation` is over: reap the child, pull the ticket off presence, and
|
||||
/// tell the user. Synthesized by the core's own notice-forwarder task; the
|
||||
/// UI never sends it. `generation` scopes the fault to one specific host
|
||||
/// spawn, so a stale fault (the user already stopped, or started a new
|
||||
/// share) is ignored rather than tearing down the wrong share.
|
||||
ScreenShareHostFault {
|
||||
generation: u64,
|
||||
},
|
||||
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
||||
/// open it in a local player.
|
||||
ViewShare {
|
||||
@@ -266,11 +275,12 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||
| CoreCommand::SetPixelpassPath(_)
|
||||
| CoreCommand::ListAudioApps
|
||||
| CoreCommand::StartScreenShare {
|
||||
audio_app: _,
|
||||
audio: _,
|
||||
settings: _,
|
||||
quality: _,
|
||||
}
|
||||
| CoreCommand::StopScreenShare
|
||||
| CoreCommand::ScreenShareHostFault { generation: _ }
|
||||
| CoreCommand::ViewShare {
|
||||
ticket: _,
|
||||
settings: _,
|
||||
@@ -358,11 +368,12 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
||||
| CoreCommand::SetPixelpassPath(_)
|
||||
| CoreCommand::ListAudioApps
|
||||
| CoreCommand::StartScreenShare {
|
||||
audio_app: _,
|
||||
audio: _,
|
||||
settings: _,
|
||||
quality: _,
|
||||
}
|
||||
| CoreCommand::StopScreenShare
|
||||
| CoreCommand::ScreenShareHostFault { generation: _ }
|
||||
| CoreCommand::ViewShare {
|
||||
ticket: _,
|
||||
settings: _,
|
||||
@@ -499,13 +510,12 @@ pub enum UiEvent {
|
||||
},
|
||||
/// The apps currently producing audio, for the screen-share audio picker
|
||||
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
||||
/// playing or enumeration isn't available. `app_audio_supported` reports
|
||||
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
||||
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
||||
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
||||
/// playing or enumeration isn't available. The two support bits are
|
||||
/// independent and belong to the exact resolved PixelPass binary.
|
||||
AudioAppsListed {
|
||||
apps: Vec<String>,
|
||||
app_audio_supported: bool,
|
||||
desktop_audio_exclusion_supported: bool,
|
||||
},
|
||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||
ScreenShareStarted,
|
||||
@@ -516,6 +526,9 @@ pub enum UiEvent {
|
||||
/// run viewers currently hear silence. The UI shows a transient warning while
|
||||
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
|
||||
ShareAudioActive(bool),
|
||||
/// One desktop-excluding status parsed from PixelPass and forwarded without
|
||||
/// translating it into a separately maintained PeerSpeak enum.
|
||||
ShareAudioExclusionStatus(crate::screenshare::AudioExclusionStatus),
|
||||
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
||||
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
||||
/// sender-stamped timestamp relative to this machine's clock.
|
||||
|
||||
+400
-95
@@ -4,6 +4,7 @@ pub mod fetchbudget;
|
||||
pub mod jitter;
|
||||
pub mod messages;
|
||||
mod recovery;
|
||||
mod teardown;
|
||||
|
||||
use crate::audio::eq::{Eq, EqSettings};
|
||||
use crate::audio::{AudioBackend, PlatformAudioBackend};
|
||||
@@ -677,31 +678,33 @@ struct ActiveSession {
|
||||
recovery_terminal_task: tokio::task::JoinHandle<()>,
|
||||
grace_timers: GraceTimers,
|
||||
transport: Arc<IrohTransport>,
|
||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||
#[cfg(target_os = "linux")]
|
||||
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
|
||||
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
||||
/// also dies if the session is dropped without an explicit stop).
|
||||
screenshare_host: Option<tokio::process::Child>,
|
||||
/// pixelpass viewer children we spawned to watch peers' shares, each paired
|
||||
/// with the share ticket it's viewing so a re-watch of the same share can
|
||||
/// replace (not stack) its player. Killed on session teardown (each also
|
||||
/// self-exits when its player window closes).
|
||||
screenshare_viewers: Vec<(String, tokio::process::Child)>,
|
||||
/// The screen-share children and the echo-cancel module, held together
|
||||
/// because their **destruction order** is load-bearing: the AEC module must
|
||||
/// not unload while a pixelpass host is alive and fanning out (design v3.4
|
||||
/// §7.1). `teardown` owns that ordering; see `core::teardown`.
|
||||
teardown: SessionTeardown,
|
||||
}
|
||||
|
||||
/// The session's teardown set, with the echo-cancel guard the platform actually
|
||||
/// has. On non-Linux there is no AEC module, and `Infallible` makes that
|
||||
/// structural — the `Option` cannot be `Some`.
|
||||
#[cfg(target_os = "linux")]
|
||||
type SessionTeardown = teardown::ScreenshareTeardown<
|
||||
tokio::process::Child,
|
||||
crate::audio::echo_cancel::EchoCancelGuard,
|
||||
>;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
type SessionTeardown =
|
||||
teardown::ScreenshareTeardown<tokio::process::Child, std::convert::Infallible>;
|
||||
|
||||
impl ActiveSession {
|
||||
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
|
||||
crate::log_msg("ActiveSession::shutdown started");
|
||||
// Tear down any screen-share children first so the host stops streaming
|
||||
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
|
||||
// see the stream end without waiting on drop ordering).
|
||||
if let Some(mut host) = self.screenshare_host.take() {
|
||||
let _ = host.kill().await;
|
||||
}
|
||||
for (_, mut viewer) in self.screenshare_viewers.drain(..) {
|
||||
let _ = viewer.kill().await;
|
||||
}
|
||||
// promptly, and so they are dead *and reaped* well before the AEC guard
|
||||
// unloads at the end of this function (design v3.4 §7.1). Drop ordering
|
||||
// is the backstop for the unwind path; this is the path we control.
|
||||
self.teardown.shutdown_children().await;
|
||||
self.datagram_task.abort();
|
||||
self.mixer_task.abort();
|
||||
self.event_task.abort();
|
||||
@@ -726,8 +729,9 @@ impl ActiveSession {
|
||||
|
||||
// Unload the echo-cancel module now that the audio streams releasing its
|
||||
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
|
||||
#[cfg(target_os = "linux")]
|
||||
drop(self.echo_cancel);
|
||||
// The screen-share children were killed *and reaped* at the top of this
|
||||
// function, so nothing pixelpass-side is alive to see the module vanish.
|
||||
drop(self.teardown);
|
||||
|
||||
crate::log_msg("Leaving room...");
|
||||
let _ = self.room_state.leave().await;
|
||||
@@ -1237,6 +1241,11 @@ const PING_INTERVAL: Duration = Duration::from_secs(15);
|
||||
/// Delay before the FIRST presence pass, so the endpoint's background `online()`
|
||||
/// has a moment to finish (otherwise the first probes fail and friends flash offline).
|
||||
const PING_STARTUP_DELAY: Duration = Duration::from_secs(3);
|
||||
/// How quickly the core polls owned PixelPass viewer children for natural exit.
|
||||
/// A viewer normally exits when the remote host stops sharing; without this
|
||||
/// independent tick it remains an unreaped zombie until another Watch click or
|
||||
/// the entire call ends.
|
||||
const VIEWER_REAP_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// One outbound presence-refresh pass (W7 B2): probe every friend and emit a
|
||||
/// *definitive* status for each, so the UI self-heals every pass instead of only
|
||||
@@ -1280,6 +1289,42 @@ async fn probe_friends_once(
|
||||
}
|
||||
}
|
||||
|
||||
fn ui_event_from_pixelpass_event(event: crate::screenshare::PixelpassEvent) -> Option<UiEvent> {
|
||||
match event {
|
||||
crate::screenshare::PixelpassEvent::AppAudioRouted => Some(UiEvent::ShareAudioActive(true)),
|
||||
crate::screenshare::PixelpassEvent::AppAudioLost => Some(UiEvent::ShareAudioActive(false)),
|
||||
crate::screenshare::PixelpassEvent::AudioExclusion(status) => {
|
||||
Some(UiEvent::ShareAudioExclusionStatus(status))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry the actual parsed PixelPass event value across the notice channel to
|
||||
/// the UI. `Eof` remains a generation-scoped core fault rather than a UI event.
|
||||
async fn forward_host_notices(
|
||||
mut notices: mpsc::UnboundedReceiver<crate::screenshare::HostNotice>,
|
||||
ui_tx: mpsc::Sender<UiEvent>,
|
||||
fault_tx: mpsc::UnboundedSender<u64>,
|
||||
generation: u64,
|
||||
) {
|
||||
while let Some(notice) = notices.recv().await {
|
||||
match notice {
|
||||
crate::screenshare::HostNotice::Event(event) => {
|
||||
if let Some(event) = ui_event_from_pixelpass_event(event)
|
||||
&& ui_tx.send(event).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
crate::screenshare::HostNotice::Eof => {
|
||||
let _ = fault_tx.send(generation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_core_loop(
|
||||
mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>,
|
||||
coalesce: CoalesceStore,
|
||||
@@ -1393,10 +1438,29 @@ async fn run_core_loop(
|
||||
// later opt-in can immediately publish whatever is currently running.
|
||||
let mut current_game: Option<crate::game::DetectedGame> = None;
|
||||
let mut network_mode = NetworkMode::default();
|
||||
// Pixelpass binary override (config), and the ticket of our own active screen
|
||||
// share (rides our presence so the room — incl. late joiners — can watch).
|
||||
// Pixelpass binary override (config), and our own active screen share: the
|
||||
// ticket rides our presence so the room — incl. late joiners — can watch,
|
||||
// and the generation ties host-fault notices to this specific host spawn
|
||||
// (see `ScreenShareHostFault`). One variable on purpose: the ticket and the
|
||||
// generation must appear and vanish together, or a stale fault could tear
|
||||
// down a share it doesn't belong to.
|
||||
let mut pixelpass_override: Option<String> = None;
|
||||
let mut current_sharing: Option<String> = None;
|
||||
// Capability results are meaningful only for the exact resolved binary
|
||||
// path that produced them. A changed override/PATH resolution must be
|
||||
// re-probed before any capability-gated argv is constructed.
|
||||
let mut pixelpass_capabilities: Option<crate::screenshare::ProbedPixelpassCapabilities> = None;
|
||||
struct ActiveShare {
|
||||
generation: u64,
|
||||
ticket: String,
|
||||
}
|
||||
let mut current_sharing: Option<ActiveShare> = None;
|
||||
// Monotonic per-spawn counter feeding `ActiveShare::generation`.
|
||||
let mut share_generations: u64 = 0;
|
||||
// Host faults re-enter the loop here (the notice-forwarder task can't touch
|
||||
// loop state). The loop keeps `host_fault_tx` to clone into each share's
|
||||
// forwarder, so this channel never closes — the select arm's `Some` pattern
|
||||
// is total in practice and a closed-channel branch would be unreachable.
|
||||
let (host_fault_tx, mut host_fault_rx) = mpsc::unbounded_channel::<u64>();
|
||||
|
||||
let mut active_session: Option<ActiveSession> = None;
|
||||
// Standalone capture-only mic meter, live only when no session exists.
|
||||
@@ -1506,11 +1570,18 @@ async fn run_core_loop(
|
||||
PING_INTERVAL,
|
||||
);
|
||||
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut viewer_reap_interval = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + VIEWER_REAP_INTERVAL,
|
||||
VIEWER_REAP_INTERVAL,
|
||||
);
|
||||
viewer_reap_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
let cmd = tokio::select! {
|
||||
biased;
|
||||
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
|
||||
Some(cmd) => cmd,
|
||||
// Every `CoreController`/`CoreCommandSender` is gone — the UI has
|
||||
// dropped the core. Teardown happens once, after the loop.
|
||||
None => break,
|
||||
},
|
||||
maybe_wake = besteffort_wake_rx.recv() => match maybe_wake {
|
||||
@@ -1529,8 +1600,33 @@ async fn run_core_loop(
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
// ⚠️ UNREACHABLE BY CONSTRUCTION, twice over — do not mistake this
|
||||
// for a live teardown path (phase 0b finding, 2026-07-26):
|
||||
// 1. this function owns `besteffort_wake_tx` (cloned at the
|
||||
// `CoreController::new` spawn site, used just above for the
|
||||
// `has_more` re-arm), so the channel can never close while
|
||||
// this loop is running;
|
||||
// 2. even without that, every holder of a wake sender —
|
||||
// `CoreController` and `CoreCommandSender` — holds
|
||||
// `reliable_tx` too, and the `biased` select polls that one
|
||||
// first, so the reliable arm always wins the race to exit.
|
||||
// Teardown is hoisted after the loop, so if this arm is ever made
|
||||
// reachable it is already covered — nothing to add here.
|
||||
None => break,
|
||||
},
|
||||
// A share's notice-forwarder task reported the host's stdout ended.
|
||||
// The `Some` pattern is total: this loop owns `host_fault_tx` (see
|
||||
// its declaration), so the channel cannot close — no `None` arm is
|
||||
// written because one would be unreachable by construction.
|
||||
Some(generation) = host_fault_rx.recv() => {
|
||||
CoreCommand::ScreenShareHostFault { generation }
|
||||
}
|
||||
_ = viewer_reap_interval.tick() => {
|
||||
if let Some(session) = &mut active_session {
|
||||
session.teardown.sweep_exited_viewers();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
game_change = next_game_change(&mut game_rx) => {
|
||||
// The detector worker published a new debounced game (or `None`).
|
||||
let Some(detected) = game_change else {
|
||||
@@ -1549,7 +1645,7 @@ async fn run_core_loop(
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
@@ -1693,6 +1789,13 @@ async fn run_core_loop(
|
||||
net.file_router.clear();
|
||||
*current_room.lock().unwrap() = None;
|
||||
}
|
||||
// Any advertised share died with that session — deliberately —
|
||||
// so retire it HERE, before the invalid-ticket early exit below
|
||||
// can skip it. Left populated, the killed host's stdout EOF
|
||||
// would pass the ScreenShareHostFault staleness gate and
|
||||
// surface as a spurious "ended unexpectedly" error on top of
|
||||
// the ticket error (Gemini review of S2, P2-1).
|
||||
current_sharing = None;
|
||||
|
||||
// If a network-mode / identity change was deferred while a call was
|
||||
// active, rebuild the persistent stack now — after the old session is
|
||||
@@ -1788,8 +1891,8 @@ async fn run_core_loop(
|
||||
secret_key.clone(),
|
||||
));
|
||||
|
||||
// Fresh join starts not sharing; clear any stale share ticket.
|
||||
current_sharing = None;
|
||||
// (The share was already retired beside the session teardown
|
||||
// above; a fresh join starts not sharing.)
|
||||
let self_state =
|
||||
presence.to_state(is_muted.load(Ordering::Relaxed), endpoint.addr(), None);
|
||||
|
||||
@@ -2726,9 +2829,9 @@ async fn run_core_loop(
|
||||
grace_timers,
|
||||
transport: transport.clone(),
|
||||
#[cfg(target_os = "linux")]
|
||||
echo_cancel: echo_cancel_guard,
|
||||
screenshare_host: None,
|
||||
screenshare_viewers: Vec::<(String, tokio::process::Child)>::new(),
|
||||
teardown: SessionTeardown::new(echo_cancel_guard),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
teardown: SessionTeardown::new(None),
|
||||
};
|
||||
|
||||
let self_id = endpoint.id().to_string();
|
||||
@@ -2810,8 +2913,11 @@ async fn run_core_loop(
|
||||
is_muted.store(new_state, Ordering::Relaxed);
|
||||
|
||||
if let Some(session) = &active_session {
|
||||
let self_state =
|
||||
presence.to_state(new_state, net.endpoint.addr(), current_sharing.clone());
|
||||
let self_state = presence.to_state(
|
||||
new_state,
|
||||
net.endpoint.addr(),
|
||||
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
}
|
||||
@@ -2824,7 +2930,7 @@ async fn run_core_loop(
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
@@ -3137,7 +3243,7 @@ async fn run_core_loop(
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
@@ -3343,7 +3449,7 @@ async fn run_core_loop(
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
@@ -3363,39 +3469,53 @@ async fn run_core_loop(
|
||||
|
||||
CoreCommand::SetPixelpassPath(path) => {
|
||||
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
||||
pixelpass_capabilities = None;
|
||||
}
|
||||
|
||||
CoreCommand::ListAudioApps => {
|
||||
// Probe whether this pixelpass supports `--strict-audio` before
|
||||
// offering per-app capture: an older binary would reject the flag
|
||||
// and hard-fail the share (audit P2). When unsupported (or
|
||||
// pixelpass is missing), skip enumeration and let the picker show
|
||||
// whole-desktop audio only — never a best-effort `--app` that
|
||||
// would reopen the A23 echo.
|
||||
let app_audio_supported =
|
||||
// Probe the versioned response from the exact binary selected
|
||||
// for this picker. The help fallback can recover legacy strict
|
||||
// per-app support, but never desktop exclusion.
|
||||
let (capabilities, apps) =
|
||||
match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||
Some(bin) => crate::screenshare::supports_strict_audio(&bin).await,
|
||||
None => false,
|
||||
Some(bin) => {
|
||||
let capabilities =
|
||||
crate::screenshare::probe_pixelpass_capabilities(&bin).await;
|
||||
pixelpass_capabilities =
|
||||
Some(crate::screenshare::ProbedPixelpassCapabilities {
|
||||
binary: bin,
|
||||
capabilities,
|
||||
});
|
||||
let apps = if capabilities.strict_app_audio {
|
||||
crate::screenshare::list_audio_apps().await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(capabilities, apps)
|
||||
}
|
||||
None => {
|
||||
pixelpass_capabilities = None;
|
||||
(
|
||||
crate::screenshare::PixelpassCapabilities::default(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
};
|
||||
let apps = if app_audio_supported {
|
||||
crate::screenshare::list_audio_apps().await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::AudioAppsListed {
|
||||
apps,
|
||||
app_audio_supported,
|
||||
app_audio_supported: capabilities.strict_app_audio,
|
||||
desktop_audio_exclusion_supported: capabilities.desktop_audio_exclusion,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
CoreCommand::StartScreenShare {
|
||||
audio_app,
|
||||
audio,
|
||||
settings,
|
||||
quality,
|
||||
} => {
|
||||
let Some(session) = &mut active_session else {
|
||||
let Some(session) = active_session.as_ref() else {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::Error(
|
||||
"Join a call before sharing your screen".into(),
|
||||
@@ -3403,7 +3523,7 @@ async fn run_core_loop(
|
||||
.await;
|
||||
continue;
|
||||
};
|
||||
if session.screenshare_host.is_some() {
|
||||
if session.teardown.is_sharing() {
|
||||
continue; // already sharing
|
||||
}
|
||||
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||
@@ -3417,46 +3537,85 @@ async fn run_core_loop(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Forward pixelpass `app_audio` events (only emitted when an app
|
||||
// is selected) to the UI so it can warn when the chosen app's
|
||||
// audio drops. The channel closes when the host dies (drain hits
|
||||
// EOF), ending the forwarder task on its own.
|
||||
let notices = audio_app.as_deref().map(|_| {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<
|
||||
crate::screenshare::PixelpassEvent,
|
||||
>();
|
||||
let ui_tx_notices = ui_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(ev) = rx.recv().await {
|
||||
let active = match ev {
|
||||
crate::screenshare::PixelpassEvent::AppAudioRouted => true,
|
||||
crate::screenshare::PixelpassEvent::AppAudioLost => false,
|
||||
_ => continue,
|
||||
};
|
||||
if ui_tx_notices
|
||||
.send(UiEvent::ShareAudioActive(active))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
// The picker probe is bound to its resolved binary. If the
|
||||
// override/PATH now resolves elsewhere, immediately re-probe
|
||||
// before constructing any capability-gated argv and fail closed
|
||||
// when the selected feature is absent.
|
||||
if !matches!(
|
||||
audio,
|
||||
crate::screenshare::ShareAudioSelection::DesktopShared
|
||||
) {
|
||||
let capabilities = crate::screenshare::capabilities_for_resolved_binary(
|
||||
&bin,
|
||||
&mut pixelpass_capabilities,
|
||||
)
|
||||
.await;
|
||||
let unsupported = match &audio {
|
||||
crate::screenshare::ShareAudioSelection::Application(_)
|
||||
if !capabilities.strict_app_audio =>
|
||||
{
|
||||
Some(
|
||||
"This PixelPass does not support strict per-app audio. Reopen the picker or update PixelPass.",
|
||||
)
|
||||
}
|
||||
});
|
||||
tx
|
||||
crate::screenshare::ShareAudioSelection::DesktopExcluding
|
||||
if !capabilities.desktop_audio_exclusion =>
|
||||
{
|
||||
Some(
|
||||
"This PixelPass does not support desktop audio exclusion. Update PixelPass or choose another audio source.",
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(message) = unsupported {
|
||||
let _ = ui_tx.send(UiEvent::Error(message.into())).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let session = active_session
|
||||
.as_mut()
|
||||
.expect("session presence checked before PixelPass probe");
|
||||
let aec_module_index = matches!(
|
||||
audio,
|
||||
crate::screenshare::ShareAudioSelection::DesktopExcluding
|
||||
)
|
||||
.then(|| {
|
||||
session
|
||||
.teardown
|
||||
.echo_cancel()
|
||||
.map(crate::audio::echo_cancel::EchoCancelGuard::module_index)
|
||||
})
|
||||
.flatten();
|
||||
// Every share gets a notice forwarder — not just app-audio ones.
|
||||
// App-audio and desktop-exclusion events become UI state, and
|
||||
// the drain's terminal `Eof` becomes a generation-scoped fault.
|
||||
share_generations += 1;
|
||||
let generation = share_generations;
|
||||
let (notices_tx, notices_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::HostNotice>();
|
||||
let ui_tx_notices = ui_tx.clone();
|
||||
let fault_tx = host_fault_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
forward_host_notices(notices_rx, ui_tx_notices, fault_tx, generation).await;
|
||||
});
|
||||
match crate::screenshare::spawn_host(
|
||||
&bin,
|
||||
audio_app.as_deref(),
|
||||
&audio,
|
||||
aec_module_index,
|
||||
&settings,
|
||||
quality,
|
||||
notices,
|
||||
notices_tx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((child, ticket)) => {
|
||||
crate::log_msg("Screen share host started");
|
||||
session.screenshare_host = Some(child);
|
||||
current_sharing = Some(ticket.clone());
|
||||
session.teardown.set_host(child);
|
||||
current_sharing = Some(ActiveShare {
|
||||
generation,
|
||||
ticket: ticket.clone(),
|
||||
});
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
@@ -3476,9 +3635,24 @@ async fn run_core_loop(
|
||||
CoreCommand::StopScreenShare => {
|
||||
current_sharing = None;
|
||||
if let Some(session) = &mut active_session {
|
||||
if let Some(mut child) = session.screenshare_host.take() {
|
||||
let _ = child.kill().await;
|
||||
crate::log_msg("Screen share host stopped");
|
||||
match session.teardown.stop_host().await {
|
||||
None => {}
|
||||
Some(teardown::StopOutcome::Reaped) => {
|
||||
crate::log_msg("Screen share host stopped");
|
||||
}
|
||||
// We gave up waiting rather than freeze the client, so
|
||||
// pixelpass may still be alive and serving. Saying
|
||||
// "stopped" and nothing else would be a lie the user
|
||||
// cannot see through (round-16 review, P3-2).
|
||||
Some(teardown::StopOutcome::Unconfirmed) => {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::Error(
|
||||
"Couldn't confirm the screen-share process exited — \
|
||||
it may still be sharing. Check for a stray pixelpass."
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
@@ -3490,6 +3664,66 @@ async fn run_core_loop(
|
||||
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
||||
}
|
||||
|
||||
CoreCommand::ScreenShareHostFault { generation } => {
|
||||
// Stale unless it names the share we are advertising RIGHT NOW.
|
||||
// Every deliberate end of a share (StopScreenShare, Leave, a
|
||||
// fresh Join) clears `current_sharing` before or while reaping
|
||||
// the child, and the reaped child's stdout EOF then arrives
|
||||
// here late — dropping it is the correct handling, not an edge
|
||||
// case. A mismatched generation likewise: that fault belongs to
|
||||
// an older spawn than the share now running.
|
||||
let stale = current_sharing.as_ref().map(|s| s.generation) != Some(generation);
|
||||
if stale {
|
||||
continue;
|
||||
}
|
||||
crate::log_msg(
|
||||
"Screen share host died (stdout EOF with the share still advertised)",
|
||||
);
|
||||
current_sharing = None;
|
||||
// Pull the ticket off presence FIRST, before the reap: if the
|
||||
// child only closed stdout and lives on, `stop_host` burns the
|
||||
// full stop grace before the SIGKILL fallback, and for that
|
||||
// whole window peers would still see (and click Watch on) a
|
||||
// share whose host is already gone (Gemini S2-merge review,
|
||||
// P2-1).
|
||||
if let Some(session) = &mut active_session {
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
None,
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
// Stopped next — it clears the UI's sharing state — so the
|
||||
// local UI also stops saying "sharing" before the reap wait,
|
||||
// and the error explaining why comes only after, so the user
|
||||
// is never left looking at a "sharing" UI with an error
|
||||
// beside it.
|
||||
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
||||
let mut unconfirmed = false;
|
||||
if let Some(session) = &mut active_session {
|
||||
// The child is usually already dead, so this confirms the
|
||||
// reap immediately; if it merely closed stdout and lives
|
||||
// on, this is the SIGINT → grace → SIGKILL path. Either
|
||||
// way the dead-or-dying child leaves the teardown slot, so
|
||||
// `is_sharing` stops lying.
|
||||
unconfirmed = matches!(
|
||||
session.teardown.stop_host().await,
|
||||
Some(teardown::StopOutcome::Unconfirmed)
|
||||
);
|
||||
}
|
||||
let detail = if unconfirmed {
|
||||
" Its process also couldn't be confirmed dead — check for a stray pixelpass."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::Error(format!(
|
||||
"Screen share ended unexpectedly — pixelpass exited.{detail}"
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
|
||||
CoreCommand::ViewShare { ticket, settings } => {
|
||||
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||
Some(b) => b,
|
||||
@@ -3505,16 +3739,12 @@ async fn run_core_loop(
|
||||
if let Some(session) = &mut active_session {
|
||||
// Drop viewers whose player window has already closed so the
|
||||
// list only tracks live players.
|
||||
session
|
||||
.screenshare_viewers
|
||||
.retain_mut(|(_, child)| !matches!(child.try_wait(), Ok(Some(_))));
|
||||
session.teardown.sweep_exited_viewers();
|
||||
// One player per share: a second Watch click on a share we're
|
||||
// already viewing is a retry (usually because the first window
|
||||
// froze), so replace the existing player rather than stacking a
|
||||
// second mpv — two players would double the shared audio.
|
||||
if let Some(pos) = replace_viewer_index(&session.screenshare_viewers, &ticket) {
|
||||
let (_, mut old) = session.screenshare_viewers.remove(pos);
|
||||
let _ = old.kill().await;
|
||||
if session.teardown.replace_viewer(&ticket).await {
|
||||
crate::log_msg("Screen share viewer replaced (re-watch)");
|
||||
}
|
||||
}
|
||||
@@ -3522,7 +3752,7 @@ async fn run_core_loop(
|
||||
Ok(child) => {
|
||||
crate::log_msg("Screen share viewer started");
|
||||
if let Some(session) = &mut active_session {
|
||||
session.screenshare_viewers.push((ticket, child));
|
||||
session.teardown.push_viewer(ticket, child);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -3535,6 +3765,21 @@ async fn run_core_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// The command loop has exited, by any route. Tear the session down
|
||||
// explicitly rather than letting it drop on the way out of this function:
|
||||
// an implicit drop unloads the echo-cancel module without first reaping the
|
||||
// pixelpass host (design v3.4 §7.2, decision D4).
|
||||
//
|
||||
// This sits *after* the loop rather than in the close arm on purpose. The
|
||||
// impl plan pinned one teardown per channel-close arm, but the best-effort
|
||||
// wake arm is unreachable by construction (see the comment at that arm), so
|
||||
// that shape would have duplicated teardown to cover one live path and one
|
||||
// dead one. Here every `break` is covered structurally, including any added
|
||||
// later. Adjudication: impl plan §10, 2026-07-26.
|
||||
if let Some(session) = active_session.take() {
|
||||
session.shutdown(audio_backend.clone()).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3553,10 +3798,11 @@ mod tests {
|
||||
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
|
||||
NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained,
|
||||
apply_peer_volume, apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop,
|
||||
frame_level, mix_frames, mix_stereo_frames, next_game_change, rebuild_with_fallback,
|
||||
replace_viewer_index, send_playback_frame, should_auto_fetch, stereo_to_mono,
|
||||
forward_host_notices, frame_level, mix_frames, mix_stereo_frames, next_game_change,
|
||||
rebuild_with_fallback, replace_viewer_index, send_playback_frame, should_auto_fetch,
|
||||
stereo_to_mono,
|
||||
};
|
||||
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
|
||||
use crate::core::messages::{CoalesceKey, CoreCommand, UiEvent, coalesce_key};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::mpsc::sync_channel;
|
||||
use std::time::Duration;
|
||||
@@ -3565,6 +3811,65 @@ mod tests {
|
||||
iroh::SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_exclusion_events_causally_cross_the_host_notice_channel_to_ui() {
|
||||
use crate::screenshare::{AudioExclusionStatus, HostNotice, parse_pixelpass_event};
|
||||
|
||||
let lines = [
|
||||
r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967303,"reason":"port-exclusive"}"#,
|
||||
r#"{"event":"stream_status_cleared","version":1,"stream_serial":4294967303}"#,
|
||||
r#"{"event":"aec_failed","version":1,"module_index":536870919}"#,
|
||||
r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#,
|
||||
r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#,
|
||||
];
|
||||
let (notice_tx, notice_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
for line in lines {
|
||||
notice_tx
|
||||
.send(HostNotice::Event(
|
||||
parse_pixelpass_event(line).expect("PixelPass event must parse"),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
drop(notice_tx);
|
||||
|
||||
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(8);
|
||||
let (fault_tx, mut fault_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
forward_host_notices(notice_rx, ui_tx, fault_tx, 17).await;
|
||||
|
||||
let mut statuses = Vec::new();
|
||||
while let Some(event) = ui_rx.recv().await {
|
||||
match event {
|
||||
UiEvent::ShareAudioExclusionStatus(status) => statuses.push(status),
|
||||
other => panic!("unexpected forwarded UI event: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
statuses,
|
||||
vec![
|
||||
AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: 4_294_967_303,
|
||||
reason: "port-exclusive".to_string(),
|
||||
},
|
||||
AudioExclusionStatus::StreamStatusCleared {
|
||||
stream_serial: 4_294_967_303,
|
||||
},
|
||||
AudioExclusionStatus::AecFailed {
|
||||
module_index: 536_870_919,
|
||||
},
|
||||
AudioExclusionStatus::AecRevoked {
|
||||
module_index: 536_870_919,
|
||||
},
|
||||
AudioExclusionStatus::ForeignAecWarning {
|
||||
link_group: "echo-cancel-9999-13".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
fault_rx.try_recv().is_err(),
|
||||
"ordinary status events must not synthesize a host fault"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_watch_replaces_existing_viewer_for_same_ticket() {
|
||||
// The value type stands in for a viewer Child; only the ticket matters.
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
//! Destruction-order guarantees for the screen-share children and the
|
||||
//! echo-cancel module (phase 0b of the screenshare audio-exclusion plan;
|
||||
//! design v3.4 §7.1–§7.2, decision D4).
|
||||
//!
|
||||
//! # The invariant
|
||||
//!
|
||||
//! > **The echo-cancel module must not unload while a pixelpass host is alive
|
||||
//! > and fanning out.**
|
||||
//!
|
||||
//! If it does, the AEC's virtual nodes vanish from under a live pixelpass that
|
||||
//! still holds link proxies and a stale module index. Phase 6 makes this sharp
|
||||
//! — it is the first phase whose objects live only as long as pixelpass does —
|
||||
//! so the ordering guarantee has to exist *before* it.
|
||||
//!
|
||||
//! Two paths have to honour it, and only one of them is code we get to run:
|
||||
//!
|
||||
//! 1. **The explicit path** — [`ScreenshareTeardown::shutdown_children`], awaited
|
||||
//! by `ActiveSession::shutdown` before the guard is dropped.
|
||||
//! 2. **The drop/unwind path** — nobody calls anything. The core has numerous
|
||||
//! `unwrap()` sites and no `panic=abort` profile, so unwind is reachable, and
|
||||
//! on that path the only thing standing between us and a violated invariant
|
||||
//! is *field declaration order* plus [`ReapOnDrop`].
|
||||
//!
|
||||
//! Hence the two structural rules enforced here:
|
||||
//!
|
||||
//! - `echo_cancel` is the **last declared field** of [`ScreenshareTeardown`].
|
||||
//! Rust drops fields in declaration order, so last-declared is last-dropped.
|
||||
//! This is not a style choice; reversing it reintroduces the bug.
|
||||
//! - Killing is not enough — a child must be **reaped**. `kill_on_drop(true)`
|
||||
//! only *signals*; it hands the child to the runtime's orphan queue and
|
||||
//! returns, which on an unwinding runtime may never be drained. [`ReapOnDrop`]
|
||||
//! therefore blocks, briefly and boundedly, until the child is actually gone.
|
||||
//!
|
||||
//! Everything here is generic over [`ChildProcess`] and over the guard type so
|
||||
//! the ordering is unit-testable without spawning processes or loading PipeWire
|
||||
//! modules — the same seam idiom as `replace_viewer_index` and
|
||||
//! `rebuild_with_fallback` in the parent module.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long [`ReapOnDrop::drop`] will block waiting for a killed child to be
|
||||
/// reaped before giving up and logging. This runs on the unwind path, so it is
|
||||
/// a deliberate trade: a bounded stall is preferable to unloading the AEC out
|
||||
/// from under a live pixelpass, and unbounded blocking in a `Drop` is not.
|
||||
const REAP_BUDGET: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Poll interval while waiting out [`REAP_BUDGET`].
|
||||
const REAP_POLL: Duration = Duration::from_millis(5);
|
||||
|
||||
/// How long a child gets to honour the graceful stop before it is killed.
|
||||
///
|
||||
/// A healthy pixelpass exits in well under this, so the normal path never
|
||||
/// spends it; only a wedged child does. It is awaited inline in the core
|
||||
/// command loop, so it is also how long a wedged child can delay other
|
||||
/// commands — hence seconds, not tens of seconds.
|
||||
const STOP_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The child-process operations the teardown ordering actually depends on.
|
||||
///
|
||||
/// Deliberately narrow, and deliberately not `ExitStatus`-shaped: the ordering
|
||||
/// rules care only about *whether* a child has been signalled and *whether* it
|
||||
/// has been reaped, so the test double is a few lines instead of a fabricated
|
||||
/// exit status.
|
||||
pub(super) trait ChildProcess {
|
||||
/// Ask the child to exit **gracefully**, so it can run its own cleanup.
|
||||
/// Does **not** wait, and is not guaranteed to be honoured.
|
||||
fn request_stop(&mut self) -> std::io::Result<()>;
|
||||
|
||||
/// Signal the child to die. Does **not** wait.
|
||||
fn start_kill(&mut self) -> std::io::Result<()>;
|
||||
|
||||
/// Poll once. `true` once the child has exited **and been reaped**.
|
||||
fn try_reap(&mut self) -> bool;
|
||||
|
||||
/// Wait until the child has exited and been reaped.
|
||||
///
|
||||
/// The `io::Result` is load-bearing and must not be discarded by callers:
|
||||
/// a failed wait is *not* a confirmed reap, and treating it as one is how
|
||||
/// the AEC ends up unloading over a live child.
|
||||
fn wait_reaped(&mut self) -> impl Future<Output = std::io::Result<()>> + Send;
|
||||
}
|
||||
|
||||
impl ChildProcess for tokio::process::Child {
|
||||
/// **SIGINT, not SIGTERM.** pixelpass installs only a `tokio::signal::ctrl_c()`
|
||||
/// handler (`pixelpass/src/common/signal.rs`), so SIGTERM would be the default
|
||||
/// disposition — instant death, no cleanup — which is indistinguishable from
|
||||
/// SIGKILL for our purposes.
|
||||
///
|
||||
/// Signalling by pid is safe against pid reuse here because we have not
|
||||
/// reaped this child: an exited-but-unreaped child is a zombie whose pid the
|
||||
/// kernel reserves until we `wait` it, so the pid cannot name a stranger.
|
||||
#[cfg(unix)]
|
||||
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||
let Some(pid) = self.id() else {
|
||||
// Already reaped — nothing to signal.
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: `kill` is async-signal-safe and takes no pointers; the pid is
|
||||
// this process's own unreaped child (see above).
|
||||
if unsafe { libc::kill(pid as libc::pid_t, libc::SIGINT) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows has no SIGINT to send to another process without attaching to its
|
||||
/// console, so the graceful request degrades to the hard kill and the
|
||||
/// bounded wait below simply returns early.
|
||||
#[cfg(not(unix))]
|
||||
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||
tokio::process::Child::start_kill(self)
|
||||
}
|
||||
|
||||
fn start_kill(&mut self) -> std::io::Result<()> {
|
||||
tokio::process::Child::start_kill(self)
|
||||
}
|
||||
|
||||
fn try_reap(&mut self) -> bool {
|
||||
matches!(self.try_wait(), Ok(Some(_)))
|
||||
}
|
||||
|
||||
async fn wait_reaped(&mut self) -> std::io::Result<()> {
|
||||
self.wait().await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the explicit stop path actually confirm the child was reaped?
|
||||
///
|
||||
/// The distinction is not cosmetic: on [`Unconfirmed`](Self::Unconfirmed) we
|
||||
/// deliberately stopped waiting (see [`ReapOnDrop::shutdown`]), so pixelpass may
|
||||
/// still be alive and fanning out. A user-initiated Stop Share must not report
|
||||
/// that as a clean stop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[must_use = "an unconfirmed stop means the child may still be sharing"]
|
||||
pub(super) enum StopOutcome {
|
||||
/// The child is gone and has been reaped.
|
||||
Reaped,
|
||||
/// We could not confirm the reap within the bound and gave up waiting.
|
||||
Unconfirmed,
|
||||
}
|
||||
|
||||
/// A child that is killed **and reaped** when it is dropped.
|
||||
///
|
||||
/// The explicit path calls [`shutdown`](Self::shutdown), which releases the
|
||||
/// child only once its reap is *confirmed*, so the `Drop` below is a no-op
|
||||
/// afterwards but stays armed through every await until then. `Drop` is the
|
||||
/// last-ditch protection for the panic/unwind/cancellation paths.
|
||||
pub(super) struct ReapOnDrop<C: ChildProcess> {
|
||||
/// `None` once the child has been reaped through the explicit path.
|
||||
child: Option<C>,
|
||||
/// Names the child in the reap-timeout log line.
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl<C: ChildProcess> ReapOnDrop<C> {
|
||||
pub(super) fn new(child: C, label: &'static str) -> Self {
|
||||
Self {
|
||||
child: Some(child),
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll once, without killing. `true` if the child has exited on its own —
|
||||
/// used to sweep player windows the user has already closed.
|
||||
pub(super) fn has_exited(&mut self) -> bool {
|
||||
match &mut self.child {
|
||||
Some(child) => {
|
||||
if child.try_reap() {
|
||||
self.child = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// Already reaped through the explicit path.
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the child gracefully if it will go, and by force if it will not.
|
||||
/// Waits for it to be reaped either way. Idempotent.
|
||||
///
|
||||
/// Ask, then insist (design v3.4 §7.4): a pixelpass host that gets SIGINT
|
||||
/// unloads its capture sink on the way out, whereas SIGKILL skips that and
|
||||
/// leaks a null-sink module on every Stop Share.
|
||||
///
|
||||
/// The wait is the point: returning after signalling would let the caller
|
||||
/// proceed to unload the AEC while the child is still running.
|
||||
///
|
||||
/// ⚠️ The child stays owned by `self` across every `.await`, and is released
|
||||
/// **only after a confirmed reap**. Taking it out first would disarm the
|
||||
/// `Drop` fallback for exactly as long as the wait lasts: cancel or unwind
|
||||
/// this future at that moment and the raw child would drop with nothing but
|
||||
/// `kill_on_drop` (which signals without reaping) while `Drop` below found
|
||||
/// `None` and did nothing — the precise hole this type exists to close.
|
||||
pub(super) async fn shutdown(&mut self) -> StopOutcome {
|
||||
let Some(child) = self.child.as_mut() else {
|
||||
return StopOutcome::Reaped;
|
||||
};
|
||||
|
||||
// Three different things can go wrong here and they want three
|
||||
// different operator diagnoses: the signal never left (a runtime or
|
||||
// permission fault), the child ignored it (a wedged pixelpass), or the
|
||||
// wait itself broke (we no longer know anything about the child).
|
||||
// Collapsing them into one line was P3-1 of the round-16 review.
|
||||
if let Err(e) = child.request_stop() {
|
||||
crate::log_msg(&format!(
|
||||
"teardown: could not ask {} to stop: {e}",
|
||||
self.label
|
||||
));
|
||||
}
|
||||
match tokio::time::timeout(STOP_GRACE, child.wait_reaped()).await {
|
||||
Ok(Ok(())) => {
|
||||
self.child = None;
|
||||
return StopOutcome::Reaped;
|
||||
}
|
||||
Ok(Err(e)) => crate::log_msg(&format!(
|
||||
"teardown: waiting for {} failed ({e}); killing it",
|
||||
self.label
|
||||
)),
|
||||
Err(_) => crate::log_msg(&format!(
|
||||
"teardown: {} ignored the graceful stop within {STOP_GRACE:?}; killing it",
|
||||
self.label
|
||||
)),
|
||||
}
|
||||
|
||||
if let Err(e) = child.start_kill() {
|
||||
crate::log_msg(&format!(
|
||||
"teardown: {} could not be killed: {e}",
|
||||
self.label
|
||||
));
|
||||
}
|
||||
|
||||
// The second wait is bounded too. An unbounded one lets a process stuck
|
||||
// in uninterruptible sleep wedge the core command loop forever, and a
|
||||
// permanently frozen app is a worse failure than the risk below.
|
||||
if let Ok(Ok(())) = tokio::time::timeout(STOP_GRACE, child.wait_reaped()).await {
|
||||
self.child = None;
|
||||
return StopOutcome::Reaped;
|
||||
}
|
||||
|
||||
// Explicit policy for the one case where the two guarantees conflict:
|
||||
// we could not confirm the reap and will NOT block indefinitely, so we
|
||||
// give up availability-first and leave the child owned — `Drop`'s
|
||||
// bounded retry stays armed, and the AEC may unload over a child that
|
||||
// is still somehow alive. That residual risk is logged, not silent —
|
||||
// and, for a user-initiated stop, reported to the caller rather than
|
||||
// dressed up as success.
|
||||
crate::log_msg(&format!(
|
||||
"teardown: {} could not be confirmed dead; the echo-cancel module \
|
||||
may unload while it lives",
|
||||
self.label
|
||||
));
|
||||
StopOutcome::Unconfirmed
|
||||
}
|
||||
|
||||
/// Is the `Drop` fallback still armed? Test-only: the arming rule is the
|
||||
/// whole point of holding the child across the waits.
|
||||
#[cfg(test)]
|
||||
fn is_armed(&self) -> bool {
|
||||
self.child.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: ChildProcess> Drop for ReapOnDrop<C> {
|
||||
fn drop(&mut self) {
|
||||
let Some(child) = self.child.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let _ = child.start_kill();
|
||||
// `Drop` cannot await, so poll on a bounded budget. See `REAP_BUDGET`.
|
||||
let deadline = Instant::now() + REAP_BUDGET;
|
||||
loop {
|
||||
if child.try_reap() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
crate::log_msg(&format!(
|
||||
"teardown: {} did not exit within the reap budget; \
|
||||
continuing (the echo-cancel module may unload while it lives)",
|
||||
self.label
|
||||
));
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(REAP_POLL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything in an `ActiveSession` whose **destruction order** is load-bearing.
|
||||
///
|
||||
/// ⚠️ Field order below **is** the invariant. `echo_cancel` is declared last so
|
||||
/// it is dropped last, after every screen-share child has been killed and
|
||||
/// reaped. Do not reorder these fields.
|
||||
pub(super) struct ScreenshareTeardown<C: ChildProcess, G> {
|
||||
/// Our pixelpass screen-share host child while sharing.
|
||||
host: Option<ReapOnDrop<C>>,
|
||||
/// pixelpass viewer children we spawned to watch peers' shares, each paired
|
||||
/// with the share ticket it is viewing so a re-watch of the same share can
|
||||
/// replace (not stack) its player.
|
||||
viewers: Vec<(String, ReapOnDrop<C>)>,
|
||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||
///
|
||||
/// ⚠️ **LAST FIELD ON PURPOSE** — see the module docs and the struct note.
|
||||
///
|
||||
echo_cancel: Option<G>,
|
||||
}
|
||||
|
||||
impl<C: ChildProcess, G> ScreenshareTeardown<C, G> {
|
||||
pub(super) fn new(echo_cancel: Option<G>) -> Self {
|
||||
Self {
|
||||
host: None,
|
||||
viewers: Vec::new(),
|
||||
echo_cancel,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_sharing(&self) -> bool {
|
||||
self.host.is_some()
|
||||
}
|
||||
|
||||
/// Borrow the session-owned AEC guard without disturbing its load-bearing
|
||||
/// last-field drop order. Phase 8 uses this only to pass the module identity
|
||||
/// to PixelPass while the guard remains owned here.
|
||||
pub(super) fn echo_cancel(&self) -> Option<&G> {
|
||||
self.echo_cancel.as_ref()
|
||||
}
|
||||
|
||||
pub(super) fn set_host(&mut self, child: C) {
|
||||
self.host = Some(ReapOnDrop::new(child, "screen-share host"));
|
||||
}
|
||||
|
||||
/// Stop sharing: kill the host and wait for it to be reaped. `None` if we
|
||||
/// were not sharing; otherwise whether the reap was actually confirmed —
|
||||
/// the caller owns telling the user, since an unconfirmed stop may leave
|
||||
/// pixelpass fanning out after the UI says sharing ended.
|
||||
pub(super) async fn stop_host(&mut self) -> Option<StopOutcome> {
|
||||
let mut host = self.host.take()?;
|
||||
Some(host.shutdown().await)
|
||||
}
|
||||
|
||||
/// Drop viewers whose player window has already closed, so the list only
|
||||
/// tracks live players.
|
||||
pub(super) fn sweep_exited_viewers(&mut self) {
|
||||
self.viewers.retain_mut(|(_, child)| !child.has_exited());
|
||||
}
|
||||
|
||||
/// Kill and reap the viewer already showing `ticket`, if any, so a re-watch
|
||||
/// replaces its player instead of stacking a second one.
|
||||
pub(super) async fn replace_viewer(&mut self, ticket: &str) -> bool {
|
||||
let Some(pos) = super::replace_viewer_index(&self.viewers, ticket) else {
|
||||
return false;
|
||||
};
|
||||
let (_, mut old) = self.viewers.remove(pos);
|
||||
// A viewer is our own player window, not the thing peers are watching:
|
||||
// an unconfirmed reap is already logged, and there is no user decision
|
||||
// riding on it the way there is for Stop Share.
|
||||
let _ = old.shutdown().await;
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn push_viewer(&mut self, ticket: String, child: C) {
|
||||
self.viewers
|
||||
.push((ticket, ReapOnDrop::new(child, "screen-share viewer")));
|
||||
}
|
||||
|
||||
/// Kill and reap **every** screen-share child, host first so viewers see the
|
||||
/// stream end promptly.
|
||||
///
|
||||
/// The caller must await this before the echo-cancel guard is dropped. On
|
||||
/// the drop/unwind path nothing calls it and field order carries the
|
||||
/// invariant instead.
|
||||
pub(super) async fn shutdown_children(&mut self) {
|
||||
// Outcomes are discarded on purpose: this runs on the session/teardown
|
||||
// path, where the policy is already availability-first and the residual
|
||||
// risk is logged by `shutdown` itself. There is no user still waiting
|
||||
// on an answer here, unlike `stop_host`.
|
||||
if let Some(host) = &mut self.host {
|
||||
let _ = host.shutdown().await;
|
||||
}
|
||||
self.host = None;
|
||||
for (_, viewer) in self.viewers.iter_mut() {
|
||||
let _ = viewer.shutdown().await;
|
||||
}
|
||||
self.viewers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ChildProcess, ReapOnDrop, STOP_GRACE, ScreenshareTeardown, StopOutcome};
|
||||
use std::future::Future;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
type Log = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
fn log() -> Log {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn entries(log: &Log) -> Vec<String> {
|
||||
log.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn position(log: &Log, entry: &str) -> Option<usize> {
|
||||
entries(log).iter().position(|e| e == entry)
|
||||
}
|
||||
|
||||
/// Records the events the ordering rules turn on. Death is gated on an
|
||||
/// actual signal, so the double cannot report a reap that nothing caused.
|
||||
struct FakeChild {
|
||||
log: Log,
|
||||
label: &'static str,
|
||||
interrupted: bool,
|
||||
killed: bool,
|
||||
reaped: bool,
|
||||
/// A well-behaved child exits on SIGINT. A wedged one ignores it and
|
||||
/// dies only to SIGKILL.
|
||||
honours_interrupt: bool,
|
||||
/// When true the child is already dead before anyone signals it — the
|
||||
/// closed-player-window case that `sweep_exited_viewers` looks for.
|
||||
exited_on_its_own: bool,
|
||||
/// Death is not instantaneous: `try_reap` reports the child alive this
|
||||
/// many more times before it goes.
|
||||
polls_before_death: u32,
|
||||
/// `wait` reports an error instead of a reap.
|
||||
wait_fails: bool,
|
||||
}
|
||||
|
||||
impl FakeChild {
|
||||
/// A well-behaved child: exits when asked.
|
||||
fn new(log: &Log, label: &'static str) -> Self {
|
||||
Self {
|
||||
log: log.clone(),
|
||||
label,
|
||||
interrupted: false,
|
||||
killed: false,
|
||||
reaped: false,
|
||||
honours_interrupt: true,
|
||||
exited_on_its_own: false,
|
||||
polls_before_death: 0,
|
||||
wait_fails: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A child that ignores the graceful stop entirely.
|
||||
fn wedged(log: &Log, label: &'static str) -> Self {
|
||||
Self {
|
||||
honours_interrupt: false,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
/// A child that does not die the instant it is signalled: `try_reap`
|
||||
/// reports it alive for `polls` calls first. Without this the `Drop`
|
||||
/// polling loop could be replaced by a single `try_reap` and no test
|
||||
/// would notice.
|
||||
fn reaps_after_polls(log: &Log, label: &'static str, polls: u32) -> Self {
|
||||
Self {
|
||||
polls_before_death: polls,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
/// A child that ignores SIGINT *and* does not die the instant it is
|
||||
/// killed — the only shape that lets a test reach the post-SIGKILL
|
||||
/// wait and still be reaped by the `Drop` poll loop afterwards.
|
||||
fn wedged_then_dies_after_polls(log: &Log, label: &'static str, polls: u32) -> Self {
|
||||
Self {
|
||||
honours_interrupt: false,
|
||||
polls_before_death: polls,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
/// A child whose `wait` fails. A failed wait is not a confirmed reap,
|
||||
/// so it must not be reported as one.
|
||||
fn wait_fails(log: &Log, label: &'static str) -> Self {
|
||||
Self {
|
||||
wait_fails: true,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
fn already_exited(log: &Log, label: &'static str) -> Self {
|
||||
Self {
|
||||
exited_on_its_own: true,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Has anything actually made this child exit yet? A signalled child
|
||||
/// still has to burn through `polls_before_death` first.
|
||||
fn is_dead(&self) -> bool {
|
||||
let signalled = self.killed
|
||||
|| self.exited_on_its_own
|
||||
|| (self.interrupted && self.honours_interrupt);
|
||||
signalled && self.polls_before_death == 0
|
||||
}
|
||||
|
||||
/// One observation of a dying-but-not-yet-dead child.
|
||||
fn tick(&mut self) {
|
||||
self.polls_before_death = self.polls_before_death.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn record(&self, event: &str) {
|
||||
self.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("{}:{event}", self.label));
|
||||
}
|
||||
|
||||
fn mark_reaped(&mut self) {
|
||||
if !self.reaped {
|
||||
self.reaped = true;
|
||||
self.record("reap");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChildProcess for FakeChild {
|
||||
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||
if !self.interrupted {
|
||||
self.interrupted = true;
|
||||
self.record("sigint");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_kill(&mut self) -> std::io::Result<()> {
|
||||
if !self.killed {
|
||||
self.killed = true;
|
||||
self.record("kill");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_reap(&mut self) -> bool {
|
||||
if self.is_dead() {
|
||||
self.mark_reaped();
|
||||
return true;
|
||||
}
|
||||
self.tick();
|
||||
false
|
||||
}
|
||||
|
||||
/// Pending until something actually kills the child, so a wedged child
|
||||
/// really does make the caller wait out `STOP_GRACE`. No waker is
|
||||
/// registered: under `start_paused` the runtime auto-advances its clock
|
||||
/// when every task is idle, which is exactly what fires the timeout.
|
||||
fn wait_reaped(&mut self) -> impl Future<Output = std::io::Result<()>> + Send {
|
||||
std::future::poll_fn(move |_cx| {
|
||||
if self.wait_fails {
|
||||
return std::task::Poll::Ready(Err(std::io::Error::other("wait failed")));
|
||||
}
|
||||
if self.is_dead() {
|
||||
self.mark_reaped();
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
} else {
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in for `EchoCancelGuard`, whose real `Drop` runs `pactl unload`.
|
||||
struct FakeAec(Log);
|
||||
|
||||
impl Drop for FakeAec {
|
||||
fn drop(&mut self) {
|
||||
self.0.lock().unwrap().push("aec:unload".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn teardown(log: &Log) -> ScreenshareTeardown<FakeChild, FakeAec> {
|
||||
ScreenshareTeardown::new(Some(FakeAec(log.clone())))
|
||||
}
|
||||
|
||||
// --- The drop/unwind path: field order + ReapOnDrop carry the invariant ---
|
||||
|
||||
/// Mutation gate #5 (remove the reap loop from `ReapOnDrop::drop`).
|
||||
///
|
||||
/// Asserts only that dropping a guard reaps, and reaps *after* killing —
|
||||
/// deliberately says nothing about the AEC, so reversing the struct's field
|
||||
/// order leaves this test green and only the ordering test below fails.
|
||||
#[test]
|
||||
fn dropping_a_guard_kills_and_then_reaps_the_child() {
|
||||
let log = log();
|
||||
drop(ReapOnDrop::new(FakeChild::new(&log, "host"), "host"));
|
||||
assert_eq!(entries(&log), vec!["host:kill", "host:reap"]);
|
||||
}
|
||||
|
||||
/// Mutation gate #4 (reverse the field order of `ScreenshareTeardown`).
|
||||
///
|
||||
/// Asserts only kill-before-unload, so removing the reap loop leaves this
|
||||
/// test green and only the reap test above fails.
|
||||
#[test]
|
||||
fn the_aec_unloads_after_the_children_on_the_drop_path() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "viewer"));
|
||||
drop(t);
|
||||
|
||||
let unload = position(&log, "aec:unload").expect("the AEC guard must be dropped");
|
||||
let host_kill = position(&log, "host:kill").expect("the host must be killed");
|
||||
let viewer_kill = position(&log, "viewer:kill").expect("the viewer must be killed");
|
||||
assert!(
|
||||
host_kill < unload,
|
||||
"the AEC unloaded while the host was alive: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
assert!(
|
||||
viewer_kill < unload,
|
||||
"the AEC unloaded while a viewer was alive: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole invariant in one sequence, as documentation.
|
||||
#[test]
|
||||
fn the_drop_path_reaps_every_child_before_unloading_the_aec() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
drop(t);
|
||||
assert_eq!(entries(&log), vec!["host:kill", "host:reap", "aec:unload"]);
|
||||
}
|
||||
|
||||
// --- The explicit path: ask, then insist ---
|
||||
|
||||
/// A healthy child must be *asked*, never killed. If Stop Share went
|
||||
/// straight to SIGKILL, pixelpass would skip its own cleanup and leak a
|
||||
/// null-sink module every time (design v3.4 §7.4).
|
||||
#[tokio::test]
|
||||
async fn a_healthy_child_is_asked_to_stop_and_never_killed() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
|
||||
assert_eq!(t.stop_host().await, Some(StopOutcome::Reaped));
|
||||
|
||||
assert_eq!(entries(&log), vec!["host:sigint", "host:reap"]);
|
||||
assert!(
|
||||
!entries(&log).contains(&"host:kill".to_string()),
|
||||
"a child that honoured the graceful stop must not be killed: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
}
|
||||
|
||||
/// ...but a child that ignores the request must not be able to hold the
|
||||
/// session open forever: the grace is bounded and SIGKILL follows.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_wedged_child_is_killed_once_the_grace_expires() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::wedged(&log, "host"));
|
||||
|
||||
// The outer bound turns "the fallback was removed" into a failure
|
||||
// rather than a hung test. Under `start_paused` no real time passes.
|
||||
let start = tokio::time::Instant::now();
|
||||
tokio::time::timeout(Duration::from_secs(60), t.stop_host())
|
||||
.await
|
||||
.expect("a wedged child must not block teardown indefinitely");
|
||||
|
||||
assert_eq!(entries(&log), vec!["host:sigint", "host:kill", "host:reap"]);
|
||||
assert!(
|
||||
start.elapsed() >= STOP_GRACE,
|
||||
"the child must actually be given the grace period, waited {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// The assertion above compares elapsed time against `STOP_GRACE` itself,
|
||||
/// so it stays vacuously true if the constant is set to zero — both sides
|
||||
/// move together. Pin the constant independently: the whole point of the
|
||||
/// graceful stop is that pixelpass gets a real interval in which to unload
|
||||
/// its capture sink, and zero is not one.
|
||||
#[test]
|
||||
fn the_grace_is_a_real_interval() {
|
||||
assert!(
|
||||
STOP_GRACE >= Duration::from_millis(500),
|
||||
"too short to let pixelpass tear its pipeline down: {STOP_GRACE:?}"
|
||||
);
|
||||
// ...and short enough that a wedged child cannot visibly stall the core
|
||||
// command loop, which awaits this inline.
|
||||
assert!(
|
||||
STOP_GRACE <= Duration::from_secs(5),
|
||||
"long enough to freeze the UI's command handling: {STOP_GRACE:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hole the whole type exists to close, and the one place the old
|
||||
/// implementation left open: if `shutdown` is cancelled while waiting, the
|
||||
/// child must still be owned, so dropping the guard still kills and reaps.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancelling_shutdown_mid_wait_leaves_the_fallback_armed() {
|
||||
let log = log();
|
||||
let mut guard = ReapOnDrop::new(FakeChild::wedged(&log, "host"), "host");
|
||||
|
||||
// Cancel well inside the grace, while it is still waiting.
|
||||
assert!(
|
||||
tokio::time::timeout(STOP_GRACE / 4, guard.shutdown())
|
||||
.await
|
||||
.is_err(),
|
||||
"the wedged child should still have been waiting when we cancelled"
|
||||
);
|
||||
assert!(
|
||||
guard.is_armed(),
|
||||
"a cancelled shutdown must not disarm the drop fallback"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(entries(&log), vec!["host:sigint", "host:kill", "host:reap"]);
|
||||
}
|
||||
|
||||
/// The test above only ever cancels during the *graceful* wait, so a
|
||||
/// mutation that disarmed the wrapper between the two waits would survive
|
||||
/// it (round-16 review, P3-3). This one cancels during the post-SIGKILL
|
||||
/// wait — the window where we have already given up on cooperation and the
|
||||
/// `Drop` fallback is the only thing left.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancelling_shutdown_after_the_kill_leaves_the_fallback_armed() {
|
||||
let log = log();
|
||||
// Ignores SIGINT, so the grace expires and we reach the kill; then
|
||||
// survives three polls, so the second wait is still pending when we
|
||||
// cancel, and the drop loop still gets to reap it.
|
||||
let mut guard = ReapOnDrop::new(
|
||||
FakeChild::wedged_then_dies_after_polls(&log, "host", 3),
|
||||
"host",
|
||||
);
|
||||
|
||||
assert!(
|
||||
tokio::time::timeout(STOP_GRACE + STOP_GRACE / 4, guard.shutdown())
|
||||
.await
|
||||
.is_err(),
|
||||
"we should have been cancelled inside the post-kill wait"
|
||||
);
|
||||
assert_eq!(
|
||||
entries(&log),
|
||||
vec!["host:sigint", "host:kill"],
|
||||
"the graceful stop must have expired and escalated before we cancelled"
|
||||
);
|
||||
assert!(
|
||||
guard.is_armed(),
|
||||
"cancelling after the kill must not disarm the drop fallback either"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
// The fake's `start_kill` is idempotent, so `Drop` re-signalling an
|
||||
// already-killed child adds no entry; the *reap* is what proves the
|
||||
// fallback ran to completion after we abandoned the wait.
|
||||
assert_eq!(
|
||||
entries(&log),
|
||||
vec!["host:sigint", "host:kill", "host:reap"],
|
||||
"Drop must poll until the child is actually gone"
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed wait is not a reap. Reporting it as one is how the AEC ends up
|
||||
/// unloading over a child that is still alive.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_failed_wait_is_not_treated_as_a_confirmed_reap() {
|
||||
let log = log();
|
||||
let mut guard = ReapOnDrop::new(FakeChild::wait_fails(&log, "host"), "host");
|
||||
|
||||
assert_eq!(
|
||||
guard.shutdown().await,
|
||||
StopOutcome::Unconfirmed,
|
||||
"a stop we could not confirm must not be reported as a clean one"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!entries(&log).contains(&"host:reap".to_string()),
|
||||
"nothing confirmed the reap: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
assert!(
|
||||
entries(&log).contains(&"host:kill".to_string()),
|
||||
"a child that would not stop must still be escalated: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
assert!(
|
||||
guard.is_armed(),
|
||||
"an unconfirmed reap must leave the drop fallback armed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Death is not instantaneous, so the drop path has to keep polling. A
|
||||
/// single `try_reap` in place of the loop must not pass.
|
||||
#[test]
|
||||
fn the_drop_path_polls_until_the_child_is_actually_gone() {
|
||||
let log = log();
|
||||
drop(ReapOnDrop::new(
|
||||
FakeChild::reaps_after_polls(&log, "host", 3),
|
||||
"host",
|
||||
));
|
||||
assert_eq!(entries(&log), vec!["host:kill", "host:reap"]);
|
||||
}
|
||||
|
||||
/// Mutation gate #3 (remove the wait after the host kill).
|
||||
#[tokio::test]
|
||||
async fn explicit_shutdown_reaps_the_host_before_the_aec_can_unload() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "viewer"));
|
||||
|
||||
t.shutdown_children().await;
|
||||
|
||||
// Reaped by the explicit path — before the guard is anywhere near dropped.
|
||||
assert_eq!(
|
||||
entries(&log),
|
||||
vec!["host:sigint", "host:reap", "viewer:sigint", "viewer:reap"],
|
||||
"children must be stopped and reaped by the explicit path"
|
||||
);
|
||||
|
||||
drop(t);
|
||||
let unload = position(&log, "aec:unload").expect("the AEC guard must be dropped");
|
||||
let host_reap = position(&log, "host:reap").expect("the host must be reaped");
|
||||
assert!(host_reap < unload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_shutdown_is_idempotent_with_the_drop_path() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
t.shutdown_children().await;
|
||||
drop(t);
|
||||
// Exactly one stop and one reap: the drop path must not re-signal a
|
||||
// child the explicit path already took.
|
||||
assert_eq!(
|
||||
entries(&log),
|
||||
vec!["host:sigint", "host:reap", "aec:unload"]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Host/viewer bookkeeping ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_host_reports_whether_it_was_sharing() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
assert!(!t.is_sharing());
|
||||
assert_eq!(t.stop_host().await, None, "not sharing: nothing to stop");
|
||||
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
assert!(t.is_sharing());
|
||||
assert_eq!(t.stop_host().await, Some(StopOutcome::Reaped));
|
||||
assert!(!t.is_sharing());
|
||||
assert_eq!(entries(&log), vec!["host:sigint", "host:reap"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeping_drops_only_the_players_that_already_closed() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.push_viewer(
|
||||
"closed".to_string(),
|
||||
FakeChild::already_exited(&log, "closed"),
|
||||
);
|
||||
t.push_viewer("live".to_string(), FakeChild::new(&log, "live"));
|
||||
|
||||
t.sweep_exited_viewers();
|
||||
|
||||
// The live player survives the sweep; only the closed one is dropped,
|
||||
// and dropping it must not kill anything (it was already gone).
|
||||
assert_eq!(t.viewers.len(), 1);
|
||||
assert_eq!(t.viewers[0].0, "live");
|
||||
assert_eq!(entries(&log), vec!["closed:reap"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn re_watching_a_share_replaces_that_player_only() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "a"));
|
||||
t.push_viewer("ticket-B".to_string(), FakeChild::new(&log, "b"));
|
||||
|
||||
assert!(t.replace_viewer("ticket-A").await);
|
||||
assert_eq!(entries(&log), vec!["a:sigint", "a:reap"]);
|
||||
assert_eq!(t.viewers.len(), 1);
|
||||
assert_eq!(t.viewers[0].0, "ticket-B");
|
||||
|
||||
// A share we are not watching has nothing to replace.
|
||||
assert!(!t.replace_viewer("ticket-C").await);
|
||||
}
|
||||
}
|
||||
+16
@@ -3,6 +3,22 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// Tag the audio we play through ALSA (rodio's `ClipPlayer`: chat clips,
|
||||
// peer music, local playlist tracks) so the screen-share exclusion engine
|
||||
// can recognise it as ours and refuse to fan it back to the far end.
|
||||
//
|
||||
// First statement in the program, and that is load-bearing: this sets an
|
||||
// environment variable, which is only sound while the process is still
|
||||
// single-threaded, and PipeWire's ALSA plugin reads it when a stream is
|
||||
// opened. See `audio::ownership::tag_this_process_alsa_audio`.
|
||||
//
|
||||
// SAFETY: nothing has been spawned yet, so no thread can be reading the
|
||||
// environment concurrently.
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
peerspeak::audio::ownership::tag_this_process_alsa_audio()
|
||||
};
|
||||
|
||||
if let Err(e) = peerspeak::app::run_gui() {
|
||||
eprintln!("Error running GUI: {:?}", e);
|
||||
}
|
||||
|
||||
+81
-4
@@ -10,6 +10,8 @@
|
||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
#[cfg(not(windows))]
|
||||
use crate::audio::ownership;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
@@ -80,6 +82,14 @@ pub enum Sound {
|
||||
MicToggle,
|
||||
/// Reconnect failed / peer evicted.
|
||||
ReconnectFailed,
|
||||
/// One of our chat messages was broadcast to the room.
|
||||
ChatSent,
|
||||
/// A chat message from another participant was admitted.
|
||||
ChatReceived,
|
||||
/// A saved contact was detected online on the home screen.
|
||||
ContactOnline,
|
||||
/// A saved contact previously seen online went offline on the home screen.
|
||||
ContactOffline,
|
||||
}
|
||||
|
||||
impl Sound {
|
||||
@@ -93,10 +103,14 @@ impl Sound {
|
||||
Sound::SelfLeave,
|
||||
Sound::MicToggle,
|
||||
Sound::ReconnectFailed,
|
||||
Sound::ChatSent,
|
||||
Sound::ChatReceived,
|
||||
Sound::ContactOnline,
|
||||
Sound::ContactOffline,
|
||||
];
|
||||
|
||||
/// Number of distinct notification events.
|
||||
pub const COUNT: usize = 8;
|
||||
pub const COUNT: usize = 12;
|
||||
|
||||
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
|
||||
fn index(self) -> usize {
|
||||
@@ -109,6 +123,10 @@ impl Sound {
|
||||
Sound::SelfLeave => 5,
|
||||
Sound::MicToggle => 6,
|
||||
Sound::ReconnectFailed => 7,
|
||||
Sound::ChatSent => 8,
|
||||
Sound::ChatReceived => 9,
|
||||
Sound::ContactOnline => 10,
|
||||
Sound::ContactOffline => 11,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +141,10 @@ impl Sound {
|
||||
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
|
||||
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
|
||||
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
|
||||
Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"),
|
||||
Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"),
|
||||
Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"),
|
||||
Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +159,10 @@ impl Sound {
|
||||
Sound::SelfLeave => "self-leave",
|
||||
Sound::MicToggle => "mic-toggle",
|
||||
Sound::ReconnectFailed => "reconnect-failed",
|
||||
Sound::ChatSent => "chat-sent",
|
||||
Sound::ChatReceived => "chat-received",
|
||||
Sound::ContactOnline => "contact-online",
|
||||
Sound::ContactOffline => "contact-offline",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,12 +266,20 @@ fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
#[cfg(not(windows))]
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
let mut command = Command::new(player);
|
||||
command
|
||||
.arg(path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
.stderr(Stdio::null());
|
||||
// Ownership tag (plan §5.1). A chime is short, but it is still our
|
||||
// audio on the default sink, and an untagged one is an unowned root
|
||||
// the exclusion engine would have to reason about from scratch.
|
||||
// Measured on this host: all three fallbacks tag correctly, `aplay`
|
||||
// included — it reaches the graph through PipeWire's ALSA plugin,
|
||||
// which honours `PIPEWIRE_PROPS` like any other client.
|
||||
ownership::tag_child(&mut command, ownership::NOTIFICATION_ROLE);
|
||||
let started = command.status();
|
||||
// `status()` errors only if the player binary isn't present; on a real
|
||||
// playback error it still returns (non-zero), so a started player ends
|
||||
// the loop either way — we don't want to double-play through fallbacks.
|
||||
@@ -289,6 +323,49 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// Phase-1 exit gate, notification half (impl plan §3): a chime peerspeak
|
||||
/// actually plays produces a live PipeWire node carrying **both**
|
||||
/// ownership carriers.
|
||||
///
|
||||
/// ⚠️ Deliberately drives `play()`, not `tag_child()`. The unit test in
|
||||
/// `audio::ownership` proves the environment is built correctly; only a
|
||||
/// live run proves this module *uses* it and that the audio stack honours
|
||||
/// it end to end. The chime is silent (a zero-filled WAV), so running it
|
||||
/// never makes noise.
|
||||
///
|
||||
/// Live: needs a running PipeWire daemon, `pw-play`/`paplay` and
|
||||
/// `pw-dump`. `cargo test --lib -- --ignored notification_chime`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||
#[cfg(not(windows))]
|
||||
fn notification_chime_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
|
||||
let dir = temp_wav_dir("ownership");
|
||||
let path = dir.join("silence.wav");
|
||||
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
|
||||
|
||||
set_enabled(true);
|
||||
set_sound_enabled(Sound::PeerJoin, true);
|
||||
play(Sound::PeerJoin, Some(path.to_str().unwrap()));
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::NOTIFICATION_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node too, not just carrier 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_play_truth_table() {
|
||||
// Plays only when BOTH the master and the per-sound flag are on.
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Live-edge catch-up for the screen-share viewer.
|
||||
//!
|
||||
//! PixelPass carries the share as MPEG-TS over a reliable, ordered transport. On
|
||||
//! a lossy link (satellite handovers are the pathological case) every loss burst
|
||||
//! becomes retransmission plus head-of-line blocking, and the viewer absorbs the
|
||||
//! stall as buffered latency. Nothing in the chain ever trims that buffer back,
|
||||
//! so the picture ends up seconds behind the host and stays there.
|
||||
//!
|
||||
//! Measured on a `tc netem` rig that simulates a satellite link (40 ms +/- 20 ms
|
||||
//! jitter, 0.5% loss, a 250 ms/30%-loss handover burst every 15 s): a viewer with
|
||||
//! ordinary timestamp pacing settles ~1.24 s behind. mpv's `--untimed` does NOT
|
||||
//! help (~1.38 s, marginally worse) because it only removes pacing at
|
||||
//! *presentation* while audio still drains at 1x the DAC rate, so an accumulated
|
||||
//! buffer never shrinks. Returning to the live edge requires consuming the
|
||||
//! backlog faster than it arrives.
|
||||
//!
|
||||
//! So we nudge playback slightly faster than realtime while the buffer is deep,
|
||||
//! and drop back to 1x once it has drained. mpv's default pitch correction
|
||||
//! (`scaletempo2`) keeps a 5% speedup inaudible, and because audio and video are
|
||||
//! sped up together A/V sync is preserved — unlike `--untimed`.
|
||||
//!
|
||||
//! The control law and the JSON-IPC message handling are pure functions with
|
||||
//! tests; the only I/O is [`drive`], which talks to mpv's `--input-ipc-server`
|
||||
//! socket.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Buffer depth (seconds) above which we start draining.
|
||||
pub const CACHE_HIGH_S: f64 = 1.0;
|
||||
/// Buffer depth (seconds) below which we return to realtime.
|
||||
pub const CACHE_LOW_S: f64 = 0.4;
|
||||
/// The buffer depth we aim to sit at; the drain rate is proportional to how far
|
||||
/// above this the buffer actually is.
|
||||
pub const CACHE_TARGET_S: f64 = 0.5;
|
||||
/// Extra playback rate per second of excess buffer.
|
||||
pub const CATCHUP_GAIN: f64 = 0.05;
|
||||
/// Hard ceiling on the drain rate. Beyond this the speedup stops being
|
||||
/// unnoticeable, and a share that far behind is better served by the operator
|
||||
/// restarting it than by a chipmunk impression.
|
||||
pub const MAX_CATCHUP_SPEED: f64 = 1.15;
|
||||
/// Normal realtime playback.
|
||||
pub const NORMAL_SPEED: f64 = 1.0;
|
||||
/// How often we sample the buffer depth.
|
||||
pub const POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
/// Smallest rate change worth sending to the player.
|
||||
pub const SPEED_EPSILON: f64 = 0.005;
|
||||
|
||||
/// The property we watch on the viewer.
|
||||
const CACHE_PROPERTY: &str = "demuxer-cache-duration";
|
||||
|
||||
/// Decide the playback rate for the next interval.
|
||||
///
|
||||
/// Proportional, because a fixed small speedup cannot recover a large backlog in
|
||||
/// any reasonable time: draining 6 s at 1.05x takes two minutes, which a viewer
|
||||
/// experiences as "still broken". The drain rate instead scales with how deep
|
||||
/// the buffer is, so a bad handover is cleared in tens of seconds while a small
|
||||
/// excursion still gets only a gentle, inaudible nudge.
|
||||
///
|
||||
/// Deliberately hysteretic: between [`CACHE_LOW_S`] and [`CACHE_HIGH_S`] the
|
||||
/// current rate is held, so a buffer hovering near a single threshold cannot
|
||||
/// oscillate the speed (and with it the audio pitch) every poll. Pure.
|
||||
///
|
||||
/// A non-finite reading (mpv reports `null` before playback starts, and the
|
||||
/// caller maps that to NaN) holds the current rate rather than guessing.
|
||||
pub fn catchup_speed(cache_s: f64, current: f64) -> f64 {
|
||||
if !cache_s.is_finite() {
|
||||
return current;
|
||||
}
|
||||
if cache_s < CACHE_LOW_S {
|
||||
return NORMAL_SPEED;
|
||||
}
|
||||
if cache_s <= CACHE_HIGH_S {
|
||||
return current;
|
||||
}
|
||||
let excess = cache_s - CACHE_TARGET_S;
|
||||
(NORMAL_SPEED + CATCHUP_GAIN * excess).clamp(NORMAL_SPEED, MAX_CATCHUP_SPEED)
|
||||
}
|
||||
|
||||
/// Where mpv should create its IPC socket. Kept separate from the runtime
|
||||
/// lookup so tests can pin a directory. Pure.
|
||||
pub fn socket_path(dir: &Path, token: u64) -> PathBuf {
|
||||
dir.join(format!("peerspeak-mpv-{token}.sock"))
|
||||
}
|
||||
|
||||
/// The directory for the IPC socket: the XDG runtime dir when the session
|
||||
/// provides one (tmpfs, user-private, cleaned at logout), else the temp dir.
|
||||
pub fn socket_dir() -> PathBuf {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
}
|
||||
|
||||
/// A `get_property` request for the buffer depth. Pure.
|
||||
pub fn get_cache_request(request_id: u64) -> String {
|
||||
format!(r#"{{"command":["get_property","{CACHE_PROPERTY}"],"request_id":{request_id}}}"#)
|
||||
}
|
||||
|
||||
/// A `set_property` request for the playback rate. Pure.
|
||||
pub fn set_speed_request(request_id: u64, speed: f64) -> String {
|
||||
format!(r#"{{"command":["set_property","speed",{speed}],"request_id":{request_id}}}"#)
|
||||
}
|
||||
|
||||
/// Extract the buffer depth from one line of mpv's IPC output.
|
||||
///
|
||||
/// mpv interleaves unsolicited event lines with command replies, so a line is
|
||||
/// only ours when it carries the matching `request_id`. Returns:
|
||||
/// - `Some(Some(secs))` — our reply, with a usable number,
|
||||
/// - `Some(None)` — our reply, but no number (mpv sends `"data":null` before
|
||||
/// playback starts, and reports `error` while the demuxer has no cache yet),
|
||||
/// - `None` — not our reply (an event, or another command's response).
|
||||
///
|
||||
/// Pure.
|
||||
pub fn parse_cache_response(line: &str, request_id: u64) -> Option<Option<f64>> {
|
||||
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
|
||||
let id = value.get("request_id")?.as_u64()?;
|
||||
if id != request_id {
|
||||
return None;
|
||||
}
|
||||
if value.get("error").and_then(|e| e.as_str()) != Some("success") {
|
||||
return Some(None);
|
||||
}
|
||||
Some(value.get("data").and_then(|d| d.as_f64()))
|
||||
}
|
||||
|
||||
/// Drive one mpv viewer's playback rate over its JSON IPC socket.
|
||||
///
|
||||
/// Runs until mpv exits (the socket dies), so it is spawned detached alongside
|
||||
/// the player and needs no shutdown signal. Every failure path just ends the
|
||||
/// task: catch-up is an optimization, and a viewer that never gets it still
|
||||
/// plays, exactly as before this existed.
|
||||
#[cfg(unix)]
|
||||
pub async fn drive(socket: PathBuf) {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
// mpv creates the socket a moment after exec, so the first connects race it.
|
||||
let mut stream = None;
|
||||
for _ in 0..40 {
|
||||
match UnixStream::connect(&socket).await {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(250)).await,
|
||||
}
|
||||
}
|
||||
let Some(stream) = stream else {
|
||||
crate::log_msg("livesync: mpv IPC socket never appeared; catch-up disabled");
|
||||
return;
|
||||
};
|
||||
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
let mut request_id: u64 = 0;
|
||||
let mut speed = NORMAL_SPEED;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
|
||||
request_id += 1;
|
||||
let query = format!("{}\n", get_cache_request(request_id));
|
||||
if write_half.write_all(query.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip event lines until our reply arrives.
|
||||
let cache = loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
if let Some(value) = parse_cache_response(&line, request_id) {
|
||||
break value;
|
||||
}
|
||||
}
|
||||
// Socket closed or unreadable: mpv is gone.
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
|
||||
let cache = cache.unwrap_or(f64::NAN);
|
||||
let next = catchup_speed(cache, speed);
|
||||
// A proportional law would otherwise re-send on every wobble of the
|
||||
// reading; only a change worth hearing is worth a round trip.
|
||||
if (next - speed).abs() > SPEED_EPSILON {
|
||||
speed = next;
|
||||
request_id += 1;
|
||||
let set = format!("{}\n", set_speed_request(request_id, speed));
|
||||
if write_half.write_all(set.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
crate::log_msg(&format!(
|
||||
"livesync: cache {cache:.2}s -> playback speed {speed}x"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deep_buffer_speeds_up_and_drained_buffer_returns_to_realtime() {
|
||||
assert!(catchup_speed(1.5, NORMAL_SPEED) > NORMAL_SPEED);
|
||||
assert_eq!(catchup_speed(0.1, MAX_CATCHUP_SPEED), NORMAL_SPEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_rate_scales_with_how_far_behind_we_are() {
|
||||
// The point of the proportional law: a small excursion gets a gentle
|
||||
// nudge, a deep backlog gets real recovery.
|
||||
let small = catchup_speed(1.5, NORMAL_SPEED);
|
||||
let large = catchup_speed(4.0, NORMAL_SPEED);
|
||||
assert!(
|
||||
large > small,
|
||||
"deeper buffer must drain faster: {small} vs {large}"
|
||||
);
|
||||
assert!(
|
||||
(small - 1.05).abs() < 1e-9,
|
||||
"1.5s buffer -> 1.05x, got {small}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_rate_is_capped_so_it_never_sounds_absurd() {
|
||||
// The ~6 s standing buffer measured on the netem rig, and far worse.
|
||||
assert_eq!(catchup_speed(6.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
|
||||
assert_eq!(catchup_speed(600.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hysteresis_band_holds_the_current_speed() {
|
||||
// Between the marks nothing changes, whichever side we came from —
|
||||
// this is what stops the rate (and audio pitch) oscillating.
|
||||
for cache in [CACHE_LOW_S, 0.7, CACHE_HIGH_S] {
|
||||
assert_eq!(catchup_speed(cache, NORMAL_SPEED), NORMAL_SPEED);
|
||||
assert_eq!(catchup_speed(cache, MAX_CATCHUP_SPEED), MAX_CATCHUP_SPEED);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_cache_holds_the_current_speed() {
|
||||
assert_eq!(
|
||||
catchup_speed(f64::NAN, MAX_CATCHUP_SPEED),
|
||||
MAX_CATCHUP_SPEED
|
||||
);
|
||||
assert_eq!(catchup_speed(f64::INFINITY, NORMAL_SPEED), NORMAL_SPEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_handover_cycle_drains_then_settles() {
|
||||
// Buffer grows through a loss burst, then drains as we play faster.
|
||||
let mut speed = NORMAL_SPEED;
|
||||
for cache in [0.2, 0.5, 1.2, 3.4, 1.4, 0.9, 0.6, 0.3, 0.2] {
|
||||
speed = catchup_speed(cache, speed);
|
||||
}
|
||||
assert_eq!(
|
||||
speed, NORMAL_SPEED,
|
||||
"should be back at realtime once drained"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_valid_json_with_their_ids() {
|
||||
let get: serde_json::Value = serde_json::from_str(&get_cache_request(7)).unwrap();
|
||||
assert_eq!(get["request_id"], 7);
|
||||
assert_eq!(get["command"][0], "get_property");
|
||||
assert_eq!(get["command"][1], CACHE_PROPERTY);
|
||||
|
||||
let set: serde_json::Value = serde_json::from_str(&set_speed_request(8, 1.05)).unwrap();
|
||||
assert_eq!(set["request_id"], 8);
|
||||
assert_eq!(set["command"][0], "set_property");
|
||||
assert_eq!(set["command"][1], "speed");
|
||||
assert_eq!(set["command"][2], 1.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_our_reply_only() {
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":3}"#, 3),
|
||||
Some(Some(1.25))
|
||||
);
|
||||
// Another command's reply, and an unsolicited event, are not ours.
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":4}"#, 3),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"event":"playback-restart"}"#, 3),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_cache_response("not json", 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_without_a_usable_number_is_ours_but_empty() {
|
||||
// mpv before playback starts, and while the demuxer has no cache.
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"error":"success","data":null,"request_id":1}"#, 1),
|
||||
Some(None)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"error":"property unavailable","request_id":1}"#, 1),
|
||||
Some(None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_is_scoped_to_its_token() {
|
||||
let a = socket_path(Path::new("/run/user/1000"), 42);
|
||||
assert_eq!(a, Path::new("/run/user/1000/peerspeak-mpv-42.sock"));
|
||||
assert_ne!(a, socket_path(Path::new("/run/user/1000"), 43));
|
||||
}
|
||||
}
|
||||
+729
-52
@@ -21,6 +21,10 @@ use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use crate::audio::ownership;
|
||||
|
||||
pub mod livesync;
|
||||
|
||||
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
||||
|
||||
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
||||
@@ -45,11 +49,59 @@ const MAX_TICKET_LEN: usize = 512;
|
||||
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
|
||||
const MAX_APP_NAME_LEN: usize = 256;
|
||||
|
||||
/// Ceiling on the viewer's demuxer byte cache in the Low latency posture. The
|
||||
/// cache is a *byte* budget, so at a given bitrate it sets the worst-case
|
||||
/// backlog in seconds; keeping it tight is what stops a lossy link parking the
|
||||
/// viewer seconds behind before [`livesync`] even gets a chance to drain it.
|
||||
const LOW_LATENCY_CACHE_CAP_MB: u32 = 1;
|
||||
|
||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// The audio source selected for one hosted screen share.
|
||||
///
|
||||
/// This is shared by the picker and the core so the new desktop-excluding
|
||||
/// choice cannot collapse back into the legacy `Option<String>` representation
|
||||
/// (where `None` could only mean whole-desktop audio).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum ShareAudioSelection {
|
||||
/// PixelPass's legacy whole-desktop monitor capture.
|
||||
#[default]
|
||||
DesktopShared,
|
||||
/// Whole-desktop audio with PeerSpeak-owned playback excluded.
|
||||
DesktopExcluding,
|
||||
/// Strict capture of one locally selected application.
|
||||
Application(String),
|
||||
}
|
||||
|
||||
/// One version-1 desktop-audio-exclusion status from PixelPass.
|
||||
///
|
||||
/// The parsed value itself crosses the core/UI boundary; PeerSpeak does not
|
||||
/// define a second matching enum that could silently drift from the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AudioExclusionStatus {
|
||||
StreamUnsupported {
|
||||
stream_serial: u64,
|
||||
reason: String,
|
||||
},
|
||||
/// PixelPass has cleared the previously reported status for this exact
|
||||
/// stream because it became capturable or left the graph.
|
||||
StreamStatusCleared {
|
||||
stream_serial: u64,
|
||||
},
|
||||
AecFailed {
|
||||
module_index: u64,
|
||||
},
|
||||
AecRevoked {
|
||||
module_index: u64,
|
||||
},
|
||||
ForeignAecWarning {
|
||||
link_group: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One parsed line from pixelpass's `--output json` stdout stream. Mirrors the
|
||||
/// `event` tags in pixelpass's `src/common/output.rs`. Recognized-but-unused
|
||||
/// events collapse to [`PixelpassEvent::Other`]; blank or non-JSON lines parse
|
||||
@@ -76,10 +128,28 @@ pub enum PixelpassEvent {
|
||||
/// our `--strict-audio` run this means viewers now hear silence (not the call
|
||||
/// echo) until the app produces audio again — we surface it as a warning.
|
||||
AppAudioLost,
|
||||
/// Host (desktop-excluding audio): a versioned status from the fail-closed
|
||||
/// fan-out controller.
|
||||
AudioExclusion(AudioExclusionStatus),
|
||||
/// A recognized event we don't act on (e.g. `host_info`).
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What the host's stdout drain forwards to the core over the notice channel.
|
||||
///
|
||||
/// `Eof` is **synthesized here**, not parsed: pixelpass has no "I died" event,
|
||||
/// and a crash can abort across `extern "C"` before any JSON line is written,
|
||||
/// so the stream ending is the only reliable death signal. A read *error*
|
||||
/// counts too — either way the event stream is gone and the host must be
|
||||
/// treated as over.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HostNotice {
|
||||
/// A parsed pixelpass event line.
|
||||
Event(PixelpassEvent),
|
||||
/// The host's stdout ended (EOF or read error). Terminal: nothing follows.
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// Parse a single stdout line from pixelpass `--output json`. Pure: no I/O.
|
||||
pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
||||
let line = line.trim();
|
||||
@@ -115,6 +185,32 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
||||
Some("lost") => PixelpassEvent::AppAudioLost,
|
||||
_ => PixelpassEvent::Other,
|
||||
},
|
||||
"stream_unsupported" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: json_u64(&v, "stream_serial")?,
|
||||
reason: v.get("reason")?.as_str()?.to_string(),
|
||||
})
|
||||
}
|
||||
"stream_status_cleared" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::StreamStatusCleared {
|
||||
stream_serial: json_u64(&v, "stream_serial")?,
|
||||
})
|
||||
}
|
||||
"aec_failed" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::AecFailed {
|
||||
module_index: json_u64(&v, "module_index")?,
|
||||
})
|
||||
}
|
||||
"aec_revoked" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::AecRevoked {
|
||||
module_index: json_u64(&v, "module_index")?,
|
||||
})
|
||||
}
|
||||
"foreign_aec_warning" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::ForeignAecWarning {
|
||||
link_group: v.get("link_group")?.as_str()?.to_string(),
|
||||
})
|
||||
}
|
||||
_ => PixelpassEvent::Other,
|
||||
};
|
||||
Some(ev)
|
||||
@@ -124,6 +220,10 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
|
||||
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
fn json_u64(v: &serde_json::Value, key: &str) -> Option<u64> {
|
||||
v.get(key).and_then(|x| x.as_u64())
|
||||
}
|
||||
|
||||
/// Build the argv for a pixelpass *host*. Always `--host --output json`; when
|
||||
/// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass
|
||||
/// captures only that app's audio instead of the whole desktop sink monitor
|
||||
@@ -155,6 +255,48 @@ pub fn host_args(
|
||||
args.push(format!("--app={name}"));
|
||||
args.push("--strict-audio".to_string());
|
||||
}
|
||||
append_host_settings(&mut args, settings, quality);
|
||||
args
|
||||
}
|
||||
|
||||
/// Build host argv from the picker's typed audio selection.
|
||||
///
|
||||
/// The existing desktop-shared and application arms deliberately delegate to
|
||||
/// [`host_args`] so their argv stays byte-for-byte compatible. Only the new
|
||||
/// desktop-excluding arm emits the public PixelPass protocol pair, and it
|
||||
/// always includes an explicit AEC state: `off` when this PeerSpeak session did
|
||||
/// not load an echo-cancel module, otherwise the exact pactl module index.
|
||||
pub fn host_args_for_selection(
|
||||
audio: &ShareAudioSelection,
|
||||
aec_module_index: Option<u64>,
|
||||
settings: &ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
) -> Vec<String> {
|
||||
match audio {
|
||||
ShareAudioSelection::DesktopShared => host_args(None, settings, quality),
|
||||
ShareAudioSelection::Application(name) => host_args(Some(name), settings, quality),
|
||||
ShareAudioSelection::DesktopExcluding => {
|
||||
let mut args = vec![
|
||||
"--host".to_string(),
|
||||
"--output".to_string(),
|
||||
"json".to_string(),
|
||||
"--audio-mode=desktop-excluding".to_string(),
|
||||
match aec_module_index {
|
||||
Some(index) => format!("--aec=pulse-module:{index}"),
|
||||
None => "--aec=off".to_string(),
|
||||
},
|
||||
];
|
||||
append_host_settings(&mut args, settings, quality);
|
||||
args
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_host_settings(
|
||||
args: &mut Vec<String>,
|
||||
settings: &ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
) {
|
||||
if quality != ShareQuality::Auto {
|
||||
args.push(format!("--quality={}", pixelpass_quality(quality)));
|
||||
}
|
||||
@@ -174,7 +316,6 @@ pub fn host_args(
|
||||
args.push(format!("--max-viewers={max}"));
|
||||
}
|
||||
args.extend(split_extra_args(&settings.extra_host_args));
|
||||
args
|
||||
}
|
||||
|
||||
fn pixelpass_quality(quality: ShareQuality) -> &'static str {
|
||||
@@ -234,22 +375,67 @@ pub async fn list_audio_apps() -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or
|
||||
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only
|
||||
/// picker (safe), never a stalled core loop.
|
||||
/// Hard cap on each PixelPass capability probe. Conservative: a slow or hung
|
||||
/// binary degrades to the legacy capability set, never a stalled core loop.
|
||||
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass
|
||||
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app
|
||||
/// share always appends `--strict-audio`, and an **older** pixelpass would have
|
||||
/// clap reject the unknown flag → the host spawn hard-fails and the share is
|
||||
/// broken (audit P2, version skew). When unsupported the picker degrades to
|
||||
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which
|
||||
/// would reintroduce the call echo (A23).
|
||||
///
|
||||
/// Any probe failure/timeout returns `false` (degrade to the safe path). The
|
||||
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger.
|
||||
pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||
/// Capabilities PeerSpeak consumes from PixelPass's versioned response.
|
||||
/// Strict per-app capture and desktop exclusion are independent by contract.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PixelpassCapabilities {
|
||||
pub strict_app_audio: bool,
|
||||
pub desktop_audio_exclusion: bool,
|
||||
}
|
||||
|
||||
/// A capability result tied to the exact resolved executable that produced it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbedPixelpassCapabilities {
|
||||
pub binary: PathBuf,
|
||||
pub capabilities: PixelpassCapabilities,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CapabilityResponse {
|
||||
schema_version: u64,
|
||||
capabilities: CapabilityFlags,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CapabilityFlags {
|
||||
strict_app_audio: bool,
|
||||
desktop_audio_exclusion: bool,
|
||||
}
|
||||
|
||||
/// Parse the schema-1 response from `pixelpass --capabilities`. Unsupported
|
||||
/// schemas and malformed output return `None`, which selects the legacy help
|
||||
/// fallback rather than guessing at a new protocol.
|
||||
pub fn parse_pixelpass_capabilities(stdout: &[u8]) -> Option<PixelpassCapabilities> {
|
||||
let response: CapabilityResponse = serde_json::from_slice(stdout).ok()?;
|
||||
(response.schema_version == 1).then_some(PixelpassCapabilities {
|
||||
strict_app_audio: response.capabilities.strict_app_audio,
|
||||
desktop_audio_exclusion: response.capabilities.desktop_audio_exclusion,
|
||||
})
|
||||
}
|
||||
|
||||
/// Probe one resolved PixelPass binary. The versioned machine response is the
|
||||
/// primary contract. `--help` survives only as a compatibility fallback for an
|
||||
/// older PixelPass that predates `--capabilities`; it can recover strict per-app
|
||||
/// support but can never advertise desktop exclusion.
|
||||
pub async fn probe_pixelpass_capabilities(bin: &Path) -> PixelpassCapabilities {
|
||||
let primary = Command::new(bin)
|
||||
.arg("--capabilities")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
if let Ok(Ok(output)) = tokio::time::timeout(HELP_PROBE_TIMEOUT, primary).await
|
||||
&& output.status.success()
|
||||
&& let Some(capabilities) = parse_pixelpass_capabilities(&output.stdout)
|
||||
{
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
let run = Command::new(bin)
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
@@ -257,12 +443,42 @@ pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
|
||||
Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout),
|
||||
let strict_app_audio = match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
|
||||
Ok(Ok(output)) => help_mentions_strict_audio(&output.stdout),
|
||||
_ => false,
|
||||
};
|
||||
PixelpassCapabilities {
|
||||
strict_app_audio,
|
||||
desktop_audio_exclusion: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return capabilities for `bin`, re-probing and replacing `cached` whenever
|
||||
/// the resolved executable path differs. This is the start-time skew guard:
|
||||
/// capability-gated argv must never be built from a probe of another binary.
|
||||
pub async fn capabilities_for_resolved_binary(
|
||||
bin: &Path,
|
||||
cached: &mut Option<ProbedPixelpassCapabilities>,
|
||||
) -> PixelpassCapabilities {
|
||||
if let Some(probe) = cached.as_ref()
|
||||
&& probe.binary == bin
|
||||
{
|
||||
return probe.capabilities;
|
||||
}
|
||||
let capabilities = probe_pixelpass_capabilities(bin).await;
|
||||
*cached = Some(ProbedPixelpassCapabilities {
|
||||
binary: bin.to_path_buf(),
|
||||
capabilities,
|
||||
});
|
||||
capabilities
|
||||
}
|
||||
|
||||
/// Compatibility helper retained for callers that only need the pre-Phase-8
|
||||
/// per-app bit.
|
||||
pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||
probe_pixelpass_capabilities(bin).await.strict_app_audio
|
||||
}
|
||||
|
||||
/// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
|
||||
/// flag token rather than a whole line, since clap may wrap/realign help text.
|
||||
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool {
|
||||
@@ -360,17 +576,21 @@ pub fn is_available(config_override: Option<&str>) -> bool {
|
||||
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
|
||||
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
||||
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
||||
/// drained in a background task so a full pipe can't stall the host. We do
|
||||
/// drained in a background task so a full pipe can't stall the host. The drain
|
||||
/// forwards every parsed event over `notices` and — the part no share may opt
|
||||
/// out of — a terminal [`HostNotice::Eof`] when the stream ends, which is the
|
||||
/// caller's only reliable signal that the host died. We do
|
||||
/// not pass encode/viewer overrides unless the local settings explicitly ask for
|
||||
/// them, so pixelpass keeps its own defaults in the common case.
|
||||
pub async fn spawn_host(
|
||||
bin: &Path,
|
||||
audio_app: Option<&str>,
|
||||
audio: &ShareAudioSelection,
|
||||
aec_module_index: Option<u64>,
|
||||
settings: &ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||
notices: tokio::sync::mpsc::UnboundedSender<HostNotice>,
|
||||
) -> std::io::Result<(Child, String)> {
|
||||
let args = host_args(audio_app, settings, quality);
|
||||
let args = host_args_for_selection(audio, aec_module_index, settings, quality);
|
||||
// Log the exact argv we hand pixelpass so a field log can confirm which
|
||||
// encode/quality flags (e.g. --bitrate) actually reached the host — these
|
||||
// are local flags with no ticket/secret, so logging them verbatim is safe.
|
||||
@@ -423,7 +643,7 @@ pub async fn spawn_host(
|
||||
if let Some(stderr) = stderr {
|
||||
drain_stderr_in_background(stderr);
|
||||
}
|
||||
drain_in_background(lines, "host", notices);
|
||||
drain_in_background(lines, "host", Some(notices));
|
||||
Ok((child, ticket))
|
||||
}
|
||||
|
||||
@@ -562,13 +782,15 @@ where
|
||||
|
||||
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
||||
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
|
||||
/// parsed event is also forwarded to the caller (the core, which translates the
|
||||
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
|
||||
/// stops forwarding, draining continues. The task ends on EOF (child exited).
|
||||
/// parsed event is also forwarded to the caller (the core), and when the stream
|
||||
/// ends — EOF or read error, i.e. the child exited or its event stream broke —
|
||||
/// a final [`HostNotice::Eof`] is sent so the caller learns the child is gone
|
||||
/// (a host that dies must not stay advertised as sharing). A send failure
|
||||
/// (receiver dropped) just stops forwarding, draining continues.
|
||||
fn drain_in_background<R>(
|
||||
mut lines: tokio::io::Lines<BufReader<R>>,
|
||||
role: &'static str,
|
||||
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||
notices: Option<tokio::sync::mpsc::UnboundedSender<HostNotice>>,
|
||||
) where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
@@ -577,10 +799,14 @@ fn drain_in_background<R>(
|
||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||
if let Some(tx) = ¬ices {
|
||||
let _ = tx.send(ev);
|
||||
let _ = tx.send(HostNotice::Event(ev));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(tx) = ¬ices {
|
||||
crate::log_msg(&format!("pixelpass {role}: stdout ended"));
|
||||
let _ = tx.send(HostNotice::Eof);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -599,6 +825,7 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
||||
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
||||
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
|
||||
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
|
||||
PixelpassEvent::AudioExclusion(status) => format!("audio_exclusion {status:?}"),
|
||||
PixelpassEvent::Other => "other".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -607,16 +834,28 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
||||
/// player is reaped in a background task so it doesn't linger as a zombie when
|
||||
/// its window closes.
|
||||
///
|
||||
/// The flags keep latency low while preserving A/V sync. We deliberately do
|
||||
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
|
||||
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
|
||||
/// progressively out of sync with its audio. Pacing to the audio clock costs a
|
||||
/// little latency (negligible for pointing at a desktop) and keeps a shared
|
||||
/// video in sync. We also leave hwdec at the `low-latency` default (software
|
||||
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
|
||||
/// kept playing.
|
||||
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
|
||||
/// keeps the viewer at the live edge: mpv gets an IPC socket and [`livesync`]
|
||||
/// drains a lagging buffer by playing slightly fast (pitch-corrected, so A/V
|
||||
/// sync is preserved). Smooth leaves a deeper buffer alone, trading live
|
||||
/// latency for immunity to jitter. Hardware decoding remains opt-in: forcing
|
||||
/// `--hwdec=auto` froze some viewers on frame 1 while audio kept playing.
|
||||
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
|
||||
let mpv_args = mpv_args(settings);
|
||||
// One socket per viewer launch, so overlapping shares can't collide on it.
|
||||
// Unix only: mpv's IPC is a named pipe on Windows, which `livesync` does not
|
||||
// speak, and an unusable socket path on the argv would help nobody.
|
||||
#[cfg(unix)]
|
||||
let ipc_socket = Some(livesync::socket_path(
|
||||
&livesync::socket_dir(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0),
|
||||
));
|
||||
#[cfg(not(unix))]
|
||||
let ipc_socket: Option<PathBuf> = None;
|
||||
|
||||
let mpv_args = mpv_args(settings, ipc_socket.as_deref());
|
||||
let vlc_args = vlc_args(settings);
|
||||
let first = match settings.player {
|
||||
SharePlayer::Mpv => ("mpv", &mpv_args),
|
||||
@@ -627,15 +866,32 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
|
||||
SharePlayer::Vlc => ("mpv", &mpv_args),
|
||||
};
|
||||
|
||||
let child = match spawn_player(first.0, first.1, url) {
|
||||
Ok(c) => c,
|
||||
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no media player found — install mpv or vlc to watch screen shares",
|
||||
)
|
||||
})?,
|
||||
let (launched, child) = match spawn_player(first.0, first.1, url) {
|
||||
Ok(c) => (first.0, c),
|
||||
Err(_) => (
|
||||
second.0,
|
||||
spawn_player(second.0, second.1, url).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no media player found — install mpv or vlc to watch screen shares",
|
||||
)
|
||||
})?,
|
||||
),
|
||||
};
|
||||
|
||||
// Only when the socket actually reached the argv: mpv (VLC has no
|
||||
// equivalent IPC) in the Low latency posture. The driver ends by itself when
|
||||
// the player exits, so it needs no shutdown path.
|
||||
#[cfg(unix)]
|
||||
if launched == "mpv"
|
||||
&& settings.buffering == ShareBuffering::LowLatency
|
||||
&& let Some(socket) = ipc_socket
|
||||
{
|
||||
tokio::spawn(livesync::drive(socket));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = launched;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut child = child;
|
||||
let _ = child.wait().await;
|
||||
@@ -643,11 +899,23 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
|
||||
/// Build the argv for an mpv viewer.
|
||||
///
|
||||
/// `ipc_socket` is where mpv should expose its JSON IPC socket so [`livesync`]
|
||||
/// can drain a lagging buffer. It is wired up for Low latency only: Smooth
|
||||
/// deliberately holds a ~2 s readahead, which the catch-up thresholds would
|
||||
/// fight on every poll.
|
||||
pub fn mpv_args(settings: &ScreenShareSettings, ipc_socket: Option<&Path>) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
match settings.buffering {
|
||||
ShareBuffering::LowLatency => {
|
||||
args.push("--profile=low-latency".to_string());
|
||||
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP, so a
|
||||
// lossy link turns every retransmission into buffered latency that
|
||||
// nothing trims back. `--untimed` does NOT fix that (measured
|
||||
// marginally worse: it only unpaces *presentation*, while audio
|
||||
// still drains at 1x, so the backlog never shrinks) — the viewer
|
||||
// instead drains it by playing slightly fast, see `livesync`.
|
||||
args.push("--audio-buffer=0.2".to_string());
|
||||
args.push("--demuxer-readahead-secs=0.5".to_string());
|
||||
}
|
||||
@@ -656,10 +924,25 @@ pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
|
||||
args.push("--demuxer-readahead-secs=2".to_string());
|
||||
}
|
||||
}
|
||||
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
|
||||
// The byte cap is what bounds how far behind a viewer can silently fall:
|
||||
// a demuxer allowed 2 MiB will happily sit on ~6 s of a 2.5 Mbps share (as
|
||||
// measured on the netem rig) and call it a buffer. Low latency therefore
|
||||
// gets a tighter ceiling than the user's Smooth-oriented setting, so the
|
||||
// catch-up has less to claw back after a bad patch of link.
|
||||
let cache_mb = match settings.buffering {
|
||||
ShareBuffering::LowLatency => settings.cache_mb.min(LOW_LATENCY_CACHE_CAP_MB),
|
||||
ShareBuffering::Smooth => settings.cache_mb,
|
||||
};
|
||||
args.push(format!("--demuxer-max-bytes={cache_mb}M"));
|
||||
if settings.hardware_decode {
|
||||
args.push("--hwdec=auto".to_string());
|
||||
}
|
||||
if let Some(socket) = ipc_socket
|
||||
&& settings.buffering == ShareBuffering::LowLatency
|
||||
{
|
||||
args.push(format!("--input-ipc-server={}", socket.display()));
|
||||
}
|
||||
// Extra args stay last so a user override wins over everything above.
|
||||
args.extend(split_extra_args(&settings.extra_mpv_args));
|
||||
args
|
||||
}
|
||||
@@ -701,20 +984,69 @@ fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child>
|
||||
// and is not needed to verify the flags. Logged on each attempt, so a
|
||||
// fallback from the preferred player to the other one is visible too.
|
||||
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
||||
Command::new(bin)
|
||||
let mut command = Command::new(bin);
|
||||
command
|
||||
.args(args)
|
||||
.arg(url)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(false)
|
||||
.spawn()
|
||||
.kill_on_drop(false);
|
||||
// Ownership tag (plan §5.1): this player is playing the *incoming*
|
||||
// screenshare's audio, so it is exactly what must not be fanned back out
|
||||
// if this machine also starts sharing. The role is the player binary, so
|
||||
// a `pw-dump` during a field test names which one produced the node.
|
||||
ownership::tag_child(command.as_std_mut(), bin);
|
||||
command.spawn()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Phase-1 exit gate, player half (impl plan §3): the mpv peerspeak
|
||||
/// actually spawns produces a live node carrying **both** ownership
|
||||
/// carriers, tagged with the player's own name as the role.
|
||||
///
|
||||
/// ⚠️ Drives the real [`spawn_player`], for the same reason the notify
|
||||
/// gate does: the plan requires the tag to be shown "landing on a live
|
||||
/// mpv node, not just in the env". Plays a silent WAV, so it is quiet.
|
||||
///
|
||||
/// Live: needs PipeWire, `mpv` and `pw-dump`.
|
||||
/// `cargo test --lib -- --ignored spawned_player`
|
||||
#[tokio::test]
|
||||
#[ignore = "live: requires a running PipeWire daemon, mpv and pw-dump"]
|
||||
async fn spawned_player_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::live_test;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("peerspeak-playertest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("silence.wav");
|
||||
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
|
||||
|
||||
let mut child = spawn_player(
|
||||
"mpv",
|
||||
&["--no-video".to_string(), "--really-quiet".to_string()],
|
||||
path.to_str().unwrap(),
|
||||
)
|
||||
.expect("mpv spawns");
|
||||
|
||||
// The role is the player binary, so this also pins that the call site
|
||||
// passes `bin` and not a fixed literal.
|
||||
let prefix = live_test::expected_prefix("mpv");
|
||||
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
|
||||
let _ = child.kill().await;
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(owned.as_deref(), Some(ownership::OWNED_PROP_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_args_guard_neutralizes_flag_like_ticket() {
|
||||
// A malicious "ticket" that looks like a flag must end up positional,
|
||||
@@ -774,6 +1106,64 @@ mod tests {
|
||||
assert_eq!(args[4], "--strict-audio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_excluding_argv_requires_both_public_mode_and_explicit_aec() {
|
||||
let settings = ScreenShareSettings::default();
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::DesktopExcluding,
|
||||
Some(536_870_919),
|
||||
&settings,
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
vec![
|
||||
"--host",
|
||||
"--output",
|
||||
"json",
|
||||
"--audio-mode=desktop-excluding",
|
||||
"--aec=pulse-module:536870919",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::DesktopExcluding,
|
||||
None,
|
||||
&settings,
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
vec![
|
||||
"--host",
|
||||
"--output",
|
||||
"json",
|
||||
"--audio-mode=desktop-excluding",
|
||||
"--aec=off",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_legacy_selections_keep_existing_argv_byte_identical() {
|
||||
let settings = ScreenShareSettings::default();
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::DesktopShared,
|
||||
Some(42),
|
||||
&settings,
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
host_args(None, &settings, ShareQuality::Auto),
|
||||
);
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::Application("Firefox".to_string()),
|
||||
Some(42),
|
||||
&settings,
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
host_args(Some("Firefox"), &settings, ShareQuality::Auto),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_args_blank_or_control_app_is_dropped() {
|
||||
// An empty / whitespace / control-laden selection is sanitized away,
|
||||
@@ -825,16 +1215,86 @@ mod tests {
|
||||
#[test]
|
||||
fn mpv_args_default_matches_low_latency_software_decode() {
|
||||
assert_eq!(
|
||||
mpv_args(&ScreenShareSettings::default()),
|
||||
mpv_args(&ScreenShareSettings::default(), None),
|
||||
vec![
|
||||
"--profile=low-latency",
|
||||
"--audio-buffer=0.2",
|
||||
"--demuxer-readahead-secs=0.5",
|
||||
"--demuxer-max-bytes=2M",
|
||||
"--demuxer-max-bytes=1M",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_latency_gets_the_ipc_socket_for_live_edge_catch_up() {
|
||||
let args = mpv_args(
|
||||
&ScreenShareSettings::default(),
|
||||
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--input-ipc-server=/run/user/1000/peerspeak-mpv-1.sock".to_string()),
|
||||
"low latency drains a lagging buffer over mpv IPC: {args:?}"
|
||||
);
|
||||
// The flag that used to hold this posture at the live edge measured no
|
||||
// better than pacing, and cost A/V sync — it must not come back.
|
||||
assert!(!args.contains(&"--untimed".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_keeps_its_deep_buffer_and_gets_no_ipc_socket() {
|
||||
let settings = ScreenShareSettings {
|
||||
buffering: ShareBuffering::Smooth,
|
||||
..ScreenShareSettings::default()
|
||||
};
|
||||
let args = mpv_args(
|
||||
&settings,
|
||||
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
|
||||
);
|
||||
assert!(
|
||||
!args.iter().any(|a| a.starts_with("--input-ipc-server")),
|
||||
"catch-up would fight Smooth's deliberate ~2s readahead: {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_latency_caps_the_byte_cache_but_smooth_keeps_the_user_value() {
|
||||
// The cache is a byte budget, so at a given bitrate it sets the
|
||||
// worst-case backlog: 2 MiB held ~6 s of a 2.5 Mbps share on the rig.
|
||||
let generous = ScreenShareSettings {
|
||||
cache_mb: 32,
|
||||
..ScreenShareSettings::default()
|
||||
};
|
||||
assert!(
|
||||
mpv_args(&generous, None)
|
||||
.contains(&format!("--demuxer-max-bytes={LOW_LATENCY_CACHE_CAP_MB}M")),
|
||||
"low latency must bound how far behind the viewer can silently fall"
|
||||
);
|
||||
|
||||
let smooth = ScreenShareSettings {
|
||||
cache_mb: 32,
|
||||
buffering: ShareBuffering::Smooth,
|
||||
..ScreenShareSettings::default()
|
||||
};
|
||||
assert!(
|
||||
mpv_args(&smooth, None).contains(&"--demuxer-max-bytes=32M".to_string()),
|
||||
"smooth is the posture where the user asked for a deep buffer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_extra_args_still_come_last() {
|
||||
let settings = ScreenShareSettings {
|
||||
extra_mpv_args: "--no-osc".to_string(),
|
||||
..ScreenShareSettings::default()
|
||||
};
|
||||
let args = mpv_args(&settings, Some(Path::new("/tmp/s.sock")));
|
||||
assert_eq!(
|
||||
args.last().map(String::as_str),
|
||||
Some("--no-osc"),
|
||||
"a user override has to win over everything we add: {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
|
||||
let settings = ScreenShareSettings {
|
||||
@@ -846,7 +1306,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
mpv_args(&settings),
|
||||
mpv_args(&settings, None),
|
||||
vec![
|
||||
"--cache=yes",
|
||||
"--demuxer-readahead-secs=2",
|
||||
@@ -1008,6 +1468,106 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_schema_keeps_strict_and_desktop_exclusion_independent() {
|
||||
assert_eq!(
|
||||
parse_pixelpass_capabilities(
|
||||
br#"{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":false}}"#,
|
||||
),
|
||||
Some(PixelpassCapabilities {
|
||||
strict_app_audio: true,
|
||||
desktop_audio_exclusion: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_capabilities(
|
||||
br#"{"schema_version":1,"capabilities":{"strict_app_audio":false,"desktop_audio_exclusion":true}}"#,
|
||||
),
|
||||
Some(PixelpassCapabilities {
|
||||
strict_app_audio: false,
|
||||
desktop_audio_exclusion: true,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
parse_pixelpass_capabilities(
|
||||
br#"{"schema_version":2,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}"#,
|
||||
)
|
||||
.is_none(),
|
||||
"an unknown schema must not advertise the new mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_fake_pixelpass(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, body).unwrap();
|
||||
let mut permissions = std::fs::metadata(&path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&path, permissions).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn old_pixelpass_help_fallback_cannot_advertise_desktop_exclusion() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"peerspeak-phase8-old-pixelpass-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bin = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-old",
|
||||
"#!/bin/sh\nif [ \"$1\" = \"--capabilities\" ]; then exit 2; fi\nprintf '%s\\n' 'Options: --app <APP> --strict-audio --output <OUTPUT>'\n",
|
||||
);
|
||||
|
||||
let capabilities = probe_pixelpass_capabilities(&bin).await;
|
||||
assert!(capabilities.strict_app_audio);
|
||||
assert!(!capabilities.desktop_audio_exclusion);
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::DesktopShared,
|
||||
None,
|
||||
&ScreenShareSettings::default(),
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
vec!["--host", "--output", "json"],
|
||||
"old-PixelPass fallback must emit no new flags"
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn capability_cache_reprobes_when_the_resolved_binary_changes() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("peerspeak-phase8-rebind-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let new_bin = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-new",
|
||||
"#!/bin/sh\nprintf '%s\\n' '{\"schema_version\":1,\"capabilities\":{\"strict_app_audio\":true,\"desktop_audio_exclusion\":true}}'\n",
|
||||
);
|
||||
let old_bin = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-old",
|
||||
"#!/bin/sh\nif [ \"$1\" = \"--capabilities\" ]; then exit 2; fi\nprintf '%s\\n' 'Options: --output <OUTPUT>'\n",
|
||||
);
|
||||
|
||||
let mut cached = None;
|
||||
let first = capabilities_for_resolved_binary(&new_bin, &mut cached).await;
|
||||
assert!(first.desktop_audio_exclusion);
|
||||
assert_eq!(cached.as_ref().unwrap().binary, new_bin);
|
||||
|
||||
let rebound = capabilities_for_resolved_binary(&old_bin, &mut cached).await;
|
||||
assert!(!rebound.desktop_audio_exclusion);
|
||||
assert!(!rebound.strict_app_audio);
|
||||
assert_eq!(cached.as_ref().unwrap().binary, old_bin);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
@@ -1125,6 +1685,64 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_all_version_one_audio_exclusion_statuses_exactly() {
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(
|
||||
r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967303,"reason":"port-exclusive"}"#,
|
||||
),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: 4_294_967_303,
|
||||
reason: "port-exclusive".to_string(),
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(
|
||||
r#"{"event":"stream_status_cleared","version":1,"stream_serial":4294967303}"#,
|
||||
),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::StreamStatusCleared {
|
||||
stream_serial: 4_294_967_303,
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"aec_failed","version":1,"module_index":536870919}"#,),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::AecFailed {
|
||||
module_index: 536_870_919,
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(
|
||||
r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#,
|
||||
),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::AecRevoked {
|
||||
module_index: 536_870_919,
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(
|
||||
r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#,
|
||||
),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::ForeignAecWarning {
|
||||
link_group: "echo-cancel-9999-13".to_string(),
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"aec_failed","version":2,"module_index":536870919}"#,),
|
||||
Some(PixelpassEvent::Other),
|
||||
"an unknown wire version must not be misinterpreted as version 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognized_but_unused_event_is_other() {
|
||||
assert_eq!(
|
||||
@@ -1191,4 +1809,63 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||
}
|
||||
|
||||
/// The host-fault contract, clean-exit half: events are forwarded in order
|
||||
/// and the stream ending yields exactly one terminal [`HostNotice::Eof`],
|
||||
/// after which the drain task drops its sender (the closed channel is what
|
||||
/// ends the core's forwarder). A host that dies silently — EOF swallowed —
|
||||
/// is the S2 defect: the dead share stays advertised in presence.
|
||||
#[tokio::test]
|
||||
async fn drain_forwards_events_then_synthesizes_eof_when_stdout_ends() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (read_half, mut write_half) = tokio::io::duplex(1024);
|
||||
drain_in_background(BufReader::new(read_half).lines(), "test", Some(tx));
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
write_half
|
||||
.write_all(b"{\"event\":\"app_audio\",\"state\":\"routed\"}\nnot json\n")
|
||||
.await
|
||||
.unwrap();
|
||||
drop(write_half); // child exited: stdout EOF
|
||||
|
||||
assert_eq!(
|
||||
rx.recv().await,
|
||||
Some(HostNotice::Event(PixelpassEvent::AppAudioRouted))
|
||||
);
|
||||
// The non-JSON line is dropped, not forwarded.
|
||||
assert_eq!(rx.recv().await, Some(HostNotice::Eof));
|
||||
assert_eq!(rx.recv().await, None, "task ended and dropped the sender");
|
||||
}
|
||||
|
||||
/// The host-fault contract, broken-stream half: a read *error* (not a tidy
|
||||
/// EOF) must synthesize the same terminal `Eof` — the event stream is gone
|
||||
/// either way, and only the drain task can tell the core so.
|
||||
#[tokio::test]
|
||||
async fn drain_synthesizes_eof_on_a_read_error_too() {
|
||||
struct BrokenPipe;
|
||||
impl tokio::io::AsyncRead for BrokenPipe {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
_buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Err(std::io::Error::other("stream broke")))
|
||||
}
|
||||
}
|
||||
|
||||
use tokio::io::AsyncReadExt;
|
||||
// One good event line, then the stream breaks mid-read.
|
||||
let reader =
|
||||
std::io::Cursor::new(b"{\"event\":\"capture\",\"state\":\"started\"}\n".to_vec())
|
||||
.chain(BrokenPipe);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
drain_in_background(BufReader::new(reader).lines(), "test", Some(tx));
|
||||
|
||||
assert_eq!(
|
||||
rx.recv().await,
|
||||
Some(HostNotice::Event(PixelpassEvent::CaptureStarted))
|
||||
);
|
||||
assert_eq!(rx.recv().await, Some(HostNotice::Eof));
|
||||
assert_eq!(rx.recv().await, None, "task ended and dropped the sender");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,13 +435,10 @@ where
|
||||
|
||||
let was_hovered = self.hovered_link.is_some();
|
||||
self.hovered_link = local_position.and_then(|position| {
|
||||
state.paragraph.hit_span(position).and_then(|span| {
|
||||
if spans.get(span)?.link.is_some() {
|
||||
Some(span)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
state
|
||||
.paragraph
|
||||
.hit_span(position)
|
||||
.filter(|&span| spans.get(span).is_some_and(|span| span.link.is_some()))
|
||||
});
|
||||
|
||||
if was_hovered != self.hovered_link.is_some() {
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Screenshare audio exclusion — ownership tagging wire contract.
|
||||
#
|
||||
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
|
||||
# CONSUMES them as the primary taint root of the exclusion engine. Neither
|
||||
# repo depends on the other, so this file is the contract: it is committed
|
||||
# byte-identical in both, and each repo has a test that asserts its own named
|
||||
# constants (and, on the producer side, the environment a real child Command
|
||||
# would carry) match these values exactly.
|
||||
#
|
||||
# peerspeak/tests/fixtures/ownership-tag-contract.txt
|
||||
# pixelpass/tests/fixtures/ownership-tag-contract.txt
|
||||
#
|
||||
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
|
||||
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
|
||||
# is a cross-repo breaking change: both repos must land in the same session,
|
||||
# and the phase 5 matrix must be re-run.
|
||||
#
|
||||
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
|
||||
# matches. Round 8 added the second because a property is invisible to the
|
||||
# PipeWire registry `global` event and readable only via a node bind, so the
|
||||
# primary taint root must not rest on one observation mechanism alone.
|
||||
|
||||
# Carrier 1 — a node property, matched EXACTLY: `prop_value` below is the
|
||||
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
|
||||
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
|
||||
#
|
||||
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
|
||||
# used to accept any value other than "false"/"0", on the theory that leniency
|
||||
# over-excludes and is therefore safe. It is not: leniency buys false-positive
|
||||
# exclusion, and it let any process suppress a rival application's audio from
|
||||
# the share with a property it did not even have to spell right. Fail-closed
|
||||
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
|
||||
# not about parsing.
|
||||
prop_key=peerspeak.owned
|
||||
prop_value=1
|
||||
|
||||
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
|
||||
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
|
||||
# Only the prefix is matched; the rest of the name is for diagnostics.
|
||||
node_name_prefix=peerspeak_owned_
|
||||
node_name_format=peerspeak_owned_<role>_<pid>
|
||||
node_name_example=peerspeak_owned_mpv_31284
|
||||
@@ -0,0 +1,590 @@
|
||||
//! S2 exit gate: a pixelpass host that dies mid-share must be torn down —
|
||||
//! reaped, pulled off presence, `ScreenShareStopped` emitted **before** the
|
||||
//! explanatory error — and a host stopped *deliberately* must NOT produce that
|
||||
//! error when its stdout EOF arrives late (the staleness gate).
|
||||
//!
|
||||
//! Drives the real core loop end to end through `CoreController`, with the
|
||||
//! pixelpass override pointed at fake shell scripts: one that emits a ticket
|
||||
//! and dies, one that emits a ticket and lives until signalled. This is the
|
||||
//! only harness that reaches the core's fault handler — the command loop has
|
||||
//! no unit seam — so these two halves are what kill the "forwarder drops the
|
||||
//! Eof" and "handler ignores the generation" mutants.
|
||||
//!
|
||||
//! Live: joins a real (solo) room, so it needs a working audio backend and
|
||||
//! network access for the endpoint bind.
|
||||
//! `cargo test --test screenshare_host_fault -- --ignored`
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::core::CoreController;
|
||||
use peerspeak::core::messages::{CoreCommand, UiEvent};
|
||||
use peerspeak::screenshare::ShareAudioSelection;
|
||||
|
||||
const EVENT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// How long to listen for events that must NOT arrive. Comfortably past the
|
||||
/// fake host's exit plus the drain/forwarder hop, so a stale fault that WOULD
|
||||
/// be mishandled has arrived by the end of it.
|
||||
const QUIET_WINDOW: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Removes the fake-pixelpass dir even when an assertion panics mid-test
|
||||
/// (a plain trailing `remove_dir_all` never runs on an unwind).
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_fake_pixelpass(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, body).expect("write fake pixelpass");
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod fake pixelpass");
|
||||
path
|
||||
}
|
||||
|
||||
/// Skip events until `pick` matches, panicking after [`EVENT_TIMEOUT`].
|
||||
/// Unrelated events (identity, presence, chat plumbing) flow on this channel
|
||||
/// too, so gates scan rather than assert exact sequences.
|
||||
async fn wait_for<T>(
|
||||
rx: &mut tokio::sync::mpsc::Receiver<UiEvent>,
|
||||
what: &str,
|
||||
mut pick: impl FnMut(&UiEvent) -> Option<T>,
|
||||
) -> T {
|
||||
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||
loop {
|
||||
let ev = tokio::time::timeout_at(deadline, rx.recv())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("timed out waiting for {what}"))
|
||||
.unwrap_or_else(|| panic!("ui channel closed waiting for {what}"));
|
||||
if let Some(v) = pick(&ev) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live: joins a real solo room (audio backend + network bind)"]
|
||||
async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() {
|
||||
let dir_guard =
|
||||
TempDir(std::env::temp_dir().join(format!("peerspeak-hostfault-{}", std::process::id())));
|
||||
let dir = dir_guard.0.clone();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Half 1's host: emits its ticket, then dies on its own — the S2 defect
|
||||
// scenario. Plain `sleep` (no exec) so the shell itself exits and closes
|
||||
// stdout with no orphan holding the pipe.
|
||||
let dying_host = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-dies",
|
||||
"#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-dies\"}'\nsleep 1\n",
|
||||
);
|
||||
// Half 2's host: lives until signalled. `exec` so the SIGINT from Stop
|
||||
// Share hits the sleep itself — the process dies AND its stdout closes,
|
||||
// which is exactly what makes the late Eof arrive and exercise the
|
||||
// staleness gate rather than vacuously never sending a fault.
|
||||
let living_host = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-lives",
|
||||
"#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-lives\"}'\nexec sleep 600\n",
|
||||
);
|
||||
|
||||
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||
let controller = CoreController::new(ui_tx);
|
||||
|
||||
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
|
||||
dying_host.to_string_lossy().into_owned()
|
||||
))));
|
||||
assert!(controller.send(CoreCommand::Join {
|
||||
name: "host-fault-gate".into(),
|
||||
ticket: "create".into(),
|
||||
room_name: "s2".into(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: false,
|
||||
avatar: Default::default(),
|
||||
}));
|
||||
wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
|
||||
UiEvent::RoomJoined { .. } => Some(()),
|
||||
UiEvent::Error(e) => panic!("join failed: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// ── Half 1: the host dies mid-share ─────────────────────────────────────
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStarted (dying host)",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStarted => Some(()),
|
||||
UiEvent::Error(e) => panic!("share start failed: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// The fake host exits ~1s in. The contract: ScreenShareStopped FIRST (it
|
||||
// clears the UI's sharing state), the explanatory error only after.
|
||||
wait_for(&mut ui_rx, "ScreenShareStopped after host death", |ev| {
|
||||
match ev {
|
||||
UiEvent::ScreenShareStopped => Some(()),
|
||||
// An error arriving first is the exact ordering defect S2 fixes:
|
||||
// the UI would show "sharing" next to the explanation.
|
||||
UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
|
||||
UiEvent::Error(e) => Some(e.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
err.contains("unexpectedly"),
|
||||
"the error should say the share ended unexpectedly, got: {err}"
|
||||
);
|
||||
|
||||
// ── Half 2: a deliberate stop must stay clean ───────────────────────────
|
||||
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
|
||||
living_host.to_string_lossy().into_owned()
|
||||
))));
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStarted (living host)",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStarted => Some(()),
|
||||
UiEvent::Error(e) => panic!("second share start failed: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(controller.send(CoreCommand::StopScreenShare));
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStopped after Stop Share",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStopped => Some(()),
|
||||
UiEvent::Error(e) => panic!("clean stop produced an error: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// The stopped host's stdout EOF is arriving about now as a *stale* fault
|
||||
// (its generation was retired when Stop Share cleared the share). Without
|
||||
// the staleness gate the handler would emit a second ScreenShareStopped
|
||||
// and a spurious "ended unexpectedly" error — listen long enough for that
|
||||
// mishandling to have shown up, and require silence.
|
||||
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
|
||||
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
|
||||
match ev {
|
||||
UiEvent::ScreenShareStopped => {
|
||||
panic!("stale host fault re-emitted ScreenShareStopped after a clean stop")
|
||||
}
|
||||
UiEvent::Error(e) if e.contains("unexpectedly") => {
|
||||
panic!("stale host fault surfaced as an error after a clean stop: {e}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Half 3: a failed room switch while sharing must not cry "crash" ─────
|
||||
// Join tears the old session down (killing the host, deliberately) BEFORE
|
||||
// it validates the ticket, so an invalid ticket exits the Join arm early.
|
||||
// The share must be retired at the teardown itself — left advertised, the
|
||||
// killed host's EOF passes the staleness gate and a spurious "ended
|
||||
// unexpectedly" lands on top of the ticket error (Gemini review, P2-1).
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStarted (before failed switch)",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStarted => Some(()),
|
||||
UiEvent::Error(e) => panic!("third share start failed: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(controller.send(CoreCommand::Join {
|
||||
name: "host-fault-gate".into(),
|
||||
ticket: "definitely-not-a-ticket".into(),
|
||||
room_name: "s2".into(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: false,
|
||||
avatar: Default::default(),
|
||||
}));
|
||||
wait_for(&mut ui_rx, "the invalid-ticket error", |ev| match ev {
|
||||
UiEvent::Error(e) if e.contains("invalid room ticket") => Some(()),
|
||||
UiEvent::Error(e) => panic!("unexpected error before the ticket error: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
// The deliberately-killed host's EOF is arriving about now; it must be
|
||||
// dropped as stale, not reported as a crash.
|
||||
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
|
||||
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
|
||||
match ev {
|
||||
UiEvent::ScreenShareStopped => {
|
||||
panic!("failed room switch re-emitted ScreenShareStopped for the torn-down share")
|
||||
}
|
||||
UiEvent::Error(e) if e.contains("unexpectedly") => {
|
||||
panic!("deliberate teardown during a failed room switch reported as a crash: {e}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// S2 presence gate: a host fault must pull the share ticket off PRESENCE —
|
||||
/// what remote peers actually see — and must do it BEFORE the reap wait, not
|
||||
/// after. Nothing on the sharer's own `UiEvent` channel can witness either
|
||||
/// half (presence is only observable from another node), so this test runs a
|
||||
/// real second core as an OBSERVER and asserts the sharer's `PeerState.sharing`
|
||||
/// goes `Some` → `None` on fault.
|
||||
///
|
||||
/// The observer runs in a SEPARATE PROCESS (`presence_probe_helper`, this same
|
||||
/// test binary re-invoked): two in-process cores would load the same
|
||||
/// `identity.key` and collapse into one node id, and swapping `XDG_CONFIG_HOME`
|
||||
/// between spawns in-process races other threads' getenv.
|
||||
///
|
||||
/// The fake host is a WEDGE — it closes stdout (the fault) but ignores SIGINT
|
||||
/// and lives until the SIGKILL fallback — so `stop_host` burns the full 2 s
|
||||
/// grace and TIME becomes the discriminator, exactly like the SIGINT gate:
|
||||
/// with presence-removal-first the observer sees the ticket clear ~1 s after
|
||||
/// it appeared (the wedge's pre-fault lifetime); with the old
|
||||
/// reap-then-presence ordering, only after ~3 s. The bound also makes the
|
||||
/// "presence removal deleted" mutant fail by timeout instead of passing
|
||||
/// vacuously.
|
||||
///
|
||||
/// Live: two real solo-room cores (audio backend + network bind each).
|
||||
#[tokio::test]
|
||||
#[ignore = "live: two real cores in one room (audio backend + network bind), observer subprocess"]
|
||||
async fn a_host_fault_pulls_the_ticket_off_presence_within_the_grace() {
|
||||
/// Mirrors `core::teardown::STOP_GRACE` (private): the wait the wedge
|
||||
/// forces before the SIGKILL fallback reaps it.
|
||||
const STOP_GRACE_MS: u128 = 2000;
|
||||
|
||||
let dir_guard = TempDir(
|
||||
std::env::temp_dir().join(format!("peerspeak-presence-gate-{}", std::process::id())),
|
||||
);
|
||||
let dir = dir_guard.0.clone();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Emits its ticket, shares for ~1 s, then closes stdout (the fault) while
|
||||
// staying alive and ignoring SIGINT, so the reap must wait out the grace.
|
||||
// The trailing sleep is NOT exec'd on purpose: it forks after stdout is
|
||||
// closed, so it holds no pipe (the vacuous-staleness trap doesn't apply),
|
||||
// and it merely idles out after the SIGKILL reaps the shell.
|
||||
//
|
||||
// The fake ticket must pass `screenshare::sanitize_ticket` (`endpoint` +
|
||||
// alphanumerics): the OBSERVER's gossip ingest sanitizes peer-advertised
|
||||
// tickets, and a garbage one is nulled to `sharing: None` there — the
|
||||
// probe would never see the share appear and the gate would go vacuous.
|
||||
let wedged_host = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-wedges",
|
||||
"#!/bin/sh\ntrap '' INT\n\
|
||||
echo '{\"event\":\"ticket\",\"value\":\"endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm\"}'\n\
|
||||
sleep 1\nexec 1>&-\nsleep 30\n",
|
||||
);
|
||||
|
||||
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||
let controller = CoreController::new(ui_tx);
|
||||
|
||||
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
|
||||
wedged_host.to_string_lossy().into_owned()
|
||||
))));
|
||||
assert!(controller.send(CoreCommand::Join {
|
||||
name: "presence-gate".into(),
|
||||
ticket: "create".into(),
|
||||
room_name: "s2-presence".into(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: false,
|
||||
avatar: Default::default(),
|
||||
}));
|
||||
let room_ticket = wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
|
||||
UiEvent::RoomJoined { ticket, .. } => Some(ticket.clone()),
|
||||
UiEvent::Error(e) => panic!("join failed: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// The observer, in its own process with its own config dir (fresh
|
||||
// identity). It prints `PROBE …` lines this test parses.
|
||||
let probe_config = dir.join("probe-config");
|
||||
std::fs::create_dir_all(&probe_config).unwrap();
|
||||
let probe = tokio::process::Command::new(std::env::current_exe().unwrap())
|
||||
.kill_on_drop(true)
|
||||
.args([
|
||||
"presence_probe_helper",
|
||||
"--exact",
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("PEERSPEAK_PROBE_TICKET", &room_ticket)
|
||||
.env("XDG_CONFIG_HOME", &probe_config)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn the presence probe");
|
||||
|
||||
// Only share once the probe is in the room, so it witnesses the ticket
|
||||
// APPEARING before the fault clears it (otherwise `Some` → `None` could
|
||||
// both predate its join and the gate would go vacuous).
|
||||
wait_for(&mut ui_rx, "the probe's PeerJoined", |ev| match ev {
|
||||
UiEvent::PeerJoined { .. } => Some(()),
|
||||
UiEvent::Error(e) => panic!("waiting for the probe: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStarted (wedged host)",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStarted => Some(()),
|
||||
UiEvent::Error(e) => panic!("share start failed: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// Sharer-side contract, unchanged by the reorder: Stopped first, the
|
||||
// explanatory error only after.
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStopped after the wedge faults",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStopped => Some(()),
|
||||
UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
|
||||
UiEvent::Error(e) => Some(e.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
err.contains("unexpectedly"),
|
||||
"the error should say the share ended unexpectedly, got: {err}"
|
||||
);
|
||||
|
||||
let out = tokio::time::timeout(Duration::from_secs(60), probe.wait_with_output())
|
||||
.await
|
||||
.expect("probe process outlived its budget")
|
||||
.expect("probe process wait");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"probe failed ({}).\nstdout:\n{stdout}\nstderr:\n{stderr}",
|
||||
out.status
|
||||
);
|
||||
let cleared_ms: u128 = stdout
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("PROBE sharing-cleared "))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("probe never saw the ticket clear from presence.\nstdout:\n{stdout}")
|
||||
})
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("probe delta should be integer millis");
|
||||
// Presence-removal-first: ~1000 ms (the wedge's pre-fault lifetime).
|
||||
// Reap-then-presence: ~3000 ms (lifetime + the full stop grace). The
|
||||
// grace itself splits them with ~1 s of jitter headroom on each side.
|
||||
assert!(
|
||||
cleared_ms < STOP_GRACE_MS,
|
||||
"presence kept advertising the dead share for {cleared_ms} ms after it appeared — \
|
||||
at or past the wedge lifetime + stop grace, i.e. the ticket was only removed \
|
||||
AFTER the reap wait instead of before it"
|
||||
);
|
||||
|
||||
assert!(controller.send(CoreCommand::Leave));
|
||||
}
|
||||
|
||||
/// Observer half of `a_host_fault_pulls_the_ticket_off_presence_within_the_grace`,
|
||||
/// run BY that test as a subprocess. Standalone (no `PEERSPEAK_PROBE_TICKET` in
|
||||
/// the env — e.g. a plain `--ignored` sweep) it is a no-op pass.
|
||||
#[tokio::test]
|
||||
#[ignore = "helper: spawned by the presence gate as a subprocess; standalone it no-ops"]
|
||||
async fn presence_probe_helper() {
|
||||
let Ok(room_ticket) = std::env::var("PEERSPEAK_PROBE_TICKET") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||
let controller = CoreController::new(ui_tx);
|
||||
assert!(controller.send(CoreCommand::Join {
|
||||
name: "presence-probe".into(),
|
||||
ticket: room_ticket,
|
||||
room_name: String::new(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: false,
|
||||
avatar: Default::default(),
|
||||
}));
|
||||
wait_for(&mut ui_rx, "RoomJoined (probe)", |ev| match ev {
|
||||
UiEvent::RoomJoined { .. } => Some(()),
|
||||
UiEvent::Error(e) => panic!("probe join failed: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Watch the sharer's presence: record when its `sharing` ticket appears,
|
||||
// report the delta when it clears. Timings on both ends are local-loopback
|
||||
// arrival times, so the parent's bound compares like with like.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
let mut seen_at: Option<std::time::Instant> = None;
|
||||
loop {
|
||||
let ev = tokio::time::timeout_at(deadline, ui_rx.recv())
|
||||
.await
|
||||
.expect("probe timed out watching for the sharing transition")
|
||||
.expect("probe ui channel closed");
|
||||
let sharing = match &ev {
|
||||
UiEvent::PeerJoined { state, .. } | UiEvent::PeerUpdated { state, .. } => {
|
||||
state.sharing.is_some()
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
match (&seen_at, sharing) {
|
||||
(None, true) => {
|
||||
seen_at = Some(std::time::Instant::now());
|
||||
println!("PROBE sharing-seen");
|
||||
}
|
||||
(Some(t0), false) => {
|
||||
println!("PROBE sharing-cleared {}", t0.elapsed().as_millis());
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(controller.send(CoreCommand::Leave));
|
||||
}
|
||||
|
||||
/// The long-owed Stop Share SIGINT gate (0c half (ii)), against the REAL
|
||||
/// pixelpass binary: a Stop Share must end the host through the graceful
|
||||
/// SIGINT path — child exits within [`STOP_GRACE`], no SIGKILL fallback, no
|
||||
/// "couldn't confirm" warning — because SIGKILL would skip pixelpass's own
|
||||
/// teardown (it unloads its capture sink on the way out in sink-owning modes).
|
||||
///
|
||||
/// The fallback is indistinguishable from success in the event stream (both
|
||||
/// end in a confirmed reap), so the discriminator is TIME: the fallback path
|
||||
/// first waits out the full 2 s grace, while a host honouring SIGINT exits in
|
||||
/// milliseconds. The bound asserts the stop completed inside the grace.
|
||||
///
|
||||
/// Live: needs `pixelpass` on `$PATH` plus a real solo room (audio + network).
|
||||
#[tokio::test]
|
||||
#[ignore = "live: real pixelpass host + a real solo room (audio backend, network bind)"]
|
||||
async fn stop_share_ends_the_real_host_via_sigint_within_the_grace() {
|
||||
/// Mirrors `core::teardown::STOP_GRACE` (private): the graceful wait
|
||||
/// before the SIGKILL fallback.
|
||||
const STOP_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||
let controller = CoreController::new(ui_tx);
|
||||
|
||||
// No override: resolve the real binary from $PATH.
|
||||
assert!(controller.send(CoreCommand::SetPixelpassPath(None)));
|
||||
assert!(controller.send(CoreCommand::Join {
|
||||
name: "sigint-gate".into(),
|
||||
ticket: "create".into(),
|
||||
room_name: "s2".into(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: false,
|
||||
avatar: Default::default(),
|
||||
}));
|
||||
wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
|
||||
UiEvent::RoomJoined { .. } => Some(()),
|
||||
UiEvent::Error(e) => panic!("join failed: {e}"),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Whole-desktop share: no viewers ever connect, so the real host sits idle
|
||||
// after its ticket (capture starts on first viewer) — exactly the state a
|
||||
// Stop Share most often hits.
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStarted (real pixelpass)",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStarted => Some(()),
|
||||
UiEvent::Error(e) => panic!("real pixelpass host failed to start: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let stop_started = std::time::Instant::now();
|
||||
assert!(controller.send(CoreCommand::StopScreenShare));
|
||||
wait_for(
|
||||
&mut ui_rx,
|
||||
"ScreenShareStopped (real pixelpass)",
|
||||
|ev| match ev {
|
||||
UiEvent::ScreenShareStopped => Some(()),
|
||||
// An Unconfirmed reap surfaces exactly this way; it means the
|
||||
// SIGINT AND the SIGKILL both failed to end the host.
|
||||
UiEvent::Error(e) => panic!("stop of the real host was not clean: {e}"),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let elapsed = stop_started.elapsed();
|
||||
assert!(
|
||||
elapsed < STOP_GRACE,
|
||||
"stop took {elapsed:?} — at or past the {STOP_GRACE:?} grace, i.e. the \
|
||||
SIGKILL fallback fired instead of pixelpass honouring SIGINT"
|
||||
);
|
||||
|
||||
// And the late stdout EOF from the SIGINTed host must stay silent (same
|
||||
// staleness contract the fake-host half pins).
|
||||
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
|
||||
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
|
||||
match ev {
|
||||
UiEvent::ScreenShareStopped => {
|
||||
panic!("stale fault from the SIGINTed real host re-emitted ScreenShareStopped")
|
||||
}
|
||||
UiEvent::Error(e) if e.contains("unexpectedly") => {
|
||||
panic!("stale fault from the SIGINTed real host surfaced as an error: {e}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(controller.send(CoreCommand::Leave));
|
||||
}
|
||||
Reference in New Issue
Block a user