fix(host): forward reconfigure_bitrate through TrackedEncoder + probe --rebitrate validator
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m5s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 13m33s
android / android (push) Successful in 14m55s
ci / bench (push) Successful in 5m19s
ci / rust (push) Successful in 16m59s
flatpak / build-publish (push) Failing after 8m7s
apple / swift (push) Successful in 4m30s
deb / build-publish (push) Successful in 11m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m31s
docker / deploy-docs (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m8s
windows-host / package (push) Successful in 8m14s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m46s
release / apple (push) Successful in 24m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m12s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m56s
apple / screenshots (push) Successful in 21m24s

The gpu-session TrackedEncoder wrapper delegates every Encoder method by
hand, so the new reconfigure_bitrate fell through to the trait's false
default and EVERY bitrate change silently took the rebuild+IDR path — the
live .21 A/B caught it (host log said 'rebuilt', never 'in place').

Also:
- punktfunk-probe --rebitrate KBPS:SECS — headless mid-stream SetBitrate
  validator (cursor-wiggles so a damage-driven idle desktop keeps
  publishing frames through the switch). Live-verified on .21: one NVENC
  session open, then 'encoder bitrate reconfigured in place (adaptive
  bitrate — no IDR)' at 20→60 Mbps.
- on-hardware nvenc_cuda reconfigure smoke test (20→60→10 Mbps in place,
  zero IDRs — green on the RTX 5070 Ti).
- BitrateChanged doc no longer claims the switch costs an IDR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 20:06:56 +02:00
parent a1af916e38
commit 9bf72cdfb5
3 changed files with 78 additions and 5 deletions
+71 -2
View File
@@ -39,6 +39,7 @@
//! exits without connecting. //! exits without connecting.
//! //!
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--remode WxHxFPS:SECS] //! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--remode WxHxFPS:SECS]
//! [--rebitrate KBPS:SECS]
//! [--out FILE] [--bitrate KBPS] [--codec auto|h264|hevc|av1] [--audio-channels 2|6|8] //! [--out FILE] [--bitrate KBPS] [--codec auto|h264|hevc|av1] [--audio-channels 2|6|8]
//! [--launch APP] [--name NAME] [--speed-test KBPS:MS] //! [--launch APP] [--name NAME] [--speed-test KBPS:MS]
//! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test] //! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test]
@@ -51,8 +52,8 @@ use punktfunk_core::config::Role;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::FLAG_PROBE; use punktfunk_core::packet::FLAG_PROBE;
use punktfunk_core::quic::{ use punktfunk_core::quic::{
endpoint, io, window_loss_ppm, Hello, LossReport, ProbeRequest, ProbeResult, Reconfigure, endpoint, io, window_loss_ppm, BitrateChanged, Hello, LossReport, ProbeRequest, ProbeResult,
Reconfigured, RequestKeyframe, Start, Welcome, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start, Welcome,
}; };
use punktfunk_core::transport::UdpTransport; use punktfunk_core::transport::UdpTransport;
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session}; use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
@@ -84,6 +85,11 @@ struct Args {
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream. /// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
remode: Option<(Mode, u32)>, remode: Option<(Mode, u32)>,
/// `--rebitrate KBPS:SECS` — send a mid-stream [`SetBitrate`] (the adaptive-bitrate control
/// message) SECS seconds into the stream: the headless validator for the host's in-place
/// encoder rate retarget (Phase 3.2) / rebuild fallback. Wiggles the cursor around the switch
/// so a damage-driven idle desktop actually publishes frames through it.
rebitrate: Option<(u32, u32)>,
/// `--pair PIN` — run the pairing ceremony instead of a session. /// `--pair PIN` — run the pairing ceremony instead of a session.
pair: Option<String>, pair: Option<String>,
/// `--name LABEL` — how the host labels this client when pairing. /// `--name LABEL` — how the host labels this client when pairing.
@@ -201,6 +207,10 @@ fn parse_args() -> Args {
let (m, secs) = s.split_once(':')?; let (m, secs) = s.split_once(':')?;
Some((parse_mode(m)?, secs.parse().ok()?)) Some((parse_mode(m)?, secs.parse().ok()?))
}); });
let rebitrate = get("--rebitrate").and_then(|s| {
let (kbps, secs) = s.split_once(':')?;
Some((kbps.parse().ok()?, secs.parse().ok()?))
});
// A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use // A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use
// (the user asked for verification; fail closed). // (the user asked for verification; fail closed).
let pin = match get("--pin") { let pin = match get("--pin") {
@@ -252,6 +262,7 @@ fn parse_args() -> Args {
seconds: get("--seconds").and_then(|s| s.parse().ok()), seconds: get("--seconds").and_then(|s| s.parse().ok()),
pin, pin,
remode, remode,
rebitrate,
pair: get("--pair").map(String::from), pair: get("--pair").map(String::from),
name: get("--name").unwrap_or("punktfunk-probe").to_string(), name: get("--name").unwrap_or("punktfunk-probe").to_string(),
compositor, compositor,
@@ -629,6 +640,64 @@ async fn session(args: Args) -> Result<()> {
other => tracing::error!(?other, "bad Reconfigured"), other => tracing::error!(?other, "bad Reconfigured"),
} }
}); });
} else if let Some((new_kbps, after_secs)) = args.rebitrate {
// Mid-stream adaptive-bitrate test: after a delay, send the SetBitrate the Automatic
// controller would and await the host's BitrateChanged ack. Host-side this exercises the
// in-place `reconfigure_bitrate` (no IDR) or the rebuild fallback — the host log says
// which. The cursor wiggle keeps a damage-driven idle desktop publishing frames through
// the whole window: the encode loop only drains bitrate requests between frames, and the
// post-switch AUs are what prove the stream carried on.
let mut rs = send;
let mut rr = recv;
let conn2 = conn.clone();
tokio::spawn(async move {
let wiggle = |i: u32| InputEvent {
kind: InputKind::MouseMove,
_pad: [0; 3],
code: 0,
x: if i % 2 == 0 { 2 } else { -2 },
y: 0,
flags: 0,
};
let end =
std::time::Instant::now() + std::time::Duration::from_secs(after_secs as u64 + 6);
let switch_at =
std::time::Instant::now() + std::time::Duration::from_secs(after_secs as u64);
let mut sent = false;
let mut i = 0u32;
while std::time::Instant::now() < end {
let _ = conn2.send_datagram(wiggle(i).encode().to_vec().into());
i += 1;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if !sent && std::time::Instant::now() >= switch_at {
sent = true;
tracing::info!(new_kbps, "requesting mid-stream bitrate change");
if io::write_msg(
&mut rs,
&SetBitrate {
bitrate_kbps: new_kbps,
}
.encode(),
)
.await
.is_err()
{
tracing::error!("SetBitrate write failed");
return;
}
match io::read_msg(&mut rr)
.await
.map(|b| BitrateChanged::decode(&b))
{
Ok(Ok(ack)) => tracing::info!(
applied_kbps = ack.bitrate_kbps,
"BITRATE CHANGE acked by host"
),
other => tracing::error!(?other, "bad BitrateChanged"),
}
}
}
});
} else if let Some((target_kbps, duration_ms)) = args.speed_test { } else if let Some((target_kbps, duration_ms)) = args.speed_test {
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler; measure // Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler; measure
// delivered WIRE packets (session-stat delta) vs. what the host reports putting on the wire. // delivered WIRE packets (session-stat delta) vs. what the host reports putting on the wire.
+4 -3
View File
@@ -415,9 +415,10 @@ pub struct SetBitrate {
} }
/// `host → client`: answer to [`SetBitrate`] — the bitrate the host actually configured (the /// `host → client`: answer to [`SetBitrate`] — the bitrate the host actually configured (the
/// request clamped to its supported band). The encoder switches on the next frame (an IDR); the /// request clamped to its supported band). The encoder retargets in place where the backend can
/// stream never pauses. Also the controller's liveness signal: no answer ⇒ an old host that /// (no IDR — the stream carries straight on); a backend without in-place reconfigure rebuilds and
/// doesn't renegotiate bitrate. /// switches on the next frame (an IDR). The stream never pauses either way. Also the controller's
/// liveness signal: no answer ⇒ an old host that doesn't renegotiate bitrate.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BitrateChanged { pub struct BitrateChanged {
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
+3
View File
@@ -476,6 +476,9 @@ impl Encoder for TrackedEncoder {
fn reset(&mut self) -> bool { fn reset(&mut self) -> bool {
self.inner.reset() self.inner.reset()
} }
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
self.inner.reconfigure_bitrate(bps)
}
fn flush(&mut self) -> Result<()> { fn flush(&mut self) -> Result<()> {
self.inner.flush() self.inner.flush()
} }