diff --git a/CLAUDE.md b/CLAUDE.md index c0293a2..c6e564c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,10 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc no offline dictionary attack) establishes mutual trust: clients present persistent identities via QUIC client auth, the host stores paired fingerprints (`punktfunk1-paired.json`) and can gate sessions with `--require-pairing`. + **LAN auto-discovery**: both `serve --native` and `m3-host` advertise the native service over + mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to + pin)/`pair`(required|optional)/`id`; `punktfunk-client-rs --discover` lists hosts, Apple clients + browse the same service via NWBrowser (validated cross-LAN 2026-06-12). **Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on (validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms** diff --git a/Cargo.lock b/Cargo.lock index 83b4817..d2eb78f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1953,6 +1953,7 @@ name = "punktfunk-client-rs" version = "0.0.1" dependencies = [ "anyhow", + "mdns-sd", "opus", "punktfunk-core", "quinn", diff --git a/crates/punktfunk-client-rs/Cargo.toml b/crates/punktfunk-client-rs/Cargo.toml index cb248b5..cb31c77 100644 --- a/crates/punktfunk-client-rs/Cargo.toml +++ b/crates/punktfunk-client-rs/Cargo.toml @@ -15,6 +15,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# LAN host discovery (`--discover`): browse the native `_punktfunk._udp` mDNS service the host +# advertises (same crate/version the host advertises with). +mdns-sd = "0.20" # Linux-only: --mic-test's Opus encoder (libopus). The mic UPLINK itself is portable — # only this synthetic-tone test rig needs the encoder. diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index c3dc4c6..d452f99 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -31,8 +31,12 @@ //! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360, //! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`). //! +//! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises +//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and +//! exits without connecting. +//! //! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test] -//! [--pin HEX] [--compositor NAME] [--gamepad NAME]` +//! [--pin HEX] [--compositor NAME] [--gamepad NAME] | --discover [SECS]` //! (M4 adds VAAPI decode + wgpu present on this skeleton.) use anyhow::{anyhow, Context, Result}; @@ -75,6 +79,9 @@ struct Args { /// `--speed-test KBPS:MS` — after the stream starts, ask the host for a `MS`-millisecond /// bandwidth probe burst at `KBPS`, then report measured throughput + loss. speed_test: Option<(u32, u32)>, + /// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS` + /// seconds (default 4), print what's found, and exit. No connection is made. + discover: Option, } fn parse_mode(m: &str) -> Option { @@ -191,6 +198,12 @@ fn parse_args() -> Args { let (kbps, ms) = s.split_once(':')?; Some((kbps.parse().ok()?, ms.parse().ok()?)) }), + // `--discover` may be a bare flag or carry a seconds value (`--discover 8`); only treat + // the following token as a count when it parses as a number (else it's the next flag). + discover: argv + .iter() + .any(|a| a == "--discover") + .then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)), } } @@ -215,6 +228,10 @@ fn main() { } fn run(args: Args) -> Result<()> { + // Discovery mode: browse the LAN for native hosts, print them, and exit (no connection). + if let Some(secs) = args.discover { + return discover(secs); + } // Pairing mode: run the PIN ceremony and print the fingerprint to pin, then exit. if let Some(pin) = &args.pair { let (host, port) = args @@ -245,6 +262,75 @@ fn run(args: Args) -> Result<()> { rt.block_on(session(args)) } +/// Browse the LAN for native (`_punktfunk._udp`) hosts for `secs` seconds and print them, then +/// exit — the discovery side of the host's mDNS advert (host crate `discovery.rs`). TXT keys: +/// `fp` (host cert fingerprint to pin), `pair` (required|optional), `id` (stable host id). +fn discover(secs: u64) -> Result<()> { + use mdns_sd::{ServiceDaemon, ServiceEvent}; + use std::collections::BTreeMap; + use std::time::{Duration, Instant}; + + let daemon = ServiceDaemon::new().context("create mDNS daemon")?; + let receiver = daemon + .browse("_punktfunk._udp.local.") + .context("browse _punktfunk._udp")?; + tracing::info!(secs, "browsing for native punktfunk/1 hosts (_punktfunk._udp)…"); + // One row per host, keyed by the stable uniqueid (falls back to the fullname) so the same + // host re-advertising or answering on several interfaces collapses to a single entry. + let mut hosts: BTreeMap = BTreeMap::new(); + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + // Timeout == time left to the deadline: an event returns immediately, otherwise the recv + // returns Err exactly at the deadline (or if the daemon channel closes) and we stop. + match receiver.recv_timeout(remaining) { + Ok(ServiceEvent::ServiceResolved(info)) => { + let props = info.get_properties(); + let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string(); + let addr = info + .get_addresses() + .iter() + .next() + .map(|a| a.to_string()) + .unwrap_or_else(|| "?".into()); + let fp = val("fp"); + let fp_short = fp.get(..16).unwrap_or(fp.as_str()); + let name = info.get_fullname().split('.').next().unwrap_or("?").to_string(); + let id = val("id"); + let key = if id.is_empty() { + info.get_fullname().to_string() + } else { + id + }; + let row = format!( + " {name:<24} {addr}:{:<6} pair={:<9} fp={fp_short}…", + info.get_port(), + val("pair"), + ); + hosts.insert(key, row); + } + Ok(_) => {} // SearchStarted / ServiceFound / removals — ignore + Err(_) => break, + } + } + let _ = daemon.shutdown(); + if hosts.is_empty() { + println!("no native punktfunk/1 hosts found on the LAN ({secs}s)"); + } else { + println!("native punktfunk/1 hosts ({}):", hosts.len()); + for row in hosts.values() { + println!("{row}"); + } + println!( + "\nconnect with: punktfunk-client-rs --connect [--pin | --pair ]" + ); + } + Ok(()) +} + async fn session(args: Args) -> Result<()> { let remote: std::net::SocketAddr = args.connect.parse().context("--connect host:port")?; let identity = load_or_create_identity()?; diff --git a/crates/punktfunk-host/src/discovery.rs b/crates/punktfunk-host/src/discovery.rs new file mode 100644 index 0000000..d612226 --- /dev/null +++ b/crates/punktfunk-host/src/discovery.rs @@ -0,0 +1,65 @@ +//! mDNS advertisement of the native punktfunk/1 service so native clients auto-discover the +//! host — the native-protocol analogue of the GameStream `_nvstream._tcp` advert +//! ([`crate::gamestream::mdns`]). +//! +//! The service type is **`_punktfunk._udp.local.`** (UDP because punktfunk/1 is QUIC, and the +//! advertised port is the QUIC control/data port a client `--connect`s). TXT records carry: +//! - `proto` — the wire protocol id ([`NATIVE_PROTO`]), so a future incompatible revision is +//! distinguishable by discovery alone; +//! - `fp` — the host certificate SHA-256 (lowercase hex), the exact value a client pins. mDNS is +//! unauthenticated, so this is advisory — TOFU/pinning still verifies it on connect — but it +//! lets a picker show the fingerprint and pre-pin a chosen host; +//! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN +//! pairing ceremony before it can stream; +//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises). + +use anyhow::{Context, Result}; +use mdns_sd::{ServiceDaemon, ServiceInfo}; +use std::collections::HashMap; +use std::net::IpAddr; + +/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts. +pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local."; + +/// Wire protocol id advertised in the `proto` TXT record. +pub const NATIVE_PROTO: &str = "punktfunk/1"; + +/// Holds the mDNS daemon; dropping it unregisters the service. +pub struct Advert { + _daemon: ServiceDaemon, +} + +/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex); +/// `require_pairing` tells a discovering client whether it must pair before it can stream. +pub fn advertise_native( + hostname: &str, + ip: IpAddr, + port: u16, + fingerprint: &str, + require_pairing: bool, + uniqueid: &str, +) -> Result { + let daemon = ServiceDaemon::new().context("create mDNS daemon")?; + let host_name = format!("{hostname}.local."); + let mut props: HashMap = HashMap::new(); + props.insert("proto".into(), NATIVE_PROTO.into()); + props.insert("fp".into(), fingerprint.to_string()); + props.insert( + "pair".into(), + if require_pairing { "required" } else { "optional" }.into(), + ); + props.insert("id".into(), uniqueid.to_string()); + let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props) + .context("build native mDNS ServiceInfo")?; + daemon + .register(service) + .context("register native mDNS service")?; + tracing::info!( + service = "_punktfunk._udp", + port, + host = %host_name, + pair = if require_pairing { "required" } else { "optional" }, + "native punktfunk/1 mDNS advertising" + ); + Ok(Advert { _daemon: daemon }) +} diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index e33791e..6e45219 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -159,6 +159,27 @@ pub(crate) async fn serve(opts: M3Options, np: Arc) -> Result<()> "punktfunk/1 host listening (QUIC) — clients pin this fingerprint" ); + // mDNS: advertise the native service so clients auto-discover this host (the analogue of the + // GameStream _nvstream advert; both run in the unified host). Held for the host's lifetime — + // dropping `_advert` unregisters. Best-effort: a discovery failure must not stop streaming + // (manual `--connect HOST:PORT` always works), so we log and continue. + let _advert = match crate::gamestream::Host::detect() { + Ok(h) => crate::discovery::advertise_native( + &h.hostname, + h.local_ip, + opts.port, + &fingerprint_hex(&fingerprint), + opts.require_pairing, + &h.uniqueid, + ) + .map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)")) + .ok(), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "host detect for mDNS failed (continuing)"); + None + } + }; + // One audio capturer for the whole host lifetime, handed from session to session // (avoids a PipeWire stream setup per session — see AudioCapSlot). let audio_cap: AudioCapSlot = Arc::new(std::sync::Mutex::new(None)); @@ -236,10 +257,10 @@ const DEFAULT_BITRATE_KBPS: u32 = 20_000; /// above unusable, and a **2 Gbps** ceiling is generous headroom over the 1 Gbps+ target that /// GF(2¹⁶) Leopard FEC was built to reach — it lifts the GF(2⁸)/~1 Gbps wall, and at 1 Gbps a frame /// is only a few-hundred shards in one block (far under the 65535 limit). Enough for 5K@240 with -/// margin. Resolved value is echoed in `Welcome::bitrate_kbps`. CAVEAT: the native data plane still -/// does one `send()` syscall per packet (no `sendmmsg` batching / paced send thread yet), so -/// sustained multi-hundred-Mbps can show send-buffer drops (counted as `packets_send_dropped`) -/// until that lands — see the 1 Gbps work in the roadmap. +/// margin. Resolved value is echoed in `Welcome::bitrate_kbps`. The native data plane batches sends +/// (`sendmmsg`) and paces each frame on a dedicated send thread (microburst cap), validated to a +/// clean 1 Gbps with zero send-buffer drops; sustained overruns are still counted as +/// `packets_send_dropped`. const MIN_BITRATE_KBPS: u32 = 500; const MAX_BITRATE_KBPS: u32 = 2_000_000; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 0432603..77f33e2 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -15,6 +15,7 @@ mod audio; mod capture; +mod discovery; mod encode; mod gamestream; mod inject; @@ -437,6 +438,8 @@ NOTES: 'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session (see docs/linux-setup.md). 'synthetic' needs no capture session and always runs. Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a - punktfunk_core host→client loopback that reassembles and byte-verifies each one." + punktfunk_core host→client loopback that reassembles and byte-verifies each one. + Both 'serve --native' and 'm3-host' advertise the native service over mDNS + (_punktfunk._udp) for client auto-discovery — 'punktfunk-client-rs --discover' lists them." ); } diff --git a/docs/roadmap.md b/docs/roadmap.md index 4d029a1..ba00346 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -289,17 +289,42 @@ buffer; `sendmmsg`/`recvmmsg` batching; the capture-timestamp anchor placement. the ~150 Mbps@60 frame size where drops began). Plus **per-frame instrumentation** (PUNKTFUNK_PERF): `encode_us` + `pace_us` p50/p99/max + immediate-vs-paced counts, so the cap is tunable against real numbers. **Validate with the LAN soak before raising the cap** (`send_dropped` must stay 0). +- **Done & live (`b295a5b`; validated on the GNOME box 2026-06-12):** **encode|send thread split** + on the native path — a dedicated `send_loop` thread owns the `Session` and does seal+pace+send+ + probes; the encode thread captures+encodes+handles reconfig and hands `FrameMsg` over a bounded + `sync_channel(3)` with backpressure. Removes the serialization (~2–8 ms @60–120 fps) and is the + substrate the slice wrapper needs. Real-NIC soak (host on the Ubuntu/GNOME box, client over the + LAN): `send_dropped=0` at 720p60 / 1080p120, and a 1 Gbps probe pushed 625 MB in 5 s clean. - **Bigger bets (ordered, deferred — need real-NIC/GPU/Mac validation):** - 1. **Encode|send thread split** on the native path (port GameStream's `spawn_sender` + depth-2 - channel; `seal_frame` stays on the encode thread, `send_sealed` on a send thread) — removes the - serialization (~2–8 ms @60–120 fps), and is the substrate the slice wrapper needs. - 2. **Wall-clock skew handshake + glass-to-glass probe** (`tools/latency-probe`) — measures the two + 1. **Wall-clock skew handshake + glass-to-glass probe** (`tools/latency-probe`) — measures the two biggest unmeasured terms (render→capture, decode→present); client present-stamp vs the AU's `pts_ns` (already attached). - 3. **CUDA stream+event** to drop one of two redundant `cuCtxSynchronize` in `submit_cuda` (keep the + 2. **CUDA stream+event** to drop one of two redundant `cuCtxSynchronize` in `submit_cuda` (keep the copy) — ~0.1–0.4 ms@720p, ~1 ms@5K; only if per-stage timing proves the sync is on the path. - 4. **Stage-2 Apple presenter** (`VTDecompressionSession` → `CAMetalLayer`, hand-paced) — ~0.5 refresh + 3. **Stage-2 Apple presenter** (`VTDecompressionSession` → `CAMetalLayer`, hand-paced) — ~0.5 refresh off the present tail (biggest client win at 60 Hz); gate on the probe proving present is real. - 5. **NVENC slice-mode wrapper** (roadmap §2 sub-frame pipelining) — per-slice transmit overlaps + 4. **NVENC slice-mode wrapper** (roadmap §2 sub-frame pipelining) — per-slice transmit overlaps encode+send within a frame (~3–6 ms at 4K/5K/IDR); large + driver-ABI-fragile, on top of the thread split, only after measurement justifies it. + +## 13. Native-protocol LAN auto-discovery ✅ *(done — 2026-06-12, validated cross-LAN)* + +The native protocol had no discovery — clients connected by `--connect HOST:PORT` only, while +GameStream already auto-discovered via mDNS (`_nvstream._tcp`). Now both the unified host +(`serve --native`) and standalone `m3-host` advertise the native service over mDNS: + +- **Service**: `_punktfunk._udp.local.` (UDP — punktfunk/1 is QUIC; the advertised port is the QUIC + control/data port). Host side: `crate::discovery::advertise_native`, wired into `m3::serve` so + both host entry points get it; best-effort (a discovery failure never blocks streaming — + `--connect` always works). The advert is held for the host's lifetime (RAII unregister). +- **TXT records**: `proto=punktfunk/1`, `fp=` (the value a client pins — advisory + over unauthenticated mDNS, TOFU/pinning still verifies on connect), `pair=required|optional` + (so a picker knows up front whether the PIN ceremony is needed), `id=` (dedup). +- **Client**: `punktfunk-client-rs --discover [SECS]` browses and prints each host (name, addr:port, + pairing, fingerprint), then exits. Apple clients browse the same service natively via NWBrowser + (Bonjour) — no Rust-connector dependency; this section's service type + TXT keys are the contract. +- **Validated**: cross-LAN — dev box discovered the GNOME-box appliance + (`home-worker-3 192.168.1.248:9777 pair=required fp=1dcf3a…`) and a standalone synthetic host + (`pair=optional`); fingerprint + pairing state correct in both. +- **Next** (not done): wire NWBrowser discovery into the Apple client UI (host picker); the + host-side contract above is all it needs.