Files
punktfunk/crates/punktfunk-host/src/native/stream.rs
T
enricobuehler 68bcfdac3e refactor(host/W1): split native.rs control task + data plane into submodules
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) <noreply@anthropic.com>
2026-07-16 20:45:22 +02:00

2154 lines
119 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The 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<ProbeRequest>,
probe_result_tx: &tokio::sync::mpsc::UnboundedSender<ProbeResult>,
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<ProbeRequest>,
probe_result_tx: &tokio::sync::mpsc::UnboundedSender<ProbeResult>,
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<usize>,
) -> Result<PaceStat> {
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<u8>,
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<StatsRecorder>,
/// 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<AtomicU64>,
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<AtomicU32>,
}
/// 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<crate::vdisplay::Compositor>,
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<FrameMsg>,
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
stop: Arc<AtomicBool>,
perf: bool,
burst_cap: Option<usize>,
fec_target: Arc<AtomicU8>,
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<quinn::Connection>,
// 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<u32> = Vec::new();
let mut pace_us: Vec<u32> = 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<u32> = None;
let (mut cap_v, mut submit_v, mut wait_v, mut queue_v): (
Vec<u32>,
Vec<u32>,
Vec<u32>,
Vec<u32>,
) = (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 = <stage>_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<SessionSwitch>, stop: Arc<AtomicBool>) {
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<AtomicBool>,
/// 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<AtomicBool>,
/// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
pub(super) reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
/// 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<u32>,
/// 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<ProbeRequest>,
/// Speed-test results back to the control task.
pub(super) probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
/// Mode-switch outcomes back to the control task (H2): a corrective
/// `Reconfigured { accepted: true, mode: <actually live> }` 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<Reconfigured>,
/// Adaptive-FEC target the control task updates from the client's loss reports.
pub(super) fec_target: Arc<AtomicU8>,
/// 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<quinn::Connection>,
/// 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<StatsRecorder>,
/// 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<String>,
/// 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<punktfunk_core::quic::HdrMeta>,
}
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<usize> = std::env::var("PUNKTFUNK_PACE_BURST_KB")
.ok()
.and_then(|s| s.parse::<usize>().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::<FrameMsg>(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::<SessionSwitch>();
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<punktfunk_core::quic::HdrMeta> = 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<std::time::Instant> = 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<u32>,
Vec<u32>,
Vec<u32>,
Vec<u32>,
) = (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<dyn crate::vdisplay::VirtualDisplay>, 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<anyhow::Error> = 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<dyn crate::capture::Capturer>,
Box<dyn crate::encode::Encoder>,
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<u64>,
);
/// 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<dyn crate::vdisplay::VirtualDisplay>,
mode: punktfunk_core::Mode,
bitrate_kbps: u32,
bit_depth: u8,
plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
stop: &Arc<AtomicBool>,
) -> Result<Pipeline> {
// ~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<dyn crate::encode::Encoder>,
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<dyn crate::vdisplay::VirtualDisplay>,
mode: punktfunk_core::Mode,
bitrate_kbps: u32,
bit_depth: u8,
plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
) -> Result<Pipeline> {
// 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<bool> = (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"));
}
}