Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e1740d3c | ||
|
|
4dc1bcd546 | ||
|
|
067997f9ba | ||
|
|
660eb27a84 | ||
|
|
913b0b6b20 | ||
|
|
36fb8bfa9a | ||
|
|
2e9164745f | ||
|
|
3b640726d7 | ||
|
|
381e00bc0e | ||
|
|
1a3c481f4c | ||
|
|
f927567105 | ||
|
|
5c11947bd7 | ||
|
|
7349744d16 | ||
|
|
a6a88d15c0 | ||
|
|
a17b930524 | ||
|
|
6100abef33 | ||
|
|
49bd2ba687 | ||
|
|
6b0b23ef69 | ||
|
|
f422150c84 | ||
|
|
86d333d4dc |
+29
@@ -2,10 +2,39 @@
|
|||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
# Debian/Ubuntu packaging (cargo-deb). Mirrors packaging/PKGBUILD: only the main
|
||||||
|
# `peerspeak` binary ships (not test_net/specview), plus the desktop entry and the
|
||||||
|
# hicolor icon set. Runtime shared-lib deps (libpipewire, libopus, libc, …) are
|
||||||
|
# resolved by dpkg-shlibdeps via `depends = "$auto"`. Build inside a Debian/Ubuntu
|
||||||
|
# distrobox so the binary links that distro's glibc, then `cargo deb --no-build`.
|
||||||
|
[package.metadata.deb]
|
||||||
|
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
|
||||||
|
copyright = "2026, mollusk. Private build — not for redistribution."
|
||||||
|
section = "net"
|
||||||
|
priority = "optional"
|
||||||
|
depends = "$auto"
|
||||||
|
# pixelpass = in-room screen sharing; mpv = the screen-share viewer (vlc fallback).
|
||||||
|
recommends = "pixelpass, mpv"
|
||||||
|
extended-description = "Decentralized peer-to-peer voice chat over iroh (QUIC) with PipeWire audio, the Opus codec, and an iced GUI. Full-mesh, no central server."
|
||||||
|
assets = [
|
||||||
|
["target/release/peerspeak", "usr/bin/", "755"],
|
||||||
|
["packaging/peerspeak.desktop", "usr/share/applications/", "644"],
|
||||||
|
["assets/icons/peerspeak.svg", "usr/share/icons/hicolor/scalable/apps/peerspeak.svg", "644"],
|
||||||
|
["assets/icons/peerspeak-16.png", "usr/share/icons/hicolor/16x16/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-24.png", "usr/share/icons/hicolor/24x24/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-32.png", "usr/share/icons/hicolor/32x32/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-48.png", "usr/share/icons/hicolor/48x48/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-64.png", "usr/share/icons/hicolor/64x64/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-128.png", "usr/share/icons/hicolor/128x128/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-256.png", "usr/share/icons/hicolor/256x256/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-512.png", "usr/share/icons/hicolor/512x512/apps/peerspeak.png", "644"],
|
||||||
|
]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|||||||
@@ -225,6 +225,20 @@ state change; rate-limit pings), tickets from friends (validate defensively, no
|
|||||||
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
|
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
|
||||||
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
|
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
|
||||||
|
|
||||||
|
**Local hardening DONE 2026-06-27:** inbound friend-presence replies are now
|
||||||
|
rate-limited per authenticated friend id (`PresenceRateLimiter`: burst 4, refill
|
||||||
|
1/15s) and wired into the live friends listener before it builds a `Pong`; denied
|
||||||
|
probes get the same silent no-data close as unauthorized probes. Existing
|
||||||
|
defensive reply handling still validates room tickets against the authenticated
|
||||||
|
friend id and never auto-joins. Verified with `cargo test presence`,
|
||||||
|
`cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, and
|
||||||
|
`cargo audit --no-fetch --stale` (local DB; reports only the two already-allowed
|
||||||
|
unmaintained advisories in `deny.toml`). A fresh advisory fetch was blocked in
|
||||||
|
this sandbox by network restrictions.
|
||||||
|
|
||||||
|
**Remaining:** live 2-machine field test on dopedart, a fresh online
|
||||||
|
`cargo audit`, and any follow-up findings from that test.
|
||||||
|
|
||||||
## The connect flow (the user's scenario, end to end)
|
## The connect flow (the user's scenario, end to end)
|
||||||
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
||||||
"HangOut."
|
"HangOut."
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
pkgname=peerspeak-git
|
pkgname=peerspeak-git
|
||||||
_pkgname=peerspeak
|
_pkgname=peerspeak
|
||||||
pkgver=0.3.0.r229.g7fb1c96
|
pkgver=0.4.0.r254.g913b0b6
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
|
|||||||
|
|
||||||
## 1. Install it
|
## 1. Install it
|
||||||
|
|
||||||
1. Double-click **`peerspeak-0.3.0-setup.exe`** (the file I sent you).
|
1. Double-click **`peerspeak-0.4.0-setup.exe`** (the file I sent you).
|
||||||
|
|
||||||
2. **Windows will probably show a blue "Windows protected your PC" warning.**
|
2. **Windows will probably show a blue "Windows protected your PC" warning.**
|
||||||
This is normal — it shows up for any app that isn't from a big company with a
|
This is normal — it shows up for any app that isn't from a big company with a
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
|
|||||||
## Version compatibility
|
## Version compatibility
|
||||||
|
|
||||||
The installer version tracks the crate version in `Cargo.toml` (currently
|
The installer version tracks the crate version in `Cargo.toml` (currently
|
||||||
**0.3.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
|
**0.4.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
|
||||||
|
|
||||||
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
|
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
|
||||||
peers on different MINOR versions can't connect (they fail fast at the
|
peers on different MINOR versions can't connect (they fail fast at the
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||||
|
|
||||||
#define MyAppName "PeerSpeak"
|
#define MyAppName "PeerSpeak"
|
||||||
#define MyAppVersion "0.3.0"
|
#define MyAppVersion "0.4.0"
|
||||||
#define MyAppPublisher "mollusk"
|
#define MyAppPublisher "mollusk"
|
||||||
#define MyAppExeName "peerspeak.exe"
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
|||||||
+977
-144
File diff suppressed because it is too large
Load Diff
+84
-13
@@ -34,6 +34,18 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
|
|||||||
/// nodes never leak past the call that created them.
|
/// nodes never leak past the call that created them.
|
||||||
pub struct EchoCancelGuard {
|
pub struct EchoCancelGuard {
|
||||||
module_index: String,
|
module_index: String,
|
||||||
|
source_name: String,
|
||||||
|
sink_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EchoCancelGuard {
|
||||||
|
pub fn source_name(&self) -> &str {
|
||||||
|
&self.source_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sink_name(&self) -> &str {
|
||||||
|
&self.sink_name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for EchoCancelGuard {
|
impl Drop for EchoCancelGuard {
|
||||||
@@ -58,12 +70,16 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
// don't stack duplicate modules / fight over the virtual node names.
|
// don't stack duplicate modules / fight over the virtual node names.
|
||||||
unload_stale();
|
unload_stale();
|
||||||
|
|
||||||
|
let owner_pid = std::process::id();
|
||||||
|
let source_name = format!("{EC_SOURCE}.{owner_pid}");
|
||||||
|
let sink_name = format!("{EC_SINK}.{owner_pid}");
|
||||||
|
|
||||||
let mut cmd = Command::new("pactl");
|
let mut cmd = Command::new("pactl");
|
||||||
cmd.arg("load-module")
|
cmd.arg("load-module")
|
||||||
.arg("module-echo-cancel")
|
.arg("module-echo-cancel")
|
||||||
.arg("aec_method=webrtc")
|
.arg("aec_method=webrtc")
|
||||||
.arg(format!("source_name={EC_SOURCE}"))
|
.arg(format!("source_name={source_name}"))
|
||||||
.arg(format!("sink_name={EC_SINK}"));
|
.arg(format!("sink_name={sink_name}"));
|
||||||
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
|
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
|
||||||
cmd.arg(format!("source_master={src}"));
|
cmd.arg(format!("source_master={src}"));
|
||||||
}
|
}
|
||||||
@@ -85,12 +101,12 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
if module_index.parse::<u64>().is_err() {
|
if module_index.parse::<u64>().is_err() {
|
||||||
return Err(format!("unexpected pactl output: {module_index:?}"));
|
return Err(format!("unexpected pactl output: {module_index:?}"));
|
||||||
}
|
}
|
||||||
let guard = EchoCancelGuard { module_index };
|
let guard = EchoCancelGuard { module_index, source_name, sink_name };
|
||||||
|
|
||||||
// The virtual nodes appear shortly after the module loads; wait for both so
|
// The virtual nodes appear shortly after the module loads; wait for both so
|
||||||
// the subsequent capture/playback streams can actually target them. If they
|
// the subsequent capture/playback streams can actually target them. If they
|
||||||
// never show, drop the guard (unloads) and report failure.
|
// never show, drop the guard (unloads) and report failure.
|
||||||
if !wait_for_nodes() {
|
if !wait_for_nodes(guard.source_name(), guard.sink_name()) {
|
||||||
return Err("echo-cancel virtual nodes did not appear in time".to_string());
|
return Err("echo-cancel virtual nodes did not appear in time".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +118,10 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Polls until both virtual nodes exist or the timeout elapses.
|
/// Polls until both virtual nodes exist or the timeout elapses.
|
||||||
fn wait_for_nodes() -> bool {
|
fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
|
||||||
let deadline = Instant::now() + NODE_READY_TIMEOUT;
|
let deadline = Instant::now() + NODE_READY_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
if node_present("sources", EC_SOURCE) && node_present("sinks", EC_SINK) {
|
if node_present("sources", source_name) && node_present("sinks", sink_name) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
@@ -126,8 +142,30 @@ fn node_present(kind: &str, name: &str) -> bool {
|
|||||||
.any(|line| line.split('\t').nth(1) == Some(name))
|
.any(|line| line.split('\t').nth(1) == Some(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unloads any leftover `module-echo-cancel` instance we previously created
|
fn pid_from_ec_args(args: &str) -> Option<u32> {
|
||||||
/// (identified by our virtual node names in its argument string). Best-effort.
|
let source_prefix = format!("source_name={EC_SOURCE}.");
|
||||||
|
args.split_whitespace()
|
||||||
|
.find_map(|arg| arg.strip_prefix(&source_prefix))?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ec_module_is_stale(args: &str, is_alive: impl Fn(u32) -> bool) -> bool {
|
||||||
|
pid_from_ec_args(args).is_some_and(|pid| !is_alive(pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn process_is_alive(pid: u32) -> bool {
|
||||||
|
std::path::Path::new("/proc").join(pid.to_string()).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn process_is_alive(_pid: u32) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
|
||||||
|
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
|
||||||
fn unload_stale() {
|
fn unload_stale() {
|
||||||
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
|
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
|
||||||
return;
|
return;
|
||||||
@@ -137,7 +175,10 @@ fn unload_stale() {
|
|||||||
let index = cols.next().unwrap_or("");
|
let index = cols.next().unwrap_or("");
|
||||||
let name = cols.next().unwrap_or("");
|
let name = cols.next().unwrap_or("");
|
||||||
let args = cols.next().unwrap_or("");
|
let args = cols.next().unwrap_or("");
|
||||||
if name == "module-echo-cancel" && args.contains(EC_SOURCE) && index.parse::<u64>().is_ok() {
|
if name == "module-echo-cancel"
|
||||||
|
&& ec_module_is_stale(args, process_is_alive)
|
||||||
|
&& index.parse::<u64>().is_ok()
|
||||||
|
{
|
||||||
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
|
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
|
||||||
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
||||||
}
|
}
|
||||||
@@ -155,12 +196,42 @@ mod tests {
|
|||||||
#[ignore]
|
#[ignore]
|
||||||
fn enable_creates_and_unloads_nodes() {
|
fn enable_creates_and_unloads_nodes() {
|
||||||
let guard = enable(None, None).expect("module-echo-cancel should load");
|
let guard = enable(None, None).expect("module-echo-cancel should load");
|
||||||
assert!(node_present("sources", EC_SOURCE), "cleaned source must exist");
|
let source_name = guard.source_name().to_string();
|
||||||
assert!(node_present("sinks", EC_SINK), "reference sink must exist");
|
let sink_name = guard.sink_name().to_string();
|
||||||
|
assert!(node_present("sources", &source_name), "cleaned source must exist");
|
||||||
|
assert!(node_present("sinks", &sink_name), "reference sink must exist");
|
||||||
drop(guard);
|
drop(guard);
|
||||||
// Give pactl a moment to tear the nodes down.
|
// Give pactl a moment to tear the nodes down.
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
assert!(!node_present("sources", EC_SOURCE), "source must be gone after unload");
|
assert!(!node_present("sources", &source_name), "source must be gone after unload");
|
||||||
assert!(!node_present("sinks", EC_SINK), "sink must be gone after unload");
|
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_owner_pid_only_from_our_source_name() {
|
||||||
|
assert_eq!(
|
||||||
|
pid_from_ec_args(
|
||||||
|
"aec_method=webrtc source_name=peerspeak_echocancel_source.4242 sink_name=peerspeak_echocancel_sink.4242"
|
||||||
|
),
|
||||||
|
Some(4242)
|
||||||
|
);
|
||||||
|
assert_eq!(pid_from_ec_args("aec_method=webrtc"), None);
|
||||||
|
assert_eq!(
|
||||||
|
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_decision_keeps_live_and_foreign_modules() {
|
||||||
|
let ours = "source_name=peerspeak_echocancel_source.4242";
|
||||||
|
assert!(!ec_module_is_stale(ours, |pid| pid == 4242));
|
||||||
|
assert!(ec_module_is_stale(ours, |_| false));
|
||||||
|
assert!(!ec_module_is_stale("source_name=foreign.4242", |_| false));
|
||||||
|
assert!(!ec_module_is_stale(
|
||||||
|
"source_name=peerspeak_echocancel_source.malformed",
|
||||||
|
|_| false
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,32 @@ const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
|
|||||||
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
|
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
|
||||||
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
|
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
|
||||||
const MAX_MIC_FIFO: usize = 48_000 / 5;
|
const MAX_MIC_FIFO: usize = 48_000 / 5;
|
||||||
|
const MAX_SESSION_DIR_ATTEMPTS: usize = 1_000;
|
||||||
|
|
||||||
|
/// Create a collision-free session directory for a timestamp. The base
|
||||||
|
/// timestamp is tried first, followed by `-2`, `-3`, and so on; an existing
|
||||||
|
/// recording is never reopened or overwritten.
|
||||||
|
pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf> {
|
||||||
|
let filename = crate::audio::recorder::timestamp_filename(now_unix_secs);
|
||||||
|
let stem = filename.trim_end_matches(".wav");
|
||||||
|
for attempt in 1..=MAX_SESSION_DIR_ATTEMPTS {
|
||||||
|
let name = if attempt == 1 {
|
||||||
|
stem.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{stem}-{attempt}")
|
||||||
|
};
|
||||||
|
let path = base.join(name);
|
||||||
|
match std::fs::create_dir(&path) {
|
||||||
|
Ok(()) => return Ok(path),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"multitrack directory suffixes exhausted",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// One output track: its WAV writer plus whether it has been written *this*
|
/// One output track: its WAV writer plus whether it has been written *this*
|
||||||
/// cycle (so `end_cycle` knows which tracks to pad with silence).
|
/// cycle (so `end_cycle` knows which tracks to pad with silence).
|
||||||
@@ -263,6 +289,19 @@ mod tests {
|
|||||||
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_second_sessions_get_unique_directories_without_reuse() {
|
||||||
|
let base = tmpdir("collision");
|
||||||
|
let first = create_session_dir(&base, 1_700_000_000).unwrap();
|
||||||
|
std::fs::write(first.join("sentinel"), b"keep me").unwrap();
|
||||||
|
|
||||||
|
let second = create_session_dir(&base, 1_700_000_000).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(second, first);
|
||||||
|
assert_eq!(std::fs::read(first.join("sentinel")).unwrap(), b"keep me");
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_tracks_equal_length_after_n_cycles() {
|
fn all_tracks_equal_length_after_n_cycles() {
|
||||||
let dir = tmpdir("equal");
|
let dir = tmpdir("equal");
|
||||||
|
|||||||
+73
-10
@@ -151,11 +151,9 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
|||||||
let data = &mut datas[0];
|
let data = &mut datas[0];
|
||||||
let size = data.chunk().size() as usize;
|
let size = data.chunk().size() as usize;
|
||||||
if let Some(slice) = data.data() {
|
if let Some(slice) = data.data() {
|
||||||
// Each sample is 2 bytes (S16LE)
|
for_each_capture_sample(slice, size, |sample| {
|
||||||
for chunk in slice[..size].chunks_exact(2) {
|
|
||||||
let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
|
|
||||||
let _ = user_data.producer.try_push(sample);
|
let _ = user_data.producer.try_push(sample);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +222,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visit the complete S16LE samples in the portion PipeWire reports as filled.
|
||||||
|
/// Clamp the reported byte count to the mapped slice before indexing: a bad
|
||||||
|
/// chunk size must not panic from the realtime capture callback.
|
||||||
|
fn for_each_capture_sample(slice: &[u8], size: usize, mut visit: impl FnMut(i16)) {
|
||||||
|
let size = size.min(slice.len());
|
||||||
|
for chunk in slice[..size].chunks_exact(2) {
|
||||||
|
visit(i16::from_le_bytes([chunk[0], chunk[1]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frames the playback RT callback should produce this cycle.
|
/// Frames the playback RT callback should produce this cycle.
|
||||||
///
|
///
|
||||||
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
|
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
|
||||||
@@ -263,6 +271,25 @@ fn drain_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reserve exact occupancy before making a frame visible to the consumer.
|
||||||
|
/// `after_reserve` is empty in production and lets the regression test force a
|
||||||
|
/// consumer interleaving at the critical ordering boundary.
|
||||||
|
fn publish_frame<P: Producer<Item = i16>>(
|
||||||
|
fill: &AtomicUsize,
|
||||||
|
dropped: &AtomicU64,
|
||||||
|
producer: &mut P,
|
||||||
|
frame: &[i16],
|
||||||
|
after_reserve: impl FnOnce(),
|
||||||
|
) {
|
||||||
|
fill.fetch_add(frame.len(), Ordering::Relaxed);
|
||||||
|
after_reserve();
|
||||||
|
let pushed = producer.push_slice(frame);
|
||||||
|
if pushed != frame.len() {
|
||||||
|
fill.fetch_sub(frame.len() - pushed, Ordering::Relaxed);
|
||||||
|
dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
||||||
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
||||||
const FALLBACK_FRAMES: usize = 1024;
|
const FALLBACK_FRAMES: usize = 1024;
|
||||||
@@ -522,10 +549,12 @@ fn run_playback(
|
|||||||
worker_dropped.fetch_add(1, Ordering::Relaxed);
|
worker_dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for &sample in &frame {
|
// Reserve occupancy BEFORE publishing samples. Otherwise the RT
|
||||||
let _ = producer.try_push(sample);
|
// consumer can pop a newly-visible sample before it is counted and
|
||||||
}
|
// wrap the exact fill gauge to usize::MAX, wedging mixer pacing.
|
||||||
worker_fill.fetch_add(frame.len(), Ordering::Relaxed);
|
// `push_slice` also publishes the frame as one operation rather than
|
||||||
|
// exposing a half-written stereo pair.
|
||||||
|
publish_frame(&worker_fill, &worker_dropped, &mut producer, &frame, || {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -577,8 +606,9 @@ fn run_playback(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{drain_loop, frames_to_produce};
|
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{sync::mpsc, thread};
|
use std::{sync::mpsc, thread};
|
||||||
@@ -614,6 +644,39 @@ mod tests {
|
|||||||
assert_eq!(frames_to_produce(1024, 0), 0);
|
assert_eq!(frames_to_produce(1024, 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_size_larger_than_mapping_is_clamped() {
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
|
||||||
|
samples.push(sample)
|
||||||
|
});
|
||||||
|
assert_eq!(samples, vec![1, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn occupancy_is_reserved_before_frame_is_published() {
|
||||||
|
let rb = HeapRb::<i16>::new(8);
|
||||||
|
let (mut producer, mut consumer) = rb.split();
|
||||||
|
assert!(producer.try_push(7).is_ok());
|
||||||
|
|
||||||
|
let fill = AtomicUsize::new(1);
|
||||||
|
let dropped = AtomicU64::new(0);
|
||||||
|
publish_frame(&fill, &dropped, &mut producer, &[10, 11], || {
|
||||||
|
// Force the consumer to drain the old sample after the new frame's
|
||||||
|
// occupancy is reserved but before that frame is published.
|
||||||
|
assert_eq!(consumer.try_pop(), Some(7));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(fill.load(Ordering::Relaxed), 2);
|
||||||
|
assert_eq!(consumer.try_pop(), Some(10));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 2);
|
||||||
|
assert_eq!(consumer.try_pop(), Some(11));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 1);
|
||||||
|
assert_eq!(fill.load(Ordering::Relaxed), 0);
|
||||||
|
assert_eq!(dropped.load(Ordering::Relaxed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- drain_loop (A7: worker must not hang shutdown) ---
|
// --- drain_loop (A7: worker must not hang shutdown) ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+57
-9
@@ -14,7 +14,7 @@
|
|||||||
//! and patches the two size fields on [`Recorder::finalize`].
|
//! and patches the two size fields on [`Recorder::finalize`].
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs::File;
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{self, Seek, SeekFrom, Write};
|
use std::io::{self, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ const BITS_PER_SAMPLE: u16 = 16;
|
|||||||
const CHANNELS: u16 = 1;
|
const CHANNELS: u16 = 1;
|
||||||
const RIFF_DATA_OVERHEAD: u64 = 36;
|
const RIFF_DATA_OVERHEAD: u64 = 36;
|
||||||
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
||||||
|
const MAX_NAME_ATTEMPTS: usize = 1_000;
|
||||||
|
|
||||||
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
||||||
/// if the capture clock runs persistently faster than playout — past this we drop
|
/// if the capture clock runs persistently faster than playout — past this we drop
|
||||||
@@ -42,7 +43,12 @@ pub struct WavWriter {
|
|||||||
impl WavWriter {
|
impl WavWriter {
|
||||||
/// Create the file and write the 44-byte header with zeroed size fields.
|
/// Create the file and write the 44-byte header with zeroed size fields.
|
||||||
pub fn new(path: &Path) -> io::Result<Self> {
|
pub fn new(path: &Path) -> io::Result<Self> {
|
||||||
let mut file = File::create(path)?;
|
Self::from_file(File::create(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a WAV in an already-opened file. This lets callers choose atomic
|
||||||
|
/// create-new semantics instead of the truncating behavior of `File::create`.
|
||||||
|
fn from_file(mut file: File) -> io::Result<Self> {
|
||||||
file.write_all(&Self::header(0))?;
|
file.write_all(&Self::header(0))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
file,
|
file,
|
||||||
@@ -125,13 +131,31 @@ impl Recorder {
|
|||||||
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
|
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
|
||||||
/// exist (the caller creates it).
|
/// exist (the caller creates it).
|
||||||
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
|
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
|
||||||
let path = dir.join(timestamp_filename(now_unix_secs));
|
let filename = timestamp_filename(now_unix_secs);
|
||||||
let writer = WavWriter::new(&path)?;
|
let stem = filename.trim_end_matches(".wav");
|
||||||
Ok(Self {
|
for attempt in 1..=MAX_NAME_ATTEMPTS {
|
||||||
writer,
|
let name = if attempt == 1 {
|
||||||
mic_fifo: VecDeque::new(),
|
filename.clone()
|
||||||
path,
|
} else {
|
||||||
})
|
format!("{stem}-{attempt}.wav")
|
||||||
|
};
|
||||||
|
let path = dir.join(name);
|
||||||
|
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||||
|
Ok(file) => {
|
||||||
|
return Ok(Self {
|
||||||
|
writer: WavWriter::from_file(file)?,
|
||||||
|
mic_fifo: VecDeque::new(),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"recording filename suffixes exhausted",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The path being written.
|
/// The path being written.
|
||||||
@@ -210,6 +234,30 @@ mod tests {
|
|||||||
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
|
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_second_recordings_get_unique_files_without_truncation() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"peerspeak-collision-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
let mut first = Recorder::create(&dir, 1_700_000_000).unwrap();
|
||||||
|
first.write_frame(&[123, 456]).unwrap();
|
||||||
|
let first_path = first.path().to_path_buf();
|
||||||
|
first.finalize().unwrap();
|
||||||
|
let original = std::fs::read(&first_path).unwrap();
|
||||||
|
|
||||||
|
let second = Recorder::create(&dir, 1_700_000_000).unwrap();
|
||||||
|
let second_path = second.path().to_path_buf();
|
||||||
|
assert_ne!(second_path, first_path);
|
||||||
|
assert_eq!(std::fs::read(&first_path).unwrap(), original);
|
||||||
|
second.finalize().unwrap();
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wav_header_round_trips_sizes() {
|
fn wav_header_round_trips_sizes() {
|
||||||
let dir = std::env::temp_dir();
|
let dir = std::env::temp_dir();
|
||||||
|
|||||||
+119
@@ -185,10 +185,129 @@ pub fn initials(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A small content-addressed LRU cache mapping image bytes to a built value
|
||||||
|
/// (e.g. an `iced` image handle), so the SAME value is reused across redraws
|
||||||
|
/// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain
|
||||||
|
/// hash map:
|
||||||
|
///
|
||||||
|
/// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on
|
||||||
|
/// overflow, so a peer can't grow the cache without limit by publishing an
|
||||||
|
/// endless stream of distinct valid avatars.
|
||||||
|
/// 2. **Collision-safe** — a hit requires full byte equality, not just a matching
|
||||||
|
/// 64-bit hash, so a hash collision can never return a different image's value.
|
||||||
|
///
|
||||||
|
/// Linear scan; intended for small `cap` (tens of entries).
|
||||||
|
pub struct ByteLru<V> {
|
||||||
|
cap: usize,
|
||||||
|
/// `(content hash, content bytes, value)`; back = most recently used.
|
||||||
|
entries: Vec<(u64, Vec<u8>, V)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<V: Clone> ByteLru<V> {
|
||||||
|
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
|
||||||
|
pub fn new(cap: usize) -> Self {
|
||||||
|
Self { cap: cap.max(1), entries: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the cached value for these exact `bytes`, building and inserting it
|
||||||
|
/// on a miss (evicting the least-recently-used entry once over `cap`). A hit
|
||||||
|
/// verifies full byte equality, so a 64-bit hash collision never returns the
|
||||||
|
/// wrong value. A hit also refreshes the entry's recency.
|
||||||
|
pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
bytes.hash(&mut hasher);
|
||||||
|
self.get_or_insert_hashed(hasher.finish(), bytes, build)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner seam with the content `hash` supplied explicitly. Production callers
|
||||||
|
/// use [`get_or_insert`]; tests use this to force a hash collision (different
|
||||||
|
/// bytes, same hash) and exercise the byte-equality guard.
|
||||||
|
fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V {
|
||||||
|
if let Some(idx) = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.position(|(h, b, _)| *h == hash && b.as_slice() == bytes)
|
||||||
|
{
|
||||||
|
// LRU touch: move the hit entry to the back (most recent).
|
||||||
|
let entry = self.entries.remove(idx);
|
||||||
|
let val = entry.2.clone();
|
||||||
|
self.entries.push(entry);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
let val = build();
|
||||||
|
if self.entries.len() >= self.cap {
|
||||||
|
self.entries.remove(0); // evict least-recently-used
|
||||||
|
}
|
||||||
|
self.entries.push((hash, bytes.to_vec(), val.clone()));
|
||||||
|
val
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_lru_reuses_value_for_identical_bytes() {
|
||||||
|
let mut lru: ByteLru<u32> = ByteLru::new(4);
|
||||||
|
let mut next = 0u32;
|
||||||
|
let mut build = |lru: &mut ByteLru<u32>, b: &[u8]| {
|
||||||
|
lru.get_or_insert(b, || {
|
||||||
|
next += 1;
|
||||||
|
next
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// Same bytes → same value, built only once.
|
||||||
|
assert_eq!(build(&mut lru, b"alice"), 1);
|
||||||
|
assert_eq!(build(&mut lru, b"alice"), 1);
|
||||||
|
// Different bytes → a freshly built value.
|
||||||
|
assert_eq!(build(&mut lru, b"bob"), 2);
|
||||||
|
assert_eq!(lru.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_lru_evicts_least_recently_used() {
|
||||||
|
let mut lru: ByteLru<u32> = ByteLru::new(2);
|
||||||
|
let mut n = 0u32;
|
||||||
|
let mut ins = |lru: &mut ByteLru<u32>, b: &[u8]| {
|
||||||
|
lru.get_or_insert(b, || {
|
||||||
|
n += 1;
|
||||||
|
n
|
||||||
|
})
|
||||||
|
};
|
||||||
|
ins(&mut lru, b"a"); // -> 1
|
||||||
|
ins(&mut lru, b"b"); // -> 2, cache = [a, b]
|
||||||
|
ins(&mut lru, b"a"); // touch a, cache = [b, a]
|
||||||
|
ins(&mut lru, b"c"); // evicts LRU (b), cache = [a, c]
|
||||||
|
assert_eq!(lru.len(), 2);
|
||||||
|
// `a` survived (recently touched) → still value 1, not rebuilt.
|
||||||
|
assert_eq!(ins(&mut lru, b"a"), 1);
|
||||||
|
// `b` was evicted → rebuilt with a new value.
|
||||||
|
assert_eq!(ins(&mut lru, b"b"), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_lru_byte_equality_survives_a_hash_collision() {
|
||||||
|
// Force the SAME 64-bit hash for two DIFFERENT byte strings (the case a
|
||||||
|
// bare-hash cache would alias — Tier C F-03 collision bug).
|
||||||
|
let mut lru: ByteLru<u32> = ByteLru::new(4);
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 1), 1);
|
||||||
|
// `bob` collides on the hash but differs in bytes → a MISS, built fresh,
|
||||||
|
// NOT aliased to alice's value.
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 2), 2);
|
||||||
|
// Both coexist; each re-lookup returns its own value (build closure unused).
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 99), 1);
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 99), 2);
|
||||||
|
assert_eq!(lru.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initials_takes_first_two_words() {
|
fn initials_takes_first_two_words() {
|
||||||
assert_eq!(initials("Alice"), "A");
|
assert_eq!(initials("Alice"), "A");
|
||||||
|
|||||||
+29
-5
@@ -64,9 +64,16 @@ pub enum CoreCommand {
|
|||||||
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||||
/// Sent at startup so screen-share can resolve the binary.
|
/// Sent at startup so screen-share can resolve the binary.
|
||||||
SetPixelpassPath(Option<String>),
|
SetPixelpassPath(Option<String>),
|
||||||
|
/// Enumerate apps currently producing audio (for the screen-share audio
|
||||||
|
/// picker, A23). Replies with [`UiEvent::AudioAppsListed`]. Cheap shell-out;
|
||||||
|
/// safe to call each time the picker opens.
|
||||||
|
ListAudioApps,
|
||||||
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
|
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
|
||||||
/// on our presence so the room can watch. No-op when not in a call.
|
/// on our presence so the room can watch. No-op when not in a call.
|
||||||
StartScreenShare,
|
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
||||||
|
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
||||||
|
/// whole desktop audio (the legacy behavior).
|
||||||
|
StartScreenShare { audio_app: Option<String> },
|
||||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||||
/// ticket. No-op when not sharing.
|
/// ticket. No-op when not sharing.
|
||||||
StopScreenShare,
|
StopScreenShare,
|
||||||
@@ -106,6 +113,9 @@ pub enum CoreCommand {
|
|||||||
pub enum UiEvent {
|
pub enum UiEvent {
|
||||||
RoomJoined { ticket: String, self_id: String },
|
RoomJoined { ticket: String, self_id: String },
|
||||||
RoomLeft,
|
RoomLeft,
|
||||||
|
/// Clear room-scoped UI state after a failed in-call room switch, without a
|
||||||
|
/// leave chime. The persistent identity remains unchanged.
|
||||||
|
RoomReset,
|
||||||
PeerJoined { id: EndpointId, state: PeerState },
|
PeerJoined { id: EndpointId, state: PeerState },
|
||||||
PeerLeft { id: EndpointId },
|
PeerLeft { id: EndpointId },
|
||||||
/// The fixed reconnect grace expired and bounded background gossip recovery
|
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||||
@@ -130,15 +140,29 @@ pub enum UiEvent {
|
|||||||
/// string, used to key their avatar (W4).
|
/// string, used to key their avatar (W4).
|
||||||
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
|
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
|
||||||
/// An attachment's bytes are now available (auto-fetched for images, or
|
/// An attachment's bytes are now available (auto-fetched for images, or
|
||||||
/// fetched on demand for files). Keyed by attachment id so the UI can match
|
/// fetched on demand for files). Keyed by `(from, id)`: the id is
|
||||||
/// it to the chat entry.
|
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author
|
||||||
AttachmentReady { id: crate::files::AttachmentId, data: Vec<u8> },
|
/// disambiguates whose bytes these are and stops content aliasing (Tier C
|
||||||
|
/// F-12).
|
||||||
|
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||||
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||||
AttachmentFailed { id: crate::files::AttachmentId, error: String },
|
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
||||||
|
/// The apps currently producing audio, for the screen-share audio picker
|
||||||
|
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
||||||
|
/// playing or enumeration isn't available. `app_audio_supported` reports
|
||||||
|
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
||||||
|
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
||||||
|
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
||||||
|
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool },
|
||||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||||
ScreenShareStarted,
|
ScreenShareStarted,
|
||||||
/// Our own screen share stopped (or failed to start).
|
/// Our own screen share stopped (or failed to start).
|
||||||
ScreenShareStopped,
|
ScreenShareStopped,
|
||||||
|
/// Per-app screen-share audio routing state (A23). `true` = the app we chose
|
||||||
|
/// is now reaching viewers; `false` = its audio stopped, so under our strict
|
||||||
|
/// run viewers currently hear silence. The UI shows a transient warning while
|
||||||
|
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
|
||||||
|
ShareAudioActive(bool),
|
||||||
/// Our node identity (W7): the current node id string, and whether it is
|
/// Our node identity (W7): the current node id string, and whether it is
|
||||||
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
||||||
/// `persisted = false` means the key file couldn't be read/written and we're
|
/// `persisted = false` means the key file couldn't be read/written and we're
|
||||||
|
|||||||
+461
-76
@@ -91,6 +91,22 @@ fn game_presence_label(game: Option<&crate::game::DetectedGame>) -> Option<Strin
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait for the next game update. A closed sender permanently disables the
|
||||||
|
/// source by clearing the receiver; subsequent calls remain pending instead of
|
||||||
|
/// leaving an always-ready closed branch in the core `select!` loop.
|
||||||
|
async fn next_game_change(
|
||||||
|
game_rx: &mut Option<tokio::sync::watch::Receiver<Option<crate::game::DetectedGame>>>,
|
||||||
|
) -> Option<Option<crate::game::DetectedGame>> {
|
||||||
|
let Some(rx) = game_rx.as_mut() else {
|
||||||
|
return std::future::pending().await;
|
||||||
|
};
|
||||||
|
if rx.changed().await.is_err() {
|
||||||
|
*game_rx = None;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(rx.borrow_and_update().clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn arm_discovery_retry(
|
fn arm_discovery_retry(
|
||||||
discovery_deadline: &mut Option<tokio::time::Instant>,
|
discovery_deadline: &mut Option<tokio::time::Instant>,
|
||||||
now: tokio::time::Instant,
|
now: tokio::time::Instant,
|
||||||
@@ -116,14 +132,33 @@ type GraceTimers = Arc<std::sync::Mutex<HashMap<EndpointId, tokio::task::JoinHan
|
|||||||
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
|
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
|
||||||
|
|
||||||
type KnownPeers =
|
type KnownPeers =
|
||||||
Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>>;
|
Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
|
||||||
|
|
||||||
|
/// Per-topic cap on the retained rejoin-bootstrap / recovery target table
|
||||||
|
/// (Tier C recovery-identity cap). Set comfortably above the live-roster cap
|
||||||
|
/// (`gossip::MAX_ACTIVE_PEERS`, 32) so a legitimate room — even one where every
|
||||||
|
/// member drops at once during a relay outage — never hits it, while an insider
|
||||||
|
/// who grace-cycles distinct identities (join, drop without a signed Leave,
|
||||||
|
/// repeat) cannot grow the table without bound. Combined with the recovery
|
||||||
|
/// terminal budget (which forgets a retained address when it gives up), abandoned
|
||||||
|
/// identities drain on their own, so this cap is a deterministic ceiling rather
|
||||||
|
/// than a pinnable slot pool.
|
||||||
|
const MAX_RETAINED_PEERS: usize = 64;
|
||||||
|
|
||||||
|
/// Whether a peer may be inserted into a retained-target table at `len` entries.
|
||||||
|
/// An update to an id already present is always allowed (it only refreshes an
|
||||||
|
/// address); a brand-new id is admitted only while below the cap. Mirrors the
|
||||||
|
/// gossip roster's `admit_into_roster` reject-when-full admission.
|
||||||
|
fn admit_retained(len: usize, is_new_id: bool, cap: usize) -> bool {
|
||||||
|
!is_new_id || len < cap
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct RecoveryContext {
|
struct RecoveryContext {
|
||||||
coordinator: RecoveryCoordinator,
|
coordinator: RecoveryCoordinator,
|
||||||
room_state: Arc<IrohGossipState>,
|
room_state: Arc<IrohGossipState>,
|
||||||
known_peers: KnownPeers,
|
known_peers: KnownPeers,
|
||||||
ticket: String,
|
topic_id: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecoveryContext {
|
impl RecoveryContext {
|
||||||
@@ -131,7 +166,7 @@ impl RecoveryContext {
|
|||||||
self.known_peers
|
self.known_peers
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get(&self.ticket)
|
.get(&self.topic_id)
|
||||||
.and_then(|peers| peers.get(peer_id))
|
.and_then(|peers| peers.get(peer_id))
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
@@ -141,7 +176,7 @@ impl RecoveryContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn forget(&self, peer_id: EndpointId) {
|
fn forget(&self, peer_id: EndpointId) {
|
||||||
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.ticket) {
|
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.topic_id) {
|
||||||
peers.remove(&peer_id);
|
peers.remove(&peer_id);
|
||||||
}
|
}
|
||||||
self.coordinator.cancel(peer_id);
|
self.coordinator.cancel(peer_id);
|
||||||
@@ -506,6 +541,7 @@ struct ActiveSession {
|
|||||||
event_task: tokio::task::JoinHandle<()>,
|
event_task: tokio::task::JoinHandle<()>,
|
||||||
conn_event_task: tokio::task::JoinHandle<()>,
|
conn_event_task: tokio::task::JoinHandle<()>,
|
||||||
recovery_task: tokio::task::JoinHandle<()>,
|
recovery_task: tokio::task::JoinHandle<()>,
|
||||||
|
recovery_terminal_task: tokio::task::JoinHandle<()>,
|
||||||
grace_timers: GraceTimers,
|
grace_timers: GraceTimers,
|
||||||
transport: Arc<IrohTransport>,
|
transport: Arc<IrohTransport>,
|
||||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||||
@@ -541,6 +577,7 @@ impl ActiveSession {
|
|||||||
handle.abort();
|
handle.abort();
|
||||||
}
|
}
|
||||||
self.recovery_task.abort();
|
self.recovery_task.abort();
|
||||||
|
self.recovery_terminal_task.abort();
|
||||||
crate::log_msg("Aborted tasks");
|
crate::log_msg("Aborted tasks");
|
||||||
|
|
||||||
let audio_backend_clone = audio_backend.clone();
|
let audio_backend_clone = audio_backend.clone();
|
||||||
@@ -728,25 +765,70 @@ async fn build_net_stack(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum number of *automatic* chat-attachment fetches in flight at once.
|
||||||
|
///
|
||||||
|
/// Auto-fetch (inline image preview) is triggered by an untrusted peer's chat
|
||||||
|
/// message, and each fetch is a detached task that can spend up to ~60s dialing
|
||||||
|
/// and reading. Without a bound, a room insider could spam attachment-carrying
|
||||||
|
/// chat to accumulate arbitrary pending tasks/dials (Tier C F-02). When the bound
|
||||||
|
/// is reached we simply skip the auto-fetch; the descriptor still renders and the
|
||||||
|
/// user can fetch it on demand (which is not rate-limited here).
|
||||||
|
const MAX_INFLIGHT_ATTACHMENT_FETCHES: usize = 4;
|
||||||
|
|
||||||
|
/// In-flight `(author, attachment_id)` markers for bounded, deduplicated auto-
|
||||||
|
/// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`].
|
||||||
|
type InflightAttachments =
|
||||||
|
Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
|
||||||
|
|
||||||
|
/// RAII bookkeeping for one bounded auto-fetch: holds the concurrency permit for
|
||||||
|
/// the task's lifetime and clears the in-flight `(author, id)` marker when the
|
||||||
|
/// fetch finishes (success OR failure), so the same image can be retried later.
|
||||||
|
struct AutoFetchGuard {
|
||||||
|
_permit: tokio::sync::OwnedSemaphorePermit,
|
||||||
|
inflight: InflightAttachments,
|
||||||
|
key: (EndpointId, crate::files::AttachmentId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AutoFetchGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.inflight.lock().unwrap().remove(&self.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to AUTO-fetch a chat image attachment. Only authenticated roster
|
||||||
|
/// authors qualify (closing the non-roster injection vector), and a `(author,
|
||||||
|
/// id)` already being fetched is skipped (dedup). The concurrency bound itself is
|
||||||
|
/// enforced separately by the permit. Pure → unit-testable (Tier C F-02).
|
||||||
|
fn should_auto_fetch(is_image: bool, author_in_roster: bool, already_inflight: bool) -> bool {
|
||||||
|
is_image && author_in_roster && !already_inflight
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
|
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
|
||||||
/// task, then report the result to the UI via [`UiEvent::AttachmentReady`] /
|
/// task, then report the result to the UI via [`UiEvent::AttachmentReady`] /
|
||||||
/// [`UiEvent::AttachmentFailed`] keyed by the attachment id. For images
|
/// [`UiEvent::AttachmentFailed`], tagged with `from` so the UI keys the bytes by
|
||||||
|
/// `(author, id)` and can't alias a same-id attachment from another sender. For images
|
||||||
/// (`is_image`) the bytes are defensively re-validated (decodable + within pixel
|
/// (`is_image`) the bytes are defensively re-validated (decodable + within pixel
|
||||||
/// limits) before being handed to the renderer; an "Image" that doesn't decode is
|
/// limits) before being handed to the renderer; an "Image" that doesn't decode is
|
||||||
/// reported as a failure rather than rendered.
|
/// reported as a failure rather than rendered. `guard` is `Some` for bounded
|
||||||
|
/// auto-fetches and `None` for user-initiated fetches; it is dropped when the
|
||||||
|
/// task ends, releasing the concurrency permit and the dedup marker.
|
||||||
fn spawn_attachment_fetch(
|
fn spawn_attachment_fetch(
|
||||||
transport: Arc<IrohTransport>,
|
transport: Arc<IrohTransport>,
|
||||||
ui_tx: mpsc::Sender<UiEvent>,
|
ui_tx: mpsc::Sender<UiEvent>,
|
||||||
from: EndpointId,
|
from: EndpointId,
|
||||||
att: crate::files::ChatAttachment,
|
att: crate::files::ChatAttachment,
|
||||||
is_image: bool,
|
is_image: bool,
|
||||||
|
guard: Option<AutoFetchGuard>,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// Held for the whole fetch; dropped here on completion (Tier C F-02).
|
||||||
|
let _guard = guard;
|
||||||
match transport.fetch_attachment(from, &att).await {
|
match transport.fetch_attachment(from, &att).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
if is_image && crate::files::validate_image_bytes(&data).is_none() {
|
if is_image && crate::files::validate_image_bytes(&data).is_none() {
|
||||||
let _ = ui_tx
|
let _ = ui_tx
|
||||||
.send(UiEvent::AttachmentFailed {
|
.send(UiEvent::AttachmentFailed {
|
||||||
|
from,
|
||||||
id: att.id,
|
id: att.id,
|
||||||
error: "received image failed to decode".to_string(),
|
error: "received image failed to decode".to_string(),
|
||||||
})
|
})
|
||||||
@@ -754,12 +836,12 @@ fn spawn_attachment_fetch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = ui_tx
|
let _ = ui_tx
|
||||||
.send(UiEvent::AttachmentReady { id: att.id, data })
|
.send(UiEvent::AttachmentReady { from, id: att.id, data })
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ui_tx
|
let _ = ui_tx
|
||||||
.send(UiEvent::AttachmentFailed { id: att.id, error: e.to_string() })
|
.send(UiEvent::AttachmentFailed { from, id: att.id, error: e.to_string() })
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -943,11 +1025,19 @@ async fn run_core_loop(
|
|||||||
// the game is *broadcast* as presence is gated by `game_presence_enabled` (opt-in,
|
// the game is *broadcast* as presence is gated by `game_presence_enabled` (opt-in,
|
||||||
// seeded false; the GUI sends `SetGamePresenceEnabled` from config at startup).
|
// seeded false; the GUI sends `SetGamePresenceEnabled` from config at startup).
|
||||||
// The override + process map start at their defaults and are set via commands.
|
// The override + process map start at their defaults and are set via commands.
|
||||||
let game_detector = crate::game::detector::GameDetector::spawn(
|
let (game_detector, mut game_rx) = match crate::game::detector::GameDetector::spawn(
|
||||||
crate::game::ManualOverride::Auto,
|
crate::game::ManualOverride::Auto,
|
||||||
std::collections::BTreeMap::new(),
|
std::collections::BTreeMap::new(),
|
||||||
);
|
) {
|
||||||
let mut game_rx = game_detector.subscribe();
|
Ok(detector) => {
|
||||||
|
let rx = detector.subscribe();
|
||||||
|
(Some(detector), Some(rx))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
crate::log_msg(&format!("game detector unavailable: {e}"));
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut game_presence_enabled = false;
|
let mut game_presence_enabled = false;
|
||||||
// The latest debounced detection, kept regardless of the broadcast toggle so a
|
// The latest debounced detection, kept regardless of the broadcast toggle so a
|
||||||
// later opt-in can immediately publish whatever is currently running.
|
// later opt-in can immediately publish whatever is currently running.
|
||||||
@@ -984,22 +1074,34 @@ async fn run_core_loop(
|
|||||||
// Join, cleared on Leave.
|
// Join, cleared on Leave.
|
||||||
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
||||||
Arc::new(std::sync::Mutex::new(None));
|
Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let presence_rate_limiter =
|
||||||
|
Arc::new(std::sync::Mutex::new(crate::presence::PresenceRateLimiter::default()));
|
||||||
|
|
||||||
// Reply policy for the idle friends listener (B2): answer friends only, never
|
// Reply policy for the idle friends listener (B2): answer friends only, never
|
||||||
// while invisible (`should_answer`), and report our current gathering so a friend
|
// while invisible (`should_answer`), and report our current gathering so a friend
|
||||||
// can one-click join. Reads the shared snapshots, so it stays correct as they
|
// can one-click join. Rate-limits allowed friends before building a reply, so a
|
||||||
// change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held
|
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the
|
||||||
// across one). Built once and handed to every `build_net_stack`.
|
// shared snapshots, so it stays correct as they change and survives a network-stack
|
||||||
|
// rebuild. Pure-sync (no awaits, no lock held across one). Built once and handed
|
||||||
|
// to every `build_net_stack`.
|
||||||
let friends_handler: crate::presence_net::Handler = {
|
let friends_handler: crate::presence_net::Handler = {
|
||||||
let friends = friends.clone();
|
let friends = friends.clone();
|
||||||
let presence_mode = presence_mode.clone();
|
let presence_mode = presence_mode.clone();
|
||||||
let current_room = current_room.clone();
|
let current_room = current_room.clone();
|
||||||
|
let presence_rate_limiter = presence_rate_limiter.clone();
|
||||||
Arc::new(move |from| {
|
Arc::new(move |from| {
|
||||||
let mode = *presence_mode.lock().unwrap();
|
let mode = *presence_mode.lock().unwrap();
|
||||||
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if !presence_rate_limiter
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.allow(from, std::time::Instant::now())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let room = current_room.lock().unwrap().clone();
|
let room = current_room.lock().unwrap().clone();
|
||||||
Some(crate::presence::ControlMsg::Pong { room })
|
Some(crate::presence::ControlMsg::Pong { room })
|
||||||
})
|
})
|
||||||
@@ -1058,13 +1160,14 @@ async fn run_core_loop(
|
|||||||
Some(cmd) => cmd,
|
Some(cmd) => cmd,
|
||||||
None => break,
|
None => break,
|
||||||
},
|
},
|
||||||
changed = game_rx.changed() => {
|
game_change = next_game_change(&mut game_rx) => {
|
||||||
// The detector worker published a new debounced game (or `None`).
|
// The detector worker published a new debounced game (or `None`).
|
||||||
if changed.is_err() {
|
let Some(detected) = game_change else {
|
||||||
// Worker gone (shouldn't happen before shutdown); stop watching.
|
// Worker gone unexpectedly. The helper fused this source, so
|
||||||
|
// this logs once and the closed channel cannot spin select!.
|
||||||
|
crate::log_msg("game detector stopped; disabling game detection");
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let detected = game_rx.borrow_and_update().clone();
|
|
||||||
current_game = detected.clone();
|
current_game = detected.clone();
|
||||||
// Always tell the GUI for the local per-game background + indicator.
|
// Always tell the GUI for the local per-game background + indicator.
|
||||||
let _ = ui_tx.send(UiEvent::GameChanged(detected.clone())).await;
|
let _ = ui_tx.send(UiEvent::GameChanged(detected.clone())).await;
|
||||||
@@ -1182,6 +1285,7 @@ async fn run_core_loop(
|
|||||||
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
||||||
presence.name = name.clone();
|
presence.name = name.clone();
|
||||||
presence.avatar = avatar;
|
presence.avatar = avatar;
|
||||||
|
let was_in_room = active_session.is_some();
|
||||||
|
|
||||||
// Finalize any recording before tearing down the old session — its
|
// Finalize any recording before tearing down the old session — its
|
||||||
// capture/mixer feeders are about to stop.
|
// capture/mixer feeders are about to stop.
|
||||||
@@ -1194,6 +1298,7 @@ async fn run_core_loop(
|
|||||||
session.shutdown(audio_backend.clone()).await;
|
session.shutdown(audio_backend.clone()).await;
|
||||||
net.audio_router.clear();
|
net.audio_router.clear();
|
||||||
net.file_router.clear();
|
net.file_router.clear();
|
||||||
|
*current_room.lock().unwrap() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a network-mode / identity change was deferred while a call was
|
// If a network-mode / identity change was deferred while a call was
|
||||||
@@ -1243,6 +1348,17 @@ async fn run_core_loop(
|
|||||||
));
|
));
|
||||||
ticket_str
|
ticket_str
|
||||||
};
|
};
|
||||||
|
let topic_id = match PeerSpeakTicket::topic_of(&ticket_str) {
|
||||||
|
Some(topic_id) => topic_id,
|
||||||
|
None => {
|
||||||
|
crate::log_msg("Error invalid room ticket");
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
|
let _ = ui_tx.send(UiEvent::Error("invalid room ticket".to_string())).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Per-session transport over the persistent endpoint, bound to the
|
// Per-session transport over the persistent endpoint, bound to the
|
||||||
// persistent audio router so this call's inbound audio links route
|
// persistent audio router so this call's inbound audio links route
|
||||||
@@ -1267,14 +1383,14 @@ async fn run_core_loop(
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Snapshot THIS room's retained peers (by ticket) as extra bootstrap
|
// Snapshot THIS room's retained peers (by topic) as extra bootstrap
|
||||||
// targets so a rejoin can dial them (A8) — including after a detour
|
// targets so a rejoin can dial them (A8) — including after a detour
|
||||||
// through another room, since the per-ticket archive isn't cleared.
|
// through another room, since the per-topic archive isn't cleared.
|
||||||
// Resolution rides the persistent address book.
|
// Resolution rides the persistent address book.
|
||||||
let extra_bootstrap: Vec<EndpointAddr> = known_peers
|
let extra_bootstrap: Vec<EndpointAddr> = known_peers
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get(&ticket_str)
|
.get(&topic_id)
|
||||||
.map(|peers| peers.values().cloned().collect())
|
.map(|peers| peers.values().cloned().collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@@ -1291,6 +1407,9 @@ async fn run_core_loop(
|
|||||||
));
|
));
|
||||||
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
|
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
|
||||||
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
|
||||||
net.audio_router.clear();
|
net.audio_router.clear();
|
||||||
net.file_router.clear();
|
net.file_router.clear();
|
||||||
@@ -1317,12 +1436,11 @@ async fn run_core_loop(
|
|||||||
output_device.as_deref(),
|
output_device.as_deref(),
|
||||||
) {
|
) {
|
||||||
Ok(guard) => {
|
Ok(guard) => {
|
||||||
|
let source_name = guard.source_name().to_string();
|
||||||
|
let sink_name = guard.sink_name().to_string();
|
||||||
echo_cancel_guard = Some(guard);
|
echo_cancel_guard = Some(guard);
|
||||||
crate::log_msg("Echo cancellation enabled");
|
crate::log_msg("Echo cancellation enabled");
|
||||||
(
|
(Some(source_name), Some(sink_name))
|
||||||
Some(crate::audio::echo_cancel::EC_SOURCE.to_string()),
|
|
||||||
Some(crate::audio::echo_cancel::EC_SINK.to_string()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::log_msg(&format!(
|
crate::log_msg(&format!(
|
||||||
@@ -1343,6 +1461,9 @@ async fn run_core_loop(
|
|||||||
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
|
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
|
||||||
|
|
||||||
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
|
||||||
let _ = room_state.leave().await;
|
let _ = room_state.leave().await;
|
||||||
net.audio_router.clear();
|
net.audio_router.clear();
|
||||||
@@ -1355,6 +1476,9 @@ async fn run_core_loop(
|
|||||||
// production to the hardware clock instead of a fixed timer.
|
// production to the hardware clock instead of a fixed timer.
|
||||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||||
if let Err(e) = audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) {
|
if let Err(e) = audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) {
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await;
|
||||||
let _ = audio_backend.stop();
|
let _ = audio_backend.stop();
|
||||||
let _ = room_state.leave().await;
|
let _ = room_state.leave().await;
|
||||||
@@ -1656,23 +1780,57 @@ async fn run_core_loop(
|
|||||||
// track in Both mode) one aligned frame per cycle; Mixed mode
|
// track in Both mode) one aligned frame per cycle; Mixed mode
|
||||||
// writes the single blended file as before.
|
// writes the single blended file as before.
|
||||||
if mt_active {
|
if mt_active {
|
||||||
if let Some(mt) = multitrack_mixer.lock().unwrap().as_mut() {
|
let write_err = multitrack_mixer
|
||||||
let res = (|| -> std::io::Result<()> {
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.map(|mt| -> std::io::Result<()> {
|
||||||
for (id, f) in &stems {
|
for (id, f) in &stems {
|
||||||
mt.write_peer(*id, f)?;
|
mt.write_peer(*id, f)?;
|
||||||
}
|
}
|
||||||
mt.write_mix(&record_mix)?;
|
mt.write_mix(&record_mix)?;
|
||||||
mt.end_cycle()
|
mt.end_cycle()
|
||||||
})();
|
})
|
||||||
if let Err(e) = res {
|
.transpose()
|
||||||
crate::log_msg(&format!("Multitrack write failed: {e}"));
|
.err();
|
||||||
}
|
if let Some(e) = write_err {
|
||||||
|
crate::log_msg(&format!("Multitrack write failed: {e}"));
|
||||||
|
stop_recording(
|
||||||
|
&recorder_mixer,
|
||||||
|
&is_recording_mixer,
|
||||||
|
&multitrack_mixer,
|
||||||
|
&is_multitrack_mixer,
|
||||||
|
&ui_tx_mixer,
|
||||||
|
).await;
|
||||||
|
let _ = ui_tx_mixer
|
||||||
|
.send(UiEvent::Error(format!(
|
||||||
|
"Recording stopped — write failed: {e}"
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
} else if is_recording_mixer.load(Ordering::Relaxed) {
|
||||||
|
let write_err = recorder_mixer
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.map(|rec| rec.write_frame(&record_mix))
|
||||||
|
.transpose()
|
||||||
|
.err();
|
||||||
|
if let Some(e) = write_err {
|
||||||
|
crate::log_msg(&format!("Recording write failed: {e}"));
|
||||||
|
stop_recording(
|
||||||
|
&recorder_mixer,
|
||||||
|
&is_recording_mixer,
|
||||||
|
&multitrack_mixer,
|
||||||
|
&is_multitrack_mixer,
|
||||||
|
&ui_tx_mixer,
|
||||||
|
).await;
|
||||||
|
let _ = ui_tx_mixer
|
||||||
|
.send(UiEvent::Error(format!(
|
||||||
|
"Recording stopped — write failed: {e}"
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
} else if is_recording_mixer.load(Ordering::Relaxed)
|
|
||||||
&& let Some(rec) = recorder_mixer.lock().unwrap().as_mut()
|
|
||||||
&& let Err(e) = rec.write_frame(&record_mix)
|
|
||||||
{
|
|
||||||
crate::log_msg(&format!("Recording write failed: {e}"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
|
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
|
||||||
@@ -1699,6 +1857,9 @@ async fn run_core_loop(
|
|||||||
let mut room_events = match room_state.subscribe_events().await {
|
let mut room_events = match room_state.subscribe_events().await {
|
||||||
Ok(rx) => rx,
|
Ok(rx) => rx,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe events: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe events: {}", e))).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1713,29 +1874,63 @@ async fn run_core_loop(
|
|||||||
let multitrack_events = multitrack.clone();
|
let multitrack_events = multitrack.clone();
|
||||||
let is_multitrack_events = is_multitrack.clone();
|
let is_multitrack_events = is_multitrack.clone();
|
||||||
let known_peers_events = known_peers.clone();
|
let known_peers_events = known_peers.clone();
|
||||||
// The ticket of the room this event loop serves, so peer add/remove
|
// The topic of the room this event loop serves, so peer add/remove
|
||||||
// updates the right per-ticket bucket in `known_peers` (A8 archive).
|
// updates the right per-topic bucket in `known_peers` (A8 archive).
|
||||||
let ticket_events = ticket_str.clone();
|
let room_topic = topic_id;
|
||||||
let (recovery_coordinator, recovery_task) =
|
let (recovery_coordinator, recovery_task, recovery_terminal_rx) =
|
||||||
RecoveryCoordinator::spawn(room_state.clone());
|
RecoveryCoordinator::spawn(room_state.clone());
|
||||||
let recovery_context = RecoveryContext {
|
let recovery_context = RecoveryContext {
|
||||||
coordinator: recovery_coordinator,
|
coordinator: recovery_coordinator,
|
||||||
room_state: room_state.clone(),
|
room_state: room_state.clone(),
|
||||||
known_peers: known_peers.clone(),
|
known_peers: known_peers.clone(),
|
||||||
ticket: ticket_str.clone(),
|
topic_id,
|
||||||
};
|
};
|
||||||
let recovery_events = recovery_context.clone();
|
let recovery_events = recovery_context.clone();
|
||||||
|
// Drain the recovery coordinator's terminal-eviction signals (Tier C
|
||||||
|
// recovery-identity cap). When background recovery exhausts its budget
|
||||||
|
// for a peer, forget its retained dial target so the per-topic retain
|
||||||
|
// table drains, scrub residual seen-connected state, and surface the
|
||||||
|
// failure. A peer that later returns can still rejoin via a gossip
|
||||||
|
// announce, so giving up never blocks a legitimate reconnect.
|
||||||
|
let recovery_terminal_ctx = recovery_context.clone();
|
||||||
|
let seen_connected_terminal = seen_connected.clone();
|
||||||
|
let ui_tx_terminal = ui_tx.clone();
|
||||||
|
let recovery_terminal_task = tokio::spawn(async move {
|
||||||
|
let mut terminal_rx = recovery_terminal_rx;
|
||||||
|
while let Some(peer_id) = terminal_rx.recv().await {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Background recovery gave up on peer {peer_id:?}; forgetting retained target"
|
||||||
|
));
|
||||||
|
recovery_terminal_ctx.forget(peer_id);
|
||||||
|
seen_connected_terminal.lock().unwrap().remove(&peer_id);
|
||||||
|
let _ = ui_tx_terminal
|
||||||
|
.send(UiEvent::PeerConnectionFailed { id: peer_id })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
// Friends store + ui sender, so a connected peer who is a friend has
|
// Friends store + ui sender, so a connected peer who is a friend has
|
||||||
// their saved address auto-healed (W7) — populates `last_addr` so the
|
// their saved address auto-healed (W7) — populates `last_addr` so the
|
||||||
// presence scheduler can reach them later.
|
// presence scheduler can reach them later.
|
||||||
let friends_events = friends.clone();
|
let friends_events = friends.clone();
|
||||||
let friends_read_only_events = friends_read_only;
|
let friends_read_only_events = friends_read_only;
|
||||||
|
// Bounded, deduplicated auto-fetch of chat image attachments (Tier C
|
||||||
|
// F-02): the permit pool caps concurrent fetch tasks; the in-flight
|
||||||
|
// set dedups identical (author, id) pairs.
|
||||||
|
let attachment_limiter =
|
||||||
|
Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_ATTACHMENT_FETCHES));
|
||||||
|
let inflight_attachments: InflightAttachments =
|
||||||
|
Arc::new(std::sync::Mutex::new(HashSet::new()));
|
||||||
let event_task = tokio::spawn(async move {
|
let event_task = tokio::spawn(async move {
|
||||||
|
// The authenticated roster for this room, maintained from the
|
||||||
|
// same sequential event stream. Only its members may trigger an
|
||||||
|
// automatic attachment fetch (Tier C F-02).
|
||||||
|
let mut roster: HashSet<EndpointId> = HashSet::new();
|
||||||
while let Some(event) = room_events.recv().await {
|
while let Some(event) = room_events.recv().await {
|
||||||
match event {
|
match event {
|
||||||
RoomEvent::PeerJoined(peer_id, state) => {
|
RoomEvent::PeerJoined(peer_id, state) => {
|
||||||
// A (re)join means the peer is back — cancel any
|
// A (re)join means the peer is back — cancel any
|
||||||
// pending reconnect grace timer before re-adding it.
|
// pending reconnect grace timer before re-adding it.
|
||||||
|
roster.insert(peer_id);
|
||||||
cancel_grace_timer(&grace_timers_events, &peer_id);
|
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||||
recovery_events.cancel(peer_id);
|
recovery_events.cancel(peer_id);
|
||||||
transport_events.admit_audio_sender(peer_id);
|
transport_events.admit_audio_sender(peer_id);
|
||||||
@@ -1759,14 +1954,23 @@ async fn run_core_loop(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
// Retain this peer under this room's ticket as a
|
// Retain this peer under this room's topic as a
|
||||||
// future rejoin bootstrap target (A8).
|
// future rejoin bootstrap target (A8), bounded by the
|
||||||
known_peers_events
|
// per-topic retain cap (Tier C recovery-identity cap):
|
||||||
.lock()
|
// refreshing a peer we already track is always allowed,
|
||||||
.unwrap()
|
// a brand-new identity only while below the cap.
|
||||||
.entry(ticket_events.clone())
|
{
|
||||||
.or_default()
|
let mut kp = known_peers_events.lock().unwrap();
|
||||||
.insert(peer_id, state.addr.clone());
|
let bucket = kp.entry(room_topic).or_default();
|
||||||
|
let is_new_id = !bucket.contains_key(&peer_id);
|
||||||
|
if admit_retained(bucket.len(), is_new_id, MAX_RETAINED_PEERS) {
|
||||||
|
bucket.insert(peer_id, state.addr.clone());
|
||||||
|
} else {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Retain table full ({MAX_RETAINED_PEERS}); not retaining new peer {peer_id:?} for rejoin"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
// If a multitrack recording is live, give this peer
|
// If a multitrack recording is live, give this peer
|
||||||
// its own stem track (silence-padded back to t=0).
|
// its own stem track (silence-padded back to t=0).
|
||||||
if is_multitrack_events.load(Ordering::Relaxed)
|
if is_multitrack_events.load(Ordering::Relaxed)
|
||||||
@@ -1779,6 +1983,7 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
RoomEvent::PeerLeft(peer_id) => {
|
RoomEvent::PeerLeft(peer_id) => {
|
||||||
// Graceful leave — evict immediately.
|
// Graceful leave — evict immediately.
|
||||||
|
roster.remove(&peer_id);
|
||||||
cancel_grace_timer(&grace_timers_events, &peer_id);
|
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||||
seen_connected_events.lock().unwrap().remove(&peer_id);
|
seen_connected_events.lock().unwrap().remove(&peer_id);
|
||||||
// A signed Leave cancels background recovery and
|
// A signed Leave cancels background recovery and
|
||||||
@@ -1816,13 +2021,21 @@ async fn run_core_loop(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
// Refresh this room's retained rejoin target with the
|
// Refresh this room's retained rejoin target with the
|
||||||
// fresh addr (A8).
|
// fresh addr (A8), under the per-topic retain cap. A
|
||||||
known_peers_events
|
// re-announce from a peer we already track always
|
||||||
.lock()
|
// refreshes; a new identity is bounded by the cap.
|
||||||
.unwrap()
|
{
|
||||||
.entry(ticket_events.clone())
|
let mut kp = known_peers_events.lock().unwrap();
|
||||||
.or_default()
|
let bucket = kp.entry(room_topic).or_default();
|
||||||
.insert(peer_id, state.addr.clone());
|
let is_new_id = !bucket.contains_key(&peer_id);
|
||||||
|
if admit_retained(bucket.len(), is_new_id, MAX_RETAINED_PEERS) {
|
||||||
|
bucket.insert(peer_id, state.addr.clone());
|
||||||
|
} else {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Retain table full ({MAX_RETAINED_PEERS}); not retaining new peer {peer_id:?} for rejoin"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await;
|
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await;
|
||||||
}
|
}
|
||||||
RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => {
|
RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => {
|
||||||
@@ -1830,16 +2043,47 @@ async fn run_core_loop(
|
|||||||
// without a click; non-image files wait for an explicit
|
// without a click; non-image files wait for an explicit
|
||||||
// FetchAttachment (the "Save" chip). The descriptor was
|
// FetchAttachment (the "Save" chip). The descriptor was
|
||||||
// already filename-sanitized + size-capped on ingest.
|
// already filename-sanitized + size-capped on ingest.
|
||||||
if let Some(att) = attachment.clone()
|
//
|
||||||
&& att.kind == crate::files::AttachmentKind::Image
|
// The auto path is an untrusted-peer-triggered detached
|
||||||
{
|
// task, so it is gated (Tier C F-02): only roster authors
|
||||||
spawn_attachment_fetch(
|
// qualify, identical (author,id) pairs are deduped, and a
|
||||||
transport_events.clone(),
|
// permit pool caps concurrent fetch tasks. The chat TEXT
|
||||||
ui_tx_events.clone(),
|
// is always forwarded (it's sanitized at the UI edge);
|
||||||
from,
|
// only the fetch is bounded.
|
||||||
att,
|
if let Some(att) = attachment.clone() {
|
||||||
true,
|
let is_image = att.kind == crate::files::AttachmentKind::Image;
|
||||||
);
|
let key = (from, att.id);
|
||||||
|
let already_inflight =
|
||||||
|
inflight_attachments.lock().unwrap().contains(&key);
|
||||||
|
if should_auto_fetch(is_image, roster.contains(&from), already_inflight) {
|
||||||
|
// Reserve the dedup slot, then a permit. If the
|
||||||
|
// pool is exhausted, drop the auto-fetch (and the
|
||||||
|
// dedup marker) — the descriptor still shows and
|
||||||
|
// the user can fetch on demand.
|
||||||
|
inflight_attachments.lock().unwrap().insert(key);
|
||||||
|
match attachment_limiter.clone().try_acquire_owned() {
|
||||||
|
Ok(permit) => {
|
||||||
|
spawn_attachment_fetch(
|
||||||
|
transport_events.clone(),
|
||||||
|
ui_tx_events.clone(),
|
||||||
|
from,
|
||||||
|
att,
|
||||||
|
true,
|
||||||
|
Some(AutoFetchGuard {
|
||||||
|
_permit: permit,
|
||||||
|
inflight: inflight_attachments.clone(),
|
||||||
|
key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
inflight_attachments.lock().unwrap().remove(&key);
|
||||||
|
crate::log_msg(
|
||||||
|
"Chat attachment auto-fetch limit reached; skipping (fetch on demand)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = ui_tx_events.send(UiEvent::ChatMessage {
|
let _ = ui_tx_events.send(UiEvent::ChatMessage {
|
||||||
from: from.to_string(),
|
from: from.to_string(),
|
||||||
@@ -1880,6 +2124,9 @@ async fn run_core_loop(
|
|||||||
let mut conn_events = match transport.subscribe_conn_events().await {
|
let mut conn_events = match transport.subscribe_conn_events().await {
|
||||||
Ok(rx) => rx,
|
Ok(rx) => rx,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe conn events: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe conn events: {}", e))).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1906,6 +2153,7 @@ async fn run_core_loop(
|
|||||||
event_task,
|
event_task,
|
||||||
conn_event_task,
|
conn_event_task,
|
||||||
recovery_task,
|
recovery_task,
|
||||||
|
recovery_terminal_task,
|
||||||
grace_timers,
|
grace_timers,
|
||||||
transport: transport.clone(),
|
transport: transport.clone(),
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -2255,11 +2503,15 @@ async fn run_core_loop(
|
|||||||
|
|
||||||
CoreCommand::SetGameOverride(override_) => {
|
CoreCommand::SetGameOverride(override_) => {
|
||||||
// Applied on the detector's next poll, immediately (bypasses debounce).
|
// Applied on the detector's next poll, immediately (bypasses debounce).
|
||||||
game_detector.set_override(override_);
|
if let Some(detector) = &game_detector {
|
||||||
|
detector.set_override(override_);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SetGameProcessMap(map) => {
|
CoreCommand::SetGameProcessMap(map) => {
|
||||||
game_detector.set_process_map(map);
|
if let Some(detector) = &game_detector {
|
||||||
|
detector.set_process_map(map);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SetRecordingMode(mode) => {
|
CoreCommand::SetRecordingMode(mode) => {
|
||||||
@@ -2283,11 +2535,13 @@ async fn run_core_loop(
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let result: Result<String, String> = if recording_mode.is_multitrack() {
|
let result: Result<String, String> = if recording_mode.is_multitrack() {
|
||||||
// Multitrack/Both: a per-session directory of stems.
|
// Multitrack/Both: a per-session directory of stems.
|
||||||
let stamp = crate::audio::recorder::timestamp_filename(now);
|
std::fs::create_dir_all(&base)
|
||||||
let session_dir = base.join(stamp.trim_end_matches(".wav"));
|
|
||||||
std::fs::create_dir_all(&session_dir)
|
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
.and_then(|_| {
|
.and_then(|_| {
|
||||||
|
crate::audio::multitrack::create_session_dir(&base, now)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.and_then(|session_dir| {
|
||||||
MultitrackRecorder::create(
|
MultitrackRecorder::create(
|
||||||
&session_dir,
|
&session_dir,
|
||||||
FRAME_SAMPLES,
|
FRAME_SAMPLES,
|
||||||
@@ -2375,12 +2629,15 @@ async fn run_core_loop(
|
|||||||
CoreCommand::FetchAttachment { from, attachment } => {
|
CoreCommand::FetchAttachment { from, attachment } => {
|
||||||
if let Some(session) = &active_session {
|
if let Some(session) = &active_session {
|
||||||
let is_image = attachment.kind == crate::files::AttachmentKind::Image;
|
let is_image = attachment.kind == crate::files::AttachmentKind::Image;
|
||||||
|
// User-initiated (the "Save" chip): not bounded here — a human
|
||||||
|
// click rate-limits it. The auto path (F-02) passes a guard.
|
||||||
spawn_attachment_fetch(
|
spawn_attachment_fetch(
|
||||||
session.transport.clone(),
|
session.transport.clone(),
|
||||||
ui_tx.clone(),
|
ui_tx.clone(),
|
||||||
from,
|
from,
|
||||||
attachment,
|
attachment,
|
||||||
is_image,
|
is_image,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2389,7 +2646,29 @@ async fn run_core_loop(
|
|||||||
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::StartScreenShare => {
|
CoreCommand::ListAudioApps => {
|
||||||
|
// Probe whether this pixelpass supports `--strict-audio` before
|
||||||
|
// offering per-app capture: an older binary would reject the flag
|
||||||
|
// and hard-fail the share (audit P2). When unsupported (or
|
||||||
|
// pixelpass is missing), skip enumeration and let the picker show
|
||||||
|
// whole-desktop audio only — never a best-effort `--app` that
|
||||||
|
// would reopen the A23 echo.
|
||||||
|
let app_audio_supported =
|
||||||
|
match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||||
|
Some(bin) => crate::screenshare::supports_strict_audio(&bin).await,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
let apps = if app_audio_supported {
|
||||||
|
crate::screenshare::list_audio_apps().await
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::AudioAppsListed { apps, app_audio_supported })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreCommand::StartScreenShare { audio_app } => {
|
||||||
let Some(session) = &mut active_session else {
|
let Some(session) = &mut active_session else {
|
||||||
let _ = ui_tx
|
let _ = ui_tx
|
||||||
.send(UiEvent::Error("Join a call before sharing your screen".into()))
|
.send(UiEvent::Error("Join a call before sharing your screen".into()))
|
||||||
@@ -2410,7 +2689,33 @@ async fn run_core_loop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match crate::screenshare::spawn_host(&bin).await {
|
// Forward pixelpass `app_audio` events (only emitted when an app
|
||||||
|
// is selected) to the UI so it can warn when the chosen app's
|
||||||
|
// audio drops. The channel closes when the host dies (drain hits
|
||||||
|
// EOF), ending the forwarder task on its own.
|
||||||
|
let notices = audio_app.as_deref().map(|_| {
|
||||||
|
let (tx, mut rx) =
|
||||||
|
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::PixelpassEvent>();
|
||||||
|
let ui_tx_notices = ui_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
let active = match ev {
|
||||||
|
crate::screenshare::PixelpassEvent::AppAudioRouted => true,
|
||||||
|
crate::screenshare::PixelpassEvent::AppAudioLost => false,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if ui_tx_notices
|
||||||
|
.send(UiEvent::ShareAudioActive(active))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx
|
||||||
|
});
|
||||||
|
match crate::screenshare::spawn_host(&bin, audio_app.as_deref(), notices).await {
|
||||||
Ok((child, ticket)) => {
|
Ok((child, ticket)) => {
|
||||||
crate::log_msg("Screen share host started");
|
crate::log_msg("Screen share host started");
|
||||||
session.screenshare_host = Some(child);
|
session.screenshare_host = Some(child);
|
||||||
@@ -2483,15 +2788,95 @@ async fn run_core_loop(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
|
admit_retained, apply_volume, audio_datagram_len_ok, frame_level, mix_frames,
|
||||||
stereo_to_mono, MicLevelMeter, MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
|
mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono, KnownPeers,
|
||||||
|
MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
|
||||||
|
MIC_LEVEL_REPORT_SAMPLES,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admit_retained_rejects_only_new_ids_at_the_cap() {
|
||||||
|
// Below the cap, a brand-new identity is retained.
|
||||||
|
assert!(admit_retained(0, true, MAX_RETAINED_PEERS));
|
||||||
|
assert!(admit_retained(MAX_RETAINED_PEERS - 1, true, MAX_RETAINED_PEERS));
|
||||||
|
// At the cap, a brand-new identity is refused — this is the bound that stops
|
||||||
|
// an insider grace-cycling distinct identities from growing the retain table.
|
||||||
|
assert!(!admit_retained(MAX_RETAINED_PEERS, true, MAX_RETAINED_PEERS));
|
||||||
|
// A peer already tracked always refreshes, even at (or past) the cap: it only
|
||||||
|
// updates an existing address and never adds a slot.
|
||||||
|
assert!(admit_retained(MAX_RETAINED_PEERS, false, MAX_RETAINED_PEERS));
|
||||||
|
assert!(admit_retained(MAX_RETAINED_PEERS + 5, false, MAX_RETAINED_PEERS));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_fetch_only_for_roster_images_not_already_inflight() {
|
||||||
|
// The happy path: a roster author's brand-new image attachment.
|
||||||
|
assert!(should_auto_fetch(true, true, false));
|
||||||
|
// A non-image (generic file) never auto-fetches — it waits for "Save".
|
||||||
|
assert!(!should_auto_fetch(false, true, false));
|
||||||
|
// A non-roster author (e.g. a sock puppet that never announced) is rejected,
|
||||||
|
// closing the F-02 unbounded-task vector.
|
||||||
|
assert!(!should_auto_fetch(true, false, false));
|
||||||
|
// An identical (author,id) already being fetched is deduped.
|
||||||
|
assert!(!should_auto_fetch(true, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_peers_are_shared_by_topic_across_restamped_tickets() {
|
||||||
|
let topic_id = [23u8; 32];
|
||||||
|
let original_host = iroh::SecretKey::generate().public();
|
||||||
|
let member_host = iroh::SecretKey::generate().public();
|
||||||
|
let retained_peer = iroh::SecretKey::generate().public();
|
||||||
|
let original = PeerSpeakTicket {
|
||||||
|
host_addr: iroh::EndpointAddr::from(original_host),
|
||||||
|
topic_id,
|
||||||
|
name: "Room".to_string(),
|
||||||
|
}.to_string();
|
||||||
|
let restamped = PeerSpeakTicket {
|
||||||
|
host_addr: iroh::EndpointAddr::from(member_host),
|
||||||
|
topic_id,
|
||||||
|
name: "Room".to_string(),
|
||||||
|
}.to_string();
|
||||||
|
assert_ne!(original, restamped);
|
||||||
|
|
||||||
|
let original_topic = PeerSpeakTicket::topic_of(&original).unwrap();
|
||||||
|
let restamped_topic = PeerSpeakTicket::topic_of(&restamped).unwrap();
|
||||||
|
assert_eq!(original_topic, restamped_topic);
|
||||||
|
|
||||||
|
let retained_addr = iroh::EndpointAddr::from(retained_peer);
|
||||||
|
let known_peers: KnownPeers =
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
|
known_peers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(original_topic)
|
||||||
|
.or_default()
|
||||||
|
.insert(retained_peer, retained_addr.clone());
|
||||||
|
|
||||||
|
let found = known_peers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&restamped_topic)
|
||||||
|
.and_then(|peers| peers.get(&retained_peer))
|
||||||
|
.cloned();
|
||||||
|
assert_eq!(found, Some(retained_addr));
|
||||||
|
}
|
||||||
|
|
||||||
/// A frame of constant amplitude with the given sample count.
|
/// A frame of constant amplitude with the given sample count.
|
||||||
fn frame(amp: i16, len: usize) -> Vec<i16> {
|
fn frame(amp: i16, len: usize) -> Vec<i16> {
|
||||||
vec![amp; len]
|
vec![amp; len]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn closed_game_watch_is_fused_after_one_ready_event() {
|
||||||
|
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||||
|
let mut rx = Some(rx);
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
assert_eq!(next_game_change(&mut rx).await, None);
|
||||||
|
assert!(rx.is_none(), "closed receiver must disable its select source");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mic_meter_reports_only_after_enough_samples() {
|
fn mic_meter_reports_only_after_enough_samples() {
|
||||||
let mut m = MicLevelMeter::new();
|
let mut m = MicLevelMeter::new();
|
||||||
|
|||||||
+68
-8
@@ -22,6 +22,27 @@ fn recovery_delay(attempt: usize) -> Duration {
|
|||||||
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
|
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Terminal retry budget for background recovery. After this many failed attempts
|
||||||
|
/// the coordinator gives up: it drops the entry, frees the active slot, and signals
|
||||||
|
/// the event task to forget the retained address (Tier C recovery-identity cap).
|
||||||
|
///
|
||||||
|
/// With the [`RECOVERY_DELAYS`] backoff this is roughly seven minutes of dialing
|
||||||
|
/// (1+2+4+8+15+30+60s, then 60s steps), far beyond any normal transient outage. A
|
||||||
|
/// genuine peer returning after a longer outage still rejoins on its own via a
|
||||||
|
/// gossip announce, so giving up only stops us from dialing a peer that is not
|
||||||
|
/// coming back — it does not break legitimate reconnect-after-outage.
|
||||||
|
const RECOVERY_TERMINAL_ATTEMPTS: usize = 12;
|
||||||
|
|
||||||
|
/// Capacity of the terminal-eviction notification channel. Bounded; on the rare
|
||||||
|
/// event of saturation the entry is still removed (the dial work stops) and only
|
||||||
|
/// the retained-address forget is skipped, which the per-topic retain cap bounds.
|
||||||
|
const RECOVERY_TERMINAL_CAPACITY: usize = 64;
|
||||||
|
|
||||||
|
/// Whether `attempt` completed recoveries have exhausted the terminal budget.
|
||||||
|
fn recovery_is_terminal(attempt: usize, max_attempts: usize) -> bool {
|
||||||
|
attempt >= max_attempts
|
||||||
|
}
|
||||||
|
|
||||||
enum RecoveryCommand {
|
enum RecoveryCommand {
|
||||||
Start {
|
Start {
|
||||||
peer_id: EndpointId,
|
peer_id: EndpointId,
|
||||||
@@ -60,19 +81,24 @@ pub(super) struct RecoveryCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RecoveryCoordinator {
|
impl RecoveryCoordinator {
|
||||||
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
|
pub(super) fn spawn(
|
||||||
|
room_state: Arc<IrohGossipState>,
|
||||||
|
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
|
||||||
Self::spawn_inner(room_state)
|
Self::spawn_inner(room_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
|
fn spawn_inner(
|
||||||
|
room_state: Arc<dyn RecoveryRoom>,
|
||||||
|
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
|
||||||
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
|
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
|
||||||
|
let (terminal_tx, terminal_rx) = mpsc::channel(RECOVERY_TERMINAL_CAPACITY);
|
||||||
let active = Arc::new(Mutex::new(HashSet::new()));
|
let active = Arc::new(Mutex::new(HashSet::new()));
|
||||||
let handle = Self {
|
let handle = Self {
|
||||||
tx,
|
tx,
|
||||||
active: active.clone(),
|
active: active.clone(),
|
||||||
};
|
};
|
||||||
let task = tokio::spawn(run_coordinator(room_state, active, rx));
|
let task = tokio::spawn(run_coordinator(room_state, active, rx, terminal_tx));
|
||||||
(handle, task)
|
(handle, task, terminal_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
||||||
@@ -116,6 +142,7 @@ async fn run_coordinator(
|
|||||||
room_state: Arc<dyn RecoveryRoom>,
|
room_state: Arc<dyn RecoveryRoom>,
|
||||||
active: Arc<Mutex<HashSet<EndpointId>>>,
|
active: Arc<Mutex<HashSet<EndpointId>>>,
|
||||||
mut rx: mpsc::Receiver<RecoveryCommand>,
|
mut rx: mpsc::Receiver<RecoveryCommand>,
|
||||||
|
terminal_tx: mpsc::Sender<EndpointId>,
|
||||||
) {
|
) {
|
||||||
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
|
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
|
||||||
|
|
||||||
@@ -155,9 +182,24 @@ async fn run_coordinator(
|
|||||||
entries.remove(&peer_id);
|
entries.remove(&peer_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(entry) = entries.get_mut(&peer_id) {
|
// Advance the backoff, then check the terminal budget.
|
||||||
|
// `attempt` counts completed attempts, so the delay
|
||||||
|
// uses the current value before it is incremented.
|
||||||
|
let terminal = if let Some(entry) = entries.get_mut(&peer_id) {
|
||||||
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
|
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
|
||||||
entry.attempt = entry.attempt.saturating_add(1);
|
entry.attempt = entry.attempt.saturating_add(1);
|
||||||
|
recovery_is_terminal(entry.attempt, RECOVERY_TERMINAL_ATTEMPTS)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if terminal {
|
||||||
|
// Give up on a peer that has not returned within the
|
||||||
|
// budget: drop its entry, free the active slot, and
|
||||||
|
// signal the event task to forget its retained
|
||||||
|
// address so the per-topic retain table drains.
|
||||||
|
entries.remove(&peer_id);
|
||||||
|
active.lock().unwrap().remove(&peer_id);
|
||||||
|
let _ = terminal_tx.try_send(peer_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +252,23 @@ mod tests {
|
|||||||
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
|
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_budget_is_terminal_only_at_or_past_the_cap() {
|
||||||
|
assert!(!recovery_is_terminal(0, RECOVERY_TERMINAL_ATTEMPTS));
|
||||||
|
assert!(!recovery_is_terminal(
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS - 1,
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS
|
||||||
|
));
|
||||||
|
assert!(recovery_is_terminal(
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS,
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS
|
||||||
|
));
|
||||||
|
assert!(recovery_is_terminal(
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS + 5,
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
|
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
|
||||||
let (tx, mut rx) = mpsc::channel(4);
|
let (tx, mut rx) = mpsc::channel(4);
|
||||||
@@ -244,9 +303,10 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn coordinator_attempts_rebootstrap_immediately() {
|
async fn coordinator_attempts_rebootstrap_immediately() {
|
||||||
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
||||||
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
let (coordinator, task, _terminal_rx) =
|
||||||
attempts: attempts_tx,
|
RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
||||||
}));
|
attempts: attempts_tx,
|
||||||
|
}));
|
||||||
let peer_id = SecretKey::generate().public();
|
let peer_id = SecretKey::generate().public();
|
||||||
let addr = EndpointAddr::from(peer_id);
|
let addr = EndpointAddr::from(peer_id);
|
||||||
|
|
||||||
|
|||||||
+19
-6
@@ -15,8 +15,10 @@ use super::{
|
|||||||
use super::scan;
|
use super::scan;
|
||||||
use super::steam::SteamProbe;
|
use super::steam::SteamProbe;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::io;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
@@ -62,13 +64,17 @@ pub struct GameDetector {
|
|||||||
inputs: Arc<DetectorInputs>,
|
inputs: Arc<DetectorInputs>,
|
||||||
rx: watch::Receiver<Option<DetectedGame>>,
|
rx: watch::Receiver<Option<DetectedGame>>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
worker: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameDetector {
|
impl GameDetector {
|
||||||
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
|
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
|
||||||
/// `override_` seeds the manual override (usually `Auto`). The worker runs
|
/// `override_` seeds the manual override (usually `Auto`). The worker runs
|
||||||
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
|
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
|
||||||
pub fn spawn(override_: ManualOverride, process_map: BTreeMap<String, String>) -> Self {
|
pub fn spawn(
|
||||||
|
override_: ManualOverride,
|
||||||
|
process_map: BTreeMap<String, String>,
|
||||||
|
) -> io::Result<Self> {
|
||||||
let inputs = Arc::new(DetectorInputs {
|
let inputs = Arc::new(DetectorInputs {
|
||||||
override_: Mutex::new(override_),
|
override_: Mutex::new(override_),
|
||||||
process_map: Mutex::new(process_map),
|
process_map: Mutex::new(process_map),
|
||||||
@@ -78,12 +84,16 @@ impl GameDetector {
|
|||||||
|
|
||||||
let worker_inputs = inputs.clone();
|
let worker_inputs = inputs.clone();
|
||||||
let worker_stop = stop.clone();
|
let worker_stop = stop.clone();
|
||||||
std::thread::Builder::new()
|
let worker = std::thread::Builder::new()
|
||||||
.name("game-detector".to_string())
|
.name("game-detector".to_string())
|
||||||
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))
|
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))?;
|
||||||
.ok();
|
|
||||||
|
|
||||||
Self { inputs, rx, stop }
|
Ok(Self {
|
||||||
|
inputs,
|
||||||
|
rx,
|
||||||
|
stop,
|
||||||
|
worker: Some(worker),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A clone of the watch receiver for detected-game changes. The current value
|
/// A clone of the watch receiver for detected-game changes. The current value
|
||||||
@@ -112,6 +122,9 @@ impl GameDetector {
|
|||||||
impl Drop for GameDetector {
|
impl Drop for GameDetector {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.stop();
|
self.stop();
|
||||||
|
if let Some(worker) = self.worker.take() {
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +236,7 @@ mod tests {
|
|||||||
fn spawn_and_stop_is_clean() {
|
fn spawn_and_stop_is_clean() {
|
||||||
// Smoke test the lifecycle: spawning and stopping must not panic, and the
|
// Smoke test the lifecycle: spawning and stopping must not panic, and the
|
||||||
// initial published value is None.
|
// initial published value is None.
|
||||||
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new());
|
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new()).unwrap();
|
||||||
assert_eq!(*det.subscribe().borrow(), None);
|
assert_eq!(*det.subscribe().borrow(), None);
|
||||||
det.set_override(ManualOverride::ForceNone);
|
det.set_override(ManualOverride::ForceNone);
|
||||||
det.set_process_map(map(&[("x", "X")]));
|
det.set_process_map(map(&[("x", "X")]));
|
||||||
|
|||||||
+44
-8
@@ -18,6 +18,29 @@ use std::time::SystemTime;
|
|||||||
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
|
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
|
||||||
/// slurped into memory before the parser's own depth guard kicks in.
|
/// slurped into memory before the parser's own depth guard kicks in.
|
||||||
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
|
||||||
|
/// prevents a corrupt registry length from driving an enormous allocation.
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn validate_reg_len(len: u32) -> Option<usize> {
|
||||||
|
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
|
||||||
|
.then_some(len as usize / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
|
||||||
|
let units = validate_reg_len(returned_bytes)?;
|
||||||
|
if units > buf.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
buf.truncate(units);
|
||||||
|
while buf.last() == Some(&0) {
|
||||||
|
buf.pop();
|
||||||
|
}
|
||||||
|
Some(String::from_utf16_lossy(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
|
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
|
||||||
/// client's emulated-registry text file). Returns the appid only when present and
|
/// client's emulated-registry text file). Returns the appid only when present and
|
||||||
@@ -333,6 +356,7 @@ mod win {
|
|||||||
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
|
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
|
||||||
//! crate. Steam stores both the live `RunningAppID` and its install path under
|
//! crate. Steam stores both the live `RunningAppID` and its install path under
|
||||||
//! `HKCU\Software\Valve\Steam`.
|
//! `HKCU\Software\Valve\Steam`.
|
||||||
|
use super::{decode_reg_sz, validate_reg_len};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||||
use windows_sys::Win32::System::Registry::{
|
use windows_sys::Win32::System::Registry::{
|
||||||
@@ -401,12 +425,17 @@ mod win {
|
|||||||
&mut len,
|
&mut len,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if rc != ERROR_SUCCESS || kind != REG_SZ || len == 0 {
|
if rc != ERROR_SUCCESS || kind != REG_SZ {
|
||||||
// SAFETY: valid handle.
|
// SAFETY: valid handle.
|
||||||
unsafe { RegCloseKey(hkey) };
|
unsafe { RegCloseKey(hkey) };
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
|
let Some(units) = validate_reg_len(len) else {
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let mut buf = vec![0u16; units];
|
||||||
let mut len2 = len;
|
let mut len2 = len;
|
||||||
// SAFETY: buffer sized to the queried byte length.
|
// SAFETY: buffer sized to the queried byte length.
|
||||||
let rc = unsafe {
|
let rc = unsafe {
|
||||||
@@ -421,14 +450,10 @@ mod win {
|
|||||||
};
|
};
|
||||||
// SAFETY: valid handle.
|
// SAFETY: valid handle.
|
||||||
unsafe { RegCloseKey(hkey) };
|
unsafe { RegCloseKey(hkey) };
|
||||||
if rc != ERROR_SUCCESS {
|
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Trim the trailing NUL(s).
|
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
|
||||||
while buf.last() == Some(&0) {
|
|
||||||
buf.pop();
|
|
||||||
}
|
|
||||||
Some(PathBuf::from(String::from_utf16_lossy(&buf)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +461,17 @@ mod win {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_string_lengths_are_bounded_and_trimmed() {
|
||||||
|
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16");
|
||||||
|
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
||||||
|
assert_eq!(validate_reg_len(8), Some(4));
|
||||||
|
|
||||||
|
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
||||||
|
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
||||||
|
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn running_app_id_reads_nonzero_and_rejects_zero() {
|
fn running_app_id_reads_nonzero_and_rejects_zero() {
|
||||||
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||||
|
|||||||
+238
-11
@@ -1,11 +1,11 @@
|
|||||||
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
|
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
|
||||||
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature};
|
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
|
||||||
use iroh_gossip::net::Gossip;
|
use iroh_gossip::net::Gossip;
|
||||||
use iroh_gossip::proto::TopicId;
|
use iroh_gossip::proto::TopicId;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
@@ -131,14 +131,98 @@ fn admit_state_mutation(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Size at which we prune stale entries from the replay-tracking map (Tier C
|
||||||
|
/// F-01 audit). `admit_state_mutation` records `(author, kind)` for every signed
|
||||||
|
/// mutation, so an insider sending validly signed `Leave`s from unlimited
|
||||||
|
/// generated keys would otherwise grow it for the room's lifetime. A mutation
|
||||||
|
/// older than the freshness window can never be the deciding `last_ts` for an
|
||||||
|
/// in-window message — `verify_gossip`'s timestamp check rejects such a replay
|
||||||
|
/// first — so dropping those entries cannot weaken replay protection; it bounds
|
||||||
|
/// the map to roughly the authors seen within one freshness window.
|
||||||
|
const STATE_MUTATIONS_SOFT_CAP: usize = 256;
|
||||||
|
|
||||||
|
/// Drop replay-tracking entries whose timestamp is older than `window_ms` before
|
||||||
|
/// `now_ms` (see [`STATE_MUTATIONS_SOFT_CAP`]). Pure → unit-testable.
|
||||||
|
fn prune_stale_mutations(
|
||||||
|
seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
|
||||||
|
now_ms: u64,
|
||||||
|
window_ms: u64,
|
||||||
|
) {
|
||||||
|
let floor = now_ms.saturating_sub(window_ms);
|
||||||
|
seen.retain(|_, last_ts| *last_ts >= floor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum number of distinct peers we hold in a room roster at once.
|
||||||
|
///
|
||||||
|
/// Everyone with the room ticket is an authenticated *insider*: a signature only
|
||||||
|
/// proves ownership of the generated keypair it was made with, not that the
|
||||||
|
/// author is a distinct human. A malicious member can therefore mint many valid
|
||||||
|
/// signed identities. Voice is full-mesh (each peer dials every other), so a real
|
||||||
|
/// room is realistically well under this bound; the cap exists purely so a flood
|
||||||
|
/// of sock-puppet `Announce`s can't grow our peer map / audio supervisors / dials
|
||||||
|
/// without limit (Tier C F-01).
|
||||||
|
const MAX_ACTIVE_PEERS: usize = 32;
|
||||||
|
|
||||||
|
/// Maximum transport addresses we retain from a single peer announce. iroh
|
||||||
|
/// normally advertises a handful (a few LAN/WAN IP candidates plus one home
|
||||||
|
/// relay); the cap stops an insider stuffing a large unique address set into each
|
||||||
|
/// announce to inflate the address lookup and the dialer's candidate list.
|
||||||
|
const MAX_PEER_ADDRS: usize = 8;
|
||||||
|
|
||||||
|
/// Maximum byte length of a relay URL we accept inside a peer address. A relay
|
||||||
|
/// URL is normal-length; anything longer is dropped rather than retained.
|
||||||
|
const MAX_RELAY_URL_LEN: usize = 256;
|
||||||
|
|
||||||
|
/// Bound an untrusted peer's advertised address set before we retain it / hand it
|
||||||
|
/// to the address lookup and dialer (Tier C F-01). Drops transport kinds we never
|
||||||
|
/// use (`Custom`) and over-long relay URLs, then truncates to at most
|
||||||
|
/// [`MAX_PEER_ADDRS`] addresses. `BTreeSet` iteration is deterministic, so the
|
||||||
|
/// kept subset is stable. Pure → unit-testable.
|
||||||
|
fn sanitize_endpoint_addr(addr: &EndpointAddr) -> EndpointAddr {
|
||||||
|
let addrs: BTreeSet<TransportAddr> = addr
|
||||||
|
.addrs
|
||||||
|
.iter()
|
||||||
|
.filter(|a| match a {
|
||||||
|
TransportAddr::Relay(url) => url.as_str().len() <= MAX_RELAY_URL_LEN,
|
||||||
|
TransportAddr::Ip(_) => true,
|
||||||
|
// `TransportAddr` is #[non_exhaustive]; we only speak IP + relay, so
|
||||||
|
// anything else (Custom / future kinds) is dropped, not retained.
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
.take(MAX_PEER_ADDRS)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
EndpointAddr { id: addr.id, addrs }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an `Announce` may enter the roster. Only a brand-new author
|
||||||
|
/// (`subject_to_cap`) is gated by [`MAX_ACTIVE_PEERS`]; updates to an
|
||||||
|
/// already-present peer AND re-announces from a peer mid-reconnect (which
|
||||||
|
/// already held a slot) always pass — exempting reconnects keeps a full room
|
||||||
|
/// from rejecting a legitimately reconnecting member and orphaning its recovery
|
||||||
|
/// state (Tier C F-01 audit). Pure → unit-testable.
|
||||||
|
fn admit_into_roster(roster_len: usize, subject_to_cap: bool, max_peers: usize) -> bool {
|
||||||
|
!subject_to_cap || roster_len < max_peers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a received `Announce`'s author is gated by the roster cap. A peer
|
||||||
|
/// already in the roster (`is_new == false`, an ordinary update) or one
|
||||||
|
/// mid-reconnect (`is_reconnecting`, it already held a slot) is exempt; only a
|
||||||
|
/// brand-new author counts against [`MAX_ACTIVE_PEERS`] (Tier C F-01 audit).
|
||||||
|
/// Pure → unit-testable.
|
||||||
|
fn announce_subject_to_cap(is_new: bool, is_reconnecting: bool) -> bool {
|
||||||
|
is_new && !is_reconnecting
|
||||||
|
}
|
||||||
|
|
||||||
fn peer_state_for_log(state: &PeerState) -> String {
|
fn peer_state_for_log(state: &PeerState) -> String {
|
||||||
format!(
|
format!(
|
||||||
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}",
|
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}, game={:?}",
|
||||||
state.name,
|
state.name,
|
||||||
state.is_muted,
|
state.is_muted,
|
||||||
crate::short_id(&state.addr.id.to_string()),
|
crate::short_id(&state.addr.id.to_string()),
|
||||||
state.addr.addrs.len(),
|
state.addr.addrs.len(),
|
||||||
state.sharing.is_some()
|
state.sharing.is_some(),
|
||||||
|
state.game
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +473,18 @@ impl RoomState for IrohGossipState {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the replay-tracking map bounded: prune entries
|
||||||
|
// older than the freshness window once it grows past the
|
||||||
|
// soft cap (Tier C F-01 audit). Stale entries can't gate
|
||||||
|
// an in-window message, so this never weakens replay
|
||||||
|
// protection.
|
||||||
|
if state_mutations_seen.len() > STATE_MUTATIONS_SOFT_CAP {
|
||||||
|
prune_stale_mutations(
|
||||||
|
&mut state_mutations_seen,
|
||||||
|
now_millis(),
|
||||||
|
GOSSIP_FRESHNESS_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
if !admit_state_mutation(
|
if !admit_state_mutation(
|
||||||
&mut state_mutations_seen,
|
&mut state_mutations_seen,
|
||||||
payload.author,
|
payload.author,
|
||||||
@@ -435,16 +531,49 @@ impl RoomState for IrohGossipState {
|
|||||||
let cleaned = crate::sanitize::sanitize_game_label(&g);
|
let cleaned = crate::sanitize::sanitize_game_label(&g);
|
||||||
(!cleaned.is_empty()).then_some(cleaned)
|
(!cleaned.is_empty()).then_some(cleaned)
|
||||||
});
|
});
|
||||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
// Bound an insider's advertised address set
|
||||||
let (is_new, state_changed) = {
|
// before we retain it / hand it to the dialer
|
||||||
|
// (Tier C F-01).
|
||||||
|
state.addr = sanitize_endpoint_addr(&state.addr);
|
||||||
|
// A peer reconnecting from a transient drop sits
|
||||||
|
// in `disconnected_peers` (not the live roster);
|
||||||
|
// it already held a slot, so it must be re-admitted
|
||||||
|
// regardless of the cap, and its disconnect marker
|
||||||
|
// cleared ONLY once re-admitted — clearing it before
|
||||||
|
// a possible reject would orphan its recovery state
|
||||||
|
// (Tier C F-01 audit).
|
||||||
|
let is_reconnecting =
|
||||||
|
disconnected_peers.lock().unwrap().contains(&payload.author);
|
||||||
|
let admitted = {
|
||||||
let mut peer_map = peers.lock().unwrap();
|
let mut peer_map = peers.lock().unwrap();
|
||||||
let is_new = !peer_map.contains_key(&payload.author);
|
let is_new = !peer_map.contains_key(&payload.author);
|
||||||
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
// Cap the roster so a flood of signed
|
||||||
if is_new || state_changed {
|
// sock-puppet identities can't grow our
|
||||||
peer_map.insert(payload.author, state.clone());
|
// memory/tasks/dials without bound (Tier C
|
||||||
|
// F-01). Existing-peer updates and reconnects
|
||||||
|
// are exempt; only brand-new authors are gated.
|
||||||
|
let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting);
|
||||||
|
if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
||||||
|
if is_new || state_changed {
|
||||||
|
peer_map.insert(payload.author, state.clone());
|
||||||
|
}
|
||||||
|
Some((is_new, state_changed))
|
||||||
}
|
}
|
||||||
(is_new, state_changed)
|
|
||||||
};
|
};
|
||||||
|
let Some((is_new, state_changed)) = admitted else {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Gossip roster full ({MAX_ACTIVE_PEERS}); rejecting new peer {}",
|
||||||
|
crate::short_id(&payload.author.to_string())
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Admitted — now it is safe to clear any reconnect
|
||||||
|
// marker (a rejected announce above leaves it intact
|
||||||
|
// so a later signed Leave still cleans up).
|
||||||
|
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||||
|
|
||||||
if is_new {
|
if is_new {
|
||||||
crate::log_msg(&format!(
|
crate::log_msg(&format!(
|
||||||
@@ -452,7 +581,12 @@ impl RoomState for IrohGossipState {
|
|||||||
crate::short_id(&payload.author.to_string()),
|
crate::short_id(&payload.author.to_string()),
|
||||||
peer_state_for_log(&state)
|
peer_state_for_log(&state)
|
||||||
));
|
));
|
||||||
address_lookup.add_endpoint_info(state.addr.clone());
|
// Replace (not union) the lookup's record for
|
||||||
|
// this id with the authenticated, sanitized
|
||||||
|
// address set, so leave/re-announce cycles
|
||||||
|
// can't accumulate attacker-supplied history
|
||||||
|
// (Tier C F-01).
|
||||||
|
let _ = address_lookup.set_endpoint_info(state.addr.clone());
|
||||||
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
||||||
} else if state_changed {
|
} else if state_changed {
|
||||||
crate::log_msg(&format!(
|
crate::log_msg(&format!(
|
||||||
@@ -465,6 +599,11 @@ impl RoomState for IrohGossipState {
|
|||||||
}
|
}
|
||||||
GossipMessage::Leave => {
|
GossipMessage::Leave => {
|
||||||
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
||||||
|
// Drop this id's address-lookup entry so cycling
|
||||||
|
// distinct identities through Announce→Leave can't
|
||||||
|
// grow the lookup for the room's lifetime (Tier C
|
||||||
|
// F-01 audit). Re-announce re-populates it.
|
||||||
|
let _ = address_lookup.remove_endpoint_info(payload.author);
|
||||||
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
||||||
let was_disconnected = disconnected_peers
|
let was_disconnected = disconnected_peers
|
||||||
.lock()
|
.lock()
|
||||||
@@ -717,6 +856,16 @@ mod tests {
|
|||||||
EndpointAddr::from(id)
|
EndpointAddr::from(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_state_log_includes_game() {
|
||||||
|
let mut state = sample_peer_state_for(fresh_id());
|
||||||
|
state.game = Some("Half-Life 2".to_string());
|
||||||
|
assert!(peer_state_for_log(&state).contains("game=Some(\"Half-Life 2\")"));
|
||||||
|
|
||||||
|
state.game = None;
|
||||||
|
assert!(peer_state_for_log(&state).contains("game=None"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bootstrap_client_dials_host() {
|
fn bootstrap_client_dials_host() {
|
||||||
// A non-host (client) with no retained peers dials just the ticket host.
|
// A non-host (client) with no retained peers dials just the ticket host.
|
||||||
@@ -757,6 +906,84 @@ mod tests {
|
|||||||
assert!(!bootstrap.contains(&me));
|
assert!(!bootstrap.contains(&me));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admit_into_roster_caps_new_authors_but_not_updates() {
|
||||||
|
// New authors are admitted while there's room...
|
||||||
|
assert!(admit_into_roster(0, true, 3));
|
||||||
|
assert!(admit_into_roster(2, true, 3));
|
||||||
|
// ...rejected once the roster is full...
|
||||||
|
assert!(!admit_into_roster(3, true, 3));
|
||||||
|
assert!(!admit_into_roster(10, true, 3));
|
||||||
|
// ...but an existing peer's update always passes, even at/over the cap.
|
||||||
|
assert!(admit_into_roster(3, false, 3));
|
||||||
|
assert!(admit_into_roster(99, false, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
|
||||||
|
// A brand-new author counts against the cap...
|
||||||
|
assert!(announce_subject_to_cap(/* is_new */ true, /* is_reconnecting */ false));
|
||||||
|
// ...but an ordinary update from an in-roster peer does not...
|
||||||
|
assert!(!announce_subject_to_cap(false, false));
|
||||||
|
// ...and neither does a re-announce from a peer mid-reconnect, even
|
||||||
|
// though it was removed from the live roster (the F-01-audit fix: a full
|
||||||
|
// room must not reject a legitimately reconnecting member).
|
||||||
|
assert!(!announce_subject_to_cap(true, true));
|
||||||
|
// Combined with admit_into_roster: a reconnecting author passes at a full
|
||||||
|
// roster, a brand-new one does not.
|
||||||
|
assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
|
||||||
|
assert!(!admit_into_roster(3, announce_subject_to_cap(true, false), 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_stale_mutations_drops_only_out_of_window_entries() {
|
||||||
|
let a = fresh_id();
|
||||||
|
let b = fresh_id();
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
seen.insert((a, StateMutationKind::Announce), 10_000u64);
|
||||||
|
seen.insert((b, StateMutationKind::Leave), 250_000u64);
|
||||||
|
// now = 300_000, window = 120_000 → floor 180_000. The 10_000 entry is
|
||||||
|
// stale (and could never gate an in-window message), the 250_000 is live.
|
||||||
|
prune_stale_mutations(&mut seen, 300_000, GOSSIP_FRESHNESS_MS);
|
||||||
|
assert_eq!(seen.len(), 1);
|
||||||
|
assert!(seen.contains_key(&(b, StateMutationKind::Leave)));
|
||||||
|
assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_endpoint_addr_caps_address_count() {
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
let id = fresh_id();
|
||||||
|
// An insider stuffs far more addresses than MAX_PEER_ADDRS into one announce.
|
||||||
|
let many: Vec<TransportAddr> = (0..(MAX_PEER_ADDRS as u16 + 50))
|
||||||
|
.map(|i| TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], 1000 + i))))
|
||||||
|
.collect();
|
||||||
|
let addr = EndpointAddr::from_parts(id, many);
|
||||||
|
let out = sanitize_endpoint_addr(&addr);
|
||||||
|
assert_eq!(out.id, id);
|
||||||
|
assert_eq!(out.addrs.len(), MAX_PEER_ADDRS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_endpoint_addr_drops_overlong_relay_url() {
|
||||||
|
use std::str::FromStr;
|
||||||
|
let id = fresh_id();
|
||||||
|
let short = iroh::RelayUrl::from_str("https://relay.example/").unwrap();
|
||||||
|
let long = iroh::RelayUrl::from_str(&format!(
|
||||||
|
"https://relay.example/{}",
|
||||||
|
"a".repeat(MAX_RELAY_URL_LEN)
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
|
||||||
|
let addr = EndpointAddr::from_parts(
|
||||||
|
id,
|
||||||
|
[TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)],
|
||||||
|
);
|
||||||
|
let out = sanitize_endpoint_addr(&addr);
|
||||||
|
let relays: Vec<_> = out.relay_urls().cloned().collect();
|
||||||
|
assert_eq!(relays, vec![short], "over-long relay URL must be dropped");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gossip_message_leave_round_trip() {
|
fn test_gossip_message_leave_round_trip() {
|
||||||
let original = GossipMessage::Leave;
|
let original = GossipMessage::Leave;
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
use crate::friends::FriendStore;
|
use crate::friends::FriendStore;
|
||||||
use iroh::EndpointId;
|
use iroh::EndpointId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Maximum immediate presence replies to one friend before throttling. Normal
|
||||||
|
/// presence polling is once per minute, so this only catches repeated/manual or
|
||||||
|
/// abusive probes while still allowing a short burst after app startup.
|
||||||
|
pub const PRESENCE_RATE_LIMIT_BURST: u32 = 4;
|
||||||
|
|
||||||
|
/// Refill one presence-reply token per friend at this cadence.
|
||||||
|
pub const PRESENCE_RATE_LIMIT_REFILL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// The user's presence posture — how reachable they are to friends while idle.
|
/// The user's presence posture — how reachable they are to friends while idle.
|
||||||
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
|
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
|
||||||
@@ -100,6 +110,48 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
|
|||||||
mode.answers_pings() && friends.contains(from)
|
mode.answers_pings() && friends.contains(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RateBucket {
|
||||||
|
tokens: u32,
|
||||||
|
last_refill: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-friend limiter for inbound presence pings. It is intentionally keyed by
|
||||||
|
/// the authenticated connection id, not payload data. Callers should only invoke
|
||||||
|
/// it after [`should_answer`] passes, so strangers do not consume memory here.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct PresenceRateLimiter {
|
||||||
|
buckets: HashMap<EndpointId, RateBucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresenceRateLimiter {
|
||||||
|
/// Return whether `from` may receive a presence reply at `now`.
|
||||||
|
///
|
||||||
|
/// This is a token bucket: each friend starts with a small burst and regains
|
||||||
|
/// one token every [`PRESENCE_RATE_LIMIT_REFILL`]. A denied probe should be
|
||||||
|
/// answered with no data, matching the listener's "reveal nothing" policy.
|
||||||
|
pub fn allow(&mut self, from: EndpointId, now: Instant) -> bool {
|
||||||
|
let bucket = self.buckets.entry(from).or_insert(RateBucket {
|
||||||
|
tokens: PRESENCE_RATE_LIMIT_BURST,
|
||||||
|
last_refill: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
let elapsed = now.saturating_duration_since(bucket.last_refill);
|
||||||
|
let refill = elapsed.as_secs() / PRESENCE_RATE_LIMIT_REFILL.as_secs();
|
||||||
|
if refill > 0 {
|
||||||
|
let refill = refill.min(u32::MAX as u64) as u32;
|
||||||
|
bucket.tokens = PRESENCE_RATE_LIMIT_BURST.min(bucket.tokens.saturating_add(refill));
|
||||||
|
bucket.last_refill = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if bucket.tokens == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bucket.tokens -= 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What we learned about a friend from a successful ping reply.
|
/// What we learned about a friend from a successful ping reply.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum FriendPresence {
|
pub enum FriendPresence {
|
||||||
@@ -177,6 +229,36 @@ mod tests {
|
|||||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presence_rate_limiter_allows_a_small_burst_then_refills() {
|
||||||
|
let mut limiter = PresenceRateLimiter::default();
|
||||||
|
let friend = id();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
|
||||||
|
assert!(limiter.allow(friend, now));
|
||||||
|
}
|
||||||
|
assert!(!limiter.allow(friend, now));
|
||||||
|
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL - Duration::from_millis(1)));
|
||||||
|
|
||||||
|
assert!(limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
|
||||||
|
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presence_rate_limiter_is_per_peer() {
|
||||||
|
let mut limiter = PresenceRateLimiter::default();
|
||||||
|
let a = id();
|
||||||
|
let b = id();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
|
||||||
|
assert!(limiter.allow(a, now));
|
||||||
|
}
|
||||||
|
assert!(!limiter.allow(a, now));
|
||||||
|
assert!(limiter.allow(b, now));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn presence_mode_flags() {
|
fn presence_mode_flags() {
|
||||||
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
||||||
|
|||||||
+405
-15
@@ -39,6 +39,10 @@ fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
|||||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||||
const MAX_TICKET_LEN: usize = 512;
|
const MAX_TICKET_LEN: usize = 512;
|
||||||
|
|
||||||
|
/// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names
|
||||||
|
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
|
||||||
|
const MAX_APP_NAME_LEN: usize = 256;
|
||||||
|
|
||||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
/// 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
|
/// 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.
|
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||||
@@ -64,6 +68,12 @@ pub enum PixelpassEvent {
|
|||||||
CaptureStarted,
|
CaptureStarted,
|
||||||
/// Host: capture pipeline torn down (on last viewer).
|
/// Host: capture pipeline torn down (on last viewer).
|
||||||
CaptureStopped,
|
CaptureStopped,
|
||||||
|
/// Host (per-app audio): the chosen app's audio is now reaching viewers.
|
||||||
|
AppAudioRouted,
|
||||||
|
/// Host (per-app audio): the chosen app's last audio stream went away. Under
|
||||||
|
/// our `--strict-audio` run this means viewers now hear silence (not the call
|
||||||
|
/// echo) until the app produces audio again — we surface it as a warning.
|
||||||
|
AppAudioLost,
|
||||||
/// A recognized event we don't act on (e.g. `host_info`).
|
/// A recognized event we don't act on (e.g. `host_info`).
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
@@ -98,6 +108,11 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
|||||||
Some("stopped") => PixelpassEvent::CaptureStopped,
|
Some("stopped") => PixelpassEvent::CaptureStopped,
|
||||||
_ => PixelpassEvent::Other,
|
_ => PixelpassEvent::Other,
|
||||||
},
|
},
|
||||||
|
"app_audio" => match v.get("state").and_then(|s| s.as_str()) {
|
||||||
|
Some("routed") => PixelpassEvent::AppAudioRouted,
|
||||||
|
Some("lost") => PixelpassEvent::AppAudioLost,
|
||||||
|
_ => PixelpassEvent::Other,
|
||||||
|
},
|
||||||
_ => PixelpassEvent::Other,
|
_ => PixelpassEvent::Other,
|
||||||
};
|
};
|
||||||
Some(ev)
|
Some(ev)
|
||||||
@@ -107,6 +122,144 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
|
|||||||
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
|
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the argv for a pixelpass *host*. Always `--host --output json`; when
|
||||||
|
/// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass
|
||||||
|
/// captures only that app's audio instead of the whole desktop sink monitor
|
||||||
|
/// (which contains our own call playout → the viewer would hear themselves
|
||||||
|
/// echoed back, backlog A23).
|
||||||
|
///
|
||||||
|
/// `--strict-audio` is what makes the fix a guarantee rather than best-effort:
|
||||||
|
/// without it, pixelpass falls back to the whole-desktop loopback before the
|
||||||
|
/// app's first stream routes and again if the app's audio later stops — both of
|
||||||
|
/// which reintroduce the echo. With it, the viewer hears only the chosen app (or
|
||||||
|
/// silence), and pixelpass emits `app_audio` events we surface as a warning.
|
||||||
|
///
|
||||||
|
/// The name is passed in the single-token `--app=<name>` form so a value that
|
||||||
|
/// happens to begin with `-` can never be reparsed as a pixelpass flag (clap
|
||||||
|
/// otherwise rejects hyphen-leading option values). The name is locally chosen
|
||||||
|
/// (our own enumeration / the user's pick), not peer-supplied, but is still
|
||||||
|
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
|
||||||
|
pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
|
||||||
|
let mut args = vec![
|
||||||
|
"--host".to_string(),
|
||||||
|
"--output".to_string(),
|
||||||
|
"json".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(name) = audio_app.and_then(sanitize_app_name) {
|
||||||
|
args.push(format!("--app={name}"));
|
||||||
|
args.push("--strict-audio".to_string());
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
|
||||||
|
/// trim, reject empty / overlong, and reject names carrying control characters
|
||||||
|
/// (newlines etc.) that have no place in a real `application.name`. `None` means
|
||||||
|
/// "no valid app selected" — the caller then shares the whole desktop audio.
|
||||||
|
pub fn sanitize_app_name(name: &str) -> Option<String> {
|
||||||
|
let name = name.trim();
|
||||||
|
let ok = !name.is_empty()
|
||||||
|
&& name.len() <= MAX_APP_NAME_LEN
|
||||||
|
&& !name.chars().any(|c| c.is_control());
|
||||||
|
ok.then(|| name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard cap on how long enumeration waits for `pactl`. It runs inline on the core
|
||||||
|
/// command loop (the picker awaits it before opening), so a wedged/slow `pactl`
|
||||||
|
/// must not stall mute/deafen/leave/stop. On timeout we treat it like any other
|
||||||
|
/// failure: empty list → "All system audio" only.
|
||||||
|
const LIST_APPS_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Enumerate the apps currently sending audio to a sink, deduplicated by
|
||||||
|
/// `application.name`. Mirrors how pixelpass itself builds its interactive
|
||||||
|
/// picker (`pactl -f json list sink-inputs`), so the names we return are exactly
|
||||||
|
/// the ones `--app` matches against. Returns an empty list on any error (pactl
|
||||||
|
/// missing, non-PipeWire host, nothing playing, or [`LIST_APPS_TIMEOUT`] elapsed)
|
||||||
|
/// — a normal, handled state that leaves the picker showing only "All system
|
||||||
|
/// audio".
|
||||||
|
pub async fn list_audio_apps() -> Vec<String> {
|
||||||
|
let run = Command::new("pactl")
|
||||||
|
.args(["-f", "json", "list", "sink-inputs"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
// On [`LIST_APPS_TIMEOUT`] the `output()` future is dropped, which drops
|
||||||
|
// the child — `kill_on_drop(true)` then SIGKILLs and reaps it so a wedged
|
||||||
|
// `pactl` can't linger/accumulate across picker opens (audit P3).
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output();
|
||||||
|
match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
|
||||||
|
Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or
|
||||||
|
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only
|
||||||
|
/// picker (safe), never a stalled core loop.
|
||||||
|
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass
|
||||||
|
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app
|
||||||
|
/// share always appends `--strict-audio`, and an **older** pixelpass would have
|
||||||
|
/// clap reject the unknown flag → the host spawn hard-fails and the share is
|
||||||
|
/// broken (audit P2, version skew). When unsupported the picker degrades to
|
||||||
|
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which
|
||||||
|
/// would reintroduce the call echo (A23).
|
||||||
|
///
|
||||||
|
/// Any probe failure/timeout returns `false` (degrade to the safe path). The
|
||||||
|
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger.
|
||||||
|
pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||||
|
let run = Command::new(bin)
|
||||||
|
.arg("--help")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output();
|
||||||
|
match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
|
||||||
|
Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
|
||||||
|
/// flag token rather than a whole line, since clap may wrap/realign help text.
|
||||||
|
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool {
|
||||||
|
String::from_utf8_lossy(help_stdout).contains("--strict-audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `pactl -f json list sink-inputs` stdout into a sorted, deduplicated list
|
||||||
|
/// of `application.name`s. Pure: no I/O. Unparseable input yields an empty list.
|
||||||
|
/// Each name is passed through [`sanitize_app_name`] so the picker only ever
|
||||||
|
/// offers names that will actually survive [`host_args`]; otherwise a name that
|
||||||
|
/// parses here but fails sanitization later would be selectable yet silently
|
||||||
|
/// drop the `--app` flag and revert the share to whole-desktop audio (A23 echo).
|
||||||
|
pub fn parse_audio_apps(stdout: &[u8]) -> Vec<String> {
|
||||||
|
let Ok(entries) = serde_json::from_slice::<Vec<SinkInput>>(stdout) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut names: Vec<String> = entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.properties.application_name)
|
||||||
|
.filter_map(|n| sanitize_app_name(&n))
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SinkInput {
|
||||||
|
properties: SinkInputProperties,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SinkInputProperties {
|
||||||
|
#[serde(rename = "application.name")]
|
||||||
|
application_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
|
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
|
||||||
/// rides gossip presence, which is untrusted and spoofable), so flags come first
|
/// rides gossip presence, which is untrusted and spoofable), so flags come first
|
||||||
/// and the ticket is passed as a positional **after a `--` end-of-options
|
/// and the ticket is passed as a positional **after a `--` end-of-options
|
||||||
@@ -162,20 +315,30 @@ pub fn is_available(config_override: Option<&str>) -> bool {
|
|||||||
pixelpass_path(config_override).is_some()
|
pixelpass_path(config_override).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a pixelpass host (`pixelpass --host --output json`), wait for its
|
/// Spawn a pixelpass host (`pixelpass --host --output json [--app=<name>]`), wait
|
||||||
/// startup ticket, and return the live child plus the ticket. The child keeps
|
/// for its startup ticket, and return the live child plus the ticket. When
|
||||||
|
/// `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
|
/// 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. We do
|
||||||
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
|
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
|
||||||
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
|
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
|
||||||
pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
pub async fn spawn_host(
|
||||||
|
bin: &Path,
|
||||||
|
audio_app: Option<&str>,
|
||||||
|
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||||
|
) -> std::io::Result<(Child, String)> {
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.arg("--host")
|
.args(host_args(audio_app))
|
||||||
.arg("--output")
|
|
||||||
.arg("json")
|
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::null())
|
// Capture stderr (not null): pixelpass prints its startup precondition
|
||||||
|
// failures there — a missing GStreamer plugin / `pactl`, each with an
|
||||||
|
// actionable "Install hint: sudo apt install ..." line. If the host dies
|
||||||
|
// before its ticket we fold that tail into our error so the user sees
|
||||||
|
// *what to install* instead of a dead-end "exited before a ticket". On
|
||||||
|
// the success path we drain it in the background so the pipe can't fill.
|
||||||
|
.stderr(Stdio::piped())
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
@@ -183,6 +346,7 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
.stdout
|
.stdout
|
||||||
.take()
|
.take()
|
||||||
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
|
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
|
||||||
|
let stderr = child.stderr.take();
|
||||||
let mut lines = BufReader::new(stdout).lines();
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
|
||||||
let ticket = match read_until(&mut lines, |e| match e {
|
let ticket = match read_until(&mut lines, |e| match e {
|
||||||
@@ -194,9 +358,10 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
Ok(Some(t)) => t,
|
Ok(Some(t)) => t,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
return Err(std::io::Error::other(
|
let detail = read_stderr_tail(stderr).await;
|
||||||
"pixelpass host exited before emitting a ticket",
|
return Err(std::io::Error::other(format!(
|
||||||
));
|
"pixelpass host exited before emitting a ticket{detail}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
@@ -204,10 +369,65 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
drain_in_background(lines, "host");
|
if let Some(stderr) = stderr {
|
||||||
|
drain_stderr_in_background(stderr);
|
||||||
|
}
|
||||||
|
drain_in_background(lines, "host", notices);
|
||||||
Ok((child, ticket))
|
Ok((child, ticket))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a killed pixelpass child's stderr to EOF and reduce it to a short,
|
||||||
|
/// user-facing diagnostic tail via [`pixelpass_failure_detail`]. Bounded: the
|
||||||
|
/// caller kills the child first, so the pipe EOFs promptly. Returns an empty
|
||||||
|
/// string when stderr was already taken or carried nothing useful.
|
||||||
|
async fn read_stderr_tail(stderr: Option<tokio::process::ChildStderr>) -> String {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let Some(mut stderr) = stderr else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let _ = stderr.read_to_end(&mut buf).await;
|
||||||
|
pixelpass_failure_detail(&String::from_utf8_lossy(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard a running pixelpass child's stderr in the background so its pipe
|
||||||
|
/// can't fill and stall the host (mirrors [`drain_in_background`] for stdout).
|
||||||
|
fn drain_stderr_in_background(mut stderr: tokio::process::ChildStderr) {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
while let Ok(n) = stderr.read(&mut buf).await {
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a human-useful tail from a failed pixelpass child's stderr to append
|
||||||
|
/// to our error. pixelpass writes actionable startup errors there (a missing
|
||||||
|
/// GStreamer element / `pactl` plus an `Install hint: sudo apt install ...`
|
||||||
|
/// line), which is exactly what a freshly-installed host needs to see. The
|
||||||
|
/// decorative host banner (box-drawing) is dropped — it only prints on the
|
||||||
|
/// success path, but we filter it defensively. Pure: no I/O. Returns an empty
|
||||||
|
/// string when there's nothing worth surfacing (so callers can append blindly).
|
||||||
|
pub fn pixelpass_failure_detail(stderr: &str) -> String {
|
||||||
|
let useful: Vec<&str> = stderr
|
||||||
|
.lines()
|
||||||
|
.map(str::trim_end)
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.filter(|l| !l.trim_start().starts_with(['│', '┌', '└', '├']))
|
||||||
|
.collect();
|
||||||
|
if useful.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
// The anyhow error and its install hint are the *last* lines printed, so
|
||||||
|
// keep the tail rather than the head.
|
||||||
|
const MAX_LINES: usize = 12;
|
||||||
|
let start = useful.len().saturating_sub(MAX_LINES);
|
||||||
|
format!("\n\npixelpass reported:\n{}", useful[start..].join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
||||||
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
|
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
|
||||||
/// child so the caller can kill it on room-leave; it also self-exits when the
|
/// child so the caller can kill it on room-leave; it also self-exits when the
|
||||||
@@ -251,7 +471,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
drain_in_background(lines, "viewer");
|
drain_in_background(lines, "viewer", None);
|
||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,15 +506,24 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
||||||
/// stall it; log notable events for diagnostics.
|
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
|
||||||
fn drain_in_background<R>(mut lines: tokio::io::Lines<BufReader<R>>, role: &'static str)
|
/// parsed event is also forwarded to the caller (the core, which translates the
|
||||||
where
|
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
|
||||||
|
/// stops forwarding, draining continues. The task ends on EOF (child exited).
|
||||||
|
fn drain_in_background<R>(
|
||||||
|
mut lines: tokio::io::Lines<BufReader<R>>,
|
||||||
|
role: &'static str,
|
||||||
|
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||||
|
) where
|
||||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||||
|
if let Some(tx) = ¬ices {
|
||||||
|
let _ = tx.send(ev);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -313,6 +542,8 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
|||||||
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
||||||
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
||||||
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
||||||
|
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
|
||||||
|
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
|
||||||
PixelpassEvent::Other => "other".to_string(),
|
PixelpassEvent::Other => "other".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +615,142 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_without_app_shares_whole_desktop() {
|
||||||
|
// No app selected → no --app flag → pixelpass keeps its default
|
||||||
|
// (whole-desktop) audio capture.
|
||||||
|
assert_eq!(host_args(None), vec!["--host", "--output", "json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_with_app_appends_single_token_flag() {
|
||||||
|
// The chosen app rides in the `--app=<name>` single-token form so a
|
||||||
|
// name beginning with `-` can never be reparsed as a flag (A23), plus
|
||||||
|
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some("Firefox")),
|
||||||
|
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"]
|
||||||
|
);
|
||||||
|
// The hyphen-leading name is still bound to --app as a single token;
|
||||||
|
// --strict-audio is the trailing flag.
|
||||||
|
let args = host_args(Some("-rm -rf"));
|
||||||
|
assert_eq!(args[3], "--app=-rm -rf");
|
||||||
|
assert_eq!(args[4], "--strict-audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_blank_or_control_app_is_dropped() {
|
||||||
|
// An empty / whitespace / control-laden selection is sanitized away,
|
||||||
|
// falling back to whole-desktop capture rather than a broken flag.
|
||||||
|
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
|
||||||
|
assert_eq!(host_args(Some("bad\nname")), vec!["--host", "--output", "json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_app_name_trims_and_rejects_garbage() {
|
||||||
|
assert_eq!(sanitize_app_name(" Firefox \n"), Some("Firefox".to_string()));
|
||||||
|
assert_eq!(sanitize_app_name(""), None);
|
||||||
|
assert_eq!(sanitize_app_name(" "), None);
|
||||||
|
assert_eq!(sanitize_app_name("a\tb"), None);
|
||||||
|
assert_eq!(sanitize_app_name(&"x".repeat(MAX_APP_NAME_LEN + 1)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_audio_apps_dedups_and_sorts_by_application_name() {
|
||||||
|
let stdout = br#"[
|
||||||
|
{"index":1,"properties":{"application.name":"Firefox"}},
|
||||||
|
{"index":2,"properties":{"application.name":"mpv"}},
|
||||||
|
{"index":3,"properties":{"application.name":"Firefox"}},
|
||||||
|
{"index":4,"properties":{"application.name":" Spotify "}},
|
||||||
|
{"index":5,"properties":{"application.name":""}},
|
||||||
|
{"index":6,"properties":{"other":"no name here"}}
|
||||||
|
]"#;
|
||||||
|
assert_eq!(
|
||||||
|
parse_audio_apps(stdout),
|
||||||
|
vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_audio_apps_empty_or_garbage_is_empty() {
|
||||||
|
assert_eq!(parse_audio_apps(b""), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_audio_apps(b"not json"), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_audio_apps(b"[]"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_audio_apps_drops_names_host_args_would_reject() {
|
||||||
|
// Names that parse from pactl but fail `sanitize_app_name` (control chars,
|
||||||
|
// overlong) must NOT be offered in the picker — otherwise the user could
|
||||||
|
// pick one, `host_args` would silently drop `--app`, and the share would
|
||||||
|
// revert to whole-desktop audio (A23 echo) with no signal. The valid name
|
||||||
|
// survives; the control-char and overlong ones are filtered out.
|
||||||
|
let overlong = "x".repeat(MAX_APP_NAME_LEN + 1);
|
||||||
|
let stdout = format!(
|
||||||
|
r#"[
|
||||||
|
{{"index":1,"properties":{{"application.name":"mpv"}}}},
|
||||||
|
{{"index":2,"properties":{{"application.name":"bad\nname"}}}},
|
||||||
|
{{"index":3,"properties":{{"application.name":"{overlong}"}}}}
|
||||||
|
]"#
|
||||||
|
);
|
||||||
|
assert_eq!(parse_audio_apps(stdout.as_bytes()), vec!["mpv".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_detail_surfaces_install_hint_and_drops_banner() {
|
||||||
|
// The real shape of a fresh-host failure: anyhow error + install hint on
|
||||||
|
// stderr. We must keep those (so the user knows what to apt install) and
|
||||||
|
// drop the decorative banner box-drawing lines.
|
||||||
|
let stderr = "\
|
||||||
|
┌─ PixelPass · host ─────────────────────────────────────────
|
||||||
|
│ display server : Wayland
|
||||||
|
└────────────────────────────────────────────────────────────
|
||||||
|
Error: GStreamer element `vah264enc` not available.
|
||||||
|
Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||||
|
";
|
||||||
|
let detail = pixelpass_failure_detail(stderr);
|
||||||
|
assert!(detail.starts_with("\n\npixelpass reported:\n"));
|
||||||
|
assert!(detail.contains("vah264enc` not available"));
|
||||||
|
assert!(detail.contains("sudo apt install gstreamer1.0-plugins-bad"));
|
||||||
|
assert!(!detail.contains('│'), "banner box-drawing must be dropped");
|
||||||
|
assert!(!detail.contains('┌'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_detail_empty_when_nothing_useful() {
|
||||||
|
// Blank / banner-only stderr yields an empty string so the caller can
|
||||||
|
// append it to the base message unconditionally without trailing noise.
|
||||||
|
assert_eq!(pixelpass_failure_detail(""), "");
|
||||||
|
assert_eq!(pixelpass_failure_detail(" \n \n"), "");
|
||||||
|
assert_eq!(
|
||||||
|
pixelpass_failure_detail("│ display server : Wayland\n│ capture : x\n"),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_detail_keeps_only_the_tail() {
|
||||||
|
// A long stderr is truncated to its last lines (where the real error
|
||||||
|
// and hint live), not its head.
|
||||||
|
let body: String = (0..30).map(|i| format!("line {i}\n")).collect();
|
||||||
|
let detail = pixelpass_failure_detail(&body);
|
||||||
|
assert!(detail.contains("line 29"));
|
||||||
|
assert!(!detail.contains("line 0\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_probe_detects_strict_audio_flag() {
|
||||||
|
// A new pixelpass advertises the flag; an old one doesn't. The probe must
|
||||||
|
// match the token even when clap wraps the option onto its own line.
|
||||||
|
let new_help = b"Options:\n --app <APP>\n --strict-audio\n With --app, never fall back...";
|
||||||
|
assert!(help_mentions_strict_audio(new_help));
|
||||||
|
let old_help = b"Options:\n --app <APP>\n --output <OUTPUT>\n -h, --help";
|
||||||
|
assert!(!help_mentions_strict_audio(old_help));
|
||||||
|
// Garbage / empty output degrades to "unsupported" (safe path).
|
||||||
|
assert!(!help_mentions_strict_audio(b""));
|
||||||
|
assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||||
@@ -470,6 +837,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_app_audio_states() {
|
||||||
|
// The wire contract from pixelpass's --strict-audio run (A23): routed =
|
||||||
|
// the chosen app's audio is live; lost = it stopped (viewers now silent).
|
||||||
|
assert_eq!(
|
||||||
|
parse_pixelpass_event(r#"{"event":"app_audio","state":"routed"}"#),
|
||||||
|
Some(PixelpassEvent::AppAudioRouted)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_pixelpass_event(r#"{"event":"app_audio","state":"lost"}"#),
|
||||||
|
Some(PixelpassEvent::AppAudioLost)
|
||||||
|
);
|
||||||
|
// Unknown / missing state is recognized-but-unused, not a parse failure.
|
||||||
|
assert_eq!(
|
||||||
|
parse_pixelpass_event(r#"{"event":"app_audio","state":"weird"}"#),
|
||||||
|
Some(PixelpassEvent::Other)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_pixelpass_event(r#"{"event":"app_audio"}"#),
|
||||||
|
Some(PixelpassEvent::Other)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recognized_but_unused_event_is_other() {
|
fn recognized_but_unused_event_is_other() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user