Files
punktfunk/crates/punktfunk-host/src/native.rs
T
enricobuehler 09849906e9 Merge perf/first-frame-latency: driver proto v4 + first-frame/resize latency (P0-P2)
Brings the first-frame-latency branch (P0.1 transition tracing, P1.1/P1.2
Welcome-time display prep, P2 in-place resize; pf-driver-proto v3 -> v4 with
IOCTL_UPDATE_MODES) onto current main. The branch predates the W6.2/W7 splits,
so git's rename detection carried most of it into the moved crates
(pf-capture idd_push, pf-vdisplay manager/pf_vdisplay, pf-win-display,
pf-driver-proto, the driver workspace) and the punktfunk1.rs remainder was
re-homed by hand:

- native/handshake.rs: welcome/start trace marks + the Welcome-time display
  prep spawn (the prep thread BECOMES the stream thread; hand-off via a
  SyncSender<SessionContext>). negotiate() gains bringup/quit/stop and returns
  the PrepHandle.
- native.rs: bringup/resize_ms creation + the stop/quit flags hoisted BEFORE
  the handshake (the close watcher splits: flags pre-handshake, lifecycle
  events post-handshake where `hello` exists); punch_done stamp; the data
  plane adopts the prep thread's result or builds inline.
- native/stream.rs: SessionContext/SendStats carry the trace; send_loop
  finishes it on the first video packet; the resize path gains the in-place
  fast path (try_inplace_resize) with the full rebuild as fallback, restructured
  so both share the post-rebuild bookkeeping; prepare_display/PreparedDisplay/
  PrepHandle; build_pipeline(+retry) thread the stage marks.
- session_status/mgmt: ttff_ms + last_resize_ms per session (union with the
  lifecycle-events fields main added to the same spots).
- pf-capture: Capturer gains capture_target_id() + resize_output() defaults.
- pf-vdisplay manager: perf's faster activation poll (60x50ms) + the settle
  floor before the PnP sweep, on main's knobs/no-trait shape.

Also: packaging/windows/build-gamepad-drivers.ps1 is ASCII again (an em-dash
from the pf-mouse work tripped windows-host.yml's locale-safety gate on main).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:08:16 +02:00

2167 lines
107 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `punktfunk/1` native host: QUIC control plane + the hardened core data plane over UDP.
//! This is punktfunk's own protocol, past the GameStream compatibility layer:
//!
//! * the Welcome negotiates **GF(2¹⁶) Leopard FEC** (inexpressible in GameStream) + AES-GCM;
//! * the client's Hello requests a display mode and the host creates a **native virtual
//! output** at exactly that size/refresh (same vdisplay backends as the GameStream path);
//! * **input arrives as QUIC datagrams** — encrypted, congestion-managed, no ENet
//! retransmission spikes — and feeds the session's input injector;
//! * video frames carry a wall-clock `pts_ns`, so a same-host client measures the full
//! capture→encode→FEC→UDP→reassemble latency per frame.
//!
//! `punktfunk-host punktfunk1-host [--port 9777] [--source synthetic|virtual] [--seconds 30]
//! [--frames 300]` serves sessions back to back (one at a time — the virtual output and
//! encoder are single-tenant); `punktfunk-probe --connect host:9777` is the counterpart.
//! The data plane runs on native threads (no async on the frame path).
//!
//! Alongside video + input, a session carries **audio** (desktop Opus, 5 ms frames, host →
//! client QUIC datagrams tagged [`punktfunk_core::quic::AUDIO_MAGIC`]) and **gamepads** (client
//! GamepadButton/GamepadAxis datagrams accumulated into per-pad state for the virtual xpad;
//! force feedback flows back as [`punktfunk_core::quic::RUMBLE_MAGIC`] datagrams).
//!
//! Trust: the host serves with its persistent identity (`~/.config/punktfunk/cert.pem`, shared
//! with GameStream pairing) and logs the SHA-256 fingerprint clients pin.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::{
mtu1500_shard_payload_for, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
};
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
endpoint, io, BitrateChanged, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport, PairRequest,
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RfiRequest, SetBitrate,
Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::Session;
use rand::RngCore;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering};
use std::sync::Arc;
/// Per-thread OS scheduling QoS lives in the shared `pf-frame` leaf crate (plan §W1/§W6);
/// re-exported so `crate::native::boost_thread_priority` stays stable (the GameStream path and the
/// native data plane reach it there).
pub(crate) use pf_frame::thread_qos::boost_thread_priority;
/// Compositor-preference resolution (plan §W1); `serve_session` reaches `resolve_compositor` here.
mod compositor;
use compositor::resolve_compositor;
/// Virtual-gamepad backend resolution (plan §W1); `serve_session` + the `Pads` state machine reach
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here.
mod gamepad;
use gamepad::{resolve_gamepad, resolve_pad_kind, route_decision};
/// The SPAKE2 pairing ceremony (plan §W1); `serve_session` dispatches a PairRequest connection here.
mod pairing;
use pairing::pair_ceremony;
/// The native audio plane (plan §W1); the session setup spawns `audio_thread` here.
mod audio;
use audio::audio_thread;
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
mod input;
use input::{input_thread, ClientInput};
/// The Hello→Welcome→Start negotiation (plan §W1); `serve_session` calls `handshake::negotiate`
/// after the pairing gate.
mod handshake;
/// The mid-stream control task (plan §W1); `serve_session` spawns `control::run` after the
/// handshake to multiplex renegotiation / speed-test control messages onto the data-plane channels.
mod control;
/// The capture→encode→send data plane (plan §W1); `serve_session` dispatches the synthetic or
/// virtual source here (`synthetic_stream` / `virtual_stream`) and hands the latter a
/// `SessionContext`. `reconfig_allowed` gates mid-stream live reconfigure.
mod stream;
use stream::{reconfig_allowed, synthetic_stream, virtual_stream, SessionContext};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Punktfunk1Source {
/// Deterministic test frames (protocol verification; the client byte-checks them).
Synthetic,
/// Real capture: virtual display at the client's requested mode → NVENC.
Virtual,
}
pub struct Punktfunk1Options {
pub port: u16,
pub source: Punktfunk1Source,
/// Virtual-source stream duration.
pub seconds: u32,
/// Synthetic-source frame count.
pub frames: u32,
/// Exit after this many sessions (0 = serve forever).
pub max_sessions: u32,
/// Maximum sessions streaming **at once** (a NVENC/GPU bound); further clients wait in the
/// accept queue until a slot frees. Concurrent sessions each get their own virtual output +
/// encoder but share the host-lifetime input/audio/mic services — i.e. multiple devices viewing
/// (and controlling) the *same* desktop on the shared-desktop backends (kwin/mutter/wlroots).
/// `0` = unlimited (bounded only by the GPU). Default a conservative few.
pub max_concurrent: usize,
/// Only serve clients whose certificate fingerprint is in the paired set. Implies
/// `allow_pairing` (a host that requires pairing must accept ceremonies to admit
/// anyone).
pub require_pairing: bool,
/// Accept pairing ceremonies (the operator "arming" pairing mode). Default off: a host
/// with neither flag set rejects unsolicited PairRequests outright, closing that
/// attack surface. `require_pairing` forces this on.
pub allow_pairing: bool,
/// Fixed pairing PIN (tests); `None` = a fresh random 4-digit PIN per ceremony.
pub pairing_pin: Option<String>,
/// Paired-clients store path override (tests); `None` = the default config path.
pub paired_store: Option<std::path::PathBuf>,
/// Fixed data-plane UDP port. `None`/`Some(0)` (default): bind a random ephemeral port and
/// **hole-punch** — wait ~2.5 s for the client's punch, then fall back to its reported address
/// (traverses NAT / a stateful inter-VLAN firewall with no forwarded port, at the cost of the
/// punch-timeout on a firewall that drops the punch). `Some(p)`: bind that fixed port and
/// stream **directly** to the client's reported address with no punch-wait — for a host whose
/// data port is fixed + firewall-opened/forwarded, this removes the punch-timeout delay. A
/// fixed port only fits one data plane at a time, so a concurrent session finding it busy
/// falls back to random + hole-punch (see [`bind_data_socket`]).
pub data_port: Option<u16>,
/// Control-connection idle timeout — the **disconnect-detection latency** (how long a vanished
/// client takes to be declared dead, which bounds how fast a dropped session tears down / lingers
/// and thus the reconnect-overlap window). `None` = the core default (8s). Set from
/// `PUNKTFUNK_IDLE_TIMEOUT_MS`; clamped to a ≥1s floor with a keep-alive that scales to it so a
/// live session never false-closes.
pub idle_timeout: Option<std::time::Duration>,
/// Advertise this host over mDNS (`_punktfunk._udp`). Default on; `--no-mdns` /
/// `PUNKTFUNK_MDNS=0` turns it off for multicast-dead environments (bridged Docker, CI netns)
/// — clients then connect via `--connect HOST:PORT` / a manually-added host, which always works.
pub mdns: bool,
}
/// Bind the per-session data-plane UDP socket, honoring [`Punktfunk1Options::data_port`]. Returns
/// `(socket, direct)`: `direct = true` (a successfully-bound fixed port) means "stream straight to
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
/// window in which a concurrent session could steal a fixed port.
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
if let Some(p) = data_port.filter(|p| *p != 0) {
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
Ok(sock) => return Ok((sock, true)),
Err(e) => tracing::warn!(
data_port = p,
error = %e,
"fixed --data-port is busy (a concurrent session already holds it?) — \
falling back to a random port + hole-punch for this session"
),
}
}
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
}
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
use crate::native_pairing::{NativePairing, PairingDecision};
use crate::send_pacing::{percentile, PaceStat};
/// The shared streaming-stats recorder (web-console capture/graph), shared with the management API
/// and the GameStream loop; threaded into each session's `SessionContext`.
use crate::stats_recorder::StatsRecorder;
/// Minimum spacing between accepted pairing ceremonies (bounds online PIN guessing — with
/// SPAKE2 an attacker already gets only one guess per ceremony; this caps the rate).
const PAIRING_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2);
/// Deterministic test frame: `u32 LE index` then `data[i] = idx + i` (wrapping).
pub fn test_frame(idx: u32, len: usize) -> Vec<u8> {
let mut d = vec![0u8; len];
d[0..4].copy_from_slice(&idx.to_le_bytes());
for (i, b) in d.iter_mut().enumerate().skip(4) {
*b = (idx as u8).wrapping_add(i as u8);
}
d
}
fn now_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
pub fn run(opts: Punktfunk1Options) -> Result<()> {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.context("tokio runtime")?;
// Standalone CLI: arm at startup iff --allow-pairing/--require-pairing (back-compat — the PIN
// is logged). The unified `serve --native` path instead arms on demand via the management API.
let np = Arc::new(NativePairing::load_with(
opts.paired_store.clone(),
opts.pairing_pin.clone(),
opts.allow_pairing || opts.require_pairing,
)?);
// Standalone `punktfunk1-host` has no mgmt API to arm capture, so this recorder stays disarmed
// (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one
// recorder across mgmt + both streaming paths instead.
let stats = StatsRecorder::new(crate::stats_recorder::default_dir());
// Standalone `punktfunk1-host` runs no management API, so advertise no `mgmt` port (0).
rt.block_on(serve(opts, 0, np, stats))
}
fn fingerprint_hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect()
}
/// The persistent listener: accept clients back to back on one endpoint. Sessions are
/// served one at a time (the virtual output + NVENC are single-tenant); a client that
/// connects mid-session waits in the accept queue. A failed session logs and the loop
/// keeps serving — only endpoint-level failures are fatal.
/// Config for the native (punktfunk/1) host when the unified `serve` runs it in-process.
pub(crate) struct NativeServe {
pub port: u16,
/// Gate sessions on pairing. **Default on** — an open host any LAN device can stream from is
/// insecure; `serve --open` turns it off (trusted single-user setups). Pairing is armed on
/// demand from the web console (arm → PIN); paired devices persist.
pub require_pairing: bool,
/// The management API's TCP port, advertised over mDNS so a client browses the game library on
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
pub mgmt_port: u16,
/// Fixed data-plane UDP port (`--data-port` / `PUNKTFUNK_DATA_PORT`); see
/// [`Punktfunk1Options::data_port`]. `None` = random port + hole-punch (the default).
pub data_port: Option<u16>,
/// Advertise over mDNS (`--no-mdns` / `PUNKTFUNK_MDNS=0` turns it off). Gates the native
/// `_punktfunk._udp` advert AND the GameStream `_nvstream` advert — the serve-level knob for
/// multicast-dead environments; see [`Punktfunk1Options::mdns`].
pub mdns: bool,
}
/// Options for the native host when the unified `serve --native` runs it: real virtual capture,
/// persistent (no session/duration cut), pairing armed on demand via the management API (the
/// shared [`NativePairing`] starts disarmed).
/// Default cap on simultaneously-streaming sessions (each holds an NVENC session; high-res
/// split-encode holds two). Conservative — consumer NVENC historically capped concurrent sessions;
/// overflow clients wait in the accept queue. Override with `--max-concurrent`.
pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4;
/// The control-connection idle timeout (disconnect-detection latency) from
/// `PUNKTFUNK_IDLE_TIMEOUT_MS`; `None` (unset/invalid/zero) = the core default (8s). Clamped
/// downstream to a ≥1s floor with a keep-alive that scales to it, so a live session never false-closes.
pub(crate) fn idle_timeout_from_env() -> Option<std::time::Duration> {
std::env::var("PUNKTFUNK_IDLE_TIMEOUT_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&ms| ms > 0)
.map(std::time::Duration::from_millis)
}
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
Punktfunk1Options {
port: cfg.port,
source: Punktfunk1Source::Virtual,
seconds: 7 * 24 * 3600, // per-session cap; large enough not to cut a live stream
frames: 0,
max_sessions: 0,
max_concurrent: DEFAULT_MAX_CONCURRENT,
require_pairing: cfg.require_pairing,
allow_pairing: false,
pairing_pin: None,
paired_store: None,
data_port: cfg.data_port,
idle_timeout: idle_timeout_from_env(),
mdns: cfg.mdns,
}
}
pub(crate) async fn serve(
opts: Punktfunk1Options,
mgmt_port: u16,
np: Arc<NativePairing>,
stats: Arc<StatsRecorder>,
) -> Result<()> {
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
.context("load host identity (~/.config/punktfunk)")?;
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
.map_err(|e| anyhow!("cert fingerprint: {e}"))?;
let ep = endpoint::server_with_identity_idle(
([0, 0, 0, 0], opts.port).into(),
&identity.cert_pem,
&identity.key_pem,
opts.idle_timeout.unwrap_or(endpoint::DEFAULT_IDLE_TIMEOUT),
)
.map_err(|e| anyhow!("QUIC server endpoint: {e}"))?;
tracing::info!(
port = opts.port,
source = ?opts.source,
fingerprint = %fingerprint_hex(&fingerprint),
"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 = if !opts.mdns {
tracing::info!(
"mDNS advertisement disabled (--no-mdns / PUNKTFUNK_MDNS) — clients connect by address"
);
None
} else {
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,
// 0 = standalone `punktfunk1-host` (no mgmt API) → don't advertise an `mgmt` port.
(mgmt_port != 0).then_some(mgmt_port),
)
.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));
// One pointer/keyboard injector for the whole host lifetime (see InjectorService): the
// RemoteDesktop-portal grant is established ONCE and reused, instead of a CreateSession per
// session — which, under rapid client reconnects, raced a prior session's portal teardown and
// wedged KWin's EIS setup ("EIS setup timed out"). Gamepads stay per-session (uinput).
let injector = crate::inject::InjectorService::start();
// One virtual microphone for the whole host lifetime (see [`crate::audio::MicPump`]): the
// client's mic uplink (0xCB) is Opus-decoded and fed into a persistent virtual mic host apps
// record from (Linux PipeWire Audio/Source; Windows a virtual audio device's render endpoint).
// The pump opens the backend EAGERLY (the mic device exists before any game launches and
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
// endpoint churn).
let mic_service = crate::audio::MicPump::start();
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
// restores the box's autologin gaming session on idle, not per-disconnect — see
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
let _restore_worker = crate::vdisplay::start_restore_worker();
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
crate::vdisplay::restore_takeover_on_startup();
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
// blocks on the network. A no-op on a host whose stores all carry their own art.
let _art_warmer = crate::library::start_art_warmer();
// Pairing state (arming PIN + trust store) is shared with the management API. If it was armed
// at startup (the CLI flags), surface the PIN the headless operator reads from the log; the
// web console arms it on demand instead (a fresh, time-limited PIN).
let st = np.status();
if let Some(pin) = &st.pin {
tracing::info!(
paired = st.paired_clients,
require = opts.require_pairing,
"pairing armed — enter the PIN shown on the console to pair a client"
);
// The PIN is a shared secret: print it straight to the operator's terminal, NOT through
// tracing. A tracing event also lands in the DEBUG log ring that field bug reports ship
// (GET /api/v1/logs), which must never carry the pairing secret.
eprintln!("[punktfunk] pairing PIN: {pin} (enter this on the client to pair)");
}
let last_pairing = Arc::new(std::sync::Mutex::new(None::<std::time::Instant>));
let opts = Arc::new(opts);
// Concurrency: serve up to `max_concurrent` sessions at once. Each gets its own virtual output +
// NVENC encoder; they share the host-lifetime input/audio/mic services — i.e. multiple devices
// viewing (and controlling) the SAME desktop on the shared-desktop backends. A permit is taken
// before accepting, so overflow clients wait in QUIC's accept backlog until a slot frees;
// `max_concurrent == 0` means unlimited (GPU-bounded). The heavy handshake + pipeline run inside
// the spawned task, so a slow client never blocks the accept loop.
let permits = match opts.max_concurrent {
0 => tokio::sync::Semaphore::MAX_PERMITS,
n => n,
};
let sem = Arc::new(tokio::sync::Semaphore::new(permits));
let mut sessions = tokio::task::JoinSet::new();
let max_sessions = opts.max_sessions;
let mut accepted = 0u32;
tracing::info!(
max_concurrent = opts.max_concurrent,
"accepting sessions (concurrent)"
);
loop {
let permit = sem
.clone()
.acquire_owned()
.await
.expect("session semaphore is never closed");
let incoming = match ep.accept().await {
Some(i) => i,
None => break, // endpoint closed
};
// Complete the QUIC handshake in the accept loop (it's ~1 RTT): a failed handshake (e.g. a
// pin mismatch — the client aborts) must NOT consume a session slot, mirroring the old
// serial loop. The slow part (control handshake, pairing, the capture/encode pipeline) runs
// in the spawned task, so a slow client still never blocks accepting the next one.
let conn = match incoming.await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "QUIC accept failed");
continue; // `permit` drops here → slot freed; not counted toward max_sessions
}
};
let peer = conn.remote_address();
tracing::info!(%peer, "punktfunk/1 client connected");
let opts = opts.clone();
let audio_cap = audio_cap.clone();
let np = np.clone();
let last_pairing = last_pairing.clone();
let stats = stats.clone();
let inj_tx = injector.sender();
let mic_tx = mic_service.sender();
// The session permit + the pool it came from are handed to serve_session, which owns the
// permit's lifetime: it's released while a knock is parked for delegated approval and
// re-acquired on approval, so the hold is no longer a simple closure-scoped binding.
let sem_session = sem.clone();
sessions.spawn(async move {
match serve_session(
conn,
&opts,
&audio_cap,
inj_tx,
mic_tx,
&fingerprint,
&np,
&last_pairing,
stats,
permit,
sem_session,
)
.await
{
Ok(()) => tracing::info!(%peer, "session complete"),
Err(e) => {
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error")
}
}
});
accepted += 1;
if max_sessions != 0 && accepted >= max_sessions {
break;
}
}
// Stop accepting; let the in-flight sessions finish (max_sessions reached or endpoint closed).
while sessions.join_next().await.is_some() {}
ep.wait_idle().await;
Ok(())
}
/// The accept loop is sequential, so the control phase must be bounded — a client that
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
/// code lets a client tell "host busy" apart from a transport failure. Shared with the clients via
/// `punktfunk_core::reject` so they can decode it (`RejectReason::Busy`).
const REJECT_BUSY_CODE: u32 = punktfunk_core::reject::REJECT_BUSY_CLOSE_CODE;
/// Make a gate rejection legible to the client BEFORE erroring out of the session task: close
/// with the typed application code (`punktfunk_core::reject`) so the client renders the real
/// reason ("pairing not armed", "denied in the console") — the task's `Err` then only logs.
/// Without this the dropped connection closes with a bare code 0, indistinguishable on the
/// client from transport trouble (the "not accepted" support-thread failure mode).
fn close_rejected(conn: &quinn::Connection, reason: punktfunk_core::reject::RejectReason) {
conn.close(reason.close_code().into(), reason.to_string().as_bytes());
}
/// QUIC application error code a client closes with on a **deliberate quit** (a user "stop", not a
/// network drop). The host reads it off the connection's `ApplicationClosed` reason and tears the
/// session's virtual display down IMMEDIATELY, skipping the keep-alive linger — an unwanted disconnect
/// (idle timeout / reset / any other code) still lingers so a reconnect can resume. Shared with the
/// clients via `punktfunk_core::quic::QUIT_CLOSE_CODE`.
const QUIT_CODE: u32 = punktfunk_core::quic::QUIT_CLOSE_CODE;
/// Encoder bitrate (kbps) the host falls back to when the client expresses no preference
/// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its
/// link (e.g. after a speed test) requests an explicit rate instead.
const DEFAULT_BITRATE_KBPS: u32 = 20_000;
/// Bounds a client's requested bitrate before configuring NVENC: a 500 kbps floor keeps the stream
/// 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`. 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;
// 8 Gbps ceiling — headroom for a 2.5 Gbps link and the 5 Gbps path (home-worker-3 → Mac Studio,
// Mac is 10G). The encoder is pixel-rate bound, not bitrate bound (NVENC emits multi-Gbps trivially;
// ~1 Gpix/s per engine, ~2 with the auto 2-way split), so the real ceiling is the transport send
// path (UDP GSO + per-packet alloc removal), not this number.
const MAX_BITRATE_KBPS: u32 = 8_000_000;
/// Resolve a client's [`Hello::bitrate_kbps`] request to the rate the host will configure:
/// `0` → host default; anything else clamped into `[MIN, MAX]`.
fn resolve_bitrate_kbps(requested: u32) -> u32 {
if requested == 0 {
DEFAULT_BITRATE_KBPS
} else {
requested.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS)
}
}
/// [`resolve_bitrate_kbps`] with the codec's floor semantics: PyroWave has no useful
/// low-rate regime (wavelet quality collapses far above the H.26x floor — plan §4.6), so
/// an Automatic client (`0`) gets the codec's ~1.6 bpp operating point for the negotiated
/// mode instead of the 20 Mbps H.26x default. The rate is then PINNED for the session:
/// the client's ABR controller stays off for this codec and the host refuses mid-stream
/// retargets. An explicit client rate is honored unchanged (the operator knows the link).
fn resolve_bitrate_kbps_for(
codec: crate::encode::Codec,
requested: u32,
mode: &punktfunk_core::config::Mode,
) -> u32 {
if requested == 0 && codec == crate::encode::Codec::PyroWave {
let bps =
mode.width as u64 * mode.height as u64 * u64::from(mode.refresh_hz.max(1)) * 16 / 10;
return u32::try_from(bps / 1000)
.unwrap_or(MAX_BITRATE_KBPS)
.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS);
}
resolve_bitrate_kbps(requested)
}
/// Resolve the audio channel count the session will capture + encode from the client's request.
/// Normalizes to one of 2 (stereo) / 6 (5.1) / 8 (7.1); anything else (older client, garbage)
/// becomes stereo. Both backends can produce the requested count (PipeWire pads/upmixes positions,
/// WASAPI loopback up/downmixes via AUTOCONVERTPCM), so no capability clamp is needed here — the
/// surround channels just carry up/downmixed content when the host's sink has fewer real channels.
fn resolve_audio_channels(requested: u8) -> u8 {
punktfunk_core::audio::normalize_channels(requested)
}
/// Static FEC override: `PUNKTFUNK_FEC_PCT`, when set, PINS the recovery percent and DISABLES
/// adaptive FEC — so a speed test / measurement keeps a fixed, known overhead. `None` ⇒ adaptive
/// FEC (the host sizes recovery to the loss the client reports). `0` disables FEC entirely.
/// Clamped to ≤ 90.
fn fec_static_override() -> Option<u8> {
std::env::var("PUNKTFUNK_FEC_PCT")
.ok()
.and_then(|s| s.trim().parse::<u8>().ok())
.map(|p| p.min(90))
}
/// Adaptive-FEC band + starting point. Every recovery shard is extra wire bytes AND an extra
/// packet, so on a clean link FEC decays toward [`FEC_MIN`] (fewer packets — the win for a
/// packet-rate-bound uplink like the Steam Deck's WiFi tx); loss ramps it toward [`FEC_MAX`].
/// Sessions start moderate so the first frames (before any loss report) are protected.
const FEC_MIN: u8 = 1;
const FEC_MAX: u8 = 50;
const FEC_ADAPTIVE_START: u8 = 10;
/// Map the client's reported data-plane loss (ppm of shards, see [`LossReport`]) to a recovery
/// percentage. FEC must EXCEED the loss rate to recover a block, so target ≈ loss × 1.4 + 1 pt of
/// margin, clamped to the band. A clean link (≈0 ppm) lands on [`FEC_MIN`].
fn adapt_fec(loss_ppm: u32) -> u8 {
let loss_pct = loss_ppm as f64 / 10_000.0; // ppm → percent
let target = (loss_pct * 1.4).ceil() as u32 + 1;
target.clamp(FEC_MIN as u32, FEC_MAX as u32) as u8
}
/// Apply the latest adaptive-FEC target to the session if it changed (cheap relaxed load + compare),
/// called once per frame on the data-plane send path.
fn apply_fec_target(session: &mut Session, fec_target: &AtomicU8) {
let t = fec_target.load(Ordering::Relaxed);
if session.fec_percent() != t {
session.set_fec_percent(t);
}
}
/// Persistent audio-capturer slot, reused across sessions (same pattern as the GameStream
/// path): keeps one warm PipeWire capture stream instead of a connect/negotiate cycle —
/// and a daemon-side node churn — per session. (Drop now tears a capturer down cleanly.)
type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>;
/// How long the host keeps an unpaired knock PARKED — connection held open — waiting for the
/// operator to click Approve in the console (delegated approval, roadmap §8b-1). The QUIC
/// keep-alive (4 s, under the 8 s idle timeout) holds the path warm meanwhile, so on approval the
/// device pairs and streams with NO reconnect. Bounded well under the pending entry's TTL (10 min);
/// the client uses a comparable connect timeout, and a client that gives up first closes the
/// connection (the host stops waiting at once).
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
/// One client session: handshake → input/audio planes → data plane until done/disconnect.
/// Everything torn down on return (RAII: virtual output, encoder, threads via channel close).
/// A connection whose first message is a PairRequest runs the pairing ceremony instead.
// Each argument is a distinct host-lifetime handle threaded from `serve` (config, the audio +
// injector services, the trust store, pairing state) — bundling them into a context struct would
// obscure more than it'd save.
#[allow(clippy::too_many_arguments)]
async fn serve_session(
conn: quinn::Connection,
opts: &Punktfunk1Options,
audio_cap: &AudioCapSlot,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
mic_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
host_fp: &[u8; 32],
np: &NativePairing,
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
stats: Arc<StatsRecorder>,
// The session slot. Owned here (not just held by the spawning task) because an unpaired knock
// RELEASES it while parked for delegated approval, then RE-ACQUIRES one on approval — so a
// parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from.
mut permit: tokio::sync::OwnedSemaphorePermit,
sem: Arc<tokio::sync::Semaphore>,
) -> Result<()> {
let peer = conn.remote_address();
// First message decides what this connection is: a pairing ceremony or a session.
let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi())
.await
.map_err(|_| anyhow!("control stream timeout"))?
.context("accept control stream")?;
let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv))
.await
.map_err(|_| anyhow!("first message timeout"))??;
if let Ok(req) = PairRequest::decode(&first) {
// The client fingerprint (cert possession is proven by the QUIC handshake) is needed to honor
// a fingerprint-bound PIN window (#9): a window the operator armed for a SPECIFIC device must
// not be consumable — or burnable — by any other fingerprint.
let Some(client_fp) = endpoint::peer_fingerprint(&conn) else {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::IdentityRequired,
);
anyhow::bail!("pairing requires the client to present a certificate");
};
let client_fp_hex = fingerprint_hex(&client_fp);
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) {
crate::native_pairing::PinAttempt::Pin(pin) => pin,
crate::native_pairing::PinAttempt::Disarmed => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::PairingNotArmed);
anyhow::bail!(
"pairing not armed (arm it in the console, or start with --allow-pairing)"
)
}
// Armed for a DIFFERENT device — reject without running the ceremony, so this attempt does
// NOT consume (burn) the operator's window for the device they actually selected (#9).
crate::native_pairing::PinAttempt::BoundToOther => {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingBoundToOtherDevice,
);
anyhow::bail!(
"pairing is armed for a different device — this attempt does not consume the window"
)
}
};
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await;
}
// Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the
// `handshake` future below for two reasons: (1) the approval wait must not be bound by the
// short HANDSHAKE_TIMEOUT — a human reads the console and clicks Approve; (2) the NVENC session
// permit is released while parked, so a knock awaiting approval can't hold a streaming slot.
// On approval the device is now paired, so the handshake proceeds and the session starts with
// NO client reconnect (delegated approval, roadmap §8b-1).
if opts.require_pairing {
// Decode just enough to gate (the Hello carries the device name for the pending label);
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
if gate_hello.abi_version != punktfunk_core::WIRE_VERSION {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::WireVersionMismatch,
);
anyhow::bail!(
"wire version mismatch: client {} host {}",
gate_hello.abi_version,
punktfunk_core::WIRE_VERSION
);
}
let fp = endpoint::peer_fingerprint(&conn);
let known = fp
.as_ref()
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
.unwrap_or(false);
if !known {
// An anonymous client (no certificate) has no identity to approve — reject outright
// (the PIN ceremony is its way in). Mirrors the prior behavior for anonymous knocks.
let Some(fp) = fp else {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::IdentityRequired,
);
anyhow::bail!(
"unpaired anonymous client rejected (this host requires pairing — present a \
client identity and approve it in the console, or run the PIN ceremony)"
);
};
let fp_hex = fingerprint_hex(&fp);
// Sanitize the wire-supplied name before it reaches the log / console (untrusted: an
// unpaired device could embed terminal escapes / bidi overrides); note_pending stores
// the same sanitized form and derives a fingerprint label when empty.
let label = crate::native_pairing::sanitize_device_name(
gate_hello.name.as_deref().unwrap_or(""),
&fp_hex,
);
tracing::info!(name = %label, fingerprint = %fp_hex,
"unpaired device knocked — parking connection for delegated approval in the console");
// Record the QUIC-validated source IP so the pending queue's per-source cap can stop one
// host from flooding/evicting genuine knocks (#13). The returned knock generation makes
// this connection the ONE an approval admits — a retrying client parks a fresh
// connection per knock, and admitting every parked sibling on a single Approve spun up
// three concurrent Mutter virtual monitors and segfaulted gnome-shell (2026-07-10).
let knock_seq = np.note_pending(&label, &fp_hex, Some(peer.ip()));
// Free the session slot while a human decides — a parked knock must not hold an NVENC
// permit (a handful of parked knocks would otherwise block every real session).
drop(permit);
let decision = tokio::select! {
d = np.wait_for_decision(&fp_hex, knock_seq, PENDING_APPROVAL_WAIT) => d,
// The client gave up (closed the connection) before a decision — stop waiting.
_ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"),
};
match decision {
PairingDecision::Approved => {
tracing::info!(name = %label, fingerprint = %fp_hex,
"device approved in console — admitting session (no reconnect)");
}
PairingDecision::Denied => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::Denied);
anyhow::bail!("pairing request denied in the console")
}
PairingDecision::TimedOut => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::ApprovalTimeout);
anyhow::bail!(
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
— the device can knock again"
)
}
PairingDecision::Superseded => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::Superseded);
anyhow::bail!(
"parked knock superseded by a newer connection from the same device — \
only the newest is admitted on approval"
)
}
}
// Re-acquire a session slot for the now-approved session (waits if all slots are busy,
// exactly like any freshly accepted client).
permit = sem
.clone()
.acquire_owned()
.await
.expect("session semaphore is never closed");
}
}
// Held for the rest of the session (RAII frees the slot on return). For an already-paired
// client this is the original permit; for a just-approved knock it's the re-acquired one.
let _permit = permit;
let source = opts.source;
let frames = opts.frames;
let data_port = opts.data_port;
// Session-transition trace (latency plan P0.1): zeroed here — the Hello is in hand, pairing
// gates are behind us — and finished by the send thread when the FIRST video packet leaves.
// The completed totals surface per session in `session_status` (→ mgmt `/status`).
let bringup = crate::bringup::Trace::start("bringup", Arc::new(AtomicU32::new(0)));
// The mid-stream resize counterpart: each accepted Reconfigure runs its own trace into this
// shared slot (latest wins), registered alongside the bring-up total.
let resize_ms: Arc<AtomicU32> = Arc::new(AtomicU32::new(0));
// Stop signal: stream duration elapsed or the client went away. Created (with its watcher)
// BEFORE the handshake so the Welcome-time display prep can already observe a client that
// vanished mid-handshake (its build-retry loop aborts on `stop`).
let stop = Arc::new(AtomicBool::new(false));
// Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when
// the client closed the connection with `QUIT_CODE` — a user "stop", which skips the
// keep-alive linger. A bare disconnect / idle timeout leaves it false → the display lingers
// for a reconnect.
let quit = Arc::new(AtomicBool::new(false));
{
let stop = stop.clone();
let quit = quit.clone();
let conn = conn.clone();
tokio::spawn(async move {
let reason = conn.closed().await;
if matches!(&reason, quinn::ConnectionError::ApplicationClosed(ac)
if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE))
{
quit.store(true, Ordering::SeqCst);
}
stop.store(true, Ordering::SeqCst);
});
}
let (hello, welcome, udp_port, data_sock, direct, start, compositor, prep) =
tokio::time::timeout(
HANDSHAKE_TIMEOUT,
handshake::negotiate(
&conn,
&mut send,
&mut recv,
&first,
source,
frames,
data_port,
&bringup,
quit.clone(),
stop.clone(),
),
)
.await
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
let (ctrl_send, ctrl_recv) = (send, recv);
// Can this session's backend live-reconfigure (mid-stream Reconfigure)? Gated OFF for:
// * gamescope (all sub-modes): a spawn respawn restarts the game, managed restarts the box's
// game-mode session, attach doesn't own the display — a resize must never relaunch the title
// (design/midstream-resolution-resize.md H1/D3). The client keeps scaling client-side.
// * an `identity: per-client-mode` policy: the mode is part of the display-identity slot key,
// so a resize would resolve a DIFFERENT slot — on Windows a fresh monitor ADD instead of the
// in-place reconfigure, on KWin a differently-named output — defeating the policy's
// per-resolution identity. Honest downgrade: reject, client scales (H5).
// The SYNTHETIC source stays reconfigurable on purpose (nothing to rebuild — the ack round-trip
// is the whole effect): it is the compositor-free protocol test source, and the C-ABI roundtrip
// test + client harnesses exercise the Reconfigure/Reconfigured plumbing through it.
// Captured once at session setup; the control task answers `accepted: false` when gated.
let live_reconfig_ok = {
let per_client_mode_identity = crate::vdisplay::policy::prefs()
.configured_effective()
.is_some_and(|e| e.identity == crate::vdisplay::policy::Identity::PerClientMode);
reconfig_allowed(compositor, per_client_mode_identity)
};
// Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's
// `async move` captures a copy and it stays usable for the data-plane SessionContext below.
let codec = crate::encode::Codec::from_wire(welcome.codec);
let client_udp = std::net::SocketAddr::new(peer.ip(), start.client_udp_port);
tracing::info!(
%client_udp,
udp_port,
mode = ?hello.mode,
compositor = compositor.map(|c| c.id()).unwrap_or("none"),
gamepad = welcome.gamepad.as_str(),
"handshake complete — streaming"
);
// Control task: the handshake stream stays open for mid-stream renegotiation and speed
// tests. A validated Reconfigure is acked, then handed to the data-plane thread, which
// rebuilds capture/encoder/virtual output at the new mode (the data plane itself is
// untouched). A ProbeRequest is handed to the data plane, which bursts FLAG_PROBE filler and
// hands back a ProbeResult that this task writes to the client. The two control directions
// (inbound requests, outbound probe results) are multiplexed with `select!`.
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
let (keyframe_tx, keyframe_rx) = std::sync::mpsc::channel::<()>();
// Client LTR-RFI recovery: the control task forwards each `RfiRequest`'s lost-frame range here;
// the encode loop prefers `Encoder::invalidate_ref_frames` (a clean re-anchor P-frame) over a
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>();
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
// ack is written BEFORE the rebuild, so a failed rebuild (host stays at the old mode) or a
// backend that honored a different refresh must CORRECT the client's mode slot with a second
// `Reconfigured { accepted: true, mode: <actually live> }` — the client handler treats any
// accepted ack as "the active mode is now X" and fixes itself; old clients just log it.
let (reconfig_result_tx, reconfig_result_rx) =
tokio::sync::mpsc::unbounded_channel::<Reconfigured>();
// Adaptive FEC: the control task maps each client LossReport to a recovery percent and publishes
// it here; the data-plane send loop reads + applies it per frame. Disabled (pinned) when
// PUNKTFUNK_FEC_PCT is set. Seeded with the session's starting FEC so it's a no-op until a report.
let adaptive_fec = fec_static_override().is_none();
let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent));
let fec_target_ctl = fec_target.clone();
// The session's negotiated rate — the pin PyroWave retarget-refusals ack (§4.6).
let session_bitrate_kbps = welcome.bitrate_kbps;
// Shared-clipboard enable state (client `ClipControl` → host). The coordinator reads it to
// decide whether to forward host copies; the control task flips it on each `ClipControl`.
let clip_enabled = Arc::new(AtomicBool::new(false));
// Start the host clipboard coordinator. On success it watches the session clipboard, forwards
// host copies as `ClipOffer`s (`clip.offer_rx` → control task → client), installs client
// offers as a lazy source, and owns the fetch-stream accept loop. `available` is false when
// there's no backend (gamescope / older GNOME / an unsupported platform) — the control task
// then answers `ClipControl` with `BACKEND_UNAVAILABLE` and the decline loop below handles
// stray fetch streams.
let clip = pf_clipboard::start(conn.clone(), clip_enabled.clone(), compositor.is_some()).await;
let clip_available = clip.available;
tokio::spawn(control::run(
ctrl_send,
ctrl_recv,
hello.mode,
codec,
live_reconfig_ok,
adaptive_fec,
session_bitrate_kbps,
fec_target_ctl,
reconfig_tx,
keyframe_tx,
rfi_tx,
bitrate_tx,
probe_tx,
probe_result_rx,
reconfig_result_rx,
clip_enabled,
clip,
));
// Fetch streams with no backend behind them are answered `CLIP_FETCH_UNAVAILABLE` instead of
// hanging (the coordinator owns `accept_bi` when a backend is live — exactly one consumer).
if !clip_available && pf_clipboard::enabled() {
pf_clipboard::spawn_decline_loop(conn.clone());
}
// Input plane: QUIC datagrams → channel → a native per-session thread. Pointer/keyboard
// events are forwarded to the host-lifetime [`InjectorService`] (`inj_tx`) so the portal
// grant persists across sessions; this thread owns the session's virtual gamepads (uinput,
// per-session) and sends force feedback back over `conn`. It exits when the channel closes
// (datagram task ends on disconnect) — fresh gamepad state per session.
//
// ONE channel for both event kinds deliberately: rich input (gyro at the pad's report
// rate) used to ride a second channel that the thread only drained after the main
// channel's 4 ms recv timeout — every motion sample of a pure-gyro aim (no button
// traffic) ate up to 4 ms of added latency/jitter. A single channel wakes the thread on
// whichever arrives.
let (input_tx, input_rx) = std::sync::mpsc::channel::<ClientInput>();
let rich_tx = input_tx.clone();
let input_handle = {
let conn = conn.clone();
let gamepad = welcome.gamepad;
std::thread::Builder::new()
.name("punktfunk1-input".into())
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
.context("spawn input thread")?
};
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
// would race for datagrams): 0xCB → mic uplink (Opus, forwarded to the host-lifetime mic
// service), 0xCC → rich input (DualSense touchpad / motion, to the per-session input thread),
// 0xC8 → input (also the input thread). The magics are disjoint, so decode order doesn't
// matter. Unknown tags are ignored.
let input_conn = conn.clone();
tokio::spawn(async move {
let (mut input_count, mut mic_count, mut rich_count) = (0u64, 0u64, 0u64);
while let Ok(d) = input_conn.read_datagram().await {
if let Some((_seq, _pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
mic_count += 1;
// Host-lifetime mic service (bounded queue): `try_send` drops the frame when the
// service is full or gone, never blocking this datagram loop (security-review S6).
let _ = mic_tx.try_send(opus.to_vec());
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
rich_count += 1;
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
break;
}
} else if let Some(mut ev) = InputEvent::decode(&d) {
input_count += 1;
// Wire hygiene: KEY_FLAG_SEMANTIC_VK is an in-process tag (GameStream ingest
// only) — strip it from network events so a client can't flip the host's
// key-decoding convention. Other kinds keep flags verbatim (MouseMoveAbs packs
// its reference extent there).
if matches!(
ev.kind,
punktfunk_core::input::InputKind::KeyDown
| punktfunk_core::input::InputKind::KeyUp
) {
ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK;
}
if input_tx.send(ClientInput::Event(ev)).is_err() {
break;
}
}
}
tracing::info!(
input = input_count,
mic = mic_count,
rich = rich_count,
"client datagram stream ended"
);
});
// (The stop/quit flags + their disconnect watcher are created above, before the handshake, so
// the Welcome-time display prep can observe a mid-handshake disconnect.)
// Lifecycle events (RFC §4): this point — handshake complete, pairing/admission passed — is
// where the client counts as CONNECTED; the close watcher below pairs it with the
// disconnect + its decoded reason. A client rejected earlier never emits either.
let event_client = crate::events::ClientRef {
name: hello.name.clone().unwrap_or_default(),
fingerprint: endpoint::peer_fingerprint(&conn).map(|fp| fingerprint_hex(&fp)),
plane: crate::events::Plane::Native,
};
crate::events::emit(crate::events::EventKind::ClientConnected {
client: event_client.clone(),
});
{
let conn = conn.clone();
tokio::spawn(async move {
let reason = conn.closed().await;
let why = match &reason {
quinn::ConnectionError::ApplicationClosed(ac)
if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE) =>
{
crate::events::DisconnectReason::Quit
}
quinn::ConnectionError::TimedOut => crate::events::DisconnectReason::Timeout,
_ => crate::events::DisconnectReason::Error,
};
crate::events::emit(crate::events::EventKind::ClientDisconnected {
client: event_client,
reason: why,
});
});
}
// Register this now-live session for mode-conflict admission (Stage 4): carry its identity, the
// negotiated mode, and its stop flag so a LATER connecting client's admission can see it and
// (under `steal`) signal it. The guard removes the entry when this session ends.
let _live_guard = {
let id = endpoint::peer_fingerprint(&conn);
let label = id
.map(|fp| {
fp.iter()
.take(4)
.map(|b| format!("{b:02x}"))
.collect::<String>()
})
.unwrap_or_else(|| "client".to_string());
crate::vdisplay::admission::register(
id,
(
welcome.mode.width,
welcome.mode.height,
welcome.mode.refresh_hz,
),
stop.clone(),
label,
)
};
// Audio plane (virtual source only — synthetic runs are protocol tests): desktop Opus
// → host→client QUIC datagrams, on its own native thread. Best-effort on every failure
// (no PipeWire audio, spawn error): the session continues without audio — and a spawn
// error must NOT early-return here, the threads above are already running.
let audio_handle = if opts.source == Punktfunk1Source::Virtual {
let conn = conn.clone();
let stop = stop.clone();
let cap = audio_cap.clone();
let channels = welcome.audio_channels;
std::thread::Builder::new()
.name("punktfunk1-audio".into())
.spawn(move || audio_thread(conn, stop, cap, channels))
.map_err(|e| tracing::warn!(error = %e, "audio thread spawn failed — session continues without audio"))
.ok()
} else {
None
};
// HDR static metadata (ST.2086 mastering + CEA-861.3 content light level), host → client, sent
// once at session start when an HDR session was negotiated, as a generic HDR10 baseline. The
// virtual-source stream loop then sends the source display's REAL mastering metadata (Windows
// GetDesc1) as soon as capture starts and re-sends it on keyframes; the client applies the
// latest it receives. This baseline covers the synthetic source and the pre-capture gap.
if welcome.color.is_hdr() {
// Prefer the CLIENT's own display volume (Hello::display_hdr): the virtual display's EDID
// now advertises it, so host apps tone-map to exactly that volume — echoing it here keeps
// the mastering metadata honest end-to-end. Generic HDR10 only for older clients.
let meta = hello
.display_hdr
.unwrap_or_else(pf_frame::hdr::generic_hdr10);
let _ = conn.send_datagram(punktfunk_core::quic::encode_hdr_meta_datagram(&meta).into());
tracing::info!(
client_volume = hello.display_hdr.is_some(),
"sent HDR10 static metadata (0xCE baseline)"
);
}
// Test hook (synthetic source only): a scripted feedback burst on the host→client
// planes — rumble (0xCA) + DualSense HID-output (0xCD) — so loopback tests can assert
// the client's feedback path without a real game writing output reports to a real pad.
if opts.source == Punktfunk1Source::Synthetic
&& std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
{
use punktfunk_core::quic::HidOutput;
// v2 envelope (seq 0, 400 ms TTL) so the loopback/probe assertion covers the self-
// terminating tail, not just the level.
let d = punktfunk_core::quic::encode_rumble_datagram_v2(0, 0x4000, 0x8000, 0, 400);
let _ = conn.send_datagram(d.to_vec().into());
for h in [
HidOutput::Led {
pad: 0,
r: 10,
g: 20,
b: 30,
},
HidOutput::PlayerLeds {
pad: 0,
bits: 0b00100,
},
HidOutput::Trigger {
pad: 0,
which: 1,
effect: vec![0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
},
] {
let _ = conn.send_datagram(h.encode().into());
}
tracing::info!("PUNKTFUNK_TEST_FEEDBACK: scripted rumble + hidout burst sent");
}
// Data plane on a native thread (no async on the hot path — design invariant).
let cfg = welcome.session_config(Role::Host);
let source = opts.source;
let (seconds, frames) = (opts.seconds, opts.frames);
let mode = hello.mode;
// Script-facing runtime marker: `$XDG_RUNTIME_DIR/punktfunk/stream` exists (with this session's
// negotiated mode) for exactly as long as this session streams. Held by RAII to session end, so
// every exit path — clean disconnect, error, panic-unwind — retracts it. Lets a launch wrapper
// branch "streaming → run the game as-is; not → my local multi-head gamescope" (see the module).
let _stream_marker = crate::stream_marker::announce(crate::stream_marker::StreamInfo {
width: mode.width,
height: mode.height,
refresh_hz: mode.refresh_hz,
hdr: welcome.color.is_hdr(),
client: hello.name.clone().unwrap_or_default(),
launch: hello.launch.clone(),
});
// The session's launch, threaded into the data plane. Windows carries the store-qualified id
// (spawned into the interactive user session once capture is live); other hosts resolve the id
// to its shell command HERE against the host's own library — a client can only ever pick an
// existing title, never send a command — and the data plane runs it per-backend (nested into a
// bare-spawn gamescope, or spawned into the live session once capture is up).
#[cfg(target_os = "windows")]
let launch_for_dp = hello.launch.clone();
#[cfg(not(target_os = "windows"))]
let launch_for_dp = hello.launch.as_deref().and_then(|id| {
match crate::library::launch_command(id) {
Some(cmd) => {
tracing::info!(launch_id = id, command = %cmd, "resolved library launch for this session");
Some(cmd)
}
None => {
tracing::warn!(
launch_id = id,
"client requested a launch id not in this host's library — ignoring"
);
None
}
}
});
// Per-title prep steps (RFC §6) for a launched CUSTOM library title: run synchronously
// before the data plane starts (so before the display opens and the title spawns); the
// guard's drop — any serve_session exit — runs the undos in reverse, best-effort.
// `block_in_place`: prep is blocking operator code and this is a multi-thread runtime;
// the closure only runs when the title actually has prep steps.
let _prep = hello.launch.as_deref().and_then(|id| {
let cmds = crate::library::prep_for(id);
let env = [("PF_APP_ID".to_string(), id.to_string())];
(!cmds.is_empty())
.then(|| tokio::task::block_in_place(|| crate::hooks::run_prep(&cmds, &env)))
});
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
// "Automatic" request: the resolved rate is a host default — for PyroWave a per-mode
// bpp pin the data plane re-resolves on a mid-stream mode switch.
let bitrate_auto = hello.bitrate_kbps == 0;
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
// session uses exactly what the client was told). `Yuv444` only when the handshake gate passed.
let chroma = if welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444 {
crate::encode::ChromaFormat::Yuv444
} else {
crate::encode::ChromaFormat::Yuv420
};
let stop_stream = stop.clone();
let quit_stream = quit.clone();
// The client display's HDR volume (Hello): the virtual display's EDID advertises it (host apps
// tone-map to the client's real panel) and the 0xCE mastering metadata echoes it. `None` =
// older client / no HDR display → the built-in defaults everywhere.
let client_hdr = hello.display_hdr;
let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target
let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream
// Per-AU host-timing emission (0xCF): only when the client advertised the cap bit. All
// first-party clients do (the core connector ORs it in); an older client leaves it clear
// and gets no extra datagrams.
let timing_conn =
(hello.video_caps & punktfunk_core::quic::VIDEO_CAP_HOST_TIMING != 0).then(|| conn.clone());
// Probe-sequence capability: the client reassembles speed-test filler in its own index window,
// so mid-session bursts don't consume video frame indexes. An older client (bit clear) gets
// mid-session probes declined instead — see `run_probe_burst`.
let probe_seq = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ != 0;
let stats_dp = stats; // data-plane handle to the shared stats recorder
// Short label for web-console stats captures: the client's cert-fingerprint prefix, else its
// peer IP (no fingerprint = anonymous TOFU/--open client).
let client_label = endpoint::peer_fingerprint(&conn)
.map(|fp| fingerprint_hex(&fp)[..12].to_string())
.unwrap_or_else(|| conn.remote_address().ip().to_string());
// Transition-trace handles for the data plane (P0.1): the punch stamp + the virtual-stream
// stages ride the same per-session trace; resizes write their totals into the shared slot.
let bringup_dp = bringup.clone();
let resize_ms_dp = resize_ms.clone();
let result: Result<()> = async {
tokio::task::spawn_blocking(move || -> Result<()> {
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
// for the client's punch, then stream to its OBSERVED source, so video traverses a
// NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
// QUIC, but the raw video UDP needs the client to open the path first); falls back to
// the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed
// `--data-port` (`direct`), skip the punch-wait and stream straight to the reported
// address — the operator declared a reachable, firewall-opened port, so there's no
// punch-timeout to pay. (Direct trusts the reported port: it can't cross a client-side
// NAT that remaps it.)
let bound = if direct {
UdpTransport::from_socket(data_sock, &client_udp.to_string()).map(|t| (t, false))
} else {
UdpTransport::from_socket_punch(
data_sock,
&client_udp.to_string(),
std::time::Duration::from_millis(2500),
)
};
let (transport, punched) = match bound {
Ok(v) => v,
Err(e) => {
// Surface the failure here directly: a data-plane bind error would otherwise be
// reported only after teardown (and a teardown stall could swallow it entirely).
tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket setup failed");
return Err(anyhow::Error::new(e)).context("bind data plane");
}
};
bringup_dp.mark("punch_done");
tracing::info!(
%client_udp,
udp_port,
direct,
punched,
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
address with no hole-punch; else punched=true → the client's observed source, \
false → no punch seen, the reported address)"
);
let mut session = Session::new(cfg, Box::new(transport))
.map_err(|e| anyhow!("host session: {e:?}"))?;
match source {
Punktfunk1Source::Synthetic => synthetic_stream(
&mut session,
frames,
&stop_stream,
&probe_rx,
&probe_result_tx,
&fec_target_dp,
timing_conn.as_ref(),
probe_seq,
),
Punktfunk1Source::Virtual => {
let compositor = compositor
.expect("the Virtual source resolves a compositor during the handshake");
let ctx = SessionContext {
session,
mode,
seconds,
stop: stop_stream,
quit: quit_stream,
reconfig: reconfig_rx,
keyframe: keyframe_rx,
rfi: rfi_rx,
bitrate_rx,
compositor,
bitrate_kbps,
bitrate_auto,
bit_depth,
chroma,
codec,
probe_rx,
probe_result_tx,
reconfig_result_tx,
fec_target: fec_target_dp,
conn: conn_stream,
timing_conn,
probe_seq,
stats: stats_dp,
client_label,
launch: launch_for_dp,
client_hdr,
bringup: bringup_dp,
resize_ms: resize_ms_dp,
};
match prep {
// P1.1: the display prep started at Welcome on its own thread — hand it
// the post-punch context and adopt its result as the stream result (that
// thread runs `virtual_stream` on the pipeline it already built).
Some((ctx_tx, prep_thread)) => match ctx_tx.send(ctx) {
Ok(()) => match prep_thread.join() {
Ok(r) => r,
Err(_) => Err(anyhow!("prepared stream thread panicked")),
},
// The prep thread died before the hand-off (panicked during prep —
// its guard/lease unwound): run the stream inline instead.
Err(std::sync::mpsc::SendError(ctx)) => {
tracing::warn!(
"display-prep thread gone before hand-off — building inline"
);
virtual_stream(ctx, None)
}
},
None => virtual_stream(ctx, None),
}
}
}
})
.await
.context("stream thread")??;
// Give the client a moment to drain before the close.
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(())
}
.await;
// Teardown on EVERY path (a failed data plane must not leave the connection open with
// audio still streaming): stop the audio thread, close, then join both side-plane
// threads so the next session starts fresh (closing the connection ends the datagram
// task, which drops the input channel, which exits the input thread + its gamepads).
stop.store(true, Ordering::SeqCst);
conn.close(
if result.is_ok() { 0u32 } else { 1u32 }.into(),
if result.is_ok() { b"done" } else { b"error" },
);
let _ = tokio::task::spawn_blocking(move || {
if let Some(h) = audio_handle {
let _ = h.join();
}
let _ = input_handle.join();
})
.await;
// The capture (and our gamescope session's VirtualOutput) are gone by here. If this was the
// host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the
// TV's gaming session back so it's the default when no one is streaming.
crate::vdisplay::restore_managed_session();
result
}
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
/// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The
/// virtual mic has its own tuning — see [`crate::audio::MicPump`].)
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
/// Pack a `(width, height, refresh_hz)` mode into one atomic word (w:16|h:16|hz:16) for the live
/// stats-mode slot — one store/load instead of three racy ones. Every dimension fits: the codec
/// max dimension caps w/h well under 2^16 (`validate_dimensions`), refresh likewise.
fn pack_mode(width: u32, height: u32, refresh_hz: u32) -> u64 {
((width as u64 & 0xffff) << 32)
| ((height as u64 & 0xffff) << 16)
| (refresh_hz as u64 & 0xffff)
}
/// Unpack a [`pack_mode`] word back into `(width, height, refresh_hz)`.
pub(crate) fn unpack_mode(packed: u64) -> (u32, u32, u32) {
(
((packed >> 32) & 0xffff) as u32,
((packed >> 16) & 0xffff) as u32,
(packed & 0xffff) as u32,
)
}
/// Recover the integer refresh rate a pipeline was actually built at from its frame interval
/// (`interval` is constructed as `1/effective_hz` in `build_pipeline`, so the round-trip is exact).
/// This is the backend-honored rate — it differs from the requested mode when e.g. KWin caps a
/// virtual output at 60 Hz.
fn interval_hz(interval: std::time::Duration) -> u32 {
(1.0 / interval.as_secs_f64()).round() as u32
}
/// The mode a pipeline is ACTUALLY delivering, for the H2/H3 corrective ack: the captured frame's
/// real dimensions (`build_pipeline` opens the encoder at `frame.{width,height}`, so this is exactly
/// what the client decodes) paced at the rate the pipeline achieved ([`interval_hz`]). It diverges
/// from the requested mode when a backend can't honor it: KWin caps a virtual output's refresh, or —
/// the case this exists for — Windows pf-vdisplay rejects an in-place `SetMode` to a resolution not
/// in the running monitor's advertised EDID list and the host falls back to the actual display mode
/// (`capture::idd_push`: "sizing the ring to the display's actual mode"). Comparing this against the
/// already-acked request decides whether a corrective `Reconfigured` ack is owed so the client
/// doesn't believe it got a resolution it never received.
fn delivered_mode(
frame_width: u32,
frame_height: u32,
interval: std::time::Duration,
) -> punktfunk_core::Mode {
punktfunk_core::Mode {
width: frame_width,
height: frame_height,
refresh_hz: interval_hz(interval),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_mode_pack_roundtrips_and_interval_recovers_hz() {
// The live-stats mode slot (H3): pack → unpack is exact for real modes.
for (w, h, hz) in [(1280u32, 720u32, 60u32), (3840, 2160, 144), (320, 200, 24)] {
assert_eq!(unpack_mode(pack_mode(w, h, hz)), (w, h, hz));
}
// `interval` is built as 1/effective_hz — the round-trip recovers the integer rate.
for hz in [24u32, 30, 60, 75, 90, 120, 144, 165, 240] {
let interval = std::time::Duration::from_secs_f64(1.0 / hz as f64);
assert_eq!(interval_hz(interval), hz);
}
}
#[test]
fn delivered_mode_reports_captured_dims_and_triggers_corrective_ack() {
let hz60 = std::time::Duration::from_secs_f64(1.0 / 60.0);
let requested = punktfunk_core::Mode {
width: 2560,
height: 1440,
refresh_hz: 60,
};
// Honored: the captured frame matches the request → no corrective ack owed (`== requested`).
let honored = delivered_mode(2560, 1440, hz60);
assert_eq!(honored, requested);
// Resolution fallback (Windows pf-vdisplay rejected the out-of-list SetMode, host stayed at
// the actual display mode): the frame's real dims flow through, so the delivered mode differs
// from the acked request and a corrective ack IS owed — the exact gap this fixes.
let fell_back = delivered_mode(1920, 1080, hz60);
assert_ne!(fell_back, requested);
assert_eq!(
fell_back,
punktfunk_core::Mode {
width: 1920,
height: 1080,
refresh_hz: 60
}
);
// Refresh cap (KWin) is still caught: same dims, achieved rate recovered from the interval.
let capped = delivered_mode(2560, 1440, std::time::Duration::from_secs_f64(1.0 / 30.0));
assert_ne!(capped, requested);
assert_eq!(capped.refresh_hz, 30);
}
#[test]
fn pyrowave_bitrate_pins_to_bpp_default() {
use punktfunk_core::config::Mode;
let mode = Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
};
// Automatic (0) on PyroWave → the ~1.6 bpp operating point, not the 20 Mbps H.26x
// default (which would turn wavelets to mush — plan §4.6).
let kbps = resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 0, &mode);
assert_eq!(kbps, 1920 * 1080 * 60 * 16 / 10 / 1000);
// An explicit client rate is honored (clamped like any other codec)...
assert_eq!(
resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 130_000, &mode),
130_000
);
// ...and the H.26x codecs keep the legacy default.
assert_eq!(
resolve_bitrate_kbps_for(crate::encode::Codec::H265, 0, &mode),
DEFAULT_BITRATE_KBPS
);
}
#[test]
fn adapt_fec_maps_loss_to_recovery_band() {
// A perfectly clean window (0 loss) lands on the floor.
assert_eq!(adapt_fec(0), FEC_MIN);
// Any nonzero loss rounds up past the floor (ceil) — tiny but never below the cushion.
assert_eq!(adapt_fec(1), 2);
// FEC exceeds the loss it covers (×1.4 + 1pt headroom).
assert_eq!(adapt_fec(50_000), 8); // 5% loss → ceil(7)+1 = 8
assert_eq!(adapt_fec(100_000), 15); // 10% → ceil(14)+1 = 15
// Heavy loss saturates at the ceiling, never beyond.
assert_eq!(adapt_fec(1_000_000), FEC_MAX); // 100% → clamped
assert!(adapt_fec(u32::MAX) <= FEC_MAX);
}
#[test]
fn data_socket_defaults_to_random_hole_punch() {
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
// caller hole-punches.
for req in [None, Some(0)] {
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
assert_ne!(sock.local_addr().unwrap().port(), 0);
}
}
#[test]
fn data_socket_fixed_binds_direct_then_falls_back_when_busy() {
// Learn a currently-free port (bind :0, read it, drop — the same reserve-then-rebind the
// host itself uses; a race here would only make the assert below flaky, not wrong).
let free = std::net::UdpSocket::bind("0.0.0.0:0")
.unwrap()
.local_addr()
.unwrap()
.port();
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
assert!(direct, "a fixed --data-port must stream direct");
assert_eq!(held.local_addr().unwrap().port(), free);
// While it's held, a second session on the same fixed port can't bind it → it must fall
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
assert_ne!(
fallback.local_addr().unwrap().port(),
free,
"the fallback must not reuse the busy fixed port"
);
}
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value in
/// `punktfunk_core::input::gamepad` — the single source both the punktfunk/1 native wire and the
/// GameStream/Limelight wire read from (they are one and the same). Renumbering a bit in core
/// silently breaks every already-shipped client, so it must fail here first. This is the host
/// counterpart to the client-side C-ABI cross-checks in the Apple/Android gamepad tests.
#[test]
fn gamepad_wire_bits_are_pinned() {
use punktfunk_core::input::gamepad as pf;
// buttonFlags — low 16 bits. The injectors now name these straight from core::input::gamepad
// (the GameStream junk-drawer aliases were removed in the pf-inject un-coupling), so this pins
// core directly.
assert_eq!(pf::BTN_DPAD_UP, 0x0000_0001);
assert_eq!(pf::BTN_DPAD_DOWN, 0x0000_0002);
assert_eq!(pf::BTN_DPAD_LEFT, 0x0000_0004);
assert_eq!(pf::BTN_DPAD_RIGHT, 0x0000_0008);
assert_eq!(pf::BTN_START, 0x0000_0010);
assert_eq!(pf::BTN_BACK, 0x0000_0020);
assert_eq!(pf::BTN_LS_CLICK, 0x0000_0040);
assert_eq!(pf::BTN_RS_CLICK, 0x0000_0080);
assert_eq!(pf::BTN_LB, 0x0000_0100);
assert_eq!(pf::BTN_RB, 0x0000_0200);
assert_eq!(pf::BTN_GUIDE, 0x0000_0400);
assert_eq!(pf::BTN_A, 0x0000_1000);
assert_eq!(pf::BTN_B, 0x0000_2000);
assert_eq!(pf::BTN_X, 0x0000_4000);
assert_eq!(pf::BTN_Y, 0x0000_8000);
// buttonFlags2 — high 16 bits: back-grip paddles, plus the touchpad-click / Share bits the
// DualSense/DS4 protos consume.
assert_eq!(pf::BTN_PADDLE1, 0x0001_0000);
assert_eq!(pf::BTN_PADDLE2, 0x0002_0000);
assert_eq!(pf::BTN_PADDLE3, 0x0004_0000);
assert_eq!(pf::BTN_PADDLE4, 0x0008_0000);
assert_eq!(pf::BTN_TOUCHPAD, 0x0010_0000);
assert_eq!(pf::BTN_MISC1, 0x0020_0000);
// Axis ids — dense, 0-based.
assert_eq!(
[
pf::AXIS_LS_X,
pf::AXIS_LS_Y,
pf::AXIS_RS_X,
pf::AXIS_RS_Y,
pf::AXIS_LT,
pf::AXIS_RT,
],
[0, 1, 2, 3, 4, 5]
);
}
/// Pull and byte-verify `count` synthetic frames through the C ABI connection.
unsafe fn pull_verified(conn: *mut punktfunk_core::abi::PunktfunkConnection, count: u32) {
use punktfunk_core::error::PunktfunkStatus;
let mut got = 0u32;
// SAFETY: the inferred type is the `#[repr(C)]` POD `PunktfunkFrame` (a raw `*const u8`, a
// `usize`, and integer fields); all-zero is a valid bit pattern for every field (a null
// `data`, `len == 0`). It is only ever read after `next_au` below fully overwrites it on `Ok`,
// so the zeroed value is never observed.
let mut frame = unsafe { std::mem::zeroed() };
while got < count {
// SAFETY: `conn` is the live, non-null `*mut PunktfunkConnection` from `punktfunk_connect`
// (the caller asserts non-null and does not close it until after this returns), meeting the
// ABI's "valid handle". `&mut frame` is an exclusive, writable borrow of the local
// `PunktfunkFrame` that outlives this synchronous call. This single test thread is the only
// video puller, satisfying the one-video-thread rule.
match unsafe {
punktfunk_core::abi::punktfunk_connection_next_au(conn, &mut frame, 2000)
} {
PunktfunkStatus::Ok => {
// SAFETY: on `Ok`, `next_au` set `frame.data`/`frame.len` to the reassembled AU
// buffer the connection owns; per the ABI contract that borrow stays valid until
// the NEXT `next_au` call on this handle. We read the whole slice here (the assert
// + length-checked indexing) before the loop's next `next_au`, and `conn` outlives
// it — so the pointer is live, exactly `len` bytes, read-only, single-threaded (no
// aliasing/use-after-free).
let data = unsafe { std::slice::from_raw_parts(frame.data, frame.len) };
let idx = u32::from_le_bytes(data[0..4].try_into().unwrap());
assert_eq!(
data,
&test_frame(idx, data.len())[..],
"frame {idx} content"
);
got += 1;
}
PunktfunkStatus::NoFrame => continue,
other => panic!("next_au: {other:?}"),
}
}
}
/// End-to-end through the C ABI — the exact contract platform clients (Swift) link:
/// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) →
/// `punktfunk_connection_next_au` pulls verified frames → `punktfunk_connection_send_input`
/// In-process-host tests each spin up a host on a fixed loopback port and share the process-global
/// admission table, so they must NOT run concurrently: a same-identity connection in one test would
/// fire the reconnect-preempt (`preempt_same_identity`) against another test's live session and
/// close it. Serialize them on this lock. Poison-tolerant (`into_inner`) so a failing test doesn't
/// cascade a poison error into the others.
static SESSION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// enqueues → `punktfunk_connection_close`. Three sequential sessions against ONE host
/// process prove the persistent listener, and a wrong pin is rejected.
#[test]
fn c_abi_connection_roundtrip() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::abi::{
punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode,
punktfunk_connection_send_input,
};
use punktfunk_core::error::PunktfunkStatus;
let host = std::thread::spawn(|| {
run(Punktfunk1Options {
port: 19777,
source: Punktfunk1Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 3,
max_concurrent: 1,
require_pairing: false,
allow_pairing: false,
pairing_pin: None,
paired_store: None,
data_port: None,
idle_timeout: None,
mdns: false, // unit tests must not advertise on the LAN
})
});
std::thread::sleep(std::time::Duration::from_millis(500));
// Session 1: TOFU (no pin) — observe the host fingerprint.
let addr = std::ffi::CString::new("127.0.0.1").unwrap();
let mut observed = [0u8; 32];
// SAFETY: `addr` is a live `CString` ("127.0.0.1") whose `as_ptr()` is the NUL-terminated
// UTF-8 host string the contract requires; `pin_sha256`/cert/key are NULL (all permitted), and
// `observed.as_mut_ptr()` is the local `[u8; 32]` — exactly the 32 writable bytes the contract
// demands, not aliased during the call. Every pointer references a live local that outlives the
// blocking connect.
let conn = unsafe {
punktfunk_connect(
addr.as_ptr(),
19777,
1280,
720,
60,
std::ptr::null(),
observed.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
10_000,
)
};
assert!(!conn.is_null(), "punktfunk_connect failed");
assert_ne!(observed, [0u8; 32], "fingerprint not reported");
let (mut w, mut h, mut hz) = (0u32, 0u32, 0u32);
// SAFETY: `conn` is the live, non-null connection handle just asserted above; `&mut w/h/hz` are
// exclusive, writable borrows of local `u32`s that outlive this synchronous call — the three
// writable out-params the contract names.
let st = unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) };
assert_eq!(st, PunktfunkStatus::Ok);
assert_eq!((w, h, hz), (1280, 720, 60));
// Mid-stream renegotiation: request a new mode, the host acks on the control
// stream, and punktfunk_connection_mode reflects the switch.
// SAFETY: `conn` is the live, non-null connection handle (the only pointer arg); the remaining
// arguments are by-value integers. The handle outlives this non-blocking enqueue.
let st = unsafe {
punktfunk_core::abi::punktfunk_connection_request_mode(conn, 1920, 1080, 144)
};
assert_eq!(st, PunktfunkStatus::Ok);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
// SAFETY: same as the earlier `punktfunk_connection_mode` call — `conn` is the live handle
// and `&mut w/h/hz` are exclusive writable borrows of locals that outlive this synchronous
// call.
let st = unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) };
assert_eq!(st, PunktfunkStatus::Ok);
if (w, h, hz) == (1920, 1080, 144) {
break;
}
assert!(
std::time::Instant::now() < deadline,
"mode switch not acked (still {w}x{h}@{hz})"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
// SAFETY: `pull_verified` requires a live connection handle it alone pulls video from; `conn` is
// the open, non-null handle from `punktfunk_connect` and this is the only thread touching it.
unsafe { pull_verified(conn, 25) };
let ev = punktfunk_core::input::InputEvent {
kind: punktfunk_core::input::InputKind::MouseMove,
_pad: [0; 3],
code: 0,
x: 1,
y: 2,
flags: 0,
};
// SAFETY: `conn` is the live handle; `&ev` borrows the local `InputEvent`, valid and immutable
// for this synchronous enqueue — the contract's "valid InputEvent" pointer.
let st = unsafe { punktfunk_connection_send_input(conn, &ev) };
assert_eq!(st, PunktfunkStatus::Ok);
// SAFETY: `conn` was returned by `punktfunk_connect` and is never used after this call (session
// 2 below uses a fresh `conn2`); `close` takes ownership and frees the handle exactly once.
unsafe { punktfunk_connection_close(conn) };
// Session 2 (same host process — the listener survived): pin the fingerprint.
// SAFETY: as for session 1 — `addr` is the live NUL-terminated host string; here
// `observed.as_ptr()` is the 32-byte pin (the fingerprint captured above, a valid `[u8; 32]`),
// `observed_sha256_out` is NULL and cert/key are NULL. All pointers reference live locals for
// the duration of the blocking connect.
let conn2 = unsafe {
punktfunk_connect(
addr.as_ptr(),
19777,
1280,
720,
60,
observed.as_ptr(),
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null(),
10_000,
)
};
assert!(!conn2.is_null(), "pinned reconnect failed");
// SAFETY: `conn2` is the live, non-null pinned handle, pulled only from this thread —
// `pull_verified`'s requirement.
unsafe { pull_verified(conn2, 25) };
// SAFETY: `conn2` came from `punktfunk_connect` and is not used after this; `close` frees it once.
unsafe { punktfunk_connection_close(conn2) };
// Session 3: a wrong pin must be rejected by the handshake.
let bad = [0xAAu8; 32];
// SAFETY: same shape as the prior connects — `addr` is the live host string, `bad.as_ptr()` is
// the 32-byte `[0xAA; 32]` pin, and out/cert/key are NULL; all reference live locals across the
// blocking call. (The handshake is expected to fail and return NULL here, which is sound.)
let conn3 = unsafe {
punktfunk_connect(
addr.as_ptr(),
19777,
1280,
720,
60,
bad.as_ptr(),
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null(),
10_000,
)
};
assert!(conn3.is_null(), "wrong pin must fail the handshake");
// The host saw the rejected handshake attempt as session 3? No — a TLS-failed
// handshake never yields a connection, so accept() is still waiting. Connect once
// more (TOFU) to complete the host's third session and let it exit.
// SAFETY: same as session 1's connect — `addr` is the live host string, pin/out/cert/key all
// NULL; the pointers reference live locals for the duration of the blocking connect.
let conn4 = unsafe {
punktfunk_connect(
addr.as_ptr(),
19777,
1280,
720,
60,
std::ptr::null(),
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null(),
10_000,
)
};
assert!(!conn4.is_null());
// SAFETY: `conn4` is the live, non-null handle, pulled only from this thread.
unsafe { pull_verified(conn4, 25) };
// SAFETY: `conn4` came from `punktfunk_connect` and is unused after this; `close` frees it once.
unsafe { punktfunk_connection_close(conn4) };
host.join().unwrap().unwrap();
}
/// Shared clipboard end to end over a real synthetic session
/// (`design/clipboard-and-file-transfer.md`): with the operator policy enabled, the host
/// advertises the capability, acknowledges an enable with a `ClipState`, and — a synthetic
/// session mirrors no compositor, so no clipboard backend binds — declines a fetch with an
/// `Error` the client surfaces. Exercises the whole 0x40-0x44 control+fetch path across two real
/// endpoints (client `NativeClient` ↔ host `serve_session`). The live-backend paths (a real
/// compositor) are covered by the on-glass test against GNOME/Hyprland.
#[test]
fn clipboard_control_and_fetch_decline_over_session() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::client::NativeClient;
use punktfunk_core::clipboard::ClipEventCore;
use punktfunk_core::quic::{
CLIP_FILE_INDEX_NONE, CLIP_FLAG_FILES, CLIP_POLICY_FILES, HOST_CAP_CLIPBOARD,
};
// Restore the env even on a panicking assert (the poisoned lock is recovered above, so a
// leaked var could otherwise reach the next session test).
struct EnvGuard(&'static str);
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var(self.0);
}
}
let _env = EnvGuard("PUNKTFUNK_CLIPBOARD");
// Operator policy on. Session tests serialize on SESSION_TEST_LOCK, and only the session
// path (a session test) reads this env, so the mutation is race-free here.
std::env::set_var("PUNKTFUNK_CLIPBOARD", "1");
let host = std::thread::spawn(|| {
run(Punktfunk1Options {
port: 19781,
source: Punktfunk1Source::Synthetic,
seconds: 0,
frames: 600, // keep the session alive well past the control exchange
max_sessions: 1,
max_concurrent: 1,
require_pairing: false,
allow_pairing: false,
pairing_pin: None,
paired_store: None,
data_port: None,
idle_timeout: None,
mdns: false,
})
});
std::thread::sleep(std::time::Duration::from_millis(500));
let mode = punktfunk_core::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
let client = NativeClient::connect(
"127.0.0.1",
19781,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0, // bitrate_kbps
0, // video_caps
2, // audio_channels
0, // video_codecs (HEVC-only)
0, // preferred_codec
None, // display_hdr
None, // launch
None, // pin (TOFU)
None, // identity (host doesn't require pairing)
std::time::Duration::from_secs(10),
)
.expect("client connects to synthetic host");
assert_ne!(
client.host_caps() & HOST_CAP_CLIPBOARD,
0,
"an enabled host advertises HOST_CAP_CLIPBOARD"
);
// A bounded poll over the clipboard event plane.
let poll = |pred: &dyn Fn(&ClipEventCore) -> bool| -> Option<ClipEventCore> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match client.next_clip(std::time::Duration::from_millis(200)) {
Ok(ev) if pred(&ev) => return Some(ev),
Ok(_) => {}
Err(punktfunk_core::PunktfunkError::NoFrame) => {}
Err(_) => break, // session closed
}
}
None
};
// Enable sync (requesting files) → the host acks with a ClipState. A synthetic session
// mirrors no compositor, so no clipboard backend binds: the host refuses the enable with
// `BACKEND_UNAVAILABLE` while still reporting the operator policy (files permitted).
client.clip_control(true, CLIP_FLAG_FILES).unwrap();
let state = poll(&|e| matches!(e, ClipEventCore::State { .. }))
.expect("host replies with a ClipState ack");
match state {
ClipEventCore::State {
enabled,
policy,
reason,
} => {
assert!(!enabled, "no backend for a synthetic session → not enabled");
assert_eq!(
reason,
punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE,
"the refusal reason is BACKEND_UNAVAILABLE"
);
assert_ne!(
policy & CLIP_POLICY_FILES,
0,
"PUNKTFUNK_CLIPBOARD=1 permits files"
);
}
_ => unreachable!(),
}
// Fetch the host clipboard: a synthetic session has no backend, so the host declines and
// the client surfaces an Error for that transfer id.
let xfer = client
.clip_fetch(1, "text/plain;charset=utf-8".into(), CLIP_FILE_INDEX_NONE)
.unwrap();
let err = poll(&|e| matches!(e, ClipEventCore::Error { id, .. } if *id == xfer))
.expect("host declines the fetch (no backend) → Error event");
assert!(matches!(err, ClipEventCore::Error { .. }));
drop(client);
host.join().unwrap().unwrap();
}
fn test_paired_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
}
/// Delegated approval (§8b-1) end to end in-process, the SEAMLESS flow: an
/// identified-but-unpaired client's knock on a pairing-required host is PARKED (connection held
/// open) and shows up as a pending request (fingerprint-derived label — the connector sends no
/// Hello name); the operator approves it WHILE the client waits, and the SAME connection is
/// admitted to a session with no PIN and no reconnect.
#[test]
fn delegated_approval_admits_after_knock() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
let store =
std::env::temp_dir().join(format!("pf-approval-test-{}.json", std::process::id()));
let _ = std::fs::remove_file(&store);
let np = Arc::new(NativePairing::load_with(Some(store.clone()), None, false).unwrap());
let np_host = np.clone();
let host = std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
rt.block_on(serve(
Punktfunk1Options {
port: 19779,
source: Punktfunk1Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 1, // the single parked-then-approved session (no reconnect)
max_concurrent: 1,
require_pairing: true,
allow_pairing: false,
pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle
data_port: None,
idle_timeout: None,
mdns: false,
},
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
np_host,
StatsRecorder::new(
std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())),
),
))
});
std::thread::sleep(std::time::Duration::from_millis(500));
let (cert, key) = endpoint::generate_identity().unwrap();
let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
let mode = punktfunk_core::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
// Approver thread: wait for the parked knock to register, assert its label, then APPROVE it
// WHILE the client is still parked — the console "click accept" flow.
let np_approve = np.clone();
let expect_fp = expected_fp.clone();
let approver = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
let pend = loop {
if let Some(p) = np_approve
.pending()
.into_iter()
.find(|p| p.fingerprint == expect_fp)
{
break p;
}
assert!(
std::time::Instant::now() < deadline,
"the knock must register while the client is parked"
);
std::thread::sleep(std::time::Duration::from_millis(40));
};
assert!(
pend.name.starts_with("device "),
"no Hello name → fingerprint-derived label, got {:?}",
pend.name
);
np_approve
.approve_pending(pend.id, Some("Approved Device"))
.unwrap()
.expect("pending id must approve");
});
// The knock: a SINGLE connect that parks until approved, then streams — no reconnect. The
// timeout is generous (it covers the park + the approver's poll latency).
let client = NativeClient::connect(
"127.0.0.1",
19779,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
0, // video_caps
2, // audio_channels (stereo)
0, // video_codecs (0 → HEVC-only)
0, // preferred_codec (auto)
None, // display_hdr
None, // launch
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
Some((cert, key)),
std::time::Duration::from_secs(15),
)
.expect("approved mid-park → session admitted with no reconnect");
approver.join().unwrap();
assert!(
np.is_paired(&expected_fp),
"approval must pin the knocking fingerprint"
);
assert_eq!(np.list()[0].name, "Approved Device");
drop(client);
let _ = std::fs::remove_file(&store);
host.join().unwrap().unwrap();
}
/// The PIN pairing ceremony + the --require-pairing gate, end to end in-process:
/// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired
/// identity gets a session on a pairing-required host; an anonymous client does not.
#[test]
fn pairing_ceremony_and_gate() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
let host = std::thread::spawn(|| {
run(Punktfunk1Options {
port: 19778,
source: Punktfunk1Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 4,
max_concurrent: 1,
require_pairing: true,
allow_pairing: false,
pairing_pin: Some("4321".into()),
paired_store: Some(test_paired_path()),
data_port: None,
idle_timeout: None,
mdns: false,
})
});
std::thread::sleep(std::time::Duration::from_millis(500));
let timeout = std::time::Duration::from_secs(10);
let (cert, key) = endpoint::generate_identity().unwrap();
let identity = (cert.as_str(), key.as_str());
let mode = punktfunk_core::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
// 1: anonymous session on a pairing-required host → rejected (independent of the PIN window).
assert!(
NativeClient::connect(
"127.0.0.1",
19778,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
0, // video_caps
2, // audio_channels (stereo)
0, // video_codecs
0, // preferred_codec
None, // display_hdr
None, // launch
None,
None,
timeout
)
.is_err(),
"anonymous session must be rejected"
);
// 2: correct PIN → paired, host fingerprint returned. The ONE online attempt CONSUMES the
// arming window (single-use), verified by step 4.
let host_fp =
NativeClient::pair("127.0.0.1", 19778, identity, "4321", "test-client", timeout)
.expect("pairing with the right PIN");
assert!(test_paired_path().exists());
// 3: the paired identity gets a session — pinned to the ceremony's fingerprint.
let client = NativeClient::connect(
"127.0.0.1",
19778,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
0, // video_caps
2, // audio_channels (stereo)
0, // video_codecs
0, // preferred_codec
None, // display_hdr
None, // launch
Some(host_fp),
Some((cert.clone(), key.clone())),
timeout,
)
.expect("paired session");
assert_eq!(client.host_fingerprint, host_fp);
// The Welcome always reports a CONCRETE resolved gamepad backend. (Not asserted
// against a specific one: resolve_gamepad honors an ambient PUNKTFUNK_GAMEPAD —
// a dev box exporting it must not fail the suite.)
assert_ne!(client.resolved_gamepad, GamepadPref::Auto);
drop(client);
// 4: SINGLE-USE PIN — the completed ceremony in step 2 consumed the arming window, so a
// second pairing attempt (even with the CORRECT PIN) is now rejected. This is the documented
// "one online guess" guarantee: an attacker can't brute-force the static 4-digit PIN. (The
// operator re-arms via the console / restart for the next device.)
std::thread::sleep(PAIRING_COOLDOWN + std::time::Duration::from_millis(200));
assert!(
NativeClient::pair("127.0.0.1", 19778, identity, "4321", "too-late", timeout).is_err(),
"the PIN window must be single-use (one online guess)"
);
let _ = std::fs::remove_file(test_paired_path()); // tidy /tmp
host.join().unwrap().unwrap();
}
}