From 68bcfdac3ed4b06974bed8b191ad50354557a79a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 20:42:52 +0200 Subject: [PATCH] refactor(host/W1): split native.rs control task + data plane into submodules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continue the W1 native-host restructure (plan §W1, steps 4+5). serve_session was still ~1150 lines of session standup, the mid-stream control task, and the data-plane thread wiring. - native/control.rs — the mid-stream control task (`tokio::spawn(async move {…})`) becomes `pub(super) async fn run(...)`: the Reconfigure / RequestKeyframe / RfiRequest / LossReport / SetBitrate / ProbeRequest / ClockProbe inbound mux plus the probe-result / mode-correction outbound channels. Call site is now `tokio::spawn(control::run(...))`. - native/stream.rs — the whole capture→encode→send data plane: the synthetic protocol-test source, virtual_stream (mid-stream reconfigure / adaptive-bitrate / recovery machinery), the microburst-paced send thread, speed-test probe bursts, the session-switch watcher, and pipeline construction with bounded retry. Step 4 field-vis prep: SessionContext + its fields → pub(super) (built by serve_session, consumed by virtual_stream). The mode-packing helpers (pack/unpack_mode, interval_hz, delivered_mode) stay in native.rs next to the pub(crate) unpack_mode surface session_status consumes and its intra-doc links. native.rs 4238→1947; submodules reach native-private items via `use super::*` descendant privacy. Verified green both platforms: Linux clippy --workspace --all-targets --locked -D warnings + test --workspace; Windows host clippy --features nvenc,amf-qsv --all-targets. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/native.rs | 2351 +------------------ crates/punktfunk-host/src/native/control.rs | 206 ++ crates/punktfunk-host/src/native/stream.rs | 2153 +++++++++++++++++ 3 files changed, 2389 insertions(+), 2321 deletions(-) create mode 100644 crates/punktfunk-host/src/native/control.rs create mode 100644 crates/punktfunk-host/src/native/stream.rs diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index f0d48d9e..7383e38d 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -74,6 +74,16 @@ use input::{input_thread, ClientInput}; /// 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). @@ -776,7 +786,7 @@ async fn serve_session( ) .await .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; - let (mut ctrl_send, mut ctrl_recv) = (send, recv); + 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 @@ -822,14 +832,13 @@ async fn serve_session( let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>(); let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::(); let (probe_tx, probe_rx) = std::sync::mpsc::channel::(); - let (probe_result_tx, mut probe_result_rx) = - tokio::sync::mpsc::unbounded_channel::(); + let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::(); // 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: }` — 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, mut reconfig_result_rx) = + let (reconfig_result_tx, reconfig_result_rx) = tokio::sync::mpsc::unbounded_channel::(); // 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 @@ -839,183 +848,23 @@ async fn serve_session( 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; - tokio::spawn(async move { - let mut active = hello.mode; - // Host-side switch rate limit (a backstop against a hostile/broken client spamming - // Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already - // coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s). - const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); - let mut last_accepted_switch: Option = None; - loop { - tokio::select! { - msg = io::read_msg(&mut ctrl_recv) => { - let Ok(msg) = msg else { break }; // stream closed - if let Ok(req) = Reconfigure::decode(&msg) { - let now = std::time::Instant::now(); - let valid = req.mode.refresh_hz > 0 - && crate::encode::validate_dimensions( - codec, - req.mode.width, - req.mode.height, - ) - .is_ok(); - let too_soon = last_accepted_switch - .is_some_and(|t| now.duration_since(t) < MIN_SWITCH_INTERVAL); - let ok = if !live_reconfig_ok { - // Backend can't live-reconfigure (gamescope / synthetic / - // per-client-mode identity — see the gate above): honest downgrade, - // the client keeps scaling client-side. - tracing::info!(mode = ?req.mode, - "mode switch rejected (backend cannot live-reconfigure)"); - false - } else if !valid { - tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)"); - false - } else if too_soon { - tracing::warn!(mode = ?req.mode, "mode switch rejected (rate-limited)"); - false - } else { - true - }; - if ok { - active = req.mode; - last_accepted_switch = Some(now); - tracing::info!(mode = ?req.mode, "mode switch accepted"); - } - let ack = Reconfigured { accepted: ok, mode: active }; - if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { - break; - } - if ok && reconfig_tx.send(req.mode).is_err() { - break; // data plane gone - } - } else if RequestKeyframe::decode(&msg).is_ok() { - // Client recovery: its decoder wedged — force the next encoded frame to - // be an IDR. Coalesced in the encode loop (a wedge fires several before - // the IDR lands); a send error just means the data plane is gone. - tracing::debug!("client requested keyframe (decode recovery)"); - if keyframe_tx.send(()).is_err() { - break; // data plane gone - } - } else if let Ok(req) = RfiRequest::decode(&msg) { - // Client LTR-RFI recovery: it lost the frame range `[first, last]` and asks - // the encoder to re-reference a known-good older frame instead of paying for - // a full IDR. The encode loop attempts `invalidate_ref_frames`, falling back - // to a coalesced keyframe when the encoder can't (range too old / no RFI). - tracing::debug!( - first = req.first_frame, - last = req.last_frame, - "client requested reference-frame invalidation (loss recovery)" - ); - if rfi_tx.send((req.first_frame, req.last_frame)).is_err() { - break; // data plane gone - } - } else if let Ok(rep) = LossReport::decode(&msg) { - // Adaptive FEC: size recovery to the loss the client is seeing. The data-plane - // send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC - // is pinned via PUNKTFUNK_FEC_PCT. - if adaptive_fec { - // Fast attack, slow decay: jump straight to what the reported loss - // needs, but come DOWN only one point per clean report (~750 ms). The - // memoryless controller ping-ponged on periodic burst loss (Wi-Fi - // scans / BT coexistence, a burst every few seconds): a single clean - // window dropped FEC back to the floor, so every next burst hit an - // unprotected stream — an unrecoverable frame, a freeze, and a - // recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps - // the stream covered across the gap while still converging to FEC_MIN - // on a genuinely clean link. - let prev = fec_target_ctl.load(Ordering::Relaxed); - let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1)); - fec_target_ctl.store(target, Ordering::Relaxed); - if prev != target { - tracing::debug!( - loss_ppm = rep.loss_ppm, - fec_pct = target, - prev_fec_pct = prev, - "adaptive FEC adjusted" - ); - } - } - } else if let Ok(req) = SetBitrate::decode(&msg) { - // Mid-stream bitrate renegotiation (adaptive bitrate): clamp exactly like - // the Hello request, ack the resolved value, then hand it to the data-plane - // thread, which rebuilds the encoder in place at the same mode — the fresh - // encoder's first frame is an IDR with in-band parameter sets, so the - // client's decoder follows without a reconnect. - // PyroWave: the rate is PINNED (§4.6 — quality collapses under rate - // descent; recovery pressure is answered by codec fallback, not AIMD). - // Our client controller is off for this codec; this guards older or - // foreign clients by acking the unchanged session rate. - let resolved = if codec == crate::encode::Codec::PyroWave { - tracing::info!( - requested_kbps = req.bitrate_kbps, - pinned_kbps = session_bitrate_kbps, - "PyroWave session: mid-stream bitrate retarget refused (pinned)" - ); - session_bitrate_kbps - } else { - resolve_bitrate_kbps(req.bitrate_kbps) - }; - tracing::debug!( - requested_kbps = req.bitrate_kbps, - resolved_kbps = resolved, - "mid-stream bitrate change requested" - ); - let ack = BitrateChanged { - bitrate_kbps: resolved, - }; - if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { - break; - } - if bitrate_tx.send(resolved).is_err() { - break; // data plane gone - } - } else if let Ok(req) = ProbeRequest::decode(&msg) { - tracing::info!( - target_kbps = req.target_kbps, - duration_ms = req.duration_ms, - "speed-test probe requested" - ); - if probe_tx.send(req).is_err() { - break; // data plane gone - } - } else if let Ok(probe) = ClockProbe::decode(&msg) { - // Wall-clock skew handshake: echo the client's t1 with our receive (t2) and - // send (t3) stamps, both in the host clock the AU pts_ns uses. Answered - // inline on the control stream — cheap, no data-plane involvement. - let t2_ns = now_ns(); - let echo = ClockEcho { - t1_ns: probe.t1_ns, - t2_ns, - t3_ns: now_ns(), - }; - if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() { - break; - } - } else { - tracing::warn!("unknown control message — ignoring"); - } - } - result = probe_result_rx.recv() => { - let Some(result) = result else { break }; // data plane gone - if io::write_msg(&mut ctrl_send, &result.encode()).await.is_err() { - break; - } - } - correction = reconfig_result_rx.recv() => { - // H2 rollback/correction ack: the data plane reports the mode ACTUALLY live - // after a rebuild that failed (stayed at the old mode) or that the backend - // honored at a different refresh. Track it so a later rejection's - // `mode: active` echo is truthful too. - let Some(ack) = correction else { break }; // data plane gone - active = ack.mode; - if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { - break; - } - } - } - } - }); + 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, + )); // 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 @@ -1417,311 +1266,6 @@ async fn serve_session( /// virtual mic has its own tuning — see [`crate::audio::MicPump`].) const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2); -/// Advance the intra-refresh wave position and decide whether this emitted AU is a wave boundary -/// that should carry [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT). -/// -/// `ir_wave_pos` counts frames since the last IDR/wave start; a real IDR re-phases it to 0 (an IDR -/// restarts the encoder's wave AND is itself a clean anchor, so it is never additionally marked). -/// Every `period`-th non-IDR AU is a boundary — the client lifts its post-loss freeze on the SECOND -/// such mark. Pure so the marking cadence is unit-tested without a GPU (see the pump's use in the -/// encode-poll loop). -fn mark_recovery_boundary(ir_wave_pos: &mut u32, is_keyframe: bool, period: u32) -> bool { - if is_keyframe { - *ir_wave_pos = 0; - false - } else { - *ir_wave_pos += 1; - if *ir_wave_pos >= period { - *ir_wave_pos = 0; - true - } else { - false - } - } -} - -#[allow(clippy::too_many_arguments)] -fn synthetic_stream( - session: &mut Session, - frames: u32, - stop: &AtomicBool, - probe_rx: &std::sync::mpsc::Receiver, - probe_result_tx: &tokio::sync::mpsc::UnboundedSender, - fec_target: &AtomicU8, - timing_conn: Option<&quinn::Connection>, - probe_seq: bool, -) -> Result<()> { - let interval = std::time::Duration::from_millis(1000 / 60); - for idx in 0..frames { - if stop.load(Ordering::SeqCst) { - break; - } - apply_fec_target(session, fec_target); - // Service speed-test probes between synthetic frames (loopback bandwidth tests). - service_probes(session, stop, probe_rx, probe_result_tx, probe_seq); - let data = test_frame(idx, 64 * 1024); - let pts_ns = now_ns(); - session - .submit_frame(&data, pts_ns, (FLAG_PIC | FLAG_SOF) as u32) - .map_err(|e| anyhow!("submit_frame: {e:?}"))?; - // Host timing (0xCF) for protocol tests: near-zero here (no capture/encode), but it - // proves the plane end-to-end on a pure loopback run. - if let Some(tc) = timing_conn { - let t = punktfunk_core::quic::HostTiming { - pts_ns, - host_us: (now_ns().saturating_sub(pts_ns) / 1000).min(u32::MAX as u64) as u32, - }; - let _ = tc.send_datagram(punktfunk_core::quic::encode_host_timing_datagram(&t).into()); - } - std::thread::sleep(interval); - } - tracing::info!(frames, "synthetic stream complete"); - Ok(()) -} - -/// Bounds a speed-test [`ProbeRequest`] before bursting: a 3 Gbps / 5 s ceiling keeps a probe from -/// monopolizing the link or stalling the stream for too long. The ceiling is set ABOVE the session -/// bitrate cap ([`MAX_BITRATE_KBPS`], 2 Gbps) on purpose — a probe should be able to demonstrate -/// headroom past the rate a session will actually be configured to use, so the client can pick a -/// confident 1 Gbps+ bitrate. GF(2¹⁶) FEC makes multi-Gbps reachable on a LAN. -const MAX_PROBE_KBPS: u32 = 10_000_000; -const MAX_PROBE_MS: u32 = 5_000; - -/// Run a bandwidth probe over `session`: burst zero-filled access units flagged [`FLAG_PROBE`] at -/// `req.target_kbps` of goodput for `req.duration_ms` (both clamped to `MAX_PROBE_*`), pacing by a -/// "bytes allowed so far" budget so scheduling jitter doesn't overshoot the target. Returns what -/// was actually offered so the client can compute delivery ratio (`received / bytes_sent`) and -/// throughput. Video is paused for the duration (the caller's loop is blocked here) — a speed test -/// is a deliberate, short interruption the client initiates. -fn run_probe_burst( - session: &mut Session, - req: ProbeRequest, - stop: &AtomicBool, - probe_seq: bool, -) -> ProbeResult { - let target_kbps = req.target_kbps.min(MAX_PROBE_KBPS); - let duration_ms = req.duration_ms.min(MAX_PROBE_MS); - // Probe filler is sealed in the PROBE index space (its own frame counter — video indexes are - // owned by the encode loop and must stay 1:1 with the encoder's RFI bookkeeping). A client - // that didn't advertise VIDEO_CAP_PROBE_SEQ reassembles everything in one window and would - // drop probe-space frames as stale against the video stream — measuring garbage — so its - // mid-session probe is DECLINED (zeroed result) instead. Old sealing (probe filler consuming - // video indexes) is not an option anymore: those indexes are invisible to every client gap - // detector and read as a phantom multi-thousand-frame loss after the burst. - if !probe_seq { - tracing::info!( - "declining speed-test probe: client predates VIDEO_CAP_PROBE_SEQ (its reassembler \ - cannot window probe-space frames)" - ); - return ProbeResult { - bytes_sent: 0, - packets_sent: 0, - duration_ms: 0, - wire_packets_sent: 0, - send_dropped: 0, - }; - } - if target_kbps == 0 || duration_ms == 0 { - return ProbeResult { - bytes_sent: 0, - packets_sent: 0, - duration_ms: 0, - wire_packets_sent: 0, - send_dropped: 0, - }; - } - // kbps -> bytes/s (x1000/8). - let bytes_per_sec = target_kbps as u64 * 125; - // Keep each AU a SMALL burst (~16 KB ≈ a dozen MTU shards) and let the byte budget below pace - // the rate finely. The old 256 KB cap blasted ~200 packets into the send buffer per submit, so - // a small buffer (e.g. the Deck's 416 KB) overflowed on a single AU and the test measured - // self-inflicted buffer overflow instead of the link — mirror how `paced_submit` spreads the - // real video path's frames so the probe stresses the same way a real stream does. - let chunk = (bytes_per_sec / 240).clamp(1200, 16 * 1024) as usize; - let filler = vec![0u8; chunk]; - // Wire-packet accounting via session-stat deltas: `packets_sent` counts every sealed wire packet - // (seal_frame), `packets_send_dropped` every one the send buffer rejected (WouldBlock/ENOBUFS). - // Their delta over the burst is exact — and isolates host-side drops from link loss for the - // client. Video is paused for the burst (the data-plane loop is blocked here), so these deltas - // are pure probe traffic. - let wire0 = session.stats().packets_sent; - let drop0 = session.stats().packets_send_dropped; - let start = std::time::Instant::now(); - let deadline = start + std::time::Duration::from_millis(duration_ms as u64); - let mut bytes_sent = 0u64; - let mut packets_sent = 0u32; // probe access-unit count (goodput chunks) - while std::time::Instant::now() < deadline && !stop.load(Ordering::SeqCst) { - let allowed = (start.elapsed().as_secs_f64() * bytes_per_sec as f64) as u64; - if bytes_sent < allowed { - // A full send buffer drops on WouldBlock/ENOBUFS (UdpTransport returns Ok) — that loss is - // part of what the probe measures (it surfaces as send_dropped), so keep going. Sealed - // in the probe index space (FLAG_PROBE + its own counter) — never a video frame_index. - let _ = session.submit_probe_frame(&filler, now_ns()); - bytes_sent += chunk as u64; - packets_sent += 1; - } else { - std::thread::sleep(std::time::Duration::from_micros(200)); - } - } - let actual_ms = start.elapsed().as_millis() as u32; - let wire_offered = (session.stats().packets_sent - wire0) as u32; - let send_dropped = (session.stats().packets_send_dropped - drop0) as u32; - let wire_packets_sent = wire_offered.saturating_sub(send_dropped); - tracing::info!( - target_kbps, - duration_ms = actual_ms, - bytes_sent, - au_count = packets_sent, - wire_offered, - wire_packets_sent, - send_dropped, - "speed-test probe burst complete" - ); - ProbeResult { - bytes_sent, - packets_sent, - duration_ms: actual_ms, - wire_packets_sent, - send_dropped, - } -} - -/// Drain any pending speed-test requests and run each burst, replying with its [`ProbeResult`]. -/// Called once per data-plane loop iteration so a probe runs between frames. `probe_seq` = the -/// client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`] (see [`run_probe_burst`]). -fn service_probes( - session: &mut Session, - stop: &AtomicBool, - probe_rx: &std::sync::mpsc::Receiver, - probe_result_tx: &tokio::sync::mpsc::UnboundedSender, - probe_seq: bool, -) { - while let Ok(req) = probe_rx.try_recv() { - let result = run_probe_burst(session, req, stop, probe_seq); - let _ = probe_result_tx.send(result); - } -} - -/// Seal one access unit and send it with MICROBURST pacing (the shared -/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap` -/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and -/// only the OVERFLOW beyond that is spread across ~90% of the time to `deadline` in ADAPTIVE -/// chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment cap) once -/// the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace instead -/// of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto: -/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a -/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` = -/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in -/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame -/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced -/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze -/// until the next keyframe). With no slack (encode ≈ interval) the budget collapses to 0 and -/// even the overflow goes out immediately, so this is never slower than unpaced. -#[allow(clippy::too_many_arguments)] -fn paced_submit( - session: &mut Session, - data: &[u8], - pts_ns: u64, - flags: u32, - frame_index: u32, - deadline: std::time::Instant, - burst_cap: Option, -) -> Result { - let wires = session - .seal_frame_at(data, pts_ns, flags, frame_index) - .map_err(|e| anyhow!("seal_frame: {e:?}"))?; - let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect(); - // FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors. - crate::send_pacing::inject_video_drop(&mut refs); - let wire_bytes: usize = refs.iter().map(|p| p.len()).sum(); - let cfg = crate::send_pacing::PaceCfg { - burst_bytes: Some(burst_cap.unwrap_or_else(|| (wire_bytes / 4).max(128 * 1024))), - chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 }, - sleep_floor: std::time::Duration::from_micros(500), - }; - // Time the socket handoff per chunk and fold it into the session's SealPerf split — the - // sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time. - let mut sock_ns = 0u64; - let result = crate::send_pacing::pace_frame( - &refs, - crate::send_pacing::PaceBudget::UntilDeadline { - deadline, - fraction: 0.9, - }, - &cfg, - |chunk| { - let t0 = std::time::Instant::now(); - let r = session.send_sealed(chunk).map(|_| ()); - sock_ns += t0.elapsed().as_nanos() as u64; - r - }, - ); - drop(refs); // release the borrow of `wires` so it can return to the seal pool - session.reclaim_wires(wires); - session.note_sock_ns(sock_ns); - result.map_err(|e| anyhow!("send_sealed: {e:?}")) -} - -/// One encoded frame handed from the capture/encode thread to the send thread (the encode|send -/// split). The send thread does FEC+seal+paced-send while this thread captures+encodes the next. -struct FrameMsg { - data: Vec, - capture_ns: u64, - flags: u32, - /// The wire `frame_index` this AU is sealed with. Assigned by the encode loop's - /// session-lifetime counter (`au_seq`) — the loop owns the video numbering so the index it - /// PREDICTED at submit time (`au_seq + inflight`, handed to `Encoder::submit_indexed`) is - /// exactly what the packetizer stamps, keeping the encoder's RFI bookkeeping 1:1 with the - /// wire across encoder rebuilds/resets. Sealed via `Session::seal_frame_at`. - frame_index: u32, - /// When this frame's packets should have fully left (the next frame's due time) = the pacing - /// budget. In the past when the send thread is behind → immediate send (catch up). - deadline: std::time::Instant, - /// submit→encoded latency (µs), measured on the encode thread, carried for the perf histogram. - encode_us: u32, - /// Capture-delivery → encoder-submit age (µs) of a fresh frame — the PipeWire delivery + - /// channel-queue time the old pre-submit stamp made invisible. Always measured (two integer - /// ops); 0 for repeats/tail frames. The wire pts (`capture_ns`) anchors at the same delivery - /// stamp, so client-side latency figures include this window too. - queue_us: u32, - /// Per-stage µs splits, measured on the capture/encode thread (0 when neither `PUNKTFUNK_PERF` - /// nor a stats capture is armed). The send thread accumulates them for the web-console sample: - /// `cap_us` = `try_latest` (ring read + colour convert), `submit_us` = NVENC `encode_picture` - /// launch, `wait_us` = `lock_bitstream` (the scheduling wait + ASIC encode = the "encode" stage). - cap_us: u32, - submit_us: u32, - wait_us: u32, - /// This frame is a re-encoded hold (the source had no fresh frame): a source-starvation signal - /// the send thread folds into `repeat_fps`. - repeat: bool, - /// Whether the per-stage splits (`cap_us`/`submit_us`/`wait_us`) were actually measured at - /// capture time (`perf` was on or a stats capture was armed). The send thread trusts this - /// instead of re-reading `is_armed()`, so a capture that arms while frames are already in flight - /// doesn't fold their zeroed splits into the first window's percentiles. - was_measured: bool, -} - -/// The dedicated send thread: it owns the whole [`Session`] (so no socket clone or shared stats are -/// needed) and does FEC+seal + microburst-paced send OFF the capture/encode thread, plus the -/// speed-test probe bursts (which also need the Session). Decoupling the paced send from encoding -/// lets the encode of frame N+1 overlap the transmit of frame N instead of waiting behind its tail. -/// Runs until the encode thread drops the frame channel (end of stream) or `stop` is set. -/// Everything the send thread needs to emit web-console stats samples at its 2 s aggregation -/// boundary: the shared recorder (whose `is_armed()` gates emission) plus the negotiated -/// mode/codec/client to seed the capture's `CaptureMeta` on the first armed registration. -struct SendStats { - rec: Arc, - /// Live session mode, packed w:16|h:16|hz:16 ([`pack_mode`]) — the capture thread updates it - /// on an accepted mid-stream mode switch (mirroring `bitrate_kbps` below), so a stats capture - /// registers the mode the stream is ACTUALLY running at, not the session-start latch (H3). - mode: Arc, - codec: &'static str, - client: String, - /// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive - /// bitrate change, so the web-console sample reports what the encoder is ACTUALLY targeting. - bitrate_kbps: Arc, -} - /// 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. @@ -1769,1769 +1313,6 @@ fn delivered_mode( } } -/// Whether a session on `compositor` (`None` = the synthetic source) with a `per_client_mode` -/// identity policy may LIVE-reconfigure — accept a mid-stream `Reconfigure` -/// (design/midstream-resolution-resize.md H1/H5). Gated OFF for: -/// * **gamescope** (every sub-mode): a resize would respawn the nested game / restart the box's -/// game-mode session — it must never relaunch the title, so the client keeps scaling client-side. -/// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a -/// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output), -/// defeating the policy — honest downgrade is to reject and let the client scale. -/// -/// Every other compositor (and the synthetic protocol-test source) with the default identity accepts. -fn reconfig_allowed( - compositor: Option, - per_client_mode: bool, -) -> bool { - compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode -} - -#[allow(clippy::too_many_arguments)] -fn send_loop( - mut session: Session, - frame_rx: std::sync::mpsc::Receiver, - probe_rx: std::sync::mpsc::Receiver, - probe_result_tx: tokio::sync::mpsc::UnboundedSender, - stop: Arc, - perf: bool, - burst_cap: Option, - fec_target: Arc, - stats: SendStats, - // `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right - // after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing). - timing_conn: Option, - // The client advertised VIDEO_CAP_PROBE_SEQ — mid-session speed-test bursts may run in the - // probe index space (else they're declined; see `run_probe_burst`). - probe_seq: bool, -) { - boost_thread_priority(false); // transmit thread: above-normal (Apollo's encoder-thread level) - let mut last_perf = std::time::Instant::now(); - let mut last_bytes = 0u64; - let mut last_send_dropped = 0u64; - let mut encode_us: Vec = Vec::new(); - let mut pace_us: Vec = Vec::new(); - let (mut paced_frames, mut immediate_frames) = (0u64, 0u64); - // Web-console stats accumulation (active when `perf` OR the recorder is armed): the per-stage - // split carried on each FrameMsg, the new-vs-repeat frame split, the cached registration id, and - // the previous window's loss snapshot for delta computation. - let mut sid: Option = None; - let (mut cap_v, mut submit_v, mut wait_v, mut queue_v): ( - Vec, - Vec, - Vec, - Vec, - ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); - let (mut new_frames, mut repeat_frames) = (0u64, 0u64); - let mut last_frames_dropped = 0u64; - let mut last_packets_dropped = 0u64; - let mut last_fec_recovered = 0u64; - loop { - if stop.load(Ordering::SeqCst) { - break; - } - // Probes run here (they need the Session); a burst pauses video — the encode thread blocks - // on the full frame channel meanwhile, which is exactly the intended pause. - service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq); - // Adaptive FEC: pick up any new recovery target the control task set from client LossReports. - apply_fec_target(&mut session, &fec_target); - // Short timeout so we keep re-checking `stop` + probes when no frames are flowing. - match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) { - Ok(msg) => match paced_submit( - &mut session, - &msg.data, - msg.capture_ns, - msg.flags, - msg.frame_index, - msg.deadline, - burst_cap, - ) { - Ok(stat) => { - // Host timing (0xCF): stamped now — the AU's packets have fully left the - // socket — against the same capture anchor the wire pts carries, so the - // client's per-frame math tiles exactly (network = its host+network − this). - // Best-effort like every side-plane datagram; skipped for speed-test filler - // (FLAG_PROBE isn't video and its pts is the burst clock). - if let Some(tc) = &timing_conn { - if msg.flags & FLAG_PROBE as u32 == 0 { - let host_us = (now_ns().saturating_sub(msg.capture_ns) / 1000) - .min(u32::MAX as u64) - as u32; - let t = punktfunk_core::quic::HostTiming { - pts_ns: msg.capture_ns, - host_us, - }; - let _ = tc.send_datagram( - punktfunk_core::quic::encode_host_timing_datagram(&t).into(), - ); - } - } - if perf || stats.rec.is_armed() { - // `encode_us`/`pace_us`/fps are valid for every frame (always measured), - // including the Windows relay + tail-drain frames. The cap/submit/wait splits - // are only real when the frame was measured at capture time — a frame captured - // before this capture armed carries zeroed splits, so skip those (an empty - // window → `percentile()` returns 0) rather than pull the percentiles down. - encode_us.push(msg.encode_us); - pace_us.push(stat.spread_us); - if msg.was_measured { - cap_v.push(msg.cap_us); - submit_v.push(msg.submit_us); - wait_v.push(msg.wait_us); - // Queue age is only meaningful for fresh frames (repeats/tail carry 0 - // by construction — including those would drag the percentiles down). - if !msg.repeat { - queue_v.push(msg.queue_us); - } - } - if msg.repeat { - repeat_frames += 1; - } else { - new_frames += 1; - } - if stat.paced { - paced_frames += 1; - } else { - immediate_frames += 1; - } - } - } - Err(e) => { - tracing::error!(error = %format!("{e:#}"), "send failed — stopping stream"); - break; - } - }, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, // encode thread done - } - if last_perf.elapsed() >= std::time::Duration::from_secs(2) { - let s = session.stats(); - let secs = last_perf.elapsed().as_secs_f64(); - // Attempted (sealed) transmit rate; `send_dropped` is what didn't reach the wire. - let tx_mbps = (s.bytes_sent - last_bytes) as f64 * 8.0 / secs / 1_000_000.0; - if perf { - // Send-thread stage split (Phase 0.4 host half): busy-time sums over this - // window, so share-of-core = _ms / window wall ms. The per-packet ns - // figures are the Phase 1.5 gate metric — seal parallelism is warranted only - // if seal_ns_pp × pkts/s approaches ~15% of a core at 2 Gbps. - let sp = session.take_seal_perf().unwrap_or_default(); - tracing::info!( - tx_mbps = format!("{tx_mbps:.0}"), - send_dropped = s.packets_send_dropped - last_send_dropped, - send_dropped_total = s.packets_send_dropped, - encode_us_p50 = percentile(&mut encode_us, 0.50), - encode_us_p99 = percentile(&mut encode_us, 0.99), - pace_us_p50 = percentile(&mut pace_us, 0.50), - pace_us_p99 = percentile(&mut pace_us, 0.99), - pace_us_max = pace_us.last().copied().unwrap_or(0), - immediate_frames, - paced_frames, - window_ms = format!("{:.0}", secs * 1000.0), - fec_ms = format!("{:.2}", sp.fec_ns as f64 / 1e6), - seal_ms = format!("{:.2}", sp.seal_ns as f64 / 1e6), - sock_ms = format!("{:.2}", sp.sock_ns as f64 / 1e6), - fec_ns_pp = sp.fec_ns.checked_div(sp.packets).unwrap_or(0), - seal_ns_pp = sp.seal_ns.checked_div(sp.packets).unwrap_or(0), - sock_ns_pp = sp.sock_ns.checked_div(sp.packets).unwrap_or(0), - sealed_pkts = sp.packets, - "perf" - ); - } - // Web-console capture: this thread owns `session.stats()`, so it emits the COMPLETE - // sample — the cap/submit/encode split carried over from the capture thread plus this - // window's pacing/goodput/loss. Loss fields are deltas vs the previous window's snapshot. - if stats.rec.is_armed() { - let session_id = *sid.get_or_insert_with(|| { - // Read the LIVE mode at registration time (H3): a capture armed after a - // mid-stream mode switch gets the mode the stream actually runs at. - let (w, h, hz) = unpack_mode(stats.mode.load(Ordering::Relaxed)); - stats - .rec - .register_session("native", w, h, hz, stats.codec, &stats.client) - }); - let sample = crate::stats_recorder::StatsSample { - t_ms: 0, // stamped by push_sample from the capture's monotonic start - session_id, - stages: vec![ - crate::stats_recorder::StageTiming { - name: "queue".into(), - p50_us: percentile(&mut queue_v, 0.50) as f32, - p99_us: percentile(&mut queue_v, 0.99) as f32, - }, - crate::stats_recorder::StageTiming { - name: "capture".into(), - p50_us: percentile(&mut cap_v, 0.50) as f32, - p99_us: percentile(&mut cap_v, 0.99) as f32, - }, - crate::stats_recorder::StageTiming { - name: "submit".into(), - p50_us: percentile(&mut submit_v, 0.50) as f32, - p99_us: percentile(&mut submit_v, 0.99) as f32, - }, - crate::stats_recorder::StageTiming { - name: "encode".into(), - p50_us: percentile(&mut wait_v, 0.50) as f32, - p99_us: percentile(&mut wait_v, 0.99) as f32, - }, - crate::stats_recorder::StageTiming { - name: "send".into(), - p50_us: percentile(&mut pace_us, 0.50) as f32, - p99_us: percentile(&mut pace_us, 0.99) as f32, - }, - ], - fps: (new_frames as f64 / secs) as f32, - repeat_fps: (repeat_frames as f64 / secs) as f32, - mbps: tx_mbps as f32, - bitrate_kbps: stats.bitrate_kbps.load(Ordering::Relaxed), - frames_dropped: s.frames_dropped.saturating_sub(last_frames_dropped) as u32, - packets_dropped: s.packets_dropped.saturating_sub(last_packets_dropped) as u32, - send_dropped: s.packets_send_dropped.saturating_sub(last_send_dropped) as u32, - fec_recovered: s.fec_recovered_shards.saturating_sub(last_fec_recovered) as u32, - }; - stats.rec.push_sample(session_id, sample); - } - last_perf = std::time::Instant::now(); - last_bytes = s.bytes_sent; - last_send_dropped = s.packets_send_dropped; - last_frames_dropped = s.frames_dropped; - last_packets_dropped = s.packets_dropped; - last_fec_recovered = s.fec_recovered_shards; - encode_us.clear(); - pace_us.clear(); - cap_v.clear(); - submit_v.clear(); - wait_v.clear(); - queue_v.clear(); - paced_frames = 0; - immediate_frames = 0; - new_frames = 0; - repeat_frames = 0; - } - } -} - -/// A mid-stream session change the watcher detected (the box flipped Gaming↔Desktop): the new -/// backend + the [`crate::vdisplay::SessionEnv`] snapshot to retarget at it. The env is applied on -/// the encode thread (not the watcher), so the watcher never does a process-global env write. -struct SessionSwitch { - kind: crate::vdisplay::ActiveKind, - compositor: crate::vdisplay::Compositor, - env: crate::vdisplay::SessionEnv, -} - -/// Poll the live graphical session ~1 s and, when its kind changes from what the stream opened with -/// (the user switched Gaming↔Desktop mid-stream) and stays changed for a debounce, send one -/// [`SessionSwitch`] so the encode loop rebuilds the backend in place. Self-baselines on the first -/// read (so no handshake plumbing). Opt-in via `PUNKTFUNK_SESSION_WATCH`; readiness of the new -/// backend is left to the encode thread's `build_pipeline_with_retry` (the watcher never writes -/// env). Exits when `stop` is set or the channel closes. -/// Whether to run the mid-stream session-switch watcher. An explicit `PUNKTFUNK_SESSION_WATCH` wins -/// (truthy → on; `0`/`false`/`no`/`off`/empty → off). When unset it defaults **on** for Steam HTPC -/// platforms (Bazzite / SteamOS) — which flip Gaming↔Desktop and need the host to follow the switch -/// mid-stream — and **off** elsewhere, preserving the opt-in default for plain desktop hosts. -fn session_watch_enabled() -> bool { - match std::env::var("PUNKTFUNK_SESSION_WATCH") { - Ok(v) => { - let v = v.trim(); - !(v.is_empty() - || v == "0" - || v.eq_ignore_ascii_case("false") - || v.eq_ignore_ascii_case("no") - || v.eq_ignore_ascii_case("off")) - } - Err(_) => is_steam_htpc_platform(), - } -} - -/// True on Bazzite or SteamOS (matched against os-release `ID`/`ID_LIKE`) — the platforms that flip -/// between Steam Gaming Mode and a Desktop session, where following a mid-stream switch is the -/// sensible default. Anything else (incl. non-Linux, where the file is absent) → false. -fn is_steam_htpc_platform() -> bool { - let Ok(os) = std::fs::read_to_string("/etc/os-release") else { - return false; - }; - os.lines().any(|line| { - let line = line.trim(); - let Some(val) = line - .strip_prefix("ID=") - .or_else(|| line.strip_prefix("ID_LIKE=")) - else { - return false; - }; - val.trim_matches('"') - .split_whitespace() - .any(|tok| tok.eq_ignore_ascii_case("bazzite") || tok.eq_ignore_ascii_case("steamos")) - }) -} - -fn session_watcher_loop(tx: std::sync::mpsc::Sender, stop: Arc) { - use crate::vdisplay; - const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(3); - // Baseline = what the stream is currently driving (matches the handshake's resolution). - let mut current = vdisplay::detect_active_session().kind; - let mut pending: Option<(vdisplay::ActiveKind, std::time::Instant)> = None; - while !stop.load(Ordering::SeqCst) { - std::thread::sleep(std::time::Duration::from_secs(1)); - if stop.load(Ordering::SeqCst) { - break; - } - let active = vdisplay::detect_active_session(); - // A4: bump the session epoch + invalidate the old backend the moment the compositor instance - // changes (kind change OR same-kind restart) — even for a same-kind restart the watcher won't - // signal a full SessionSwitch for. Self-dedupes; the debounced SessionSwitch below still drives - // the in-place rebuild. - vdisplay::observe_session_instance(&active); - let cur = active.kind; - if cur == current { - pending = None; // back to the current backend before debounce elapsed — no switch - continue; - } - match pending { - // Stable at the new kind for the debounce window — the switch is real, signal it. - Some((k, since)) if k == cur && since.elapsed() >= DEBOUNCE => { - match vdisplay::compositor_for_kind(cur) { - Some(comp) => { - tracing::info!(from = ?current, to = ?cur, compositor = comp.id(), - "session watcher: mid-stream switch — signaling backend rebuild"); - if tx - .send(SessionSwitch { - kind: cur, - compositor: comp, - env: active.env, - }) - .is_err() - { - break; // encode loop gone - } - current = cur; // new baseline; don't re-signal until it changes again - } - // Logout / no usable backend for the new session — keep streaming the old one. - None => tracing::debug!(to = ?cur, - "session watcher: no usable backend for the new session — staying put"), - } - pending = None; - } - // Still debouncing this kind. - Some((k, _)) if k == cur => {} - // A new (or different) change — start the debounce window. - _ => pending = Some((cur, std::time::Instant::now())), - } - } -} - -/// All per-session inputs for [`virtual_stream`], bundled so the session entry -/// is one moved value instead of a 13-positional-argument `#[allow(too_many_arguments)]` signature -/// (Goal-1 stage 4, plan §2.4). Everything is **owned** — the receivers move in (`virtual_stream` is their -/// only consumer) — so the whole context moves into the stream thread and the borrow plumbing disappears. -struct SessionContext { - /// The hardened data-plane `Session` (Leopard FEC + AES-GCM over UDP); moved into the send thread. - session: Session, - /// The client's requested mode — the virtual output is created at exactly this WxH@Hz (no scaling). - mode: punktfunk_core::Mode, - /// Stream duration cap (the persistent listener bounds back-to-back sessions). - seconds: u32, - /// Session stop flag (set on disconnect / reconnect-preempt). - stop: Arc, - /// Deliberate-quit flag (set when the client closed with `QUIT_CODE`): the display lease reads it - /// on teardown to skip the keep-alive linger for a user "stop" (vs. an unwanted disconnect). - quit: Arc, - /// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode. - reconfig: std::sync::mpsc::Receiver, - /// Client decode-recovery keyframe requests. - keyframe: std::sync::mpsc::Receiver<()>, - /// Client LTR-RFI recovery requests — the lost-frame range `(first, last)`. The encode loop - /// prefers `Encoder::invalidate_ref_frames` over a full IDR when the encoder supports it. - rfi: std::sync::mpsc::Receiver<(u32, u32)>, - /// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder - /// alone is rebuilt in place at the new rate; capture + virtual output are untouched. - bitrate_rx: std::sync::mpsc::Receiver, - /// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there). - compositor: crate::vdisplay::Compositor, - /// Negotiated encoder bitrate (kbps). - bitrate_kbps: u32, - /// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from - /// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of - /// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it - /// for the new mode (the pin follows the resolution; an explicit client rate stays put). - bitrate_auto: bool, - /// Negotiated encode bit depth (8, or 10 = HEVC Main10). - bit_depth: u8, - /// Negotiated chroma subsampling (4:2:0, or 4:4:4 when the client + host + GPU all support it). - chroma: crate::encode::ChromaFormat, - /// Negotiated video codec the encoder emits (HEVC by default; H.264 / AV1 when the client - /// prefers one the GPU encodes; H.264 for a software host). Also used to rebuild the encoder - /// at the same codec across a mid-stream mode reconfigure. - codec: crate::encode::Codec, - /// Speed-test burst requests (see [`service_probes`]). - probe_rx: std::sync::mpsc::Receiver, - /// Speed-test results back to the control task. - probe_result_tx: tokio::sync::mpsc::UnboundedSender, - /// Mode-switch outcomes back to the control task (H2): a corrective - /// `Reconfigured { accepted: true, mode: }` when a rebuild failed (stayed at - /// the old mode) or the backend honored a different refresh than requested. - reconfig_result_tx: tokio::sync::mpsc::UnboundedSender, - /// Adaptive-FEC target the control task updates from the client's loss reports. - fec_target: Arc, - /// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream). - conn: quinn::Connection, - /// `Some` when the client advertised [`punktfunk_core::quic::VIDEO_CAP_HOST_TIMING`]: the send - /// thread emits one 0xCF datagram per AU (capture→sent µs) on it, so the client can split its - /// `host+network` latency stage. `None` = older client, no emission. - timing_conn: Option, - /// The client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`]: speed-test bursts may - /// run mid-session in the probe index space (its reassembler keeps a separate probe window). - /// `false` = older client whose single-window reassembler would drop probe-space frames as - /// stale — mid-session probes are DECLINED for it (a zeroed [`ProbeResult`]) rather than - /// consuming video frame indexes its gap detectors can't see (the phantom-gap freeze). - probe_seq: bool, - /// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide - /// whether to measure the per-stage split; the send thread builds + pushes the aggregated - /// `StatsSample` at its 2 s boundary. - stats: Arc, - /// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on - /// the first armed stats registration. - client_label: String, - /// The session's requested launch, `None` = none. On Windows the store-qualified library id - /// (spawned into the interactive user session once capture is live); on other hosts the shell - /// command already resolved against the host's own library — nested into gamescope's bare spawn - /// via `set_launch_command`, or spawned into the live session once capture is up. - launch: Option, - /// The client display's HDR colour volume (`Hello::display_hdr`; `None` = older client / SDR). - /// Threaded into the vdisplay backend before `create` (→ the pf-vdisplay EDID's CTA HDR block, - /// so host apps tone-map to the client's real panel) and preferred over the generic baseline - /// for the 0xCE mastering metadata. - client_hdr: Option, -} - -fn virtual_stream(ctx: SessionContext) -> Result<()> { - // This thread runs the capture+encode loop (single-process — the only topology: Linux portal / - // synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU - // submission. - boost_thread_priority(true); - // Resolve the per-session capture / topology / encoder decision ONCE (Goal-1 stage 3): the deployed - // path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site - // (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the - // only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`. - let mut plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma, ctx.codec); - // PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens - // packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames. - if ctx.codec == crate::encode::Codec::PyroWave { - plan.wire_chunk = Some(ctx.session.shard_payload()); - } - tracing::info!(?plan, "resolved session plan"); - // Single-process path: unpack the context into the locals the loop below uses (names unchanged, so the - // body is byte-for-byte the same; the receivers are now owned but `try_recv()` is identical). - let SessionContext { - session, - mode, - seconds, - stop, - quit, - reconfig, - keyframe, - rfi, - bitrate_rx, - compositor, - mut bitrate_kbps, - bitrate_auto, - bit_depth, - // The resolved chroma is already captured in `plan` (above); ignore the duplicate here. - chroma: _, - // Likewise the codec — `plan.codec` (resolved from `ctx.codec`) is the source of truth below. - codec: _, - probe_rx, - probe_result_tx, - reconfig_result_tx, - fec_target, - conn, - timing_conn, - probe_seq, - stats, - client_label, - launch, - client_hdr, - } = ctx; - tracing::info!( - compositor = compositor.id(), - ?mode, - bitrate_kbps, - bit_depth, - "punktfunk/1 virtual display" - ); - // Open the backend FIRST — on Windows this constructs the vdisplay backend, which initialises the - // host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push - // preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics. - let mut vd = crate::vdisplay::open(compositor)?; - // Per-client STABLE monitor identity (Phase 2): hand the backend the connecting client's cert - // fingerprint so a freshly CREATED virtual monitor gets this client's persistent id — Windows then - // reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends - // and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates). - vd.set_client_identity(endpoint::peer_fingerprint(&conn)); - // The client display's HDR volume (Hello) → a freshly created virtual monitor's EDID CTA HDR - // block (pf-vdisplay), so host apps + the OS tone-map to the client's real panel instead of the - // driver's built-in ~1000-nit placeholder. No-op on Linux backends and for older/SDR clients. - vd.set_client_hdr(client_hdr); - // Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the backend mints — - // the retry-hold below AND the capturer's — carries the session's quit flag, so a user "stop" - // (⌘D → the QUIT close code) tears the virtual monitor down the moment the pipeline drops instead - // of lingering 10 s. The reconnect then finds the manager Idle and does a clean fresh ADD (with - // the user's think-time as driver settle) rather than the Lingering-preempt's REMOVE→ADD churn. - // `keep_alive = forever` (gaming-rig) outranks the quit — the monitor pins as before. - vd.set_quit_flag(quit.clone()); - // Per-session launch (non-Windows): hand the resolved command to the backend instance so - // gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions - // can't stomp each other's launch target. The other backends' default `set_launch_command` is a - // no-op; they get the command spawned into the live session after capture is up (below). - #[cfg(not(target_os = "windows"))] - vd.set_launch_command(launch.clone()); - // IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a - // reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it - // to release its monitor (instead of tearing a monitor out from under a still-live session), and - // register THIS session's stop. The returned guard holds the setup lock across the pipeline build; - // dropping it lets the next reconnect begin (and preempt us). Held BEFORE the monitor is created - // (build_pipeline → vd.create), so the preempt still precedes this session's monitor creation. - // SLOT-scoped (Stage W1): the preempt targets only a prior session holding THIS client's slot — - // a different identity's session is an admission question, never a preempt. - #[cfg(target_os = "windows")] - let _idd_setup_guard = - (plan.capture == crate::session_plan::CaptureBackend::IddPush).then(|| { - let slot = crate::vdisplay::manager::slot_id_for( - endpoint::peer_fingerprint(&conn), - (mode.width, mode.height), - ); - crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone()) - }); - let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id, mut cur_display_gen) = - build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?; - // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). - #[cfg(target_os = "windows")] - drop(_idd_setup_guard); - - // Capture is live — launch the requested title so it renders onto the streamed output and - // grabs focus. Windows spawns the library id into the interactive user session; Linux spawns - // the resolved command into the live session for every backend that didn't already nest it - // (gamescope's bare spawn ran it inside the fresh gamescope — launching again would start it - // twice). Best-effort: a launch failure (no recipe, launcher missing, no interactive user) - // leaves the user on the streamed desktop/session, never tears the stream down. Launched ONCE - // here — the mid-stream rebuild paths below must not re-spawn it. - #[cfg(target_os = "windows")] - if let Some(id) = launch.as_deref() { - if let Err(e) = crate::library::launch_title(id) { - tracing::warn!(launch_id = id, error = %e, "could not launch requested library title"); - } - } - #[cfg(target_os = "linux")] - if let Some(cmd) = launch.as_deref() { - if crate::vdisplay::launch_is_nested(compositor) { - tracing::info!(command = %cmd, "launch nested into the per-session gamescope"); - } else if let Err(e) = crate::library::launch_session_command(compositor, cmd) { - tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session"); - } - } - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - let _ = &launch; - - let perf = crate::config::config().perf; - // Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out - // immediately; only a bigger frame's overflow is spread. `None` = auto — max(128 KB, the - // AU's wire bytes / 4), so the burst stays a bounded fraction of high-rate frames instead - // of swallowing them whole (plan Phase 1.3). PUNKTFUNK_PACE_BURST_KB pins an absolute cap. - let burst_cap: Option = std::env::var("PUNKTFUNK_PACE_BURST_KB") - .ok() - .and_then(|s| s.parse::().ok()) - .map(|kb| kb * 1024); - - // Encode|send split: this thread captures+encodes (the GPU work) + handles reconfig, and hands - // each AU to a dedicated send thread that owns the Session and does FEC+seal+paced-send — so the - // encode of frame N+1 overlaps the paced transmit of frame N instead of waiting behind its tail. - // The bounded channel applies backpressure (the encode thread blocks if the send falls behind, - // so frames slow down rather than a dropped frame freezing the infinite-GOP stream). - let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::(3); - // Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive - // bitrate change (bitrate_rx below) updates it so the console shows the actual target. - let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps)); - // Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so - // a stats capture armed after a resize registers the real mode. Seeded with the refresh the - // initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual - // output at 60 Hz. - let live_mode = Arc::new(AtomicU64::new(pack_mode( - mode.width, - mode.height, - interval_hz(interval), - ))); - // One-shot force-keyframe flag driven by the management API (`POST /session/idr`, the web-console - // Dashboard's "Request IDR" button) — drained in the encode loop below exactly like a client - // decode-recovery request. Registered with `session_status` so the mgmt handler can reach THIS - // session (the native plane never touches the GameStream `AppState.force_idr`). - let force_idr = Arc::new(AtomicBool::new(false)); - // The send thread emits the web-console stats sample (it owns `session.stats()`); clone the - // recorder so the capture loop keeps its own handle for the per-frame `is_armed()` gate. - let send_stats = SendStats { - rec: stats.clone(), - mode: live_mode.clone(), - codec: plan.codec.label(), - client: client_label.clone(), - bitrate_kbps: live_bitrate.clone(), - }; - let send_thread = std::thread::Builder::new() - .name("punktfunk-send".into()) - .spawn({ - let stop = stop.clone(); - move || { - send_loop( - session, - frame_rx, - probe_rx, - probe_result_tx, - stop, - perf, - burst_cap, - fec_target, - send_stats, - timing_conn, - probe_seq, - ) - } - }) - .context("spawn send thread")?; - - // Publish this session to the plane-neutral live-session registry so the web-console Dashboard - // (`GET /status`) shows the native stream — resolution/fps/codec/bitrate resolve live from the - // same handles a mid-stream mode switch / adaptive-bitrate change updates. The guard clears the - // entry when this loop exits (return / `?` / panic), so the Dashboard tracks the session's life. - let _live_session = crate::session_status::register( - live_mode.clone(), - live_bitrate.clone(), - plan.codec, - stop.clone(), - force_idr.clone(), - client_label, - plan.hdr, - ); - - // Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit - // PUNKTFUNK_COMPOSITOR pin). It self-baselines and signals the loop below to swap the backend in - // place when the box flips Gaming↔Desktop. When not spawned, session_rx just stays empty. - let mut compositor = compositor; - let (session_tx, session_rx) = std::sync::mpsc::channel::(); - let watch = session_watch_enabled() && crate::config::config().compositor.is_none(); - let _watcher = if watch { - tracing::info!("session watcher on — following a mid-stream Gaming↔Desktop switch"); - let stop = stop.clone(); - std::thread::Builder::new() - .name("punktfunk1-watcher".into()) - .spawn(move || session_watcher_loop(session_tx, stop)) - .ok() - } else { - None - }; - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64); - let mut next = std::time::Instant::now(); - let mut sent: u64 = 0; - // The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this - // loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`). - // A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs - // are emitted FIFO, one per submission, and every event that forfeits in-flight frames - // (reset/rebuild/teardown) clears `inflight` AND the encoder's reference state, so the reused - // predictions can never meet stale bookkeeping. Passing it to `Encoder::submit_indexed` keeps - // the RFI backends' frame numbers 1:1 with the client's across encoder rebuilds — an - // encoder-internal counter desyncs on the first adaptive-bitrate rebuild (NVENC RFI then - // silently dies; AMF may anchor onto a post-loss LTR). - let mut au_seq: u32 = 0; - // Rebuild-in-place on capture loss: track the live mode (a mode switch updates it) so a rebuild - // targets the CURRENT mode, and cap consecutive rebuilds so a flapping source can't loop the - // client through endless cold restarts. - let mut cur_mode = mode; - const MAX_CAPTURE_REBUILDS: u32 = 5; - let mut capture_rebuilds: u32 = 0; - // Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver - // shows up as poll() returning None forever while submits keep succeeding — `inflight` grows, - // no AU ever reaches the send thread, and the client freezes on the last frame with nothing - // logged (field reports: AMD/Intel Windows streams freezing after minutes). Track when the - // encoder last produced an AU and rebuild it in place (bounded, like the capture rebuilds) - // when it stops. `ENCODE_STALL_WINDOW` also sizes the in-flight backlog bound: a backlog worth - // more than the window's frames means AUs still trickle (so the gap never trips) but latency - // is growing without bound — the slow-leak form of the same stall. - const ENCODE_STALL_WINDOW: std::time::Duration = std::time::Duration::from_secs(2); - const MAX_ENCODER_RESETS: u32 = 5; - let mut encoder_resets: u32 = 0; - let mut last_au_at = std::time::Instant::now(); - // Last HDR mastering metadata we forwarded — re-sent as 0xCE on change/keyframe (see below). - let mut last_hdr_meta: Option = None; - // Frames submitted to NVENC but not yet polled (wire pts, submit stamp, pacing deadline). With a - // capturer that hands a fresh output texture per frame, the loop submits N+1 before polling N - // (pipeline depth > 1), overlapping the convert/copy of N+1 on the 3D engine with the encode of N - // on the NVENC ASIC. The wire pts and the submit stamp are carried separately so `encode_us` - // keeps meaning submit→AU while the wire pts anchors at PipeWire delivery (queue age included). - let mut inflight: std::collections::VecDeque<(u64, u64, std::time::Instant)> = - std::collections::VecDeque::new(); - // Diagnostic: distinguish NEW captured frames (the source produced a fresh frame) from REPEATS (the - // loop re-encoded the last frame because `try_latest` had nothing). A low new-frame rate at a high - // send rate ⇒ the capture source isn't producing frames (e.g. an IDD virtual display DWM isn't - // compositing), NOT an encoder problem. Logged every 2 s when `PUNKTFUNK_PERF`. - let (mut diag_new, mut diag_repeat) = (0u64, 0u64); - let mut diag_at = std::time::Instant::now(); - // Anchor for the forced-IDR cooldown (see the keyframe-request handling below): the timestamp of - // the most recent forced/opening IDR. The session's pipeline just opened on an IDR, so start the - // clock now — that coalesces the keyframe storm a client fires while its decoder wedges on the cold - // opening GOP, instead of answering it with a redundant second IDR. - let mut last_forced_idr: Option = Some(std::time::Instant::now()); - // Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle - // into a stable multi-second rhythm (see [`crate::metronome::Metronome`]). - let mut recovery_cadence = crate::metronome::Metronome::new(); - // Position within the current intra-refresh wave (frames since the last IDR/wave start). Only - // meaningful on a `caps().intra_refresh_recovery` encoder; the pump tags every wave-boundary AU - // with `USER_FLAG_RECOVERY_POINT` so the client can lift its post-loss freeze on a clean - // re-anchor without a full IDR. Re-phased to 0 at each emitted IDR (which restarts the wave). - let mut ir_wave_pos: u32 = 0; - // Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see - // exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert), - // submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one - // that dominates under a GPU-saturating game). - let (mut st_cap, mut st_submit, mut st_wait, mut st_queue): ( - Vec, - Vec, - Vec, - Vec, - ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); - while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { - // Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in - // place — a different compositor at the SAME client mode — keeping the Session + send thread - // (and thus the QUIC control + UDP data plane) up. Takes precedence over a queued mode change. - let mut switch = None; - while let Ok(s) = session_rx.try_recv() { - switch = Some(s); // coalesce to the newest - } - if let Some(sw) = switch { - if sw.compositor != compositor { - tracing::info!(from = compositor.id(), to = sw.compositor.id(), kind = ?sw.kind, - "session switch — rebuilding backend in place"); - // Retarget the process env at the new session BEFORE opening the new backend (this - // thread is the only env writer; the watcher only snapshots). - crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession { - kind: sw.kind, - env: sw.env, - compositor_pid: None, - }); - // A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the - // switched-to backend's normal sub-mode. - crate::vdisplay::apply_input_env(sw.compositor, false); - // Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still - // point at the old session, so input would silently not land until a reconnect. - // Settle it (env push + KWin portal restart) before the injector reopens against it. - if matches!( - sw.compositor, - crate::vdisplay::Compositor::Kwin | crate::vdisplay::Compositor::Mutter - ) { - crate::vdisplay::settle_desktop_portal(sw.compositor); - } - // Build the new backend's pipeline BEFORE dropping the old one (retry absorbs the - // brief compositor-coexistence race during a switch); on failure keep the old. - let rebuilt = - (|| -> Result<(Box, Pipeline)> { - let mut new_vd = crate::vdisplay::open(sw.compositor)?; - let pipe = build_pipeline_with_retry( - &mut new_vd, - cur_mode, - bitrate_kbps, - bit_depth, - plan, - &quit, - &stop, - )?; - Ok((new_vd, pipe)) - })(); - match rebuilt { - Ok(( - new_vd, - (new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen), - )) => { - // Replace the pipeline first (drops the old capturer → old PipeWire stream + - // virtual output), then the factory (drops e.g. the old KWin connection). - capturer = new_cap; - enc = new_enc; - frame = new_frame; - interval = new_interval; - cur_node_id = new_node_id; - cur_display_gen = new_gen; - vd = new_vd; - compositor = sw.compositor; - next = std::time::Instant::now(); - // The owed AUs died with the old encoder — drop their in-flight records - // and restart the encode-stall clock for the fresh one. - inflight.clear(); - last_au_at = std::time::Instant::now(); - encoder_resets = 0; - tracing::info!( - compositor = compositor.id(), - "session switch — backend rebuilt, stream continues" - ); - } - Err(e) => { - let chain = format!("{e:#}"); - let kind = if is_permanent_build_error(&chain) { - "permanent" - } else { - "transient" - }; - tracing::warn!(error = %chain, kind, - "session-switch rebuild failed — staying on the current backend"); - } - } - } - } - // Drain to the NEWEST requested mode (a resize drag queues many) so we rebuild once, - // not once per stale intermediate mode. - let mut want = None; - while let Ok(m) = reconfig.try_recv() { - want = Some(m); - } - if let Some(new_mode) = want { - tracing::info!(?new_mode, "rebuilding pipeline for mode switch"); - // PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) — - // a resolution change moves the operating point (1080p→4K quadruples the pixel rate), - // so re-resolve it for the new mode. Explicit client rates stay put (the operator knows - // the link), and the H.26x codecs keep their mode-independent rate (ABR owns it). - let mode_bitrate = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave { - resolve_bitrate_kbps_for(plan.codec, 0, &new_mode) - } else { - bitrate_kbps - }; - // Build the new pipeline BEFORE dropping the old one: the host already acked - // the switch as accepted, so a rebuild failure must not kill an otherwise - // healthy session — keep streaming the current mode and log instead. - match build_pipeline(&mut vd, new_mode, mode_bitrate, bit_depth, plan, &quit) { - Ok(next_pipe) => { - if mode_bitrate != bitrate_kbps { - tracing::info!( - from_kbps = bitrate_kbps, - to_kbps = mode_bitrate, - "pinned PyroWave bitrate re-resolved for the new mode" - ); - bitrate_kbps = mode_bitrate; - live_bitrate.store(mode_bitrate, Ordering::Relaxed); - } - let old_display_gen = cur_display_gen; - // The destructuring assignment drops the OLD capturer (→ its display lease) as - // each binding is replaced — the new pipeline is already up (create-before-drop). - (capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe; - cur_mode = new_mode; - next = std::time::Instant::now(); - // H4: the old display's lease drop above is indistinguishable from a disconnect - // to the keep-alive machinery — under linger/forever policies every resize would - // ACCUMULATE kept monitors at stale modes. Retire the superseded entry now (a - // no-op when it was already torn down under `immediate`, or off Linux). - if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) { - crate::vdisplay::registry::retire(g); - } - // H2/H3: the backend may have honored a different mode than requested — KWin - // caps a virtual output's refresh, or Windows pf-vdisplay rejects an in-place - // SetMode to a resolution its running monitor doesn't advertise and the host - // falls back to the actual display mode. `frame` is the NEW pipeline's first - // frame (just rebound above), so its dims are what the client actually decodes. - // Publish that ACTUAL mode to the live stats slot, and correct the client's mode - // slot when it differs from the accept ack it already got. - let actual = delivered_mode(frame.width, frame.height, interval); - live_mode.store( - pack_mode(actual.width, actual.height, actual.refresh_hz), - Ordering::Relaxed, - ); - if actual != new_mode { - let _ = reconfig_result_tx.send(Reconfigured { - accepted: true, - mode: actual, - }); - } - // The owed AUs died with the old encoder — drop their in-flight records - // and restart the encode-stall clock for the fresh one. - inflight.clear(); - last_au_at = std::time::Instant::now(); - encoder_resets = 0; - last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown - } - Err(e) => { - tracing::warn!(error = %format!("{e:#}"), ?new_mode, - "mode-switch rebuild failed — staying on the current mode"); - // H2 rollback: the control task acked the switch BEFORE this rebuild, so the - // client's mode slot already flipped to `new_mode`. A second accepted ack - // carrying the still-live mode corrects it (any accepted ack means "the active - // mode is now X" client-side; old clients just log it). `frame` is untouched - // here (the destructure only runs on the Ok arm), so it's still the OLD - // pipeline's frame — its real dims + interval are exactly what's still on glass. - let _ = reconfig_result_tx.send(Reconfigured { - accepted: true, - mode: delivered_mode(frame.width, frame.height, interval), - }); - } - } - } - // Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step - // several times while we stream) and retarget the ENCODER ONLY — the mode didn't change, - // so capture and the virtual output are untouched. Preferred lever: an IN-PLACE - // `reconfigure_bitrate` (Phase 3.2 — NVENC nvEncReconfigureEncoder / AMF dynamic props / - // Vulkan RC control), which keeps the encoder, its reference chain and the in-flight AUs, - // so the step costs NOTHING on the wire (no IDR, no forfeit — exactly what the Automatic - // controller's doubling climb wants). A backend that can't (libavcodec paths) or a driver - // rejection falls back to the full rebuild, which costs the IDR the fresh encoder opens - // with (the same resync discipline as a mode switch, minus the pipeline churn) and owns - // the bitrate clamping. Rates arrive pre-clamped by the control task - // (`resolve_bitrate_kbps`). - let mut want_kbps = None; - while let Ok(k) = bitrate_rx.try_recv() { - want_kbps = Some(k); - } - if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) { - if enc.reconfigure_bitrate(new_kbps as u64 * 1000) { - tracing::info!( - from_kbps = bitrate_kbps, - to_kbps = new_kbps, - "encoder bitrate reconfigured in place (adaptive bitrate — no IDR)" - ); - bitrate_kbps = new_kbps; - live_bitrate.store(new_kbps, Ordering::Relaxed); - // Same encoder, same stream: the in-flight AUs and the wire-index prediction - // stay valid — no inflight forfeit, no IDR-cooldown anchor. - } else { - // `interval` was built as 1/effective_hz, so the round-trip recovers the integer - // rate. - let hz = interval_hz(interval); - match crate::encode::open_video( - plan.codec, - frame.format, - frame.width, - frame.height, - hz, - new_kbps as u64 * 1000, - frame.is_cuda(), - bit_depth, - plan.chroma, - ) { - Ok(mut new_enc) => { - tracing::info!( - from_kbps = bitrate_kbps, - to_kbps = new_kbps, - "encoder rebuilt at new bitrate (adaptive bitrate)" - ); - if let Some(c) = plan.wire_chunk { - new_enc.set_wire_chunking(c); - } - enc = new_enc; - bitrate_kbps = new_kbps; - live_bitrate.store(new_kbps, Ordering::Relaxed); - // The owed AUs died with the old encoder — same bookkeeping as a - // mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the - // IDR cooldown too. - inflight.clear(); - last_au_at = std::time::Instant::now(); - encoder_resets = 0; - last_forced_idr = Some(std::time::Instant::now()); - } - Err(e) => { - tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps, - "bitrate-change encoder rebuild failed — keeping the current rate"); - } - } - } - } - // Client recovery: it asked for a fresh IDR (its decoder wedged on the cold opening - // GOP). Coalesce the backlog — several requests fire before the IDR lands — and force - // the next encoded frame to be a keyframe. (A reconfig rebuild above already opens with - // an IDR, so this is for the steady-state wedge, not mode switches.) - let mut want_kf = false; - while keyframe.try_recv().is_ok() { - want_kf = true; - } - // Management API `POST /session/idr` (web-console Dashboard) targets this session's registry - // flag; drain it into the same forced-keyframe path a client decode-recovery request takes. - if force_idr.swap(false, Ordering::Relaxed) { - want_kf = true; - } - // Client LTR-RFI recovery: prefer re-referencing a known-good older frame (a clean recovery - // P-frame — no 20-40× IDR spike) over a full keyframe when the encoder supports it (native - // AMF LTR / Windows NVENC). Drain the backlog (the client re-requests until the recovery - // frame lands) coalesced to the widest lost range. Attempt the invalidate only when a full - // IDR isn't already queued — an explicit keyframe request means a fully wedged decoder that - // needs the IDR, which supersedes an RFI recovery. A failure (range older than the encoder's - // live references, or no RFI backend) falls through to the coalesced keyframe path below. - let mut rfi_range: Option<(u32, u32)> = None; - while let Ok((first, last)) = rfi.try_recv() { - rfi_range = Some(match rfi_range { - Some((pf, pl)) => (pf.min(first), pl.max(last)), - None => (first, last), - }); - } - // All-intra (§4.6): every PyroWave AU is a keyframe, so the NEXT frame already is - // the recovery a request asks for — drop the drained requests instead of running - // the forced-IDR cooldown / RFI / storm machinery (whose frame-size reasoning is - // meaningless when frames are uniform). Defense in depth: the backend's - // request_keyframe/invalidate_ref_frames are no-ops anyway. - if plan.codec == crate::encode::Codec::PyroWave && (want_kf || rfi_range.is_some()) { - tracing::debug!( - want_kf, - ?rfi_range, - "PyroWave session: recovery request ignored (all-intra — next frame is the recovery)" - ); - want_kf = false; - rfi_range = None; - } - if !want_kf { - if let Some((first, last)) = rfi_range { - // Sanity-cap the range before consulting the encoder: RFI can only re-reference - // history the encoder still holds (NVENC: a 5-frame DPB; AMD LTR: ~1 s of marks). - // A range wider than RFI_MAX_RANGE is either a seconds-long outage (no valid - // reference anywhere) or a phantom jump from a desynced counter — both belong on - // the keyframe path, never a force-reference that could ship corruption as a - // recovery anchor. Wrapping width: frame indexes are u32 counters. - let width = last.wrapping_sub(first); - if width > punktfunk_core::packet::RFI_MAX_RANGE { - tracing::debug!(first, last, width, "RFI range too wide — keyframe instead"); - want_kf = true; - } else if enc.caps().supports_rfi - && enc.invalidate_ref_frames(first as i64, last as i64) - { - // The RFI recovered the loss with a clean re-anchor P-frame (no IDR). Anchor the - // keyframe cooldown so the client's echo of the SAME loss — its frames_dropped- - // driven keyframe request, arriving ~one loss-window later — is coalesced away - // instead of emitting a redundant full IDR right after the cheap recovery. - last_forced_idr = Some(std::time::Instant::now()); - } else { - want_kf = true; // range too old / no RFI backend → coalesced keyframe below - } - } - } - if want_kf { - // Clients request a keyframe on EVERY FEC-unrecoverable frame (`frames_dropped` polling) - // and keep asking until the IDR actually arrives + decodes — a full round-trip on a link - // that is already behind. Answering each request with a full IDR is a 20-40× bitrate spike - // that DEEPENS the very loss it is recovering from: a burst of loss → a storm of IDRs → - // more loss, the periodic double-jolt a Wi-Fi client sees. So coalesce a request storm into - // at most ONE forced IDR per cooldown, ALWAYS — not only under intra-refresh (the old gate; - // a full-IDR recovery is exactly where the storm is worst). Serve the first request - // immediately (a genuinely wedged decoder recovers at once), then suppress for the window. - // - // Intra-refresh heals via its own gradual wave (~0.5 s) and can afford a long window; a - // full-IDR recovery relies on the keyframe itself, so its window is shorter — long enough to - // swallow the round-trip echo of one recovery event, short enough to re-issue a *lost* IDR - // promptly. - const IDR_COOLDOWN_INTRA: std::time::Duration = std::time::Duration::from_secs(2); - const IDR_COOLDOWN_FULL: std::time::Duration = std::time::Duration::from_millis(750); - let window = if enc.caps().intra_refresh { - IDR_COOLDOWN_INTRA - } else { - IDR_COOLDOWN_FULL - }; - let suppress = last_forced_idr.is_some_and(|t| t.elapsed() < window); - if suppress { - tracing::debug!("keyframe request coalesced — within the IDR cooldown"); - } else { - tracing::debug!("forcing keyframe (client decode recovery)"); - enc.request_keyframe(); - let now = std::time::Instant::now(); - last_forced_idr = Some(now); - if let Some(period) = recovery_cadence.note(now) { - tracing::warn!( - period_s = format!("{:.1}", period.as_secs_f64()), - "client keyframe recoveries are METRONOMIC — a periodic host/display \ - disturbance (display-topology churn, display-poller software, \ - virtual-display timing) is the likely cause, not random network loss; \ - correlate with 'slow display-descriptor poll' / 'display descriptor \ - changed' / 'IDD-push capture stall' lines" - ); - } - } - } - // Measure the per-stage split when `PUNKTFUNK_PERF` is set OR a web-console stats capture is - // armed (a cheap Relaxed atomic, re-read each frame). The values feed the existing perf log - // unchanged and ride each FrameMsg to the send thread, which builds the aggregated sample. - let measure = perf || stats.is_armed(); - let t_cap = std::time::Instant::now(); - let cap_result = capturer.try_latest(); - let cap_us = if measure { - t_cap.elapsed().as_micros() as u32 - } else { - 0 - }; - if perf { - st_cap.push(cap_us); - } - let mut repeat = false; - match cap_result { - Ok(Some(f)) => { - frame = f; - diag_new += 1; - capture_rebuilds = 0; // a delivered frame clears the consecutive-loss counter - } - Ok(None) => { - diag_repeat += 1; // no new frame (static desktop / mid-rebuild) — repeat the last - repeat = true; - } - // The capture source died (PipeWire/compositor thread ended, virtual output gone). Rather - // than tear the whole session down — the client has no reconnect path and would have to - // cold-restart the handshake — rebuild the pipeline IN PLACE at the current mode, exactly - // like a mode/session switch. A genuinely dead source still ends the session once the - // bounded retry is exhausted; the consecutive cap stops a flapping source from looping the - // client through endless cold IDRs. - Err(e) => { - // B2: a DEDICATED gamescope game session whose gamescope node is gone = the game - // exited (gamescope is a single-app compositor — it dies with its app). End the session - // CLEANLY — close with `APP_EXITED_CLOSE_CODE` so a launcher client returns to its - // library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s - // timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal - // Bazzite/desktop capture loss still rebuilds in place. - // `cur_node_id` (the capture 5-tuple's node id) is read only by the Linux - // dedicated-game-exit check below; keep it read on other platforms so it isn't a - // write-only variable under `-D warnings` (the `let _ = &launch` idiom above). - #[cfg(not(target_os = "linux"))] - let _ = &cur_node_id; - #[cfg(target_os = "linux")] - if launch.is_some() - && crate::vdisplay::launch_is_nested(compositor) - && crate::vdisplay::dedicated_game_exited(cur_node_id) - { - tracing::info!( - "dedicated game session: the game exited — ending the session cleanly" - ); - quit.store(true, Ordering::SeqCst); // skip keep-alive linger — the game is gone - conn.close( - punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(), - b"game exited", - ); - break; - } - capture_rebuilds += 1; - if capture_rebuilds > MAX_CAPTURE_REBUILDS { - return Err(e).context("capture lost — rebuild attempts exhausted"); - } - tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds, - "capture lost — rebuilding pipeline in place"); - // A Bazzite/SteamOS Gaming↔Desktop switch tears the old compositor down and can take - // 15s+ to bring the new one up. Don't fail the session over that (the client would - // have to cold-reconnect, surfacing a "session failed") — keep retrying within a - // generous budget while the QUIC keepalive (its own thread) holds the connection, - // RE-DETECTING the live compositor each attempt so we follow the box to whatever - // session comes up: a fresh instance of the same compositor, OR a different one - // (the kind-change case the session watcher also handles). The client stays - // connected, frozen on the last frame, and the stream resumes when the new output - // appears — no reconnect. - const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); - let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET; - let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop { - // Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids - // retargeting (then we stick to the pinned backend and just rebuild it). - if crate::config::config().compositor.is_none() { - let active = crate::vdisplay::detect_active_session(); - // A4: fold any compositor-instance change into the epoch/invalidation before we - // rebuild, so the rebuild's acquire won't reuse a dead-instance node. - crate::vdisplay::observe_session_instance(&active); - if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) { - crate::vdisplay::apply_session_env(&active); - // Capture-loss rebuild follows the live box session, not a fresh dedicated launch. - crate::vdisplay::apply_input_env(c, false); - if c != compositor { - if matches!( - c, - crate::vdisplay::Compositor::Kwin - | crate::vdisplay::Compositor::Mutter - ) { - crate::vdisplay::settle_desktop_portal(c); - } - match crate::vdisplay::open(c) { - Ok(v) => { - tracing::info!(from = compositor.id(), to = c.id(), - "capture loss: active session switched compositor — retargeting"); - vd = v; - compositor = c; - } - Err(e2) => tracing::warn!(error = %format!("{e2:#}"), - "capture loss: opening the newly-detected compositor failed — retrying"), - } - } - } - } - match build_pipeline_with_retry( - &mut vd, - cur_mode, - bitrate_kbps, - bit_depth, - plan, - &quit, - &stop, - ) { - Ok(p) => break p, - Err(e2) => { - if stop.load(Ordering::SeqCst) - || std::time::Instant::now() >= rebuild_deadline - { - return Err(e2) - .context("capture lost — no compositor came up within the rebuild budget"); - } - tracing::warn!(error = %format!("{e2:#}"), - "capture lost — new session not up yet, retrying"); - } - } - }; - capturer = new_cap; - enc = new_enc; - frame = new_frame; - interval = new_interval; - cur_node_id = new_node_id; - cur_display_gen = new_display_gen; - enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway - last_forced_idr = Some(std::time::Instant::now()); // anchor the IDR cooldown from the rebuild - next = std::time::Instant::now(); - // The owed AUs died with the old encoder — drop their in-flight records and - // restart the encode-stall clock (the rebuild loop above may have eaten seconds, - // which must not count against the fresh encoder). - inflight.clear(); - last_au_at = std::time::Instant::now(); - encoder_resets = 0; - tracing::info!( - compositor = compositor.id(), - "capture loss: pipeline rebuilt — stream resumes" - ); - } - } - if perf && diag_at.elapsed() >= std::time::Duration::from_secs(2) { - let secs = diag_at.elapsed().as_secs_f64(); - tracing::info!( - new_fps = format!("{:.0}", diag_new as f64 / secs), - repeat_fps = format!("{:.0}", diag_repeat as f64 / secs), - "capture diag: NEW frames from the source vs REPEATS (low new_fps at high send rate ⇒ \ - the source isn't producing frames, not an encode stall)" - ); - let wait_max = st_wait.iter().copied().max().unwrap_or(0); - tracing::info!( - queue_us_p50 = percentile(&mut st_queue, 0.50), - queue_us_p99 = percentile(&mut st_queue, 0.99), - cap_us_p50 = percentile(&mut st_cap, 0.50), - cap_us_p99 = percentile(&mut st_cap, 0.99), - submit_us_p50 = percentile(&mut st_submit, 0.50), - submit_us_p99 = percentile(&mut st_submit, 0.99), - wait_us_p50 = percentile(&mut st_wait, 0.50), - wait_us_p99 = percentile(&mut st_wait, 0.99), - wait_us_max = wait_max, - "stage perf (µs/call): queue=delivery→submit cap=try_latest(ring+convert) submit=encode_picture wait=lock_bitstream(sched+ASIC)" - ); - st_cap.clear(); - st_submit.clear(); - st_wait.clear(); - st_queue.clear(); - diag_new = 0; - diag_repeat = 0; - diag_at = std::time::Instant::now(); - } - // The source's static HDR mastering metadata is the single source of truth: hand it to the - // encoder (in-band SEI on keyframes) and, when it changes, to the client (0xCE). Re-sent on - // each keyframe below so a dropped best-effort datagram converges within a GOP. PRESENCE is - // the capturer's call (Some iff the virtual display is in HDR mode); the VALUE prefers the - // client's own display volume when it sent one — the virtual display's EDID advertises - // exactly that volume, so host apps already tone-mapped the content into it and the honest - // mastering description IS the client's panel. (The IDD capturer only knows the generic - // baseline; if the driver ever forwards per-content IDDCX_HDR10_METADATA, prefer that here.) - let hdr_meta = capturer.hdr_meta().map(|m| client_hdr.unwrap_or(m)); - enc.set_hdr_meta(hdr_meta); - let mut resend_meta = hdr_meta != last_hdr_meta; - if resend_meta { - last_hdr_meta = hdr_meta; - } - // How deep to pipeline (1 = synchronous submit→poll, the original behaviour). The IDD-push - // capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1. - let depth = capturer.pipeline_depth().max(1); - let submit_ns = now_ns(); - // Wire pts: a fresh frame anchors at its capture-delivery stamp (`CapturedFrame.pts_ns`, - // stamped when the capture thread handed it over) so client-measured latency covers - // delivery + queue age, not just submit→glass; `queue_us` splits that age out as its own - // stage. A re-encoded hold anchors at "now" (its content age is unbounded by design). The - // stamp must be a recent wall-clock time — a synthetic/index-based or ahead-of-clock stamp - // (SyntheticCapturer counts from 0, not the epoch) falls back to "now". - let age_ns = submit_ns.saturating_sub(frame.pts_ns); - let plausible = frame.pts_ns > 0 && frame.pts_ns <= submit_ns && age_ns < 10_000_000_000; - let (capture_ns, queue_us) = if !repeat && plausible { - (frame.pts_ns, (age_ns / 1000) as u32) - } else { - (submit_ns, 0) - }; - if perf && !repeat { - st_queue.push(queue_us); - } - let t_submit = std::time::Instant::now(); - // This submission's future wire frame index (see `au_seq`): AUs are emitted FIFO one per - // submission, so it lands `inflight.len()` AUs after the `au_seq` the loop is about to - // assign next. The RFI backends pin their frame numbering to it. - let wire_index = au_seq.wrapping_add(inflight.len() as u32); - if let Err(e) = enc.submit_indexed(&frame, wire_index) { - // The input half of an encode stall: once the driver stops draining AUs, libavcodec's - // one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same - // wedge the watchdog below catches, seen from submit. Rebuild the encoder in place - // (bounded) instead of killing an otherwise healthy session; a backend without an - // in-place rebuild keeps today's fail-fast behavior. - encoder_resets += 1; - if encoder_resets > MAX_ENCODER_RESETS - || !reset_stalled_encoder(&mut enc, &mut inflight) - { - // Terminal: rebuilds are exhausted (or the backend can't rebuild in place). Say so - // plainly with the underlying cause — the per-reset lines above only ever repeat - // "rebuilt in place", so without this the session just vanishes. The error carries - // its own actionable text now (e.g. an NVENC version mismatch → "update/reboot the - // driver"), so this is the one line an operator needs. - tracing::error!( - error = %format!("{e:#}"), - resets = encoder_resets, - "encoder did not recover after repeated in-place rebuilds — ending the video \ - session (see the error above for the cause)"); - return Err(e).context("encoder submit"); - } - tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets, - max = MAX_ENCODER_RESETS, - "encoder submit failed — encoder rebuilt in place, forcing an IDR"); - last_au_at = std::time::Instant::now(); - // Back off exponentially between rebuild attempts (100 ms → 1.6 s, ~3 s total across - // the reset budget). One frame period is NOT enough: a 2026-07 field report showed all - // 5 resets burning within 40 ms at 120 Hz against a driver-side condition (NVENC - // session open failing after a codec switch) that no 8 ms retry could outlive — any - // transient like the previous session's deferred driver teardown needs real time. A - // genuinely dead encoder now costs ~3 s before the session ends with the terminal - // error, which the client's stall UI already covers. - let backoff = std::cmp::max( - interval, - std::time::Duration::from_millis(100u64 << (encoder_resets - 1).min(4)), - ); - next = std::time::Instant::now() + backoff; - std::thread::sleep(backoff); - continue; - } - let submit_us = if measure { - t_submit.elapsed().as_micros() as u32 - } else { - 0 - }; - if perf { - st_submit.push(submit_us); - } - // This frame's pacing deadline (the next frame's due time); the send thread spreads a big frame - // up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled. - next += interval; - inflight.push_back((capture_ns, submit_ns, next)); - // Drain the OLDEST in-flight frames, keeping at most depth-1 deferred. At depth 1 this polls - // immediately after every submit (synchronous); at depth 2 it polls N right after submitting N+1, - // so the encode of N overlaps the convert/copy of N+1. NVENC's `pending` is FIFO, so poll() returns - // the oldest submitted frame's AU — matching `inflight.pop_front()`. - let mut send_gone = false; - // A poll error is the explicit form of an encode stall (e.g. a QSV device failure); - // carry it to the shared stall recovery below instead of killing the session outright. - let mut poll_err: Option = None; - while inflight.len() >= depth { - let t_wait = std::time::Instant::now(); - let polled = enc.poll(); - let wait_us = if measure { - t_wait.elapsed().as_micros() as u32 - } else { - 0 - }; - if perf { - st_wait.push(wait_us); - } - let au = match polled { - Ok(Some(au)) => au, - // No AU ready for a submitted frame. Routine on the non-blocking backends (the - // libavcodec AMF/QSV wrapper holds ~2 frames; async NVENC drains a ready queue) — - // the frame stays in flight and the next tick re-polls. The stall watchdog below - // decides when "not ready yet" has become "the driver is wedged". - Ok(None) => break, - Err(e) => { - poll_err = Some(e); - break; - } - }; - // The encoder is alive: feed the stall watchdog, clear the consecutive-reset counter. - last_au_at = std::time::Instant::now(); - encoder_resets = 0; - let (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty"); - let mut flags = if au.keyframe { - (FLAG_PIC | FLAG_SOF) as u32 - } else { - FLAG_PIC as u32 - }; - // Intra-refresh recovery marking (inert unless the backend validated its constrained GDR - // via `intra_refresh_recovery`): tag every wave-boundary AU with USER_FLAG_RECOVERY_POINT - // so the client lifts its post-loss freeze on the second mark — a proven clean re-anchor — - // instead of forcing a full IDR. See [`mark_recovery_boundary`] for the cadence. - let caps = enc.caps(); - if caps.intra_refresh_recovery - && caps.intra_refresh_period > 0 - && mark_recovery_boundary(&mut ir_wave_pos, au.keyframe, caps.intra_refresh_period) - { - flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_POINT; - } - // Reference-frame-invalidation recovery frame (AMD LTR force-reference): a clean P-frame - // off a known-good reference. Tag it so the client lifts its post-loss freeze on this one - // AU without an IDR — the definitive single-frame re-anchor (see USER_FLAG_RECOVERY_ANCHOR). - if au.recovery_anchor { - flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR; - } - // Datagram-aligned PyroWave AU (plan §4.4): the client windows its parse at the - // shard payload and may opt into partial delivery of lossy frames. - if au.chunk_aligned { - flags |= punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED; - } - // Re-send the HDR mastering metadata (0xCE) on each keyframe (a decoder-resync point) and - // whenever it changed, so a client that dropped the best-effort datagram re-converges. - if let Some(m) = last_hdr_meta { - if au.keyframe || resend_meta { - let _ = conn - .send_datagram(punktfunk_core::quic::encode_hdr_meta_datagram(&m).into()); - resend_meta = false; - } - } - let encode_us = (now_ns().saturating_sub(sub_ns) / 1000) as u32; - let msg = FrameMsg { - data: au.data, - capture_ns: cap_ns, - flags, - frame_index: au_seq, - deadline, - encode_us, - queue_us, - cap_us, - submit_us, - wait_us, - repeat, - was_measured: measure, - }; - // Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it - // exited (send failure / stop) — end the encode loop too. - if frame_tx.send(msg).is_err() { - send_gone = true; - break; - } - au_seq = au_seq.wrapping_add(1); - sent += 1; - } - if send_gone { - break; - } - // Encode-stall watchdog. Trip on: an explicit poll error; no AU within the window while - // frames are owed (the full wedge — AMF/QSV's non-blocking poll returns None forever and - // nothing else ever errors); or an owed backlog worth more than the window's frames (the - // slow leak — AUs still trickle, so the gap never trips, but latency grows without bound). - // Recovery rebuilds the encoder in place and forces an IDR — a logged ~one-second hiccup - // instead of a silent permanent freeze — bounded so a genuinely dead encoder still ends - // the session with a clear error. The window scales with the frame interval so low-fps - // modes (where the AMF wrapper's ~2-frame hold spans seconds) can't false-trip. - let stall_window = ENCODE_STALL_WINDOW.max(interval * 8); - let stall_backlog = - depth + (stall_window.as_secs_f64() / interval.as_secs_f64().max(1e-6)).ceil() as usize; - if poll_err.is_some() - || (!inflight.is_empty() - && (last_au_at.elapsed() >= stall_window || inflight.len() > stall_backlog)) - { - let why = match &poll_err { - Some(e) => format!("poll failed: {e:#}"), - None => format!( - "no AU for {} ms with {} frame(s) in flight", - last_au_at.elapsed().as_millis(), - inflight.len() - ), - }; - encoder_resets += 1; - if encoder_resets > MAX_ENCODER_RESETS - || !reset_stalled_encoder(&mut enc, &mut inflight) - { - return Err(poll_err.unwrap_or_else(|| anyhow!("{why}"))) - .context("encoder stalled — in-place rebuild unavailable or exhausted"); - } - tracing::warn!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why, - "encode stall detected — encoder rebuilt in place, forcing an IDR"); - last_au_at = std::time::Instant::now(); - } - match next.checked_duration_since(std::time::Instant::now()) { - Some(d) => std::thread::sleep(d), - None => next = std::time::Instant::now(), - } - } - // Drain the in-flight tail (the depth-1 frames submitted but not yet polled) so the last frames still - // reach the client instead of being dropped on the way out. - while let Some((cap_ns, sub_ns, deadline)) = inflight.pop_front() { - let Ok(Some(au)) = enc.poll() else { break }; - let flags = if au.keyframe { - (FLAG_PIC | FLAG_SOF) as u32 - } else { - FLAG_PIC as u32 - }; - let encode_us = (now_ns().saturating_sub(sub_ns) / 1000) as u32; - // End-of-stream tail drain: the per-stage split isn't measured here (the capture loop has - // exited), so leave it zero — these last few frames are negligible for the aggregates. - let msg = FrameMsg { - data: au.data, - capture_ns: cap_ns, - flags, - frame_index: au_seq, - deadline, - encode_us, - queue_us: 0, - cap_us: 0, - submit_us: 0, - wait_us: 0, - repeat: false, - was_measured: false, - }; - if frame_tx.send(msg).is_err() { - break; - } - au_seq = au_seq.wrapping_add(1); - sent += 1; - } - // Signal the send thread to drain + exit (drop the channel), then join it. - drop(frame_tx); - let _ = send_thread.join(); - tracing::info!(sent, "punktfunk/1 virtual stream complete"); - Ok(()) -} - -/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval). -/// Dropping the capturer tears down the PipeWire stream and the virtual output with it. -type Pipeline = ( - Box, - Box, - crate::capture::CapturedFrame, - std::time::Duration, - // The virtual output's PipeWire node id — used by the B2 dedicated game-exit probe to check THIS - // session's own node (scoped), not any gamescope node. `0` for backends without a PipeWire node - // (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway. - u32, - // The display's registry pool generation (Linux keep-alive pool only; `None` on Windows — the - // manager leases in place — and for non-poolable outputs). A mode-switch rebuild uses it to - // `registry::retire` the superseded old display, so linger/forever keep-alive policies don't - // accumulate kept monitors at stale modes (design/midstream-resolution-resize.md H4). - Option, -); - -/// Build the pipeline, retrying *transient* failures with bounded exponential backoff. -/// -/// Bringing a virtual output to first-frame races several async steps — the compositor parenting -/// the output, the portal/RemoteDesktop grant, PipeWire format negotiation — any of which can -/// momentarily time out on a cold session. A single timed-out attempt shouldn't abort the whole -/// punktfunk/1 session. But a *permanent* failure (unsupported compositor/mode, a KWin too old to -/// create virtual outputs, a missing tool) must fail fast instead of burning the budget — so the -/// error chain is classified and permanent ones short-circuit. Each failed attempt drops its -/// capturer, which (via `PortalCapturer::Drop`) tears the PipeWire thread + virtual output down -/// before the next attempt — no leak across retries. -fn build_pipeline_with_retry( - vd: &mut Box, - mode: punktfunk_core::Mode, - bitrate_kbps: u32, - bit_depth: u8, - plan: crate::session_plan::SessionPlan, - quit: &Arc, - stop: &Arc, -) -> Result { - // ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed - // gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take - // 30-60s to produce its first frame, and a first-connect timeout would tear down the warm - // session (forcing another cold start on reconnect). A genuinely permanent failure still fails - // fast via `is_permanent_build_error`; only transient "no frame yet" retries consume the budget. - // IDD-push only: HOLD one monitor lease across all build attempts. A failed attempt's capturer - // drop releases ITS lease, but this held lease keeps the shared monitor Active (refs >= 1), so the - // next attempt's `vd.create` JOINS it (refcount++) instead of finding it Lingering and tripping the - // IDD-push reconnect PREEMPT (teardown + recreate). That preempt-per-retry was the REMOVE→ADD churn - // that exhausts the IddCx monitor-slot pool and wedges ADD at 0x80070490 — one ADD per cold start - // now, not one per attempt. Non-IDD-push backends (Linux portal, WGC) don't use the refcount manager - // and aren't churn-wedge-prone, so they keep create-per-attempt (a held lease there would allocate a - // second virtual output). Dropped when this fn returns — on success the Pipeline's own lease keeps - // the monitor Active; on failure refs falls to 0 → Lingering → linger-timeout teardown. - let _retry_hold = if matches!(plan.capture, crate::session_plan::CaptureBackend::IddPush) { - Some( - vd.create(mode) - .context("acquire virtual output for the session (retry-hold lease)")?, - ) - } else { - None - }; - const MAX_ATTEMPTS: u32 = 8; - let mut backoff = std::time::Duration::from_millis(500); - for attempt in 1..=MAX_ATTEMPTS { - // The client is gone (connection closed → `stop`): every further attempt only churns the - // box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and - // relaunching the box's Steam session once per attempt for minutes (the .181 storm - // 2026-07-07). One in-flight attempt can still overhang; this bounds the damage to it. - if attempt > 1 && stop.load(Ordering::SeqCst) { - anyhow::bail!( - "session ended (client disconnected) during pipeline build — aborting retries \ - after {} attempt(s)", - attempt - 1 - ); - } - match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) { - Ok(pipe) => { - if attempt > 1 { - tracing::info!(attempt, "pipeline up after retry"); - } - return Ok(pipe); - } - Err(e) => { - let chain = format!("{e:#}"); - let permanent = is_permanent_build_error(&chain); - if permanent || attempt == MAX_ATTEMPTS { - let why = if permanent { - "permanent" - } else { - "out of retries" - }; - return Err(e).with_context(|| { - format!("pipeline build failed ({why}) after {attempt} attempt(s)") - }); - } - tracing::warn!( - attempt, - max = MAX_ATTEMPTS, - backoff_ms = backoff.as_millis() as u64, - error = %chain, - "pipeline build failed — retrying" - ); - std::thread::sleep(backoff); - backoff = (backoff * 2).min(std::time::Duration::from_secs(2)); - } - } - } - unreachable!("the final attempt returns inside the loop") -} - -/// Is a pipeline-build error permanent (retrying won't help within this session)? Matches the -/// error chain against signatures that don't change between attempts: unsupported compositor or -/// mode, a KWin too old to expose virtual outputs, a missing/unparseable config, a tool that -/// isn't installed. Everything else — portal/PipeWire negotiation timeouts, "no frame within -/// 10s", transient node races — is treated as transient and retried. Biased toward "transient": -/// a misjudged permanent error only costs a few seconds before it fails anyway. -fn is_permanent_build_error(chain: &str) -> bool { - const PERMANENT: &[&str] = &[ - "virtual displays require linux", - "unknown punktfunk_compositor", - "could not detect compositor", - "could not find output", // KWin < 6.5.6: createVirtualOutput unsupported - "must be a node id", // PUNKTFUNK_GAMESCOPE_NODE not an integer - "is it installed", // gamescope / kscreen-doctor not on PATH - // 4:4:4 NVENC got a CUDA frame — should never happen now the Linux capturer honors gpu=false, - // but fail fast instead of 8× retry (~90 s) rather than wedge the session if it ever recurs. - "capture/encoder negotiation mismatch", - ]; - let lower = chain.to_ascii_lowercase(); - PERMANENT.iter().any(|p| lower.contains(p)) -} - -/// Encode-stall recovery: rebuild the encoder in place (keeping capture + the session up) and -/// discard the owed in-flight frame records — their AUs died with the old encoder instance. -/// Returns `false` when the backend has no in-place rebuild ([`crate::encode::Encoder::reset`]'s -/// default); the caller then surfaces the stall as a session error instead. The forced keyframe -/// makes the rebuilt encoder's first frame an immediate decoder resync point (belt-and-suspenders: -/// a fresh encoder opens on an IDR anyway). -fn reset_stalled_encoder( - enc: &mut Box, - inflight: &mut std::collections::VecDeque<(u64, u64, std::time::Instant)>, -) -> bool { - if !enc.reset() { - return false; - } - inflight.clear(); - enc.request_keyframe(); - true -} - -fn build_pipeline( - vd: &mut Box, - mode: punktfunk_core::Mode, - bitrate_kbps: u32, - bit_depth: u8, - plan: crate::session_plan::SessionPlan, - quit: &Arc, -) -> Result { - // Acquire through the registry (design/display-management.md): on Linux this pools the display - // for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the - // session per policy); on Windows it delegates to `vd.create` (the manager already leases). The - // returned `VirtualOutput`'s keepalive is a registry lease — the capturer holds it as before. The - // `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger. - let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone()) - .context("create virtual output")?; - // A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down - // so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the - // gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.) - #[cfg(target_os = "linux")] - let reused_gen = vout.reused_gen; - // The display's pool generation (fresh AND reused), threaded out so a mode-switch rebuild can - // `registry::retire` the display this pipeline supersedes (H4). `None` off Linux / non-poolable. - #[cfg(target_os = "linux")] - let pool_gen = vout.pool_gen; - #[cfg(not(target_os = "linux"))] - let pool_gen = None; - // The virtual output's PipeWire node id — kept for the B2 dedicated game-exit probe (scoped to - // this session's own node). Read before `capture_virtual_output` consumes `vout`. - let node_id = vout.node_id; - // The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a - // virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame - // clock to that, not the requested rate, so we don't emit phantom duplicate frames over a - // slower source. Falls back to the requested rate when a backend reports nothing. - let effective_hz = vout - .preferred_mode - .map(|(_, _, hz)| hz) - .filter(|&hz| hz > 0) - .unwrap_or(mode.refresh_hz); - if effective_hz != mode.refresh_hz { - tracing::warn!( - requested = mode.refresh_hz, - effective = effective_hz, - "compositor did not honor the requested refresh — encoding at the achieved rate" - ); - } - // HDR vs SDR for the IDD-push conversion: a negotiated 10-bit session (client advertised - // VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2; - // otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends, - // which auto-detect HDR from the monitor state.) - let mut capturer = - crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture) - .context("capture virtual output")?; - capturer.set_active(true); - let frame = match capturer.next_frame().context("first frame") { - Ok(f) => f, - Err(e) => { - // A reused kept display was dead — invalidate it so the next attempt creates fresh (A2). - #[cfg(target_os = "linux")] - if let Some(g) = reused_gen { - crate::vdisplay::registry::mark_failed(g); - } - return Err(e); - } - }; - // `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client - // advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome. - let mut enc = crate::encode::open_video( - plan.codec, - frame.format, - frame.width, - frame.height, - effective_hz, - bitrate_kbps as u64 * 1000, - frame.is_cuda(), - bit_depth, - plan.chroma, - ) - .context("open video encoder")?; - if let Some(c) = plan.wire_chunk { - enc.set_wire_chunking(c); - } - // Post-open cross-check: the Welcome already committed `chroma_format` from the pre-open probe, so - // warn loudly if the encoder actually opened a different chroma than negotiated (the in-band SPS is - // authoritative for the decoder, but a mismatch means the probe and the live open disagreed). - let opened_444 = enc.caps().chroma_444; - if opened_444 != plan.chroma.is_444() { - tracing::warn!( - negotiated_444 = plan.chroma.is_444(), - opened_444, - "encoder chroma disagrees with the negotiated Welcome — the client was told the other value" - ); - } - let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); - Ok((capturer, enc, frame, interval, node_id, pool_gen)) -} - #[cfg(test)] mod tests { use super::*; @@ -3582,55 +1363,6 @@ mod tests { assert_eq!(capped.refresh_hz, 30); } - #[test] - fn reconfig_allowed_gates_gamescope_and_per_client_mode() { - use crate::vdisplay::Compositor::{Gamescope, Hyprland, Kwin, Mutter, Wlroots}; - // gamescope ALWAYS rejects — a resize would respawn the nested game (H1/D3), regardless of - // the identity policy. - assert!(!reconfig_allowed(Some(Gamescope), false)); - assert!(!reconfig_allowed(Some(Gamescope), true)); - // A per-client-mode identity policy rejects on every backend — the resize resolves a - // different display-identity slot (H5). - assert!(!reconfig_allowed(Some(Kwin), true)); - assert!(!reconfig_allowed(Some(Mutter), true)); - assert!(!reconfig_allowed(None, true)); - // Every other compositor with the default identity ACCEPTS (recreate / re-arrival / in-place). - for c in [Kwin, Mutter, Wlroots, Hyprland] { - assert!( - reconfig_allowed(Some(c), false), - "{c:?} should allow live reconfigure" - ); - } - // The synthetic source (no compositor) is the protocol-test path — always reconfigurable. - assert!(reconfig_allowed(None, false)); - } - - #[test] - fn recovery_marks_land_every_period_and_rephase_at_idr() { - let period = 4; - let mut pos = 0u32; - // Frames 1..=3 are mid-wave (no mark), frame 4 is the boundary; then it repeats. - let marks: Vec = (0..10) - .map(|_| mark_recovery_boundary(&mut pos, false, period)) - .collect(); - assert_eq!( - marks, - vec![false, false, false, true, false, false, false, true, false, false] - ); - - // An IDR mid-wave re-phases: the counter restarts, so the next boundary is a full period - // later (an IDR is itself a clean anchor, so it is not additionally marked). - let mut pos = 0u32; - assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 1 - assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 2 - assert!(!mark_recovery_boundary(&mut pos, true, period)); // IDR → pos 0, no mark - // Now a fresh full period is needed, not just the 2 remaining frames. - assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 1 - assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 2 - assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 3 - assert!(mark_recovery_boundary(&mut pos, false, period)); // pos 4 → mark - } - #[test] fn pyrowave_bitrate_pins_to_bpp_default() { use punktfunk_core::config::Mode; @@ -3706,29 +1438,6 @@ mod tests { ); } - #[test] - fn permanent_errors_short_circuit_retry() { - // Permanent: config / version / missing-tool — retrying within a session can't fix these. - assert!(is_permanent_build_error( - "create virtual output: KWin virtual output failed: Could not find output" - )); - assert!(is_permanent_build_error( - "unknown PUNKTFUNK_COMPOSITOR 'foo' (kwin|wlroots|mutter|gamescope)" - )); - assert!(is_permanent_build_error( - "spawn gamescope (is it installed? `apt install gamescope`)" - )); - assert!(is_permanent_build_error("virtual displays require Linux")); - // Transient: negotiation/timeout races — exactly what backoff is for. - assert!(!is_permanent_build_error( - "first frame: no PipeWire frame within 10s (node 42): format negotiation never completed" - )); - assert!(!is_permanent_build_error( - "create virtual output: timed out creating the KWin virtual output" - )); - assert!(!is_permanent_build_error("open NVENC: device busy")); - } - /// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value, read /// through the GameStream namespace (`crate::gamestream::gamepad`, which re-exports /// `punktfunk_core::input::gamepad` — the punktfunk/1 native wire and the GameStream/Limelight diff --git a/crates/punktfunk-host/src/native/control.rs b/crates/punktfunk-host/src/native/control.rs new file mode 100644 index 00000000..a4e1af48 --- /dev/null +++ b/crates/punktfunk-host/src/native/control.rs @@ -0,0 +1,206 @@ +//! The native `punktfunk/1` mid-stream control task (plan §W1 — carved out of [`super`]'s +//! `serve_session`). After the handshake the control stream stays open for renegotiation and +//! speed tests; this task multiplexes the inbound client requests (`Reconfigure` / +//! `RequestKeyframe` / `RfiRequest` / `LossReport` / `SetBitrate` / `ProbeRequest` / `ClockProbe`) +//! with the outbound probe-result and mode-correction channels, handing every validated change to +//! the data-plane thread over the session's mpsc bridges. + +use super::*; + +/// Run the control task for one live session. Owns the control streams (`serve_session` hands them +/// off after negotiation) plus every channel end that bridges to the data-plane thread. Returns +/// when the control stream closes or a data-plane channel drops. +#[allow(clippy::too_many_arguments)] +pub(super) async fn run( + mut ctrl_send: quinn::SendStream, + mut ctrl_recv: quinn::RecvStream, + initial_mode: punktfunk_core::Mode, + codec: crate::encode::Codec, + live_reconfig_ok: bool, + adaptive_fec: bool, + session_bitrate_kbps: u32, + fec_target_ctl: Arc, + reconfig_tx: std::sync::mpsc::Sender, + keyframe_tx: std::sync::mpsc::Sender<()>, + rfi_tx: std::sync::mpsc::Sender<(u32, u32)>, + bitrate_tx: std::sync::mpsc::Sender, + probe_tx: std::sync::mpsc::Sender, + mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver, + mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver, +) { + let mut active = initial_mode; + // Host-side switch rate limit (a backstop against a hostile/broken client spamming + // Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already + // coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s). + const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); + let mut last_accepted_switch: Option = None; + loop { + tokio::select! { + msg = io::read_msg(&mut ctrl_recv) => { + let Ok(msg) = msg else { break }; // stream closed + if let Ok(req) = Reconfigure::decode(&msg) { + let now = std::time::Instant::now(); + let valid = req.mode.refresh_hz > 0 + && crate::encode::validate_dimensions( + codec, + req.mode.width, + req.mode.height, + ) + .is_ok(); + let too_soon = last_accepted_switch + .is_some_and(|t| now.duration_since(t) < MIN_SWITCH_INTERVAL); + let ok = if !live_reconfig_ok { + // Backend can't live-reconfigure (gamescope / synthetic / + // per-client-mode identity — see the gate above): honest downgrade, + // the client keeps scaling client-side. + tracing::info!(mode = ?req.mode, + "mode switch rejected (backend cannot live-reconfigure)"); + false + } else if !valid { + tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)"); + false + } else if too_soon { + tracing::warn!(mode = ?req.mode, "mode switch rejected (rate-limited)"); + false + } else { + true + }; + if ok { + active = req.mode; + last_accepted_switch = Some(now); + tracing::info!(mode = ?req.mode, "mode switch accepted"); + } + let ack = Reconfigured { accepted: ok, mode: active }; + if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { + break; + } + if ok && reconfig_tx.send(req.mode).is_err() { + break; // data plane gone + } + } else if RequestKeyframe::decode(&msg).is_ok() { + // Client recovery: its decoder wedged — force the next encoded frame to + // be an IDR. Coalesced in the encode loop (a wedge fires several before + // the IDR lands); a send error just means the data plane is gone. + tracing::debug!("client requested keyframe (decode recovery)"); + if keyframe_tx.send(()).is_err() { + break; // data plane gone + } + } else if let Ok(req) = RfiRequest::decode(&msg) { + // Client LTR-RFI recovery: it lost the frame range `[first, last]` and asks + // the encoder to re-reference a known-good older frame instead of paying for + // a full IDR. The encode loop attempts `invalidate_ref_frames`, falling back + // to a coalesced keyframe when the encoder can't (range too old / no RFI). + tracing::debug!( + first = req.first_frame, + last = req.last_frame, + "client requested reference-frame invalidation (loss recovery)" + ); + if rfi_tx.send((req.first_frame, req.last_frame)).is_err() { + break; // data plane gone + } + } else if let Ok(rep) = LossReport::decode(&msg) { + // Adaptive FEC: size recovery to the loss the client is seeing. The data-plane + // send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC + // is pinned via PUNKTFUNK_FEC_PCT. + if adaptive_fec { + // Fast attack, slow decay: jump straight to what the reported loss + // needs, but come DOWN only one point per clean report (~750 ms). The + // memoryless controller ping-ponged on periodic burst loss (Wi-Fi + // scans / BT coexistence, a burst every few seconds): a single clean + // window dropped FEC back to the floor, so every next burst hit an + // unprotected stream — an unrecoverable frame, a freeze, and a + // recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps + // the stream covered across the gap while still converging to FEC_MIN + // on a genuinely clean link. + let prev = fec_target_ctl.load(Ordering::Relaxed); + let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1)); + fec_target_ctl.store(target, Ordering::Relaxed); + if prev != target { + tracing::debug!( + loss_ppm = rep.loss_ppm, + fec_pct = target, + prev_fec_pct = prev, + "adaptive FEC adjusted" + ); + } + } + } else if let Ok(req) = SetBitrate::decode(&msg) { + // Mid-stream bitrate renegotiation (adaptive bitrate): clamp exactly like + // the Hello request, ack the resolved value, then hand it to the data-plane + // thread, which rebuilds the encoder in place at the same mode — the fresh + // encoder's first frame is an IDR with in-band parameter sets, so the + // client's decoder follows without a reconnect. + // PyroWave: the rate is PINNED (§4.6 — quality collapses under rate + // descent; recovery pressure is answered by codec fallback, not AIMD). + // Our client controller is off for this codec; this guards older or + // foreign clients by acking the unchanged session rate. + let resolved = if codec == crate::encode::Codec::PyroWave { + tracing::info!( + requested_kbps = req.bitrate_kbps, + pinned_kbps = session_bitrate_kbps, + "PyroWave session: mid-stream bitrate retarget refused (pinned)" + ); + session_bitrate_kbps + } else { + resolve_bitrate_kbps(req.bitrate_kbps) + }; + tracing::debug!( + requested_kbps = req.bitrate_kbps, + resolved_kbps = resolved, + "mid-stream bitrate change requested" + ); + let ack = BitrateChanged { + bitrate_kbps: resolved, + }; + if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { + break; + } + if bitrate_tx.send(resolved).is_err() { + break; // data plane gone + } + } else if let Ok(req) = ProbeRequest::decode(&msg) { + tracing::info!( + target_kbps = req.target_kbps, + duration_ms = req.duration_ms, + "speed-test probe requested" + ); + if probe_tx.send(req).is_err() { + break; // data plane gone + } + } else if let Ok(probe) = ClockProbe::decode(&msg) { + // Wall-clock skew handshake: echo the client's t1 with our receive (t2) and + // send (t3) stamps, both in the host clock the AU pts_ns uses. Answered + // inline on the control stream — cheap, no data-plane involvement. + let t2_ns = now_ns(); + let echo = ClockEcho { + t1_ns: probe.t1_ns, + t2_ns, + t3_ns: now_ns(), + }; + if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() { + break; + } + } else { + tracing::warn!("unknown control message — ignoring"); + } + } + result = probe_result_rx.recv() => { + let Some(result) = result else { break }; // data plane gone + if io::write_msg(&mut ctrl_send, &result.encode()).await.is_err() { + break; + } + } + correction = reconfig_result_rx.recv() => { + // H2 rollback/correction ack: the data plane reports the mode ACTUALLY live + // after a rebuild that failed (stayed at the old mode) or that the backend + // honored at a different refresh. Track it so a later rejection's + // `mode: active` echo is truthful too. + let Some(ack) = correction else { break }; // data plane gone + active = ack.mode; + if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { + break; + } + } + } + } +} diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs new file mode 100644 index 00000000..eff57d85 --- /dev/null +++ b/crates/punktfunk-host/src/native/stream.rs @@ -0,0 +1,2153 @@ +//! The native `punktfunk/1` data plane (plan §W1 — carved out of [`super`]'s `serve_session`). +//! This module owns the capture→encode→send pipeline: the synthetic protocol-test source, the +//! virtual-display stream loop ([`virtual_stream`]) with its mid-stream reconfigure / adaptive- +//! bitrate / recovery machinery, the dedicated microburst-paced send thread ([`send_loop`]), the +//! speed-test probe bursts, the mid-stream session-switch watcher, and pipeline construction with +//! bounded retry. `serve_session` stands a session up and hands it a [`SessionContext`]. + +use super::*; + +/// Advance the intra-refresh wave position and decide whether this emitted AU is a wave boundary +/// that should carry [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT). +/// +/// `ir_wave_pos` counts frames since the last IDR/wave start; a real IDR re-phases it to 0 (an IDR +/// restarts the encoder's wave AND is itself a clean anchor, so it is never additionally marked). +/// Every `period`-th non-IDR AU is a boundary — the client lifts its post-loss freeze on the SECOND +/// such mark. Pure so the marking cadence is unit-tested without a GPU (see the pump's use in the +/// encode-poll loop). +fn mark_recovery_boundary(ir_wave_pos: &mut u32, is_keyframe: bool, period: u32) -> bool { + if is_keyframe { + *ir_wave_pos = 0; + false + } else { + *ir_wave_pos += 1; + if *ir_wave_pos >= period { + *ir_wave_pos = 0; + true + } else { + false + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn synthetic_stream( + session: &mut Session, + frames: u32, + stop: &AtomicBool, + probe_rx: &std::sync::mpsc::Receiver, + probe_result_tx: &tokio::sync::mpsc::UnboundedSender, + fec_target: &AtomicU8, + timing_conn: Option<&quinn::Connection>, + probe_seq: bool, +) -> Result<()> { + let interval = std::time::Duration::from_millis(1000 / 60); + for idx in 0..frames { + if stop.load(Ordering::SeqCst) { + break; + } + apply_fec_target(session, fec_target); + // Service speed-test probes between synthetic frames (loopback bandwidth tests). + service_probes(session, stop, probe_rx, probe_result_tx, probe_seq); + let data = test_frame(idx, 64 * 1024); + let pts_ns = now_ns(); + session + .submit_frame(&data, pts_ns, (FLAG_PIC | FLAG_SOF) as u32) + .map_err(|e| anyhow!("submit_frame: {e:?}"))?; + // Host timing (0xCF) for protocol tests: near-zero here (no capture/encode), but it + // proves the plane end-to-end on a pure loopback run. + if let Some(tc) = timing_conn { + let t = punktfunk_core::quic::HostTiming { + pts_ns, + host_us: (now_ns().saturating_sub(pts_ns) / 1000).min(u32::MAX as u64) as u32, + }; + let _ = tc.send_datagram(punktfunk_core::quic::encode_host_timing_datagram(&t).into()); + } + std::thread::sleep(interval); + } + tracing::info!(frames, "synthetic stream complete"); + Ok(()) +} + +/// Bounds a speed-test [`ProbeRequest`] before bursting: a 3 Gbps / 5 s ceiling keeps a probe from +/// monopolizing the link or stalling the stream for too long. The ceiling is set ABOVE the session +/// bitrate cap ([`MAX_BITRATE_KBPS`], 2 Gbps) on purpose — a probe should be able to demonstrate +/// headroom past the rate a session will actually be configured to use, so the client can pick a +/// confident 1 Gbps+ bitrate. GF(2¹⁶) FEC makes multi-Gbps reachable on a LAN. +const MAX_PROBE_KBPS: u32 = 10_000_000; +const MAX_PROBE_MS: u32 = 5_000; + +/// Run a bandwidth probe over `session`: burst zero-filled access units flagged [`FLAG_PROBE`] at +/// `req.target_kbps` of goodput for `req.duration_ms` (both clamped to `MAX_PROBE_*`), pacing by a +/// "bytes allowed so far" budget so scheduling jitter doesn't overshoot the target. Returns what +/// was actually offered so the client can compute delivery ratio (`received / bytes_sent`) and +/// throughput. Video is paused for the duration (the caller's loop is blocked here) — a speed test +/// is a deliberate, short interruption the client initiates. +fn run_probe_burst( + session: &mut Session, + req: ProbeRequest, + stop: &AtomicBool, + probe_seq: bool, +) -> ProbeResult { + let target_kbps = req.target_kbps.min(MAX_PROBE_KBPS); + let duration_ms = req.duration_ms.min(MAX_PROBE_MS); + // Probe filler is sealed in the PROBE index space (its own frame counter — video indexes are + // owned by the encode loop and must stay 1:1 with the encoder's RFI bookkeeping). A client + // that didn't advertise VIDEO_CAP_PROBE_SEQ reassembles everything in one window and would + // drop probe-space frames as stale against the video stream — measuring garbage — so its + // mid-session probe is DECLINED (zeroed result) instead. Old sealing (probe filler consuming + // video indexes) is not an option anymore: those indexes are invisible to every client gap + // detector and read as a phantom multi-thousand-frame loss after the burst. + if !probe_seq { + tracing::info!( + "declining speed-test probe: client predates VIDEO_CAP_PROBE_SEQ (its reassembler \ + cannot window probe-space frames)" + ); + return ProbeResult { + bytes_sent: 0, + packets_sent: 0, + duration_ms: 0, + wire_packets_sent: 0, + send_dropped: 0, + }; + } + if target_kbps == 0 || duration_ms == 0 { + return ProbeResult { + bytes_sent: 0, + packets_sent: 0, + duration_ms: 0, + wire_packets_sent: 0, + send_dropped: 0, + }; + } + // kbps -> bytes/s (x1000/8). + let bytes_per_sec = target_kbps as u64 * 125; + // Keep each AU a SMALL burst (~16 KB ≈ a dozen MTU shards) and let the byte budget below pace + // the rate finely. The old 256 KB cap blasted ~200 packets into the send buffer per submit, so + // a small buffer (e.g. the Deck's 416 KB) overflowed on a single AU and the test measured + // self-inflicted buffer overflow instead of the link — mirror how `paced_submit` spreads the + // real video path's frames so the probe stresses the same way a real stream does. + let chunk = (bytes_per_sec / 240).clamp(1200, 16 * 1024) as usize; + let filler = vec![0u8; chunk]; + // Wire-packet accounting via session-stat deltas: `packets_sent` counts every sealed wire packet + // (seal_frame), `packets_send_dropped` every one the send buffer rejected (WouldBlock/ENOBUFS). + // Their delta over the burst is exact — and isolates host-side drops from link loss for the + // client. Video is paused for the burst (the data-plane loop is blocked here), so these deltas + // are pure probe traffic. + let wire0 = session.stats().packets_sent; + let drop0 = session.stats().packets_send_dropped; + let start = std::time::Instant::now(); + let deadline = start + std::time::Duration::from_millis(duration_ms as u64); + let mut bytes_sent = 0u64; + let mut packets_sent = 0u32; // probe access-unit count (goodput chunks) + while std::time::Instant::now() < deadline && !stop.load(Ordering::SeqCst) { + let allowed = (start.elapsed().as_secs_f64() * bytes_per_sec as f64) as u64; + if bytes_sent < allowed { + // A full send buffer drops on WouldBlock/ENOBUFS (UdpTransport returns Ok) — that loss is + // part of what the probe measures (it surfaces as send_dropped), so keep going. Sealed + // in the probe index space (FLAG_PROBE + its own counter) — never a video frame_index. + let _ = session.submit_probe_frame(&filler, now_ns()); + bytes_sent += chunk as u64; + packets_sent += 1; + } else { + std::thread::sleep(std::time::Duration::from_micros(200)); + } + } + let actual_ms = start.elapsed().as_millis() as u32; + let wire_offered = (session.stats().packets_sent - wire0) as u32; + let send_dropped = (session.stats().packets_send_dropped - drop0) as u32; + let wire_packets_sent = wire_offered.saturating_sub(send_dropped); + tracing::info!( + target_kbps, + duration_ms = actual_ms, + bytes_sent, + au_count = packets_sent, + wire_offered, + wire_packets_sent, + send_dropped, + "speed-test probe burst complete" + ); + ProbeResult { + bytes_sent, + packets_sent, + duration_ms: actual_ms, + wire_packets_sent, + send_dropped, + } +} + +/// Drain any pending speed-test requests and run each burst, replying with its [`ProbeResult`]. +/// Called once per data-plane loop iteration so a probe runs between frames. `probe_seq` = the +/// client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`] (see [`run_probe_burst`]). +fn service_probes( + session: &mut Session, + stop: &AtomicBool, + probe_rx: &std::sync::mpsc::Receiver, + probe_result_tx: &tokio::sync::mpsc::UnboundedSender, + probe_seq: bool, +) { + while let Ok(req) = probe_rx.try_recv() { + let result = run_probe_burst(session, req, stop, probe_seq); + let _ = probe_result_tx.send(result); + } +} + +/// Seal one access unit and send it with MICROBURST pacing (the shared +/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap` +/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and +/// only the OVERFLOW beyond that is spread across ~90% of the time to `deadline` in ADAPTIVE +/// chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment cap) once +/// the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace instead +/// of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto: +/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a +/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` = +/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in +/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame +/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced +/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze +/// until the next keyframe). With no slack (encode ≈ interval) the budget collapses to 0 and +/// even the overflow goes out immediately, so this is never slower than unpaced. +#[allow(clippy::too_many_arguments)] +fn paced_submit( + session: &mut Session, + data: &[u8], + pts_ns: u64, + flags: u32, + frame_index: u32, + deadline: std::time::Instant, + burst_cap: Option, +) -> Result { + let wires = session + .seal_frame_at(data, pts_ns, flags, frame_index) + .map_err(|e| anyhow!("seal_frame: {e:?}"))?; + let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect(); + // FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors. + crate::send_pacing::inject_video_drop(&mut refs); + let wire_bytes: usize = refs.iter().map(|p| p.len()).sum(); + let cfg = crate::send_pacing::PaceCfg { + burst_bytes: Some(burst_cap.unwrap_or_else(|| (wire_bytes / 4).max(128 * 1024))), + chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 }, + sleep_floor: std::time::Duration::from_micros(500), + }; + // Time the socket handoff per chunk and fold it into the session's SealPerf split — the + // sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time. + let mut sock_ns = 0u64; + let result = crate::send_pacing::pace_frame( + &refs, + crate::send_pacing::PaceBudget::UntilDeadline { + deadline, + fraction: 0.9, + }, + &cfg, + |chunk| { + let t0 = std::time::Instant::now(); + let r = session.send_sealed(chunk).map(|_| ()); + sock_ns += t0.elapsed().as_nanos() as u64; + r + }, + ); + drop(refs); // release the borrow of `wires` so it can return to the seal pool + session.reclaim_wires(wires); + session.note_sock_ns(sock_ns); + result.map_err(|e| anyhow!("send_sealed: {e:?}")) +} + +/// One encoded frame handed from the capture/encode thread to the send thread (the encode|send +/// split). The send thread does FEC+seal+paced-send while this thread captures+encodes the next. +struct FrameMsg { + data: Vec, + capture_ns: u64, + flags: u32, + /// The wire `frame_index` this AU is sealed with. Assigned by the encode loop's + /// session-lifetime counter (`au_seq`) — the loop owns the video numbering so the index it + /// PREDICTED at submit time (`au_seq + inflight`, handed to `Encoder::submit_indexed`) is + /// exactly what the packetizer stamps, keeping the encoder's RFI bookkeeping 1:1 with the + /// wire across encoder rebuilds/resets. Sealed via `Session::seal_frame_at`. + frame_index: u32, + /// When this frame's packets should have fully left (the next frame's due time) = the pacing + /// budget. In the past when the send thread is behind → immediate send (catch up). + deadline: std::time::Instant, + /// submit→encoded latency (µs), measured on the encode thread, carried for the perf histogram. + encode_us: u32, + /// Capture-delivery → encoder-submit age (µs) of a fresh frame — the PipeWire delivery + + /// channel-queue time the old pre-submit stamp made invisible. Always measured (two integer + /// ops); 0 for repeats/tail frames. The wire pts (`capture_ns`) anchors at the same delivery + /// stamp, so client-side latency figures include this window too. + queue_us: u32, + /// Per-stage µs splits, measured on the capture/encode thread (0 when neither `PUNKTFUNK_PERF` + /// nor a stats capture is armed). The send thread accumulates them for the web-console sample: + /// `cap_us` = `try_latest` (ring read + colour convert), `submit_us` = NVENC `encode_picture` + /// launch, `wait_us` = `lock_bitstream` (the scheduling wait + ASIC encode = the "encode" stage). + cap_us: u32, + submit_us: u32, + wait_us: u32, + /// This frame is a re-encoded hold (the source had no fresh frame): a source-starvation signal + /// the send thread folds into `repeat_fps`. + repeat: bool, + /// Whether the per-stage splits (`cap_us`/`submit_us`/`wait_us`) were actually measured at + /// capture time (`perf` was on or a stats capture was armed). The send thread trusts this + /// instead of re-reading `is_armed()`, so a capture that arms while frames are already in flight + /// doesn't fold their zeroed splits into the first window's percentiles. + was_measured: bool, +} + +/// The dedicated send thread: it owns the whole [`Session`] (so no socket clone or shared stats are +/// needed) and does FEC+seal + microburst-paced send OFF the capture/encode thread, plus the +/// speed-test probe bursts (which also need the Session). Decoupling the paced send from encoding +/// lets the encode of frame N+1 overlap the transmit of frame N instead of waiting behind its tail. +/// Runs until the encode thread drops the frame channel (end of stream) or `stop` is set. +/// Everything the send thread needs to emit web-console stats samples at its 2 s aggregation +/// boundary: the shared recorder (whose `is_armed()` gates emission) plus the negotiated +/// mode/codec/client to seed the capture's `CaptureMeta` on the first armed registration. +struct SendStats { + rec: Arc, + /// Live session mode, packed w:16|h:16|hz:16 ([`pack_mode`]) — the capture thread updates it + /// on an accepted mid-stream mode switch (mirroring `bitrate_kbps` below), so a stats capture + /// registers the mode the stream is ACTUALLY running at, not the session-start latch (H3). + mode: Arc, + codec: &'static str, + client: String, + /// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive + /// bitrate change, so the web-console sample reports what the encoder is ACTUALLY targeting. + bitrate_kbps: Arc, +} + +/// Whether a session on `compositor` (`None` = the synthetic source) with a `per_client_mode` +/// identity policy may LIVE-reconfigure — accept a mid-stream `Reconfigure` +/// (design/midstream-resolution-resize.md H1/H5). Gated OFF for: +/// * **gamescope** (every sub-mode): a resize would respawn the nested game / restart the box's +/// game-mode session — it must never relaunch the title, so the client keeps scaling client-side. +/// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a +/// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output), +/// defeating the policy — honest downgrade is to reject and let the client scale. +/// +/// Every other compositor (and the synthetic protocol-test source) with the default identity accepts. +pub(super) fn reconfig_allowed( + compositor: Option, + per_client_mode: bool, +) -> bool { + compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode +} + +#[allow(clippy::too_many_arguments)] +fn send_loop( + mut session: Session, + frame_rx: std::sync::mpsc::Receiver, + probe_rx: std::sync::mpsc::Receiver, + probe_result_tx: tokio::sync::mpsc::UnboundedSender, + stop: Arc, + perf: bool, + burst_cap: Option, + fec_target: Arc, + stats: SendStats, + // `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right + // after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing). + timing_conn: Option, + // The client advertised VIDEO_CAP_PROBE_SEQ — mid-session speed-test bursts may run in the + // probe index space (else they're declined; see `run_probe_burst`). + probe_seq: bool, +) { + boost_thread_priority(false); // transmit thread: above-normal (Apollo's encoder-thread level) + let mut last_perf = std::time::Instant::now(); + let mut last_bytes = 0u64; + let mut last_send_dropped = 0u64; + let mut encode_us: Vec = Vec::new(); + let mut pace_us: Vec = Vec::new(); + let (mut paced_frames, mut immediate_frames) = (0u64, 0u64); + // Web-console stats accumulation (active when `perf` OR the recorder is armed): the per-stage + // split carried on each FrameMsg, the new-vs-repeat frame split, the cached registration id, and + // the previous window's loss snapshot for delta computation. + let mut sid: Option = None; + let (mut cap_v, mut submit_v, mut wait_v, mut queue_v): ( + Vec, + Vec, + Vec, + Vec, + ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let (mut new_frames, mut repeat_frames) = (0u64, 0u64); + let mut last_frames_dropped = 0u64; + let mut last_packets_dropped = 0u64; + let mut last_fec_recovered = 0u64; + loop { + if stop.load(Ordering::SeqCst) { + break; + } + // Probes run here (they need the Session); a burst pauses video — the encode thread blocks + // on the full frame channel meanwhile, which is exactly the intended pause. + service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq); + // Adaptive FEC: pick up any new recovery target the control task set from client LossReports. + apply_fec_target(&mut session, &fec_target); + // Short timeout so we keep re-checking `stop` + probes when no frames are flowing. + match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) { + Ok(msg) => match paced_submit( + &mut session, + &msg.data, + msg.capture_ns, + msg.flags, + msg.frame_index, + msg.deadline, + burst_cap, + ) { + Ok(stat) => { + // Host timing (0xCF): stamped now — the AU's packets have fully left the + // socket — against the same capture anchor the wire pts carries, so the + // client's per-frame math tiles exactly (network = its host+network − this). + // Best-effort like every side-plane datagram; skipped for speed-test filler + // (FLAG_PROBE isn't video and its pts is the burst clock). + if let Some(tc) = &timing_conn { + if msg.flags & FLAG_PROBE as u32 == 0 { + let host_us = (now_ns().saturating_sub(msg.capture_ns) / 1000) + .min(u32::MAX as u64) + as u32; + let t = punktfunk_core::quic::HostTiming { + pts_ns: msg.capture_ns, + host_us, + }; + let _ = tc.send_datagram( + punktfunk_core::quic::encode_host_timing_datagram(&t).into(), + ); + } + } + if perf || stats.rec.is_armed() { + // `encode_us`/`pace_us`/fps are valid for every frame (always measured), + // including the Windows relay + tail-drain frames. The cap/submit/wait splits + // are only real when the frame was measured at capture time — a frame captured + // before this capture armed carries zeroed splits, so skip those (an empty + // window → `percentile()` returns 0) rather than pull the percentiles down. + encode_us.push(msg.encode_us); + pace_us.push(stat.spread_us); + if msg.was_measured { + cap_v.push(msg.cap_us); + submit_v.push(msg.submit_us); + wait_v.push(msg.wait_us); + // Queue age is only meaningful for fresh frames (repeats/tail carry 0 + // by construction — including those would drag the percentiles down). + if !msg.repeat { + queue_v.push(msg.queue_us); + } + } + if msg.repeat { + repeat_frames += 1; + } else { + new_frames += 1; + } + if stat.paced { + paced_frames += 1; + } else { + immediate_frames += 1; + } + } + } + Err(e) => { + tracing::error!(error = %format!("{e:#}"), "send failed — stopping stream"); + break; + } + }, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, // encode thread done + } + if last_perf.elapsed() >= std::time::Duration::from_secs(2) { + let s = session.stats(); + let secs = last_perf.elapsed().as_secs_f64(); + // Attempted (sealed) transmit rate; `send_dropped` is what didn't reach the wire. + let tx_mbps = (s.bytes_sent - last_bytes) as f64 * 8.0 / secs / 1_000_000.0; + if perf { + // Send-thread stage split (Phase 0.4 host half): busy-time sums over this + // window, so share-of-core = _ms / window wall ms. The per-packet ns + // figures are the Phase 1.5 gate metric — seal parallelism is warranted only + // if seal_ns_pp × pkts/s approaches ~15% of a core at 2 Gbps. + let sp = session.take_seal_perf().unwrap_or_default(); + tracing::info!( + tx_mbps = format!("{tx_mbps:.0}"), + send_dropped = s.packets_send_dropped - last_send_dropped, + send_dropped_total = s.packets_send_dropped, + encode_us_p50 = percentile(&mut encode_us, 0.50), + encode_us_p99 = percentile(&mut encode_us, 0.99), + pace_us_p50 = percentile(&mut pace_us, 0.50), + pace_us_p99 = percentile(&mut pace_us, 0.99), + pace_us_max = pace_us.last().copied().unwrap_or(0), + immediate_frames, + paced_frames, + window_ms = format!("{:.0}", secs * 1000.0), + fec_ms = format!("{:.2}", sp.fec_ns as f64 / 1e6), + seal_ms = format!("{:.2}", sp.seal_ns as f64 / 1e6), + sock_ms = format!("{:.2}", sp.sock_ns as f64 / 1e6), + fec_ns_pp = sp.fec_ns.checked_div(sp.packets).unwrap_or(0), + seal_ns_pp = sp.seal_ns.checked_div(sp.packets).unwrap_or(0), + sock_ns_pp = sp.sock_ns.checked_div(sp.packets).unwrap_or(0), + sealed_pkts = sp.packets, + "perf" + ); + } + // Web-console capture: this thread owns `session.stats()`, so it emits the COMPLETE + // sample — the cap/submit/encode split carried over from the capture thread plus this + // window's pacing/goodput/loss. Loss fields are deltas vs the previous window's snapshot. + if stats.rec.is_armed() { + let session_id = *sid.get_or_insert_with(|| { + // Read the LIVE mode at registration time (H3): a capture armed after a + // mid-stream mode switch gets the mode the stream actually runs at. + let (w, h, hz) = unpack_mode(stats.mode.load(Ordering::Relaxed)); + stats + .rec + .register_session("native", w, h, hz, stats.codec, &stats.client) + }); + let sample = crate::stats_recorder::StatsSample { + t_ms: 0, // stamped by push_sample from the capture's monotonic start + session_id, + stages: vec![ + crate::stats_recorder::StageTiming { + name: "queue".into(), + p50_us: percentile(&mut queue_v, 0.50) as f32, + p99_us: percentile(&mut queue_v, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "capture".into(), + p50_us: percentile(&mut cap_v, 0.50) as f32, + p99_us: percentile(&mut cap_v, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "submit".into(), + p50_us: percentile(&mut submit_v, 0.50) as f32, + p99_us: percentile(&mut submit_v, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "encode".into(), + p50_us: percentile(&mut wait_v, 0.50) as f32, + p99_us: percentile(&mut wait_v, 0.99) as f32, + }, + crate::stats_recorder::StageTiming { + name: "send".into(), + p50_us: percentile(&mut pace_us, 0.50) as f32, + p99_us: percentile(&mut pace_us, 0.99) as f32, + }, + ], + fps: (new_frames as f64 / secs) as f32, + repeat_fps: (repeat_frames as f64 / secs) as f32, + mbps: tx_mbps as f32, + bitrate_kbps: stats.bitrate_kbps.load(Ordering::Relaxed), + frames_dropped: s.frames_dropped.saturating_sub(last_frames_dropped) as u32, + packets_dropped: s.packets_dropped.saturating_sub(last_packets_dropped) as u32, + send_dropped: s.packets_send_dropped.saturating_sub(last_send_dropped) as u32, + fec_recovered: s.fec_recovered_shards.saturating_sub(last_fec_recovered) as u32, + }; + stats.rec.push_sample(session_id, sample); + } + last_perf = std::time::Instant::now(); + last_bytes = s.bytes_sent; + last_send_dropped = s.packets_send_dropped; + last_frames_dropped = s.frames_dropped; + last_packets_dropped = s.packets_dropped; + last_fec_recovered = s.fec_recovered_shards; + encode_us.clear(); + pace_us.clear(); + cap_v.clear(); + submit_v.clear(); + wait_v.clear(); + queue_v.clear(); + paced_frames = 0; + immediate_frames = 0; + new_frames = 0; + repeat_frames = 0; + } + } +} + +/// A mid-stream session change the watcher detected (the box flipped Gaming↔Desktop): the new +/// backend + the [`crate::vdisplay::SessionEnv`] snapshot to retarget at it. The env is applied on +/// the encode thread (not the watcher), so the watcher never does a process-global env write. +struct SessionSwitch { + kind: crate::vdisplay::ActiveKind, + compositor: crate::vdisplay::Compositor, + env: crate::vdisplay::SessionEnv, +} + +/// Poll the live graphical session ~1 s and, when its kind changes from what the stream opened with +/// (the user switched Gaming↔Desktop mid-stream) and stays changed for a debounce, send one +/// [`SessionSwitch`] so the encode loop rebuilds the backend in place. Self-baselines on the first +/// read (so no handshake plumbing). Opt-in via `PUNKTFUNK_SESSION_WATCH`; readiness of the new +/// backend is left to the encode thread's `build_pipeline_with_retry` (the watcher never writes +/// env). Exits when `stop` is set or the channel closes. +/// Whether to run the mid-stream session-switch watcher. An explicit `PUNKTFUNK_SESSION_WATCH` wins +/// (truthy → on; `0`/`false`/`no`/`off`/empty → off). When unset it defaults **on** for Steam HTPC +/// platforms (Bazzite / SteamOS) — which flip Gaming↔Desktop and need the host to follow the switch +/// mid-stream — and **off** elsewhere, preserving the opt-in default for plain desktop hosts. +fn session_watch_enabled() -> bool { + match std::env::var("PUNKTFUNK_SESSION_WATCH") { + Ok(v) => { + let v = v.trim(); + !(v.is_empty() + || v == "0" + || v.eq_ignore_ascii_case("false") + || v.eq_ignore_ascii_case("no") + || v.eq_ignore_ascii_case("off")) + } + Err(_) => is_steam_htpc_platform(), + } +} + +/// True on Bazzite or SteamOS (matched against os-release `ID`/`ID_LIKE`) — the platforms that flip +/// between Steam Gaming Mode and a Desktop session, where following a mid-stream switch is the +/// sensible default. Anything else (incl. non-Linux, where the file is absent) → false. +fn is_steam_htpc_platform() -> bool { + let Ok(os) = std::fs::read_to_string("/etc/os-release") else { + return false; + }; + os.lines().any(|line| { + let line = line.trim(); + let Some(val) = line + .strip_prefix("ID=") + .or_else(|| line.strip_prefix("ID_LIKE=")) + else { + return false; + }; + val.trim_matches('"') + .split_whitespace() + .any(|tok| tok.eq_ignore_ascii_case("bazzite") || tok.eq_ignore_ascii_case("steamos")) + }) +} + +fn session_watcher_loop(tx: std::sync::mpsc::Sender, stop: Arc) { + use crate::vdisplay; + const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(3); + // Baseline = what the stream is currently driving (matches the handshake's resolution). + let mut current = vdisplay::detect_active_session().kind; + let mut pending: Option<(vdisplay::ActiveKind, std::time::Instant)> = None; + while !stop.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_secs(1)); + if stop.load(Ordering::SeqCst) { + break; + } + let active = vdisplay::detect_active_session(); + // A4: bump the session epoch + invalidate the old backend the moment the compositor instance + // changes (kind change OR same-kind restart) — even for a same-kind restart the watcher won't + // signal a full SessionSwitch for. Self-dedupes; the debounced SessionSwitch below still drives + // the in-place rebuild. + vdisplay::observe_session_instance(&active); + let cur = active.kind; + if cur == current { + pending = None; // back to the current backend before debounce elapsed — no switch + continue; + } + match pending { + // Stable at the new kind for the debounce window — the switch is real, signal it. + Some((k, since)) if k == cur && since.elapsed() >= DEBOUNCE => { + match vdisplay::compositor_for_kind(cur) { + Some(comp) => { + tracing::info!(from = ?current, to = ?cur, compositor = comp.id(), + "session watcher: mid-stream switch — signaling backend rebuild"); + if tx + .send(SessionSwitch { + kind: cur, + compositor: comp, + env: active.env, + }) + .is_err() + { + break; // encode loop gone + } + current = cur; // new baseline; don't re-signal until it changes again + } + // Logout / no usable backend for the new session — keep streaming the old one. + None => tracing::debug!(to = ?cur, + "session watcher: no usable backend for the new session — staying put"), + } + pending = None; + } + // Still debouncing this kind. + Some((k, _)) if k == cur => {} + // A new (or different) change — start the debounce window. + _ => pending = Some((cur, std::time::Instant::now())), + } + } +} + +/// All per-session inputs for [`virtual_stream`], bundled so the session entry +/// is one moved value instead of a 13-positional-argument `#[allow(too_many_arguments)]` signature +/// (Goal-1 stage 4, plan §2.4). Everything is **owned** — the receivers move in (`virtual_stream` is their +/// only consumer) — so the whole context moves into the stream thread and the borrow plumbing disappears. +pub(super) struct SessionContext { + /// The hardened data-plane `Session` (Leopard FEC + AES-GCM over UDP); moved into the send thread. + pub(super) session: Session, + /// The client's requested mode — the virtual output is created at exactly this WxH@Hz (no scaling). + pub(super) mode: punktfunk_core::Mode, + /// Stream duration cap (the persistent listener bounds back-to-back sessions). + pub(super) seconds: u32, + /// Session stop flag (set on disconnect / reconnect-preempt). + pub(super) stop: Arc, + /// Deliberate-quit flag (set when the client closed with `QUIT_CODE`): the display lease reads it + /// on teardown to skip the keep-alive linger for a user "stop" (vs. an unwanted disconnect). + pub(super) quit: Arc, + /// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode. + pub(super) reconfig: std::sync::mpsc::Receiver, + /// Client decode-recovery keyframe requests. + pub(super) keyframe: std::sync::mpsc::Receiver<()>, + /// Client LTR-RFI recovery requests — the lost-frame range `(first, last)`. The encode loop + /// prefers `Encoder::invalidate_ref_frames` over a full IDR when the encoder supports it. + pub(super) rfi: std::sync::mpsc::Receiver<(u32, u32)>, + /// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder + /// alone is rebuilt in place at the new rate; capture + virtual output are untouched. + pub(super) bitrate_rx: std::sync::mpsc::Receiver, + /// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there). + pub(super) compositor: crate::vdisplay::Compositor, + /// Negotiated encoder bitrate (kbps). + pub(super) bitrate_kbps: u32, + /// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from + /// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of + /// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it + /// for the new mode (the pin follows the resolution; an explicit client rate stays put). + pub(super) bitrate_auto: bool, + /// Negotiated encode bit depth (8, or 10 = HEVC Main10). + pub(super) bit_depth: u8, + /// Negotiated chroma subsampling (4:2:0, or 4:4:4 when the client + host + GPU all support it). + pub(super) chroma: crate::encode::ChromaFormat, + /// Negotiated video codec the encoder emits (HEVC by default; H.264 / AV1 when the client + /// prefers one the GPU encodes; H.264 for a software host). Also used to rebuild the encoder + /// at the same codec across a mid-stream mode reconfigure. + pub(super) codec: crate::encode::Codec, + /// Speed-test burst requests (see [`service_probes`]). + pub(super) probe_rx: std::sync::mpsc::Receiver, + /// Speed-test results back to the control task. + pub(super) probe_result_tx: tokio::sync::mpsc::UnboundedSender, + /// Mode-switch outcomes back to the control task (H2): a corrective + /// `Reconfigured { accepted: true, mode: }` when a rebuild failed (stayed at + /// the old mode) or the backend honored a different refresh than requested. + pub(super) reconfig_result_tx: tokio::sync::mpsc::UnboundedSender, + /// Adaptive-FEC target the control task updates from the client's loss reports. + pub(super) fec_target: Arc, + /// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream). + pub(super) conn: quinn::Connection, + /// `Some` when the client advertised [`punktfunk_core::quic::VIDEO_CAP_HOST_TIMING`]: the send + /// thread emits one 0xCF datagram per AU (capture→sent µs) on it, so the client can split its + /// `host+network` latency stage. `None` = older client, no emission. + pub(super) timing_conn: Option, + /// The client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`]: speed-test bursts may + /// run mid-session in the probe index space (its reassembler keeps a separate probe window). + /// `false` = older client whose single-window reassembler would drop probe-space frames as + /// stale — mid-session probes are DECLINED for it (a zeroed [`ProbeResult`]) rather than + /// consuming video frame indexes its gap detectors can't see (the phantom-gap freeze). + pub(super) probe_seq: bool, + /// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide + /// whether to measure the per-stage split; the send thread builds + pushes the aggregated + /// `StatsSample` at its 2 s boundary. + pub(super) stats: Arc, + /// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on + /// the first armed stats registration. + pub(super) client_label: String, + /// The session's requested launch, `None` = none. On Windows the store-qualified library id + /// (spawned into the interactive user session once capture is live); on other hosts the shell + /// command already resolved against the host's own library — nested into gamescope's bare spawn + /// via `set_launch_command`, or spawned into the live session once capture is up. + pub(super) launch: Option, + /// The client display's HDR colour volume (`Hello::display_hdr`; `None` = older client / SDR). + /// Threaded into the vdisplay backend before `create` (→ the pf-vdisplay EDID's CTA HDR block, + /// so host apps tone-map to the client's real panel) and preferred over the generic baseline + /// for the 0xCE mastering metadata. + pub(super) client_hdr: Option, +} + +pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { + // This thread runs the capture+encode loop (single-process — the only topology: Linux portal / + // synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU + // submission. + boost_thread_priority(true); + // Resolve the per-session capture / topology / encoder decision ONCE (Goal-1 stage 3): the deployed + // path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site + // (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the + // only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`. + let mut plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma, ctx.codec); + // PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens + // packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames. + if ctx.codec == crate::encode::Codec::PyroWave { + plan.wire_chunk = Some(ctx.session.shard_payload()); + } + tracing::info!(?plan, "resolved session plan"); + // Single-process path: unpack the context into the locals the loop below uses (names unchanged, so the + // body is byte-for-byte the same; the receivers are now owned but `try_recv()` is identical). + let SessionContext { + session, + mode, + seconds, + stop, + quit, + reconfig, + keyframe, + rfi, + bitrate_rx, + compositor, + mut bitrate_kbps, + bitrate_auto, + bit_depth, + // The resolved chroma is already captured in `plan` (above); ignore the duplicate here. + chroma: _, + // Likewise the codec — `plan.codec` (resolved from `ctx.codec`) is the source of truth below. + codec: _, + probe_rx, + probe_result_tx, + reconfig_result_tx, + fec_target, + conn, + timing_conn, + probe_seq, + stats, + client_label, + launch, + client_hdr, + } = ctx; + tracing::info!( + compositor = compositor.id(), + ?mode, + bitrate_kbps, + bit_depth, + "punktfunk/1 virtual display" + ); + // Open the backend FIRST — on Windows this constructs the vdisplay backend, which initialises the + // host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push + // preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics. + let mut vd = crate::vdisplay::open(compositor)?; + // Per-client STABLE monitor identity (Phase 2): hand the backend the connecting client's cert + // fingerprint so a freshly CREATED virtual monitor gets this client's persistent id — Windows then + // reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends + // and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates). + vd.set_client_identity(endpoint::peer_fingerprint(&conn)); + // The client display's HDR volume (Hello) → a freshly created virtual monitor's EDID CTA HDR + // block (pf-vdisplay), so host apps + the OS tone-map to the client's real panel instead of the + // driver's built-in ~1000-nit placeholder. No-op on Linux backends and for older/SDR clients. + vd.set_client_hdr(client_hdr); + // Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the backend mints — + // the retry-hold below AND the capturer's — carries the session's quit flag, so a user "stop" + // (⌘D → the QUIT close code) tears the virtual monitor down the moment the pipeline drops instead + // of lingering 10 s. The reconnect then finds the manager Idle and does a clean fresh ADD (with + // the user's think-time as driver settle) rather than the Lingering-preempt's REMOVE→ADD churn. + // `keep_alive = forever` (gaming-rig) outranks the quit — the monitor pins as before. + vd.set_quit_flag(quit.clone()); + // Per-session launch (non-Windows): hand the resolved command to the backend instance so + // gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions + // can't stomp each other's launch target. The other backends' default `set_launch_command` is a + // no-op; they get the command spawned into the live session after capture is up (below). + #[cfg(not(target_os = "windows"))] + vd.set_launch_command(launch.clone()); + // IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a + // reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it + // to release its monitor (instead of tearing a monitor out from under a still-live session), and + // register THIS session's stop. The returned guard holds the setup lock across the pipeline build; + // dropping it lets the next reconnect begin (and preempt us). Held BEFORE the monitor is created + // (build_pipeline → vd.create), so the preempt still precedes this session's monitor creation. + // SLOT-scoped (Stage W1): the preempt targets only a prior session holding THIS client's slot — + // a different identity's session is an admission question, never a preempt. + #[cfg(target_os = "windows")] + let _idd_setup_guard = + (plan.capture == crate::session_plan::CaptureBackend::IddPush).then(|| { + let slot = crate::vdisplay::manager::slot_id_for( + endpoint::peer_fingerprint(&conn), + (mode.width, mode.height), + ); + crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone()) + }); + let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id, mut cur_display_gen) = + build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?; + // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). + #[cfg(target_os = "windows")] + drop(_idd_setup_guard); + + // Capture is live — launch the requested title so it renders onto the streamed output and + // grabs focus. Windows spawns the library id into the interactive user session; Linux spawns + // the resolved command into the live session for every backend that didn't already nest it + // (gamescope's bare spawn ran it inside the fresh gamescope — launching again would start it + // twice). Best-effort: a launch failure (no recipe, launcher missing, no interactive user) + // leaves the user on the streamed desktop/session, never tears the stream down. Launched ONCE + // here — the mid-stream rebuild paths below must not re-spawn it. + #[cfg(target_os = "windows")] + if let Some(id) = launch.as_deref() { + if let Err(e) = crate::library::launch_title(id) { + tracing::warn!(launch_id = id, error = %e, "could not launch requested library title"); + } + } + #[cfg(target_os = "linux")] + if let Some(cmd) = launch.as_deref() { + if crate::vdisplay::launch_is_nested(compositor) { + tracing::info!(command = %cmd, "launch nested into the per-session gamescope"); + } else if let Err(e) = crate::library::launch_session_command(compositor, cmd) { + tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session"); + } + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + let _ = &launch; + + let perf = crate::config::config().perf; + // Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out + // immediately; only a bigger frame's overflow is spread. `None` = auto — max(128 KB, the + // AU's wire bytes / 4), so the burst stays a bounded fraction of high-rate frames instead + // of swallowing them whole (plan Phase 1.3). PUNKTFUNK_PACE_BURST_KB pins an absolute cap. + let burst_cap: Option = std::env::var("PUNKTFUNK_PACE_BURST_KB") + .ok() + .and_then(|s| s.parse::().ok()) + .map(|kb| kb * 1024); + + // Encode|send split: this thread captures+encodes (the GPU work) + handles reconfig, and hands + // each AU to a dedicated send thread that owns the Session and does FEC+seal+paced-send — so the + // encode of frame N+1 overlaps the paced transmit of frame N instead of waiting behind its tail. + // The bounded channel applies backpressure (the encode thread blocks if the send falls behind, + // so frames slow down rather than a dropped frame freezing the infinite-GOP stream). + let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::(3); + // Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive + // bitrate change (bitrate_rx below) updates it so the console shows the actual target. + let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps)); + // Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so + // a stats capture armed after a resize registers the real mode. Seeded with the refresh the + // initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual + // output at 60 Hz. + let live_mode = Arc::new(AtomicU64::new(pack_mode( + mode.width, + mode.height, + interval_hz(interval), + ))); + // One-shot force-keyframe flag driven by the management API (`POST /session/idr`, the web-console + // Dashboard's "Request IDR" button) — drained in the encode loop below exactly like a client + // decode-recovery request. Registered with `session_status` so the mgmt handler can reach THIS + // session (the native plane never touches the GameStream `AppState.force_idr`). + let force_idr = Arc::new(AtomicBool::new(false)); + // The send thread emits the web-console stats sample (it owns `session.stats()`); clone the + // recorder so the capture loop keeps its own handle for the per-frame `is_armed()` gate. + let send_stats = SendStats { + rec: stats.clone(), + mode: live_mode.clone(), + codec: plan.codec.label(), + client: client_label.clone(), + bitrate_kbps: live_bitrate.clone(), + }; + let send_thread = std::thread::Builder::new() + .name("punktfunk-send".into()) + .spawn({ + let stop = stop.clone(); + move || { + send_loop( + session, + frame_rx, + probe_rx, + probe_result_tx, + stop, + perf, + burst_cap, + fec_target, + send_stats, + timing_conn, + probe_seq, + ) + } + }) + .context("spawn send thread")?; + + // Publish this session to the plane-neutral live-session registry so the web-console Dashboard + // (`GET /status`) shows the native stream — resolution/fps/codec/bitrate resolve live from the + // same handles a mid-stream mode switch / adaptive-bitrate change updates. The guard clears the + // entry when this loop exits (return / `?` / panic), so the Dashboard tracks the session's life. + let _live_session = crate::session_status::register( + live_mode.clone(), + live_bitrate.clone(), + plan.codec, + stop.clone(), + force_idr.clone(), + client_label, + plan.hdr, + ); + + // Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit + // PUNKTFUNK_COMPOSITOR pin). It self-baselines and signals the loop below to swap the backend in + // place when the box flips Gaming↔Desktop. When not spawned, session_rx just stays empty. + let mut compositor = compositor; + let (session_tx, session_rx) = std::sync::mpsc::channel::(); + let watch = session_watch_enabled() && crate::config::config().compositor.is_none(); + let _watcher = if watch { + tracing::info!("session watcher on — following a mid-stream Gaming↔Desktop switch"); + let stop = stop.clone(); + std::thread::Builder::new() + .name("punktfunk1-watcher".into()) + .spawn(move || session_watcher_loop(session_tx, stop)) + .ok() + } else { + None + }; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64); + let mut next = std::time::Instant::now(); + let mut sent: u64 = 0; + // The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this + // loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`). + // A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs + // are emitted FIFO, one per submission, and every event that forfeits in-flight frames + // (reset/rebuild/teardown) clears `inflight` AND the encoder's reference state, so the reused + // predictions can never meet stale bookkeeping. Passing it to `Encoder::submit_indexed` keeps + // the RFI backends' frame numbers 1:1 with the client's across encoder rebuilds — an + // encoder-internal counter desyncs on the first adaptive-bitrate rebuild (NVENC RFI then + // silently dies; AMF may anchor onto a post-loss LTR). + let mut au_seq: u32 = 0; + // Rebuild-in-place on capture loss: track the live mode (a mode switch updates it) so a rebuild + // targets the CURRENT mode, and cap consecutive rebuilds so a flapping source can't loop the + // client through endless cold restarts. + let mut cur_mode = mode; + const MAX_CAPTURE_REBUILDS: u32 = 5; + let mut capture_rebuilds: u32 = 0; + // Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver + // shows up as poll() returning None forever while submits keep succeeding — `inflight` grows, + // no AU ever reaches the send thread, and the client freezes on the last frame with nothing + // logged (field reports: AMD/Intel Windows streams freezing after minutes). Track when the + // encoder last produced an AU and rebuild it in place (bounded, like the capture rebuilds) + // when it stops. `ENCODE_STALL_WINDOW` also sizes the in-flight backlog bound: a backlog worth + // more than the window's frames means AUs still trickle (so the gap never trips) but latency + // is growing without bound — the slow-leak form of the same stall. + const ENCODE_STALL_WINDOW: std::time::Duration = std::time::Duration::from_secs(2); + const MAX_ENCODER_RESETS: u32 = 5; + let mut encoder_resets: u32 = 0; + let mut last_au_at = std::time::Instant::now(); + // Last HDR mastering metadata we forwarded — re-sent as 0xCE on change/keyframe (see below). + let mut last_hdr_meta: Option = None; + // Frames submitted to NVENC but not yet polled (wire pts, submit stamp, pacing deadline). With a + // capturer that hands a fresh output texture per frame, the loop submits N+1 before polling N + // (pipeline depth > 1), overlapping the convert/copy of N+1 on the 3D engine with the encode of N + // on the NVENC ASIC. The wire pts and the submit stamp are carried separately so `encode_us` + // keeps meaning submit→AU while the wire pts anchors at PipeWire delivery (queue age included). + let mut inflight: std::collections::VecDeque<(u64, u64, std::time::Instant)> = + std::collections::VecDeque::new(); + // Diagnostic: distinguish NEW captured frames (the source produced a fresh frame) from REPEATS (the + // loop re-encoded the last frame because `try_latest` had nothing). A low new-frame rate at a high + // send rate ⇒ the capture source isn't producing frames (e.g. an IDD virtual display DWM isn't + // compositing), NOT an encoder problem. Logged every 2 s when `PUNKTFUNK_PERF`. + let (mut diag_new, mut diag_repeat) = (0u64, 0u64); + let mut diag_at = std::time::Instant::now(); + // Anchor for the forced-IDR cooldown (see the keyframe-request handling below): the timestamp of + // the most recent forced/opening IDR. The session's pipeline just opened on an IDR, so start the + // clock now — that coalesces the keyframe storm a client fires while its decoder wedges on the cold + // opening GOP, instead of answering it with a redundant second IDR. + let mut last_forced_idr: Option = Some(std::time::Instant::now()); + // Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle + // into a stable multi-second rhythm (see [`crate::metronome::Metronome`]). + let mut recovery_cadence = crate::metronome::Metronome::new(); + // Position within the current intra-refresh wave (frames since the last IDR/wave start). Only + // meaningful on a `caps().intra_refresh_recovery` encoder; the pump tags every wave-boundary AU + // with `USER_FLAG_RECOVERY_POINT` so the client can lift its post-loss freeze on a clean + // re-anchor without a full IDR. Re-phased to 0 at each emitted IDR (which restarts the wave). + let mut ir_wave_pos: u32 = 0; + // Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see + // exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert), + // submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one + // that dominates under a GPU-saturating game). + let (mut st_cap, mut st_submit, mut st_wait, mut st_queue): ( + Vec, + Vec, + Vec, + Vec, + ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { + // Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in + // place — a different compositor at the SAME client mode — keeping the Session + send thread + // (and thus the QUIC control + UDP data plane) up. Takes precedence over a queued mode change. + let mut switch = None; + while let Ok(s) = session_rx.try_recv() { + switch = Some(s); // coalesce to the newest + } + if let Some(sw) = switch { + if sw.compositor != compositor { + tracing::info!(from = compositor.id(), to = sw.compositor.id(), kind = ?sw.kind, + "session switch — rebuilding backend in place"); + // Retarget the process env at the new session BEFORE opening the new backend (this + // thread is the only env writer; the watcher only snapshots). + crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession { + kind: sw.kind, + env: sw.env, + compositor_pid: None, + }); + // A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the + // switched-to backend's normal sub-mode. + crate::vdisplay::apply_input_env(sw.compositor, false); + // Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still + // point at the old session, so input would silently not land until a reconnect. + // Settle it (env push + KWin portal restart) before the injector reopens against it. + if matches!( + sw.compositor, + crate::vdisplay::Compositor::Kwin | crate::vdisplay::Compositor::Mutter + ) { + crate::vdisplay::settle_desktop_portal(sw.compositor); + } + // Build the new backend's pipeline BEFORE dropping the old one (retry absorbs the + // brief compositor-coexistence race during a switch); on failure keep the old. + let rebuilt = + (|| -> Result<(Box, Pipeline)> { + let mut new_vd = crate::vdisplay::open(sw.compositor)?; + let pipe = build_pipeline_with_retry( + &mut new_vd, + cur_mode, + bitrate_kbps, + bit_depth, + plan, + &quit, + &stop, + )?; + Ok((new_vd, pipe)) + })(); + match rebuilt { + Ok(( + new_vd, + (new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen), + )) => { + // Replace the pipeline first (drops the old capturer → old PipeWire stream + + // virtual output), then the factory (drops e.g. the old KWin connection). + capturer = new_cap; + enc = new_enc; + frame = new_frame; + interval = new_interval; + cur_node_id = new_node_id; + cur_display_gen = new_gen; + vd = new_vd; + compositor = sw.compositor; + next = std::time::Instant::now(); + // The owed AUs died with the old encoder — drop their in-flight records + // and restart the encode-stall clock for the fresh one. + inflight.clear(); + last_au_at = std::time::Instant::now(); + encoder_resets = 0; + tracing::info!( + compositor = compositor.id(), + "session switch — backend rebuilt, stream continues" + ); + } + Err(e) => { + let chain = format!("{e:#}"); + let kind = if is_permanent_build_error(&chain) { + "permanent" + } else { + "transient" + }; + tracing::warn!(error = %chain, kind, + "session-switch rebuild failed — staying on the current backend"); + } + } + } + } + // Drain to the NEWEST requested mode (a resize drag queues many) so we rebuild once, + // not once per stale intermediate mode. + let mut want = None; + while let Ok(m) = reconfig.try_recv() { + want = Some(m); + } + if let Some(new_mode) = want { + tracing::info!(?new_mode, "rebuilding pipeline for mode switch"); + // PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) — + // a resolution change moves the operating point (1080p→4K quadruples the pixel rate), + // so re-resolve it for the new mode. Explicit client rates stay put (the operator knows + // the link), and the H.26x codecs keep their mode-independent rate (ABR owns it). + let mode_bitrate = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave { + resolve_bitrate_kbps_for(plan.codec, 0, &new_mode) + } else { + bitrate_kbps + }; + // Build the new pipeline BEFORE dropping the old one: the host already acked + // the switch as accepted, so a rebuild failure must not kill an otherwise + // healthy session — keep streaming the current mode and log instead. + match build_pipeline(&mut vd, new_mode, mode_bitrate, bit_depth, plan, &quit) { + Ok(next_pipe) => { + if mode_bitrate != bitrate_kbps { + tracing::info!( + from_kbps = bitrate_kbps, + to_kbps = mode_bitrate, + "pinned PyroWave bitrate re-resolved for the new mode" + ); + bitrate_kbps = mode_bitrate; + live_bitrate.store(mode_bitrate, Ordering::Relaxed); + } + let old_display_gen = cur_display_gen; + // The destructuring assignment drops the OLD capturer (→ its display lease) as + // each binding is replaced — the new pipeline is already up (create-before-drop). + (capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe; + cur_mode = new_mode; + next = std::time::Instant::now(); + // H4: the old display's lease drop above is indistinguishable from a disconnect + // to the keep-alive machinery — under linger/forever policies every resize would + // ACCUMULATE kept monitors at stale modes. Retire the superseded entry now (a + // no-op when it was already torn down under `immediate`, or off Linux). + if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) { + crate::vdisplay::registry::retire(g); + } + // H2/H3: the backend may have honored a different mode than requested — KWin + // caps a virtual output's refresh, or Windows pf-vdisplay rejects an in-place + // SetMode to a resolution its running monitor doesn't advertise and the host + // falls back to the actual display mode. `frame` is the NEW pipeline's first + // frame (just rebound above), so its dims are what the client actually decodes. + // Publish that ACTUAL mode to the live stats slot, and correct the client's mode + // slot when it differs from the accept ack it already got. + let actual = delivered_mode(frame.width, frame.height, interval); + live_mode.store( + pack_mode(actual.width, actual.height, actual.refresh_hz), + Ordering::Relaxed, + ); + if actual != new_mode { + let _ = reconfig_result_tx.send(Reconfigured { + accepted: true, + mode: actual, + }); + } + // The owed AUs died with the old encoder — drop their in-flight records + // and restart the encode-stall clock for the fresh one. + inflight.clear(); + last_au_at = std::time::Instant::now(); + encoder_resets = 0; + last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown + } + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), ?new_mode, + "mode-switch rebuild failed — staying on the current mode"); + // H2 rollback: the control task acked the switch BEFORE this rebuild, so the + // client's mode slot already flipped to `new_mode`. A second accepted ack + // carrying the still-live mode corrects it (any accepted ack means "the active + // mode is now X" client-side; old clients just log it). `frame` is untouched + // here (the destructure only runs on the Ok arm), so it's still the OLD + // pipeline's frame — its real dims + interval are exactly what's still on glass. + let _ = reconfig_result_tx.send(Reconfigured { + accepted: true, + mode: delivered_mode(frame.width, frame.height, interval), + }); + } + } + } + // Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step + // several times while we stream) and retarget the ENCODER ONLY — the mode didn't change, + // so capture and the virtual output are untouched. Preferred lever: an IN-PLACE + // `reconfigure_bitrate` (Phase 3.2 — NVENC nvEncReconfigureEncoder / AMF dynamic props / + // Vulkan RC control), which keeps the encoder, its reference chain and the in-flight AUs, + // so the step costs NOTHING on the wire (no IDR, no forfeit — exactly what the Automatic + // controller's doubling climb wants). A backend that can't (libavcodec paths) or a driver + // rejection falls back to the full rebuild, which costs the IDR the fresh encoder opens + // with (the same resync discipline as a mode switch, minus the pipeline churn) and owns + // the bitrate clamping. Rates arrive pre-clamped by the control task + // (`resolve_bitrate_kbps`). + let mut want_kbps = None; + while let Ok(k) = bitrate_rx.try_recv() { + want_kbps = Some(k); + } + if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) { + if enc.reconfigure_bitrate(new_kbps as u64 * 1000) { + tracing::info!( + from_kbps = bitrate_kbps, + to_kbps = new_kbps, + "encoder bitrate reconfigured in place (adaptive bitrate — no IDR)" + ); + bitrate_kbps = new_kbps; + live_bitrate.store(new_kbps, Ordering::Relaxed); + // Same encoder, same stream: the in-flight AUs and the wire-index prediction + // stay valid — no inflight forfeit, no IDR-cooldown anchor. + } else { + // `interval` was built as 1/effective_hz, so the round-trip recovers the integer + // rate. + let hz = interval_hz(interval); + match crate::encode::open_video( + plan.codec, + frame.format, + frame.width, + frame.height, + hz, + new_kbps as u64 * 1000, + frame.is_cuda(), + bit_depth, + plan.chroma, + ) { + Ok(mut new_enc) => { + tracing::info!( + from_kbps = bitrate_kbps, + to_kbps = new_kbps, + "encoder rebuilt at new bitrate (adaptive bitrate)" + ); + if let Some(c) = plan.wire_chunk { + new_enc.set_wire_chunking(c); + } + enc = new_enc; + bitrate_kbps = new_kbps; + live_bitrate.store(new_kbps, Ordering::Relaxed); + // The owed AUs died with the old encoder — same bookkeeping as a + // mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the + // IDR cooldown too. + inflight.clear(); + last_au_at = std::time::Instant::now(); + encoder_resets = 0; + last_forced_idr = Some(std::time::Instant::now()); + } + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps, + "bitrate-change encoder rebuild failed — keeping the current rate"); + } + } + } + } + // Client recovery: it asked for a fresh IDR (its decoder wedged on the cold opening + // GOP). Coalesce the backlog — several requests fire before the IDR lands — and force + // the next encoded frame to be a keyframe. (A reconfig rebuild above already opens with + // an IDR, so this is for the steady-state wedge, not mode switches.) + let mut want_kf = false; + while keyframe.try_recv().is_ok() { + want_kf = true; + } + // Management API `POST /session/idr` (web-console Dashboard) targets this session's registry + // flag; drain it into the same forced-keyframe path a client decode-recovery request takes. + if force_idr.swap(false, Ordering::Relaxed) { + want_kf = true; + } + // Client LTR-RFI recovery: prefer re-referencing a known-good older frame (a clean recovery + // P-frame — no 20-40× IDR spike) over a full keyframe when the encoder supports it (native + // AMF LTR / Windows NVENC). Drain the backlog (the client re-requests until the recovery + // frame lands) coalesced to the widest lost range. Attempt the invalidate only when a full + // IDR isn't already queued — an explicit keyframe request means a fully wedged decoder that + // needs the IDR, which supersedes an RFI recovery. A failure (range older than the encoder's + // live references, or no RFI backend) falls through to the coalesced keyframe path below. + let mut rfi_range: Option<(u32, u32)> = None; + while let Ok((first, last)) = rfi.try_recv() { + rfi_range = Some(match rfi_range { + Some((pf, pl)) => (pf.min(first), pl.max(last)), + None => (first, last), + }); + } + // All-intra (§4.6): every PyroWave AU is a keyframe, so the NEXT frame already is + // the recovery a request asks for — drop the drained requests instead of running + // the forced-IDR cooldown / RFI / storm machinery (whose frame-size reasoning is + // meaningless when frames are uniform). Defense in depth: the backend's + // request_keyframe/invalidate_ref_frames are no-ops anyway. + if plan.codec == crate::encode::Codec::PyroWave && (want_kf || rfi_range.is_some()) { + tracing::debug!( + want_kf, + ?rfi_range, + "PyroWave session: recovery request ignored (all-intra — next frame is the recovery)" + ); + want_kf = false; + rfi_range = None; + } + if !want_kf { + if let Some((first, last)) = rfi_range { + // Sanity-cap the range before consulting the encoder: RFI can only re-reference + // history the encoder still holds (NVENC: a 5-frame DPB; AMD LTR: ~1 s of marks). + // A range wider than RFI_MAX_RANGE is either a seconds-long outage (no valid + // reference anywhere) or a phantom jump from a desynced counter — both belong on + // the keyframe path, never a force-reference that could ship corruption as a + // recovery anchor. Wrapping width: frame indexes are u32 counters. + let width = last.wrapping_sub(first); + if width > punktfunk_core::packet::RFI_MAX_RANGE { + tracing::debug!(first, last, width, "RFI range too wide — keyframe instead"); + want_kf = true; + } else if enc.caps().supports_rfi + && enc.invalidate_ref_frames(first as i64, last as i64) + { + // The RFI recovered the loss with a clean re-anchor P-frame (no IDR). Anchor the + // keyframe cooldown so the client's echo of the SAME loss — its frames_dropped- + // driven keyframe request, arriving ~one loss-window later — is coalesced away + // instead of emitting a redundant full IDR right after the cheap recovery. + last_forced_idr = Some(std::time::Instant::now()); + } else { + want_kf = true; // range too old / no RFI backend → coalesced keyframe below + } + } + } + if want_kf { + // Clients request a keyframe on EVERY FEC-unrecoverable frame (`frames_dropped` polling) + // and keep asking until the IDR actually arrives + decodes — a full round-trip on a link + // that is already behind. Answering each request with a full IDR is a 20-40× bitrate spike + // that DEEPENS the very loss it is recovering from: a burst of loss → a storm of IDRs → + // more loss, the periodic double-jolt a Wi-Fi client sees. So coalesce a request storm into + // at most ONE forced IDR per cooldown, ALWAYS — not only under intra-refresh (the old gate; + // a full-IDR recovery is exactly where the storm is worst). Serve the first request + // immediately (a genuinely wedged decoder recovers at once), then suppress for the window. + // + // Intra-refresh heals via its own gradual wave (~0.5 s) and can afford a long window; a + // full-IDR recovery relies on the keyframe itself, so its window is shorter — long enough to + // swallow the round-trip echo of one recovery event, short enough to re-issue a *lost* IDR + // promptly. + const IDR_COOLDOWN_INTRA: std::time::Duration = std::time::Duration::from_secs(2); + const IDR_COOLDOWN_FULL: std::time::Duration = std::time::Duration::from_millis(750); + let window = if enc.caps().intra_refresh { + IDR_COOLDOWN_INTRA + } else { + IDR_COOLDOWN_FULL + }; + let suppress = last_forced_idr.is_some_and(|t| t.elapsed() < window); + if suppress { + tracing::debug!("keyframe request coalesced — within the IDR cooldown"); + } else { + tracing::debug!("forcing keyframe (client decode recovery)"); + enc.request_keyframe(); + let now = std::time::Instant::now(); + last_forced_idr = Some(now); + if let Some(period) = recovery_cadence.note(now) { + tracing::warn!( + period_s = format!("{:.1}", period.as_secs_f64()), + "client keyframe recoveries are METRONOMIC — a periodic host/display \ + disturbance (display-topology churn, display-poller software, \ + virtual-display timing) is the likely cause, not random network loss; \ + correlate with 'slow display-descriptor poll' / 'display descriptor \ + changed' / 'IDD-push capture stall' lines" + ); + } + } + } + // Measure the per-stage split when `PUNKTFUNK_PERF` is set OR a web-console stats capture is + // armed (a cheap Relaxed atomic, re-read each frame). The values feed the existing perf log + // unchanged and ride each FrameMsg to the send thread, which builds the aggregated sample. + let measure = perf || stats.is_armed(); + let t_cap = std::time::Instant::now(); + let cap_result = capturer.try_latest(); + let cap_us = if measure { + t_cap.elapsed().as_micros() as u32 + } else { + 0 + }; + if perf { + st_cap.push(cap_us); + } + let mut repeat = false; + match cap_result { + Ok(Some(f)) => { + frame = f; + diag_new += 1; + capture_rebuilds = 0; // a delivered frame clears the consecutive-loss counter + } + Ok(None) => { + diag_repeat += 1; // no new frame (static desktop / mid-rebuild) — repeat the last + repeat = true; + } + // The capture source died (PipeWire/compositor thread ended, virtual output gone). Rather + // than tear the whole session down — the client has no reconnect path and would have to + // cold-restart the handshake — rebuild the pipeline IN PLACE at the current mode, exactly + // like a mode/session switch. A genuinely dead source still ends the session once the + // bounded retry is exhausted; the consecutive cap stops a flapping source from looping the + // client through endless cold IDRs. + Err(e) => { + // B2: a DEDICATED gamescope game session whose gamescope node is gone = the game + // exited (gamescope is a single-app compositor — it dies with its app). End the session + // CLEANLY — close with `APP_EXITED_CLOSE_CODE` so a launcher client returns to its + // library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s + // timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal + // Bazzite/desktop capture loss still rebuilds in place. + // `cur_node_id` (the capture 5-tuple's node id) is read only by the Linux + // dedicated-game-exit check below; keep it read on other platforms so it isn't a + // write-only variable under `-D warnings` (the `let _ = &launch` idiom above). + #[cfg(not(target_os = "linux"))] + let _ = &cur_node_id; + #[cfg(target_os = "linux")] + if launch.is_some() + && crate::vdisplay::launch_is_nested(compositor) + && crate::vdisplay::dedicated_game_exited(cur_node_id) + { + tracing::info!( + "dedicated game session: the game exited — ending the session cleanly" + ); + quit.store(true, Ordering::SeqCst); // skip keep-alive linger — the game is gone + conn.close( + punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(), + b"game exited", + ); + break; + } + capture_rebuilds += 1; + if capture_rebuilds > MAX_CAPTURE_REBUILDS { + return Err(e).context("capture lost — rebuild attempts exhausted"); + } + tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds, + "capture lost — rebuilding pipeline in place"); + // A Bazzite/SteamOS Gaming↔Desktop switch tears the old compositor down and can take + // 15s+ to bring the new one up. Don't fail the session over that (the client would + // have to cold-reconnect, surfacing a "session failed") — keep retrying within a + // generous budget while the QUIC keepalive (its own thread) holds the connection, + // RE-DETECTING the live compositor each attempt so we follow the box to whatever + // session comes up: a fresh instance of the same compositor, OR a different one + // (the kind-change case the session watcher also handles). The client stays + // connected, frozen on the last frame, and the stream resumes when the new output + // appears — no reconnect. + const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); + let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET; + let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop { + // Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids + // retargeting (then we stick to the pinned backend and just rebuild it). + if crate::config::config().compositor.is_none() { + let active = crate::vdisplay::detect_active_session(); + // A4: fold any compositor-instance change into the epoch/invalidation before we + // rebuild, so the rebuild's acquire won't reuse a dead-instance node. + crate::vdisplay::observe_session_instance(&active); + if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) { + crate::vdisplay::apply_session_env(&active); + // Capture-loss rebuild follows the live box session, not a fresh dedicated launch. + crate::vdisplay::apply_input_env(c, false); + if c != compositor { + if matches!( + c, + crate::vdisplay::Compositor::Kwin + | crate::vdisplay::Compositor::Mutter + ) { + crate::vdisplay::settle_desktop_portal(c); + } + match crate::vdisplay::open(c) { + Ok(v) => { + tracing::info!(from = compositor.id(), to = c.id(), + "capture loss: active session switched compositor — retargeting"); + vd = v; + compositor = c; + } + Err(e2) => tracing::warn!(error = %format!("{e2:#}"), + "capture loss: opening the newly-detected compositor failed — retrying"), + } + } + } + } + match build_pipeline_with_retry( + &mut vd, + cur_mode, + bitrate_kbps, + bit_depth, + plan, + &quit, + &stop, + ) { + Ok(p) => break p, + Err(e2) => { + if stop.load(Ordering::SeqCst) + || std::time::Instant::now() >= rebuild_deadline + { + return Err(e2) + .context("capture lost — no compositor came up within the rebuild budget"); + } + tracing::warn!(error = %format!("{e2:#}"), + "capture lost — new session not up yet, retrying"); + } + } + }; + capturer = new_cap; + enc = new_enc; + frame = new_frame; + interval = new_interval; + cur_node_id = new_node_id; + cur_display_gen = new_display_gen; + enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway + last_forced_idr = Some(std::time::Instant::now()); // anchor the IDR cooldown from the rebuild + next = std::time::Instant::now(); + // The owed AUs died with the old encoder — drop their in-flight records and + // restart the encode-stall clock (the rebuild loop above may have eaten seconds, + // which must not count against the fresh encoder). + inflight.clear(); + last_au_at = std::time::Instant::now(); + encoder_resets = 0; + tracing::info!( + compositor = compositor.id(), + "capture loss: pipeline rebuilt — stream resumes" + ); + } + } + if perf && diag_at.elapsed() >= std::time::Duration::from_secs(2) { + let secs = diag_at.elapsed().as_secs_f64(); + tracing::info!( + new_fps = format!("{:.0}", diag_new as f64 / secs), + repeat_fps = format!("{:.0}", diag_repeat as f64 / secs), + "capture diag: NEW frames from the source vs REPEATS (low new_fps at high send rate ⇒ \ + the source isn't producing frames, not an encode stall)" + ); + let wait_max = st_wait.iter().copied().max().unwrap_or(0); + tracing::info!( + queue_us_p50 = percentile(&mut st_queue, 0.50), + queue_us_p99 = percentile(&mut st_queue, 0.99), + cap_us_p50 = percentile(&mut st_cap, 0.50), + cap_us_p99 = percentile(&mut st_cap, 0.99), + submit_us_p50 = percentile(&mut st_submit, 0.50), + submit_us_p99 = percentile(&mut st_submit, 0.99), + wait_us_p50 = percentile(&mut st_wait, 0.50), + wait_us_p99 = percentile(&mut st_wait, 0.99), + wait_us_max = wait_max, + "stage perf (µs/call): queue=delivery→submit cap=try_latest(ring+convert) submit=encode_picture wait=lock_bitstream(sched+ASIC)" + ); + st_cap.clear(); + st_submit.clear(); + st_wait.clear(); + st_queue.clear(); + diag_new = 0; + diag_repeat = 0; + diag_at = std::time::Instant::now(); + } + // The source's static HDR mastering metadata is the single source of truth: hand it to the + // encoder (in-band SEI on keyframes) and, when it changes, to the client (0xCE). Re-sent on + // each keyframe below so a dropped best-effort datagram converges within a GOP. PRESENCE is + // the capturer's call (Some iff the virtual display is in HDR mode); the VALUE prefers the + // client's own display volume when it sent one — the virtual display's EDID advertises + // exactly that volume, so host apps already tone-mapped the content into it and the honest + // mastering description IS the client's panel. (The IDD capturer only knows the generic + // baseline; if the driver ever forwards per-content IDDCX_HDR10_METADATA, prefer that here.) + let hdr_meta = capturer.hdr_meta().map(|m| client_hdr.unwrap_or(m)); + enc.set_hdr_meta(hdr_meta); + let mut resend_meta = hdr_meta != last_hdr_meta; + if resend_meta { + last_hdr_meta = hdr_meta; + } + // How deep to pipeline (1 = synchronous submit→poll, the original behaviour). The IDD-push + // capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1. + let depth = capturer.pipeline_depth().max(1); + let submit_ns = now_ns(); + // Wire pts: a fresh frame anchors at its capture-delivery stamp (`CapturedFrame.pts_ns`, + // stamped when the capture thread handed it over) so client-measured latency covers + // delivery + queue age, not just submit→glass; `queue_us` splits that age out as its own + // stage. A re-encoded hold anchors at "now" (its content age is unbounded by design). The + // stamp must be a recent wall-clock time — a synthetic/index-based or ahead-of-clock stamp + // (SyntheticCapturer counts from 0, not the epoch) falls back to "now". + let age_ns = submit_ns.saturating_sub(frame.pts_ns); + let plausible = frame.pts_ns > 0 && frame.pts_ns <= submit_ns && age_ns < 10_000_000_000; + let (capture_ns, queue_us) = if !repeat && plausible { + (frame.pts_ns, (age_ns / 1000) as u32) + } else { + (submit_ns, 0) + }; + if perf && !repeat { + st_queue.push(queue_us); + } + let t_submit = std::time::Instant::now(); + // This submission's future wire frame index (see `au_seq`): AUs are emitted FIFO one per + // submission, so it lands `inflight.len()` AUs after the `au_seq` the loop is about to + // assign next. The RFI backends pin their frame numbering to it. + let wire_index = au_seq.wrapping_add(inflight.len() as u32); + if let Err(e) = enc.submit_indexed(&frame, wire_index) { + // The input half of an encode stall: once the driver stops draining AUs, libavcodec's + // one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same + // wedge the watchdog below catches, seen from submit. Rebuild the encoder in place + // (bounded) instead of killing an otherwise healthy session; a backend without an + // in-place rebuild keeps today's fail-fast behavior. + encoder_resets += 1; + if encoder_resets > MAX_ENCODER_RESETS + || !reset_stalled_encoder(&mut enc, &mut inflight) + { + // Terminal: rebuilds are exhausted (or the backend can't rebuild in place). Say so + // plainly with the underlying cause — the per-reset lines above only ever repeat + // "rebuilt in place", so without this the session just vanishes. The error carries + // its own actionable text now (e.g. an NVENC version mismatch → "update/reboot the + // driver"), so this is the one line an operator needs. + tracing::error!( + error = %format!("{e:#}"), + resets = encoder_resets, + "encoder did not recover after repeated in-place rebuilds — ending the video \ + session (see the error above for the cause)"); + return Err(e).context("encoder submit"); + } + tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets, + max = MAX_ENCODER_RESETS, + "encoder submit failed — encoder rebuilt in place, forcing an IDR"); + last_au_at = std::time::Instant::now(); + // Back off exponentially between rebuild attempts (100 ms → 1.6 s, ~3 s total across + // the reset budget). One frame period is NOT enough: a 2026-07 field report showed all + // 5 resets burning within 40 ms at 120 Hz against a driver-side condition (NVENC + // session open failing after a codec switch) that no 8 ms retry could outlive — any + // transient like the previous session's deferred driver teardown needs real time. A + // genuinely dead encoder now costs ~3 s before the session ends with the terminal + // error, which the client's stall UI already covers. + let backoff = std::cmp::max( + interval, + std::time::Duration::from_millis(100u64 << (encoder_resets - 1).min(4)), + ); + next = std::time::Instant::now() + backoff; + std::thread::sleep(backoff); + continue; + } + let submit_us = if measure { + t_submit.elapsed().as_micros() as u32 + } else { + 0 + }; + if perf { + st_submit.push(submit_us); + } + // This frame's pacing deadline (the next frame's due time); the send thread spreads a big frame + // up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled. + next += interval; + inflight.push_back((capture_ns, submit_ns, next)); + // Drain the OLDEST in-flight frames, keeping at most depth-1 deferred. At depth 1 this polls + // immediately after every submit (synchronous); at depth 2 it polls N right after submitting N+1, + // so the encode of N overlaps the convert/copy of N+1. NVENC's `pending` is FIFO, so poll() returns + // the oldest submitted frame's AU — matching `inflight.pop_front()`. + let mut send_gone = false; + // A poll error is the explicit form of an encode stall (e.g. a QSV device failure); + // carry it to the shared stall recovery below instead of killing the session outright. + let mut poll_err: Option = None; + while inflight.len() >= depth { + let t_wait = std::time::Instant::now(); + let polled = enc.poll(); + let wait_us = if measure { + t_wait.elapsed().as_micros() as u32 + } else { + 0 + }; + if perf { + st_wait.push(wait_us); + } + let au = match polled { + Ok(Some(au)) => au, + // No AU ready for a submitted frame. Routine on the non-blocking backends (the + // libavcodec AMF/QSV wrapper holds ~2 frames; async NVENC drains a ready queue) — + // the frame stays in flight and the next tick re-polls. The stall watchdog below + // decides when "not ready yet" has become "the driver is wedged". + Ok(None) => break, + Err(e) => { + poll_err = Some(e); + break; + } + }; + // The encoder is alive: feed the stall watchdog, clear the consecutive-reset counter. + last_au_at = std::time::Instant::now(); + encoder_resets = 0; + let (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty"); + let mut flags = if au.keyframe { + (FLAG_PIC | FLAG_SOF) as u32 + } else { + FLAG_PIC as u32 + }; + // Intra-refresh recovery marking (inert unless the backend validated its constrained GDR + // via `intra_refresh_recovery`): tag every wave-boundary AU with USER_FLAG_RECOVERY_POINT + // so the client lifts its post-loss freeze on the second mark — a proven clean re-anchor — + // instead of forcing a full IDR. See [`mark_recovery_boundary`] for the cadence. + let caps = enc.caps(); + if caps.intra_refresh_recovery + && caps.intra_refresh_period > 0 + && mark_recovery_boundary(&mut ir_wave_pos, au.keyframe, caps.intra_refresh_period) + { + flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_POINT; + } + // Reference-frame-invalidation recovery frame (AMD LTR force-reference): a clean P-frame + // off a known-good reference. Tag it so the client lifts its post-loss freeze on this one + // AU without an IDR — the definitive single-frame re-anchor (see USER_FLAG_RECOVERY_ANCHOR). + if au.recovery_anchor { + flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR; + } + // Datagram-aligned PyroWave AU (plan §4.4): the client windows its parse at the + // shard payload and may opt into partial delivery of lossy frames. + if au.chunk_aligned { + flags |= punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED; + } + // Re-send the HDR mastering metadata (0xCE) on each keyframe (a decoder-resync point) and + // whenever it changed, so a client that dropped the best-effort datagram re-converges. + if let Some(m) = last_hdr_meta { + if au.keyframe || resend_meta { + let _ = conn + .send_datagram(punktfunk_core::quic::encode_hdr_meta_datagram(&m).into()); + resend_meta = false; + } + } + let encode_us = (now_ns().saturating_sub(sub_ns) / 1000) as u32; + let msg = FrameMsg { + data: au.data, + capture_ns: cap_ns, + flags, + frame_index: au_seq, + deadline, + encode_us, + queue_us, + cap_us, + submit_us, + wait_us, + repeat, + was_measured: measure, + }; + // Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it + // exited (send failure / stop) — end the encode loop too. + if frame_tx.send(msg).is_err() { + send_gone = true; + break; + } + au_seq = au_seq.wrapping_add(1); + sent += 1; + } + if send_gone { + break; + } + // Encode-stall watchdog. Trip on: an explicit poll error; no AU within the window while + // frames are owed (the full wedge — AMF/QSV's non-blocking poll returns None forever and + // nothing else ever errors); or an owed backlog worth more than the window's frames (the + // slow leak — AUs still trickle, so the gap never trips, but latency grows without bound). + // Recovery rebuilds the encoder in place and forces an IDR — a logged ~one-second hiccup + // instead of a silent permanent freeze — bounded so a genuinely dead encoder still ends + // the session with a clear error. The window scales with the frame interval so low-fps + // modes (where the AMF wrapper's ~2-frame hold spans seconds) can't false-trip. + let stall_window = ENCODE_STALL_WINDOW.max(interval * 8); + let stall_backlog = + depth + (stall_window.as_secs_f64() / interval.as_secs_f64().max(1e-6)).ceil() as usize; + if poll_err.is_some() + || (!inflight.is_empty() + && (last_au_at.elapsed() >= stall_window || inflight.len() > stall_backlog)) + { + let why = match &poll_err { + Some(e) => format!("poll failed: {e:#}"), + None => format!( + "no AU for {} ms with {} frame(s) in flight", + last_au_at.elapsed().as_millis(), + inflight.len() + ), + }; + encoder_resets += 1; + if encoder_resets > MAX_ENCODER_RESETS + || !reset_stalled_encoder(&mut enc, &mut inflight) + { + return Err(poll_err.unwrap_or_else(|| anyhow!("{why}"))) + .context("encoder stalled — in-place rebuild unavailable or exhausted"); + } + tracing::warn!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why, + "encode stall detected — encoder rebuilt in place, forcing an IDR"); + last_au_at = std::time::Instant::now(); + } + match next.checked_duration_since(std::time::Instant::now()) { + Some(d) => std::thread::sleep(d), + None => next = std::time::Instant::now(), + } + } + // Drain the in-flight tail (the depth-1 frames submitted but not yet polled) so the last frames still + // reach the client instead of being dropped on the way out. + while let Some((cap_ns, sub_ns, deadline)) = inflight.pop_front() { + let Ok(Some(au)) = enc.poll() else { break }; + let flags = if au.keyframe { + (FLAG_PIC | FLAG_SOF) as u32 + } else { + FLAG_PIC as u32 + }; + let encode_us = (now_ns().saturating_sub(sub_ns) / 1000) as u32; + // End-of-stream tail drain: the per-stage split isn't measured here (the capture loop has + // exited), so leave it zero — these last few frames are negligible for the aggregates. + let msg = FrameMsg { + data: au.data, + capture_ns: cap_ns, + flags, + frame_index: au_seq, + deadline, + encode_us, + queue_us: 0, + cap_us: 0, + submit_us: 0, + wait_us: 0, + repeat: false, + was_measured: false, + }; + if frame_tx.send(msg).is_err() { + break; + } + au_seq = au_seq.wrapping_add(1); + sent += 1; + } + // Signal the send thread to drain + exit (drop the channel), then join it. + drop(frame_tx); + let _ = send_thread.join(); + tracing::info!(sent, "punktfunk/1 virtual stream complete"); + Ok(()) +} + +/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval). +/// Dropping the capturer tears down the PipeWire stream and the virtual output with it. +type Pipeline = ( + Box, + Box, + crate::capture::CapturedFrame, + std::time::Duration, + // The virtual output's PipeWire node id — used by the B2 dedicated game-exit probe to check THIS + // session's own node (scoped), not any gamescope node. `0` for backends without a PipeWire node + // (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway. + u32, + // The display's registry pool generation (Linux keep-alive pool only; `None` on Windows — the + // manager leases in place — and for non-poolable outputs). A mode-switch rebuild uses it to + // `registry::retire` the superseded old display, so linger/forever keep-alive policies don't + // accumulate kept monitors at stale modes (design/midstream-resolution-resize.md H4). + Option, +); + +/// Build the pipeline, retrying *transient* failures with bounded exponential backoff. +/// +/// Bringing a virtual output to first-frame races several async steps — the compositor parenting +/// the output, the portal/RemoteDesktop grant, PipeWire format negotiation — any of which can +/// momentarily time out on a cold session. A single timed-out attempt shouldn't abort the whole +/// punktfunk/1 session. But a *permanent* failure (unsupported compositor/mode, a KWin too old to +/// create virtual outputs, a missing tool) must fail fast instead of burning the budget — so the +/// error chain is classified and permanent ones short-circuit. Each failed attempt drops its +/// capturer, which (via `PortalCapturer::Drop`) tears the PipeWire thread + virtual output down +/// before the next attempt — no leak across retries. +fn build_pipeline_with_retry( + vd: &mut Box, + mode: punktfunk_core::Mode, + bitrate_kbps: u32, + bit_depth: u8, + plan: crate::session_plan::SessionPlan, + quit: &Arc, + stop: &Arc, +) -> Result { + // ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed + // gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take + // 30-60s to produce its first frame, and a first-connect timeout would tear down the warm + // session (forcing another cold start on reconnect). A genuinely permanent failure still fails + // fast via `is_permanent_build_error`; only transient "no frame yet" retries consume the budget. + // IDD-push only: HOLD one monitor lease across all build attempts. A failed attempt's capturer + // drop releases ITS lease, but this held lease keeps the shared monitor Active (refs >= 1), so the + // next attempt's `vd.create` JOINS it (refcount++) instead of finding it Lingering and tripping the + // IDD-push reconnect PREEMPT (teardown + recreate). That preempt-per-retry was the REMOVE→ADD churn + // that exhausts the IddCx monitor-slot pool and wedges ADD at 0x80070490 — one ADD per cold start + // now, not one per attempt. Non-IDD-push backends (Linux portal, WGC) don't use the refcount manager + // and aren't churn-wedge-prone, so they keep create-per-attempt (a held lease there would allocate a + // second virtual output). Dropped when this fn returns — on success the Pipeline's own lease keeps + // the monitor Active; on failure refs falls to 0 → Lingering → linger-timeout teardown. + let _retry_hold = if matches!(plan.capture, crate::session_plan::CaptureBackend::IddPush) { + Some( + vd.create(mode) + .context("acquire virtual output for the session (retry-hold lease)")?, + ) + } else { + None + }; + const MAX_ATTEMPTS: u32 = 8; + let mut backoff = std::time::Duration::from_millis(500); + for attempt in 1..=MAX_ATTEMPTS { + // The client is gone (connection closed → `stop`): every further attempt only churns the + // box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and + // relaunching the box's Steam session once per attempt for minutes (the .181 storm + // 2026-07-07). One in-flight attempt can still overhang; this bounds the damage to it. + if attempt > 1 && stop.load(Ordering::SeqCst) { + anyhow::bail!( + "session ended (client disconnected) during pipeline build — aborting retries \ + after {} attempt(s)", + attempt - 1 + ); + } + match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) { + Ok(pipe) => { + if attempt > 1 { + tracing::info!(attempt, "pipeline up after retry"); + } + return Ok(pipe); + } + Err(e) => { + let chain = format!("{e:#}"); + let permanent = is_permanent_build_error(&chain); + if permanent || attempt == MAX_ATTEMPTS { + let why = if permanent { + "permanent" + } else { + "out of retries" + }; + return Err(e).with_context(|| { + format!("pipeline build failed ({why}) after {attempt} attempt(s)") + }); + } + tracing::warn!( + attempt, + max = MAX_ATTEMPTS, + backoff_ms = backoff.as_millis() as u64, + error = %chain, + "pipeline build failed — retrying" + ); + std::thread::sleep(backoff); + backoff = (backoff * 2).min(std::time::Duration::from_secs(2)); + } + } + } + unreachable!("the final attempt returns inside the loop") +} + +/// Is a pipeline-build error permanent (retrying won't help within this session)? Matches the +/// error chain against signatures that don't change between attempts: unsupported compositor or +/// mode, a KWin too old to expose virtual outputs, a missing/unparseable config, a tool that +/// isn't installed. Everything else — portal/PipeWire negotiation timeouts, "no frame within +/// 10s", transient node races — is treated as transient and retried. Biased toward "transient": +/// a misjudged permanent error only costs a few seconds before it fails anyway. +fn is_permanent_build_error(chain: &str) -> bool { + const PERMANENT: &[&str] = &[ + "virtual displays require linux", + "unknown punktfunk_compositor", + "could not detect compositor", + "could not find output", // KWin < 6.5.6: createVirtualOutput unsupported + "must be a node id", // PUNKTFUNK_GAMESCOPE_NODE not an integer + "is it installed", // gamescope / kscreen-doctor not on PATH + // 4:4:4 NVENC got a CUDA frame — should never happen now the Linux capturer honors gpu=false, + // but fail fast instead of 8× retry (~90 s) rather than wedge the session if it ever recurs. + "capture/encoder negotiation mismatch", + ]; + let lower = chain.to_ascii_lowercase(); + PERMANENT.iter().any(|p| lower.contains(p)) +} + +/// Encode-stall recovery: rebuild the encoder in place (keeping capture + the session up) and +/// discard the owed in-flight frame records — their AUs died with the old encoder instance. +/// Returns `false` when the backend has no in-place rebuild ([`crate::encode::Encoder::reset`]'s +/// default); the caller then surfaces the stall as a session error instead. The forced keyframe +/// makes the rebuilt encoder's first frame an immediate decoder resync point (belt-and-suspenders: +/// a fresh encoder opens on an IDR anyway). +fn reset_stalled_encoder( + enc: &mut Box, + inflight: &mut std::collections::VecDeque<(u64, u64, std::time::Instant)>, +) -> bool { + if !enc.reset() { + return false; + } + inflight.clear(); + enc.request_keyframe(); + true +} + +fn build_pipeline( + vd: &mut Box, + mode: punktfunk_core::Mode, + bitrate_kbps: u32, + bit_depth: u8, + plan: crate::session_plan::SessionPlan, + quit: &Arc, +) -> Result { + // Acquire through the registry (design/display-management.md): on Linux this pools the display + // for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the + // session per policy); on Windows it delegates to `vd.create` (the manager already leases). The + // returned `VirtualOutput`'s keepalive is a registry lease — the capturer holds it as before. The + // `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger. + let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone()) + .context("create virtual output")?; + // A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down + // so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the + // gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.) + #[cfg(target_os = "linux")] + let reused_gen = vout.reused_gen; + // The display's pool generation (fresh AND reused), threaded out so a mode-switch rebuild can + // `registry::retire` the display this pipeline supersedes (H4). `None` off Linux / non-poolable. + #[cfg(target_os = "linux")] + let pool_gen = vout.pool_gen; + #[cfg(not(target_os = "linux"))] + let pool_gen = None; + // The virtual output's PipeWire node id — kept for the B2 dedicated game-exit probe (scoped to + // this session's own node). Read before `capture_virtual_output` consumes `vout`. + let node_id = vout.node_id; + // The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a + // virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame + // clock to that, not the requested rate, so we don't emit phantom duplicate frames over a + // slower source. Falls back to the requested rate when a backend reports nothing. + let effective_hz = vout + .preferred_mode + .map(|(_, _, hz)| hz) + .filter(|&hz| hz > 0) + .unwrap_or(mode.refresh_hz); + if effective_hz != mode.refresh_hz { + tracing::warn!( + requested = mode.refresh_hz, + effective = effective_hz, + "compositor did not honor the requested refresh — encoding at the achieved rate" + ); + } + // HDR vs SDR for the IDD-push conversion: a negotiated 10-bit session (client advertised + // VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2; + // otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends, + // which auto-detect HDR from the monitor state.) + let mut capturer = + crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture) + .context("capture virtual output")?; + capturer.set_active(true); + let frame = match capturer.next_frame().context("first frame") { + Ok(f) => f, + Err(e) => { + // A reused kept display was dead — invalidate it so the next attempt creates fresh (A2). + #[cfg(target_os = "linux")] + if let Some(g) = reused_gen { + crate::vdisplay::registry::mark_failed(g); + } + return Err(e); + } + }; + // `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client + // advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome. + let mut enc = crate::encode::open_video( + plan.codec, + frame.format, + frame.width, + frame.height, + effective_hz, + bitrate_kbps as u64 * 1000, + frame.is_cuda(), + bit_depth, + plan.chroma, + ) + .context("open video encoder")?; + if let Some(c) = plan.wire_chunk { + enc.set_wire_chunking(c); + } + // Post-open cross-check: the Welcome already committed `chroma_format` from the pre-open probe, so + // warn loudly if the encoder actually opened a different chroma than negotiated (the in-band SPS is + // authoritative for the decoder, but a mismatch means the probe and the live open disagreed). + let opened_444 = enc.caps().chroma_444; + if opened_444 != plan.chroma.is_444() { + tracing::warn!( + negotiated_444 = plan.chroma.is_444(), + opened_444, + "encoder chroma disagrees with the negotiated Welcome — the client was told the other value" + ); + } + let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); + Ok((capturer, enc, frame, interval, node_id, pool_gen)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reconfig_allowed_gates_gamescope_and_per_client_mode() { + use crate::vdisplay::Compositor::{Gamescope, Hyprland, Kwin, Mutter, Wlroots}; + // gamescope ALWAYS rejects — a resize would respawn the nested game (H1/D3), regardless of + // the identity policy. + assert!(!reconfig_allowed(Some(Gamescope), false)); + assert!(!reconfig_allowed(Some(Gamescope), true)); + // A per-client-mode identity policy rejects on every backend — the resize resolves a + // different display-identity slot (H5). + assert!(!reconfig_allowed(Some(Kwin), true)); + assert!(!reconfig_allowed(Some(Mutter), true)); + assert!(!reconfig_allowed(None, true)); + // Every other compositor with the default identity ACCEPTS (recreate / re-arrival / in-place). + for c in [Kwin, Mutter, Wlroots, Hyprland] { + assert!( + reconfig_allowed(Some(c), false), + "{c:?} should allow live reconfigure" + ); + } + // The synthetic source (no compositor) is the protocol-test path — always reconfigurable. + assert!(reconfig_allowed(None, false)); + } + + #[test] + fn recovery_marks_land_every_period_and_rephase_at_idr() { + let period = 4; + let mut pos = 0u32; + // Frames 1..=3 are mid-wave (no mark), frame 4 is the boundary; then it repeats. + let marks: Vec = (0..10) + .map(|_| mark_recovery_boundary(&mut pos, false, period)) + .collect(); + assert_eq!( + marks, + vec![false, false, false, true, false, false, false, true, false, false] + ); + + // An IDR mid-wave re-phases: the counter restarts, so the next boundary is a full period + // later (an IDR is itself a clean anchor, so it is not additionally marked). + let mut pos = 0u32; + assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 1 + assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 2 + assert!(!mark_recovery_boundary(&mut pos, true, period)); // IDR → pos 0, no mark + // Now a fresh full period is needed, not just the 2 remaining frames. + assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 1 + assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 2 + assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 3 + assert!(mark_recovery_boundary(&mut pos, false, period)); // pos 4 → mark + } + + #[test] + fn permanent_errors_short_circuit_retry() { + // Permanent: config / version / missing-tool — retrying within a session can't fix these. + assert!(is_permanent_build_error( + "create virtual output: KWin virtual output failed: Could not find output" + )); + assert!(is_permanent_build_error( + "unknown PUNKTFUNK_COMPOSITOR 'foo' (kwin|wlroots|mutter|gamescope)" + )); + assert!(is_permanent_build_error( + "spawn gamescope (is it installed? `apt install gamescope`)" + )); + assert!(is_permanent_build_error("virtual displays require Linux")); + // Transient: negotiation/timeout races — exactly what backoff is for. + assert!(!is_permanent_build_error( + "first frame: no PipeWire frame within 10s (node 42): format negotiation never completed" + )); + assert!(!is_permanent_build_error( + "create virtual output: timed out creating the KWin virtual output" + )); + assert!(!is_permanent_build_error("open NVENC: device busy")); + } +}