Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,53 @@ All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.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.4] — 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+2
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
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.6"
|
||||
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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,472 @@
|
||||
# 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
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
⚠️ **The readiness budget still has no calibration argument.** 1–2 ms against
|
||||
2000 ms is three orders of magnitude of slack on *this* host with 18 binds; it is
|
||||
not an argument about a host with a large USB interface, many virtual devices, or
|
||||
a cold cache. Carried forward as open, unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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.6"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
+363
-92
@@ -700,6 +700,8 @@ pub enum AppMessage {
|
||||
PeerPanChanged(EndpointId, f32),
|
||||
PeerGateChanged(EndpointId, f32),
|
||||
PeerEqChanged(EndpointId, EqBand, f32),
|
||||
/// Show or hide the secondary audio controls on one participant card.
|
||||
TogglePeerAdvancedAudio(EndpointId),
|
||||
/// Toggle local mute of a peer (silence them just for us).
|
||||
TogglePeerMute(EndpointId),
|
||||
InputDeviceSelected(AudioDevice),
|
||||
@@ -758,6 +760,10 @@ pub enum AppMessage {
|
||||
ToggleNotifications(bool),
|
||||
ToggleEchoCancellation(bool),
|
||||
CustomSoundPathChanged(Sound, String),
|
||||
/// Open a native WAV picker for one notification event.
|
||||
BrowseCustomSound(Sound),
|
||||
/// Result of the notification WAV picker (`None` = cancelled).
|
||||
CustomSoundFilePicked(Sound, Option<std::path::PathBuf>),
|
||||
/// Toggle the per-sound enable flag for a single chime (W6).
|
||||
ToggleSoundEnabled(Sound, bool),
|
||||
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
|
||||
@@ -1047,6 +1053,9 @@ pub struct AppState {
|
||||
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
|
||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||
locally_muted: HashSet<EndpointId>,
|
||||
/// Participant cards whose volume/pan/gate/EQ foldout is open. Session-only:
|
||||
/// a fresh room starts compact, regardless of the previous room's UI state.
|
||||
peer_audio_expanded: HashSet<EndpointId>,
|
||||
/// When we joined the current room, for the in-room call-duration timer.
|
||||
call_started: Option<std::time::Instant>,
|
||||
/// Whether a local call recording is in progress (confirmed by the core).
|
||||
@@ -1240,6 +1249,7 @@ impl AppState {
|
||||
self.audio_levels.clear();
|
||||
self.conn_stats.clear();
|
||||
self.locally_muted.clear();
|
||||
self.peer_audio_expanded.clear();
|
||||
self.chat_messages.clear();
|
||||
// Unsent queue + retry bytes die with the room's transcript. The pacer
|
||||
// and id counter deliberately survive: receivers' per-author buckets
|
||||
@@ -1333,16 +1343,20 @@ impl AppState {
|
||||
/// so Retry can re-dispatch, unless the entry is already gone (history
|
||||
/// eviction / room reset), in which case the payload is dropped so its map
|
||||
/// can't leak. Either way an id with no matching entry is a harmless no-op.
|
||||
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) {
|
||||
/// Returns `true` only when a successful result matched a live local echo,
|
||||
/// which is the boundary used for the outgoing-message notification.
|
||||
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) -> bool {
|
||||
match error {
|
||||
None => {
|
||||
self.set_send_status(local_id, SendStatus::Broadcast);
|
||||
let matched = self.set_send_status(local_id, SendStatus::Broadcast);
|
||||
self.send_payloads.remove(&local_id);
|
||||
matched
|
||||
}
|
||||
Some(e) => {
|
||||
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
|
||||
self.send_payloads.remove(&local_id);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1373,9 +1387,30 @@ impl AppState {
|
||||
Sound::SelfLeave => &self.config.custom_sound_self_leave,
|
||||
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
|
||||
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
|
||||
Sound::ChatSent => &self.config.custom_sound_chat_sent,
|
||||
Sound::ChatReceived => &self.config.custom_sound_chat_received,
|
||||
Sound::ContactOnline => &self.config.custom_sound_contact_online,
|
||||
Sound::ContactOffline => &self.config.custom_sound_contact_offline,
|
||||
};
|
||||
opt.as_deref().unwrap_or("")
|
||||
}
|
||||
|
||||
fn set_custom_sound_path(&mut self, sound: Sound, path: Option<String>) {
|
||||
match sound {
|
||||
Sound::SelfJoin => self.config.custom_sound_self_join = path,
|
||||
Sound::PeerJoin => self.config.custom_sound_peer_join = path,
|
||||
Sound::PeerLeave => self.config.custom_sound_peer_leave = path,
|
||||
Sound::ReconnectAttempt => self.config.custom_sound_reconnect_attempt = path,
|
||||
Sound::Reconnected => self.config.custom_sound_reconnected = path,
|
||||
Sound::SelfLeave => self.config.custom_sound_self_leave = path,
|
||||
Sound::MicToggle => self.config.custom_sound_mic_toggle = path,
|
||||
Sound::ReconnectFailed => self.config.custom_sound_reconnect_failed = path,
|
||||
Sound::ChatSent => self.config.custom_sound_chat_sent = path,
|
||||
Sound::ChatReceived => self.config.custom_sound_chat_received = path,
|
||||
Sound::ContactOnline => self.config.custom_sound_contact_online = path,
|
||||
Sound::ContactOffline => self.config.custom_sound_contact_offline = path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
@@ -1502,6 +1537,7 @@ impl Default for AppState {
|
||||
audio_levels: HashMap::new(),
|
||||
conn_stats: HashMap::new(),
|
||||
locally_muted: HashSet::new(),
|
||||
peer_audio_expanded: HashSet::new(),
|
||||
call_started: None,
|
||||
recording: false,
|
||||
recording_started: None,
|
||||
@@ -1864,6 +1900,47 @@ fn reconnected_chime(
|
||||
was_reconnect.then_some(Sound::Reconnected)
|
||||
}
|
||||
|
||||
/// Return the landing-page contact chime for one definitive presence update.
|
||||
/// An initial online result is an arrival (so contacts already online at app
|
||||
/// startup are announced), while an initial offline result is silent. Online
|
||||
/// includes both plain `Online` and `InRoom`; moving between those two states is
|
||||
/// not a connection transition. Updates continue to populate the presence map
|
||||
/// off-home, but notification sounds are intentionally limited to the home page.
|
||||
fn friend_presence_notification(
|
||||
screen: Screen,
|
||||
previous: Option<&crate::presence::FriendPresence>,
|
||||
next: &crate::presence::FriendPresence,
|
||||
) -> Option<Sound> {
|
||||
if screen != Screen::Home {
|
||||
return None;
|
||||
}
|
||||
|
||||
let online = |presence: &crate::presence::FriendPresence| {
|
||||
matches!(
|
||||
presence,
|
||||
crate::presence::FriendPresence::Online
|
||||
| crate::presence::FriendPresence::InRoom { .. }
|
||||
)
|
||||
};
|
||||
match (previous.map(online), online(next)) {
|
||||
(None | Some(false), true) => Some(Sound::ContactOnline),
|
||||
(Some(true), false) => Some(Sound::ContactOffline),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a native picker result into the persisted notification path. The
|
||||
/// dialog filter is advisory on some desktops, so enforce WAV here as well.
|
||||
/// `None` (cancel) and a non-WAV selection leave the existing setting untouched.
|
||||
fn selected_wav_path(picked: Option<std::path::PathBuf>) -> Option<String> {
|
||||
let path = picked?;
|
||||
let is_wav = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("wav"));
|
||||
is_wav.then(|| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn in_call(state: &AppState) -> bool {
|
||||
!state.ticket.is_empty()
|
||||
}
|
||||
@@ -2217,6 +2294,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.peers.remove(&id);
|
||||
state.audio_levels.remove(&id);
|
||||
state.locally_muted.remove(&id);
|
||||
state.peer_audio_expanded.remove(&id);
|
||||
state.connecting.remove(&id);
|
||||
state.ever_connected.remove(&id);
|
||||
if state.music_listening_to == Some(id) {
|
||||
@@ -2238,6 +2316,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.peers.remove(&id);
|
||||
state.audio_levels.remove(&id);
|
||||
state.locally_muted.remove(&id);
|
||||
state.peer_audio_expanded.remove(&id);
|
||||
state.connecting.remove(&id);
|
||||
state.ever_connected.remove(&id);
|
||||
notify::play(
|
||||
@@ -2301,7 +2380,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
// failure it's retained for Retry — unless the entry is gone
|
||||
// (history eviction / room reset), in which case drop it so
|
||||
// the payload map can't leak.
|
||||
state.apply_send_result(local_id, error);
|
||||
if state.apply_send_result(local_id, error) {
|
||||
notify::play(
|
||||
Sound::ChatSent,
|
||||
state.config.custom_sound_chat_sent.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
UiEvent::ChatMessage {
|
||||
from,
|
||||
@@ -2334,6 +2418,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
local_send: None,
|
||||
},
|
||||
);
|
||||
notify::play(
|
||||
Sound::ChatReceived,
|
||||
state.config.custom_sound_chat_received.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
UiEvent::AttachmentReady { from, id, data } => {
|
||||
@@ -2503,7 +2591,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.friends_read_only = read_only;
|
||||
}
|
||||
UiEvent::FriendPresence { id, presence } => {
|
||||
let sound = friend_presence_notification(
|
||||
state.current_screen,
|
||||
state.friend_presence.get(&id),
|
||||
&presence,
|
||||
);
|
||||
state.friend_presence.insert(id, presence);
|
||||
if let Some(sound) = sound {
|
||||
notify::play(sound, Some(state.custom_sound_path(sound)));
|
||||
}
|
||||
}
|
||||
UiEvent::FriendsRescanned => {
|
||||
// The manual pass finished. Stamp the time for the live "scanned
|
||||
@@ -2588,6 +2684,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
|
||||
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
|
||||
}
|
||||
AppMessage::TogglePeerAdvancedAudio(id) => {
|
||||
if !state.peer_audio_expanded.remove(&id) && state.peers.contains_key(&id) {
|
||||
state.peer_audio_expanded.insert(id);
|
||||
}
|
||||
}
|
||||
AppMessage::TogglePeerMute(id) => {
|
||||
let now_muted = if state.locally_muted.contains(&id) {
|
||||
state.locally_muted.remove(&id);
|
||||
@@ -2873,15 +2974,36 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
} else {
|
||||
Some(path)
|
||||
};
|
||||
match sound {
|
||||
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt,
|
||||
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt,
|
||||
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt,
|
||||
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt,
|
||||
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt,
|
||||
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt,
|
||||
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt,
|
||||
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
|
||||
state.set_custom_sound_path(sound, path_opt);
|
||||
}
|
||||
AppMessage::BrowseCustomSound(sound) => {
|
||||
let initial_dir = {
|
||||
let current = state.custom_sound_path(sound);
|
||||
(!current.trim().is_empty())
|
||||
.then(|| notify::expand_tilde(current))
|
||||
.and_then(|path| path.parent().map(std::path::Path::to_path_buf))
|
||||
.filter(|path| path.is_dir())
|
||||
};
|
||||
return Task::perform(
|
||||
async move {
|
||||
let mut dialog = rfd::AsyncFileDialog::new()
|
||||
.add_filter("WAV audio", &["wav"])
|
||||
.set_title("Choose a notification sound");
|
||||
if let Some(dir) = initial_dir {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
dialog
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|handle| handle.path().to_path_buf())
|
||||
},
|
||||
move |picked| AppMessage::CustomSoundFilePicked(sound, picked),
|
||||
);
|
||||
}
|
||||
AppMessage::CustomSoundFilePicked(sound, picked) => {
|
||||
if let Some(path) = selected_wav_path(picked) {
|
||||
state.set_custom_sound_path(sound, Some(path));
|
||||
state.config.save();
|
||||
}
|
||||
}
|
||||
AppMessage::ToggleSoundEnabled(sound, enabled) => {
|
||||
@@ -5268,10 +5390,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
context_input("Default (embedded)...", path)
|
||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||
.style(t_style)
|
||||
.padding(8)
|
||||
row![
|
||||
context_input("Default (embedded)...", path)
|
||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||
.style(t_style)
|
||||
.padding(8)
|
||||
.width(iced::Length::Fill),
|
||||
button(text("Browse…").size(11))
|
||||
.on_press(AppMessage::BrowseCustomSound(sound))
|
||||
.style(b_style(color_surface, color_blue, color_text, 5.0))
|
||||
.padding([8, 10]),
|
||||
]
|
||||
.spacing(6)
|
||||
.width(iced::Length::Fill)
|
||||
]
|
||||
.spacing(4)
|
||||
.width(iced::Length::Fill)
|
||||
@@ -6021,6 +6152,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
path_field("Mic Toggle", Sound::MicToggle),
|
||||
path_field("Reconnect Failed", Sound::ReconnectFailed),
|
||||
].spacing(20).width(iced::Length::Fill),
|
||||
row![
|
||||
path_field("Chat Sent", Sound::ChatSent),
|
||||
path_field("Chat Received", Sound::ChatReceived),
|
||||
].spacing(20).width(iced::Length::Fill),
|
||||
row![
|
||||
path_field("Contact Online", Sound::ContactOnline),
|
||||
path_field("Contact Offline", Sound::ContactOffline),
|
||||
].spacing(20).width(iced::Length::Fill),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
@@ -6748,15 +6887,48 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
// Peer volume slider
|
||||
let current_vol = state
|
||||
.config
|
||||
.peer_volume
|
||||
.get(&peer_id.to_string())
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let advanced_audio_open = state.peer_audio_expanded.contains(peer_id);
|
||||
let foldout_symbol = if advanced_audio_open { "▾" } else { "▸" };
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
button(
|
||||
row![
|
||||
text(foldout_symbol).size(13).color(color_subtext),
|
||||
text("Advanced audio").size(12).color(color_text),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
)
|
||||
.on_press(AppMessage::TogglePeerAdvancedAudio(peer_id_clone))
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding([6, 8])
|
||||
.width(iced::Length::Fill),
|
||||
);
|
||||
|
||||
if advanced_audio_open {
|
||||
let peer_key = peer_id.to_string();
|
||||
let current_vol = state
|
||||
.config
|
||||
.peer_volume
|
||||
.get(&peer_key)
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
|
||||
let current_gate = state
|
||||
.config
|
||||
.peer_gate
|
||||
.get(&peer_key)
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
let gate_label = if current_gate <= 0.0 {
|
||||
"Off".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{:.0}%",
|
||||
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
|
||||
)
|
||||
};
|
||||
|
||||
let volume_row = row![
|
||||
text("Vol:").size(12).color(color_subtext),
|
||||
slider(0.0..=2.0, current_vol, move |v| {
|
||||
AppMessage::PeerVolumeChanged(peer_id_clone, v)
|
||||
@@ -6765,47 +6937,24 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.on_release(AppMessage::PersistConfig)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
|
||||
let peer_key = peer_id.to_string();
|
||||
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
let pan_row = row![
|
||||
text("Pan:").size(12).color(color_subtext),
|
||||
container(text(pan_label(current_pan)).size(11).color(color_subtext))
|
||||
.width(iced::Length::Fixed(58.0)),
|
||||
slider(
|
||||
-1.0..=1.0,
|
||||
current_pan,
|
||||
move |v| AppMessage::PeerPanChanged(peer_id_clone, v)
|
||||
)
|
||||
slider(-1.0..=1.0, current_pan, move |v| {
|
||||
AppMessage::PeerPanChanged(peer_id_clone, v)
|
||||
})
|
||||
.step(0.05)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
|
||||
// Peer noise gate: suppress this peer's background noise on our end.
|
||||
// Threshold is normalized RMS on the same 0..METER_MAX scale as the
|
||||
// mic gate; 0 = off.
|
||||
let current_gate = state
|
||||
.config
|
||||
.peer_gate
|
||||
.get(&peer_key)
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
let gate_label = if current_gate <= 0.0 {
|
||||
"Off".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{:.0}%",
|
||||
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
|
||||
)
|
||||
};
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
// Peer noise gate: suppress this peer's background noise on our
|
||||
// end. Threshold is on the mic meter's 0..METER_MAX scale; 0 = off.
|
||||
let gate_row = row![
|
||||
text("Gate:").size(12).color(color_subtext),
|
||||
container(text(gate_label).size(11).color(color_subtext))
|
||||
.width(iced::Length::Fixed(58.0)),
|
||||
@@ -6816,38 +6965,45 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
|
||||
let eq = peer_eq_settings(&state.config, peer_id);
|
||||
let eq_row =
|
||||
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||||
row![
|
||||
container(
|
||||
text(format!("{label} {value:+.1} dB"))
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
)
|
||||
.width(iced::Length::Fixed(86.0)),
|
||||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||||
})
|
||||
.step(0.5)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.into()
|
||||
};
|
||||
card_content = card_content.push(
|
||||
column![
|
||||
let eq = peer_eq_settings(&state.config, peer_id);
|
||||
let eq_row =
|
||||
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||||
row![
|
||||
container(
|
||||
text(format!("{label} {value:+.1} dB"))
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
)
|
||||
.width(iced::Length::Fixed(86.0)),
|
||||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||||
})
|
||||
.step(0.5)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.into()
|
||||
};
|
||||
let advanced_audio = column![
|
||||
volume_row,
|
||||
pan_row,
|
||||
gate_row,
|
||||
text("EQ").size(11).color(color_subtext),
|
||||
eq_row("Low", EqBand::Low, eq.low_gain_db),
|
||||
eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
|
||||
eq_row("High", EqBand::High, eq.high_gain_db),
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
.spacing(6);
|
||||
card_content = card_content.push(
|
||||
container(advanced_audio)
|
||||
.style(c_style(color_crust, color_surface, 6.0))
|
||||
.padding(10)
|
||||
.width(iced::Length::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
let card = container(card_content)
|
||||
.style(c_style(
|
||||
@@ -9513,12 +9669,12 @@ mod tests {
|
||||
use super::sendqueue::{self, LocalSend, SendStatus};
|
||||
use super::{
|
||||
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
|
||||
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX,
|
||||
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen,
|
||||
ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
|
||||
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
|
||||
format_relative_ago, initial_window_position, now_playing_label, reconnect_attempt_chime,
|
||||
reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning,
|
||||
update,
|
||||
format_relative_ago, friend_presence_notification, initial_window_position,
|
||||
now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
|
||||
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
|
||||
};
|
||||
use iroh::SecretKey;
|
||||
use std::collections::VecDeque;
|
||||
@@ -9776,6 +9932,7 @@ mod tests {
|
||||
);
|
||||
state.audio_levels.insert(peer, 0.5);
|
||||
state.locally_muted.insert(peer);
|
||||
state.peer_audio_expanded.insert(peer);
|
||||
state.chat_messages.push(ChatEntry {
|
||||
name: "Peer".to_string(),
|
||||
text: "old room".to_string(),
|
||||
@@ -9837,6 +9994,7 @@ mod tests {
|
||||
assert!(state.peers.is_empty());
|
||||
assert!(state.audio_levels.is_empty());
|
||||
assert!(state.locally_muted.is_empty());
|
||||
assert!(state.peer_audio_expanded.is_empty());
|
||||
assert!(state.chat_messages.is_empty());
|
||||
assert!(state.chat_input.is_empty());
|
||||
assert!(state.attachments.len() == 0);
|
||||
@@ -9886,6 +10044,34 @@ mod tests {
|
||||
panic!("clip player did not stop during room reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_advanced_audio_toggle_is_per_peer_and_rejects_stale_ids() {
|
||||
let mut state = AppState::default();
|
||||
let peer = SecretKey::generate().public();
|
||||
state.peers.insert(
|
||||
peer,
|
||||
crate::network::PeerState {
|
||||
name: "Peer".to_string(),
|
||||
is_muted: false,
|
||||
addr: iroh::EndpointAddr::from(peer),
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
music: None,
|
||||
},
|
||||
);
|
||||
|
||||
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
|
||||
assert!(state.peer_audio_expanded.contains(&peer));
|
||||
|
||||
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
|
||||
assert!(!state.peer_audio_expanded.contains(&peer));
|
||||
|
||||
let stale = SecretKey::generate().public();
|
||||
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(stale));
|
||||
assert!(!state.peer_audio_expanded.contains(&stale));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_warning_shows_dismisses_and_expires() {
|
||||
let mut state = AppState::default();
|
||||
@@ -10579,6 +10765,91 @@ mod tests {
|
||||
|
||||
const W: f32 = 200.0;
|
||||
|
||||
#[test]
|
||||
fn initial_contact_presence_announces_only_online() {
|
||||
use crate::presence::FriendPresence;
|
||||
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Home, None, &FriendPresence::Online),
|
||||
Some(Sound::ContactOnline)
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Home, None, &FriendPresence::Offline),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_presence_chimes_only_on_online_boundary() {
|
||||
use crate::presence::FriendPresence;
|
||||
|
||||
let in_room = FriendPresence::InRoom {
|
||||
name: "Game night".to_string(),
|
||||
ticket: "ticket".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Home, Some(&FriendPresence::Offline), &in_room,),
|
||||
Some(Sound::ContactOnline)
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(
|
||||
Screen::Home,
|
||||
Some(&FriendPresence::Online),
|
||||
&FriendPresence::Offline,
|
||||
),
|
||||
Some(Sound::ContactOffline)
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Home, Some(&FriendPresence::Online), &in_room,),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(
|
||||
Screen::Home,
|
||||
Some(&FriendPresence::Offline),
|
||||
&FriendPresence::Offline,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_presence_is_silent_away_from_landing_page() {
|
||||
use crate::presence::FriendPresence;
|
||||
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Room, None, &FriendPresence::Online),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(
|
||||
Screen::Settings,
|
||||
Some(&FriendPresence::Online),
|
||||
&FriendPresence::Offline,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_notification_sound_accepts_wav_and_preserves_cancel() {
|
||||
use std::path::PathBuf;
|
||||
|
||||
assert_eq!(selected_wav_path(None), None);
|
||||
assert_eq!(
|
||||
selected_wav_path(Some(PathBuf::from("/tmp/notify.mp3"))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
selected_wav_path(Some(PathBuf::from("/tmp/notify.wav"))),
|
||||
Some("/tmp/notify.wav".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
selected_wav_path(Some(PathBuf::from("/tmp/notify.WAV"))),
|
||||
Some("/tmp/notify.WAV".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_drag_maps_left_edge_to_zero() {
|
||||
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
|
||||
@@ -10862,7 +11133,7 @@ mod tests {
|
||||
// Empty queue + a fresh full pacer → dispatched immediately.
|
||||
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
|
||||
assert!(state.send_payloads.contains_key(&id));
|
||||
state.apply_send_result(id, None);
|
||||
assert!(state.apply_send_result(id, None));
|
||||
assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
|
||||
// A completed send releases its retry payload.
|
||||
assert!(!state.send_payloads.contains_key(&id));
|
||||
@@ -10873,7 +11144,7 @@ mod tests {
|
||||
let mut state = AppState::default();
|
||||
let id = push_own(&mut state, "yo");
|
||||
state.submit_send(id, PendingSend::Text("yo".to_string()));
|
||||
state.apply_send_result(id, Some("not in a room".to_string()));
|
||||
assert!(!state.apply_send_result(id, Some("not in a room".to_string())));
|
||||
assert_eq!(
|
||||
status_of(&state, id),
|
||||
Some(SendStatus::Failed("not in a room".to_string()))
|
||||
@@ -10889,11 +11160,11 @@ mod tests {
|
||||
state.submit_send(a, PendingSend::Text("a".to_string()));
|
||||
let b = push_own(&mut state, "b");
|
||||
state.submit_send(b, PendingSend::Text("b".to_string()));
|
||||
state.apply_send_result(a, None);
|
||||
assert!(state.apply_send_result(a, None));
|
||||
assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast));
|
||||
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
||||
// A result for an id with no matching entry is a harmless no-op.
|
||||
state.apply_send_result(9999, None);
|
||||
assert!(!state.apply_send_result(9999, None));
|
||||
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
||||
}
|
||||
|
||||
@@ -10907,7 +11178,7 @@ mod tests {
|
||||
state
|
||||
.chat_messages
|
||||
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
|
||||
state.apply_send_result(id, Some("dead".to_string()));
|
||||
assert!(!state.apply_send_result(id, Some("dead".to_string())));
|
||||
// No entry to mark → the payload must not leak.
|
||||
assert!(!state.send_payloads.contains_key(&id));
|
||||
}
|
||||
@@ -10922,7 +11193,7 @@ mod tests {
|
||||
assert!(state.send_queue.is_empty());
|
||||
assert!(state.send_payloads.is_empty());
|
||||
// A late result for the pre-reset send touches nothing and adds no entry.
|
||||
state.apply_send_result(id, None);
|
||||
assert!(!state.apply_send_result(id, None));
|
||||
assert!(state.chat_messages.is_empty());
|
||||
assert!(state.send_payloads.is_empty());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -137,6 +137,16 @@ pub enum CoreCommand {
|
||||
/// 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 {
|
||||
@@ -271,6 +281,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||
quality: _,
|
||||
}
|
||||
| CoreCommand::StopScreenShare
|
||||
| CoreCommand::ScreenShareHostFault { generation: _ }
|
||||
| CoreCommand::ViewShare {
|
||||
ticket: _,
|
||||
settings: _,
|
||||
@@ -363,6 +374,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
||||
quality: _,
|
||||
}
|
||||
| CoreCommand::StopScreenShare
|
||||
| CoreCommand::ScreenShareHostFault { generation: _ }
|
||||
| CoreCommand::ViewShare {
|
||||
ticket: _,
|
||||
settings: _,
|
||||
|
||||
+224
-72
@@ -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;
|
||||
@@ -1393,10 +1397,25 @@ 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;
|
||||
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.
|
||||
@@ -1511,6 +1530,8 @@ async fn run_core_loop(
|
||||
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 +1550,27 @@ 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 }
|
||||
}
|
||||
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 +1589,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 +1733,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 +1835,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 +2773,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 +2857,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 +2874,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 +3187,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 +3393,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;
|
||||
}
|
||||
@@ -3403,7 +3453,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 +3467,62 @@ 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()
|
||||
{
|
||||
// Every share gets a notice forwarder — not just app-audio ones.
|
||||
// pixelpass `app_audio` events (only emitted when an app is
|
||||
// selected) become UI warnings, and the drain's terminal `Eof`
|
||||
// becomes a host fault scoped to this spawn's generation, so a
|
||||
// host that dies is torn down instead of staying advertised in
|
||||
// presence forever. On a failed spawn the sender is dropped
|
||||
// before the drain ever runs, so the forwarder just ends and no
|
||||
// fault is sent (the spawn error carries the news instead).
|
||||
share_generations += 1;
|
||||
let generation = share_generations;
|
||||
let (notices_tx, mut 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 {
|
||||
while let Some(notice) = notices_rx.recv().await {
|
||||
match notice {
|
||||
crate::screenshare::HostNotice::Event(ev) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Terminal by contract: nothing follows on the
|
||||
// channel, so the task ends here.
|
||||
crate::screenshare::HostNotice::Eof => {
|
||||
let _ = fault_tx.send(generation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
tx
|
||||
}
|
||||
});
|
||||
match crate::screenshare::spawn_host(
|
||||
&bin,
|
||||
audio_app.as_deref(),
|
||||
&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 +3542,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 +3571,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 +3646,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 +3659,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 +3672,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
//! 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.
|
||||
///
|
||||
/// Never read, and that is the design: the guard is held only so that its
|
||||
/// `Drop` runs, and only so that it runs *here*, last. `dead_code` is right
|
||||
/// that nothing reads it and wrong that it does nothing.
|
||||
#[allow(dead_code)]
|
||||
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()
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
+301
-33
@@ -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,6 +49,12 @@ 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.
|
||||
@@ -80,6 +90,21 @@ pub enum PixelpassEvent {
|
||||
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();
|
||||
@@ -360,7 +385,10 @@ 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(
|
||||
@@ -368,7 +396,7 @@ pub async fn spawn_host(
|
||||
audio_app: Option<&str>,
|
||||
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);
|
||||
// Log the exact argv we hand pixelpass so a field log can confirm which
|
||||
@@ -423,7 +451,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 +590,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 +607,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -607,16 +641,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 +673,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 +706,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 +731,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 +791,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,
|
||||
@@ -825,16 +964,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 +1055,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
mpv_args(&settings),
|
||||
mpv_args(&settings, None),
|
||||
vec![
|
||||
"--cache=yes",
|
||||
"--demuxer-readahead-secs=2",
|
||||
@@ -1191,4 +1400,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");
|
||||
}
|
||||
}
|
||||
|
||||
+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,589 @@
|
||||
//! 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};
|
||||
|
||||
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_app: None,
|
||||
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_app: None,
|
||||
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_app: None,
|
||||
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_app: None,
|
||||
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_app: None,
|
||||
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