feat(punktfunk/1): negotiable encoder bitrate + bandwidth speed-test probe
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Two related additions to the native protocol, host-side (the client side of
each is exposed over the C ABI so the platform clients can wire it up).
Bitrate negotiation
- Hello/Welcome carry `bitrate_kbps` (appended trailing-byte field, back-compat:
old peers decode 0 = host default). The client requests a rate; the host
clamps it to [500 kbps, 500 Mbps] (or its 20 Mbps default when 0) and echoes
the resolved value in Welcome. Replaces the hardcoded 20 Mbps NVENC bitrate in
m3.rs — threaded through virtual_stream → build_pipeline → open_video, applied
on the initial mode and every reconfigure rebuild.
- C ABI: punktfunk_connect_ex3(..., bitrate_kbps, ...) (ex2 delegates with 0);
punktfunk_connection_bitrate() reads the resolved value.
Speed test (bandwidth probe)
- New typed control messages ProbeRequest{target_kbps,duration_ms} (0x20) /
ProbeResult{bytes_sent,packets_sent,duration_ms} (0x21), plus a FLAG_PROBE
packet flag. The client asks the host to burst zero-filled, FLAG_PROBE-tagged
access units over the data plane at a target goodput for a duration (clamped
≤ 1 Gbps / ≤ 5 s), pacing by a bytes-allowed budget; video pauses for the
burst. The host reports what it actually sent; the client measures received
bytes + window → goodput and loss. Probe filler is never fed to the decoder
(diverted in the connector pump and the reference client's poll loop).
- The host control task now multiplexes Reconfigure + ProbeRequest (inbound)
and ProbeResult (outbound) over select!; a probe channel reaches the
data-plane thread (both virtual and synthetic sources).
- Connector: NativeClient::request_probe()/probe_result() with an internal
accumulator; C ABI punktfunk_connection_speed_test() +
punktfunk_connection_probe_result() → PunktfunkProbeResult.
- punktfunk-client-rs gains `--bitrate KBPS` and `--speed-test KBPS:MS` (its own
loop measures + logs goodput/loss) for loopback verification.
Validated on loopback (synthetic source): a 20 Mbps / 2 s probe measured
20050 kbps at 0% loss, bitrate negotiated (0→20000 and 50000→50000), and the
interleaved probe AUs were correctly excluded from frame verification
(mismatched=0). Wire codecs + trailing-byte back-compat have unit tests. C
header regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,10 @@ use anyhow::{anyhow, Context, Result};
|
|||||||
use punktfunk_core::config::GamepadPref;
|
use punktfunk_core::config::GamepadPref;
|
||||||
use punktfunk_core::config::Role;
|
use punktfunk_core::config::Role;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome};
|
use punktfunk_core::packet::FLAG_PROBE;
|
||||||
|
use punktfunk_core::quic::{
|
||||||
|
endpoint, io, Hello, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, 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};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -67,6 +70,11 @@ struct Args {
|
|||||||
compositor: CompositorPref,
|
compositor: CompositorPref,
|
||||||
/// `--gamepad NAME` — request a host virtual-pad backend (auto|xbox360|dualsense).
|
/// `--gamepad NAME` — request a host virtual-pad backend (auto|xbox360|dualsense).
|
||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
|
/// `--bitrate KBPS` — request this encoder bitrate (kilobits/s); 0 = host default.
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
/// `--speed-test KBPS:MS` — after the stream starts, ask the host for a `MS`-millisecond
|
||||||
|
/// bandwidth probe burst at `KBPS`, then report measured throughput + loss.
|
||||||
|
speed_test: Option<(u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_mode(m: &str) -> Option<Mode> {
|
fn parse_mode(m: &str) -> Option<Mode> {
|
||||||
@@ -178,6 +186,11 @@ fn parse_args() -> Args {
|
|||||||
name: get("--name").unwrap_or("punktfunk-client-rs").to_string(),
|
name: get("--name").unwrap_or("punktfunk-client-rs").to_string(),
|
||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
|
bitrate_kbps: get("--bitrate").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
|
speed_test: get("--speed-test").and_then(|s| {
|
||||||
|
let (kbps, ms) = s.split_once(':')?;
|
||||||
|
Some((kbps.parse().ok()?, ms.parse().ok()?))
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +276,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
mode: args.mode,
|
mode: args.mode,
|
||||||
compositor: args.compositor,
|
compositor: args.compositor,
|
||||||
gamepad: args.gamepad,
|
gamepad: args.gamepad,
|
||||||
|
bitrate_kbps: args.bitrate_kbps,
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -292,9 +306,18 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Speed-test accumulators: the data-plane loop folds each FLAG_PROBE filler AU in here; the
|
||||||
|
// --speed-test reporter below reads them once the host's ProbeResult lands. first/last hold
|
||||||
|
// now_ns timestamps of the receive window (0 = unset).
|
||||||
|
let probe_recv_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
|
let probe_recv_packets = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
|
let probe_first_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
|
let probe_last_ns = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
|
|
||||||
// Mid-stream renegotiation test: after a delay, ask the host to switch modes on the
|
// Mid-stream renegotiation test: after a delay, ask the host to switch modes on the
|
||||||
// still-open control stream. The stream then carries new-mode AUs (IDR + in-band
|
// still-open control stream. The stream then carries new-mode AUs (IDR + in-band
|
||||||
// parameter sets) — ffprobe the --out file to see both resolutions.
|
// parameter sets) — ffprobe the --out file to see both resolutions. Mutually exclusive with
|
||||||
|
// --speed-test (both own the control stream).
|
||||||
if let Some((new_mode, after_secs)) = args.remode {
|
if let Some((new_mode, after_secs)) = args.remode {
|
||||||
let mut rs = send;
|
let mut rs = send;
|
||||||
let mut rr = recv;
|
let mut rr = recv;
|
||||||
@@ -319,6 +342,70 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
other => tracing::error!(?other, "bad Reconfigured"),
|
other => tracing::error!(?other, "bad Reconfigured"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} 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 what arrives vs. what it reports sending.
|
||||||
|
let mut ss = send;
|
||||||
|
let mut sr = recv;
|
||||||
|
let (pb, pp, pf, pl) = (
|
||||||
|
probe_recv_bytes.clone(),
|
||||||
|
probe_recv_packets.clone(),
|
||||||
|
probe_first_ns.clone(),
|
||||||
|
probe_last_ns.clone(),
|
||||||
|
);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
|
||||||
|
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
|
||||||
|
if io::write_msg(
|
||||||
|
&mut ss,
|
||||||
|
&ProbeRequest {
|
||||||
|
target_kbps,
|
||||||
|
duration_ms,
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::error!("ProbeRequest write failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let res = match io::read_msg(&mut sr).await.map(|b| ProbeResult::decode(&b)) {
|
||||||
|
Ok(Ok(r)) => r,
|
||||||
|
other => {
|
||||||
|
tracing::error!(?other, "bad ProbeResult");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The reliable result can beat the last UDP shards — let them reassemble.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||||
|
let recv_bytes = pb.load(Relaxed);
|
||||||
|
let recv_packets = pp.load(Relaxed);
|
||||||
|
let (first, last) = (pf.load(Relaxed), pl.load(Relaxed));
|
||||||
|
let window_ms = if first > 0 && last > first {
|
||||||
|
(last - first) / 1_000_000
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let throughput_kbps = recv_bytes.saturating_mul(8).checked_div(window_ms).unwrap_or(0);
|
||||||
|
let loss_pct = if res.bytes_sent > 0 {
|
||||||
|
res.bytes_sent.saturating_sub(recv_bytes) as f64 / res.bytes_sent as f64 * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
target_kbps,
|
||||||
|
host_sent_bytes = res.bytes_sent,
|
||||||
|
host_sent_packets = res.packets_sent,
|
||||||
|
recv_bytes,
|
||||||
|
recv_packets,
|
||||||
|
window_ms,
|
||||||
|
throughput_kbps,
|
||||||
|
loss_pct = format!("{loss_pct:.1}%"),
|
||||||
|
"SPEED TEST complete",
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
|
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
|
||||||
@@ -581,6 +668,12 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
let cfg = welcome.session_config(Role::Client);
|
let cfg = welcome.session_config(Role::Client);
|
||||||
let expected = welcome.frames;
|
let expected = welcome.frames;
|
||||||
let out_path = args.out.clone();
|
let out_path = args.out.clone();
|
||||||
|
let (pb, pp, pf, pl) = (
|
||||||
|
probe_recv_bytes.clone(),
|
||||||
|
probe_recv_packets.clone(),
|
||||||
|
probe_first_ns.clone(),
|
||||||
|
probe_last_ns.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
// Data plane on a blocking thread (native threads only on the frame path).
|
// Data plane on a blocking thread (native threads only on the frame path).
|
||||||
let result = tokio::task::spawn_blocking(move || -> Result<()> {
|
let result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
@@ -619,6 +712,17 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
match session.poll_frame() {
|
match session.poll_frame() {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
last_rx = std::time::Instant::now();
|
last_rx = std::time::Instant::now();
|
||||||
|
// Speed-test filler isn't video: fold it into the probe accumulators and skip
|
||||||
|
// verification / the --out sink.
|
||||||
|
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
let n = now_ns();
|
||||||
|
let _ = pf.compare_exchange(0, n, Relaxed, Relaxed);
|
||||||
|
pl.store(n, Relaxed);
|
||||||
|
pb.fetch_add(frame.data.len() as u64, Relaxed);
|
||||||
|
pp.fetch_add(1, Relaxed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
bytes += frame.data.len() as u64;
|
bytes += frame.data.len() as u64;
|
||||||
// The host stamps pts with its capture wall clock; same-host runs share it.
|
// The host stamps pts with its capture wall clock; same-host runs share it.
|
||||||
let lat = now_ns().saturating_sub(frame.pts_ns);
|
let lat = now_ns().saturating_sub(frame.pts_ns);
|
||||||
|
|||||||
@@ -748,6 +748,50 @@ pub unsafe extern "C" fn punktfunk_connect_ex2(
|
|||||||
client_cert_pem: *const std::os::raw::c_char,
|
client_cert_pem: *const std::os::raw::c_char,
|
||||||
client_key_pem: *const std::os::raw::c_char,
|
client_key_pem: *const std::os::raw::c_char,
|
||||||
timeout_ms: u32,
|
timeout_ms: u32,
|
||||||
|
) -> *mut PunktfunkConnection {
|
||||||
|
unsafe {
|
||||||
|
punktfunk_connect_ex3(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
refresh_hz,
|
||||||
|
compositor,
|
||||||
|
gamepad,
|
||||||
|
0, // bitrate_kbps = 0: let the host pick its default
|
||||||
|
pin_sha256,
|
||||||
|
observed_sha256_out,
|
||||||
|
client_cert_pem,
|
||||||
|
client_key_pem,
|
||||||
|
timeout_ms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`punktfunk_connect_ex2`], but additionally requests the video encoder `bitrate_kbps`
|
||||||
|
/// (kilobits per second). `0` lets the host pick its default; any other value is clamped to the
|
||||||
|
/// host's supported range. After a speed test ([`punktfunk_connection_speed_test`]) a client can
|
||||||
|
/// reconnect (or pick at connect time) with the measured rate. The value the host actually
|
||||||
|
/// configured is readable via [`punktfunk_connection_bitrate`].
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Same as [`punktfunk_connect`].
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connect_ex3(
|
||||||
|
host: *const std::os::raw::c_char,
|
||||||
|
port: u16,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
refresh_hz: u32,
|
||||||
|
compositor: u32,
|
||||||
|
gamepad: u32,
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
pin_sha256: *const u8,
|
||||||
|
observed_sha256_out: *mut u8,
|
||||||
|
client_cert_pem: *const std::os::raw::c_char,
|
||||||
|
client_key_pem: *const std::os::raw::c_char,
|
||||||
|
timeout_ms: u32,
|
||||||
) -> *mut PunktfunkConnection {
|
) -> *mut PunktfunkConnection {
|
||||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||||
if host.is_null() {
|
if host.is_null() {
|
||||||
@@ -790,6 +834,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex2(
|
|||||||
mode,
|
mode,
|
||||||
pref,
|
pref,
|
||||||
gamepad,
|
gamepad,
|
||||||
|
bitrate_kbps,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
std::time::Duration::from_millis(timeout_ms as u64),
|
std::time::Duration::from_millis(timeout_ms as u64),
|
||||||
@@ -1245,6 +1290,32 @@ pub unsafe extern "C" fn punktfunk_connection_gamepad(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The video encoder bitrate (kilobits per second) the host actually configured for this session
|
||||||
|
/// — the [`punktfunk_connect_ex3`] request clamped to the host's range, or its default when `0`
|
||||||
|
/// was requested. `0` = an older host that didn't report it. Safe any time after connect.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `bitrate_kbps` is writable (NULL is skipped).
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_bitrate(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
bitrate_kbps: *mut u32,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
if !bitrate_kbps.is_null() {
|
||||||
|
*bitrate_kbps = c.inner.resolved_bitrate_kbps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
||||||
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
||||||
/// stream continues at the new mode — the first new-mode access unit is an IDR with
|
/// stream continues at the new mode — the first new-mode access unit is an IDR with
|
||||||
@@ -1278,6 +1349,91 @@ pub unsafe extern "C" fn punktfunk_connection_request_mode(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||||
|
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||||
|
/// measured goodput to drive a bitrate choice from; `loss_pct` is the delivery loss at that rate.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct PunktfunkProbeResult {
|
||||||
|
/// 1 once the host's end-of-burst report arrived (measurement final); else 0 (partial).
|
||||||
|
pub done: u8,
|
||||||
|
/// Probe payload bytes / packets the client received.
|
||||||
|
pub recv_bytes: u64,
|
||||||
|
pub recv_packets: u32,
|
||||||
|
/// Probe payload bytes / packets the host reported sending.
|
||||||
|
pub host_bytes: u64,
|
||||||
|
pub host_packets: u32,
|
||||||
|
/// Client-measured receive window (first→last probe AU), milliseconds.
|
||||||
|
pub elapsed_ms: u32,
|
||||||
|
/// Measured goodput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
|
||||||
|
pub throughput_kbps: u32,
|
||||||
|
/// Delivery loss `(host_bytes - recv_bytes) / host_bytes` as a percentage (0 if unknown).
|
||||||
|
pub loss_pct: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||||
|
/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 1 Gbps / ≤ 5 s),
|
||||||
|
/// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
|
||||||
|
/// `done` field is 1. Starting a probe resets any prior measurement.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_speed_test(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
target_kbps: u32,
|
||||||
|
duration_ms: u32,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
match c.inner.request_probe(target_kbps, duration_ms) {
|
||||||
|
Ok(()) => PunktfunkStatus::Ok,
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current speed-test measurement into `*out` (partial until `out->done == 1`). Safe to
|
||||||
|
/// poll repeatedly after [`punktfunk_connection_speed_test`]; before any probe it reports zeros.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkProbeResult` (NULL is an
|
||||||
|
/// error).
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_probe_result(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
out: *mut PunktfunkProbeResult,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if out.is_null() {
|
||||||
|
return PunktfunkStatus::NullPointer;
|
||||||
|
}
|
||||||
|
let o = c.inner.probe_result();
|
||||||
|
unsafe {
|
||||||
|
*out = PunktfunkProbeResult {
|
||||||
|
done: o.done as u8,
|
||||||
|
recv_bytes: o.recv_bytes,
|
||||||
|
recv_packets: o.recv_packets,
|
||||||
|
host_bytes: o.host_bytes,
|
||||||
|
host_packets: o.host_packets,
|
||||||
|
elapsed_ms: o.elapsed_ms,
|
||||||
|
throughput_kbps: o.throughput_kbps,
|
||||||
|
loss_pct: o.loss_pct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
|
|||||||
@@ -14,15 +14,70 @@
|
|||||||
use crate::config::{CompositorPref, GamepadPref, Mode, Role};
|
use crate::config::{CompositorPref, GamepadPref, Mode, Role};
|
||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
use crate::input::InputEvent;
|
use crate::input::InputEvent;
|
||||||
|
use crate::packet::FLAG_PROBE;
|
||||||
use crate::quic::{
|
use crate::quic::{
|
||||||
endpoint, io, Hello, HidOutput, Reconfigure, Reconfigured, RichInput, Start, Welcome,
|
endpoint, io, Hello, HidOutput, ProbeRequest, ProbeResult, Reconfigure, Reconfigured,
|
||||||
|
RichInput, Start, Welcome,
|
||||||
};
|
};
|
||||||
use crate::session::{Frame, Session};
|
use crate::session::{Frame, Session};
|
||||||
use crate::transport::UdpTransport;
|
use crate::transport::UdpTransport;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
|
||||||
|
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
|
||||||
|
/// (two `&mut ctrl_send` borrows across select branches don't compile).
|
||||||
|
enum CtrlRequest {
|
||||||
|
Mode(Mode),
|
||||||
|
Probe(ProbeRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the negotiated
|
||||||
|
/// mode, the host-resolved gamepad backend, the host's certificate fingerprint, and the resolved
|
||||||
|
/// encoder bitrate (kbps).
|
||||||
|
type Negotiated = (Mode, GamepadPref, [u8; 32], u32);
|
||||||
|
|
||||||
|
/// Accumulated state of an in-flight / finished speed test. The data-plane pump folds each
|
||||||
|
/// received [`FLAG_PROBE`] access unit in; the control task records the host's [`ProbeResult`]
|
||||||
|
/// when it lands. Read (and finalized into numbers) by [`NativeClient::probe_result`].
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProbeState {
|
||||||
|
/// A probe is in progress (set by `request_probe`, cleared by nothing — the latest one wins).
|
||||||
|
active: bool,
|
||||||
|
/// Probe access-unit payload bytes the client received, and their count.
|
||||||
|
recv_bytes: u64,
|
||||||
|
recv_packets: u32,
|
||||||
|
/// First/last probe AU arrival — the measured receive window.
|
||||||
|
start: Option<Instant>,
|
||||||
|
last: Option<Instant>,
|
||||||
|
/// The host's report ([`ProbeResult`]); present once the burst finished.
|
||||||
|
host_bytes: u64,
|
||||||
|
host_packets: u32,
|
||||||
|
/// The host's `ProbeResult` arrived → the measurement is final.
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished/partial speed-test measurement, returned by [`NativeClient::probe_result`].
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct ProbeOutcome {
|
||||||
|
/// The host's end-of-burst report has arrived — the numbers below are final.
|
||||||
|
pub done: bool,
|
||||||
|
/// Probe payload bytes / packets the client received.
|
||||||
|
pub recv_bytes: u64,
|
||||||
|
pub recv_packets: u32,
|
||||||
|
/// Probe payload bytes / packets the host reported sending.
|
||||||
|
pub host_bytes: u64,
|
||||||
|
pub host_packets: u32,
|
||||||
|
/// The client-measured receive window (first→last probe AU), in milliseconds.
|
||||||
|
pub elapsed_ms: u32,
|
||||||
|
/// Measured goodput = `recv_bytes * 8 / elapsed_ms` (kilobits/second). This is the figure to
|
||||||
|
/// drive a [`Hello::bitrate_kbps`] choice from.
|
||||||
|
pub throughput_kbps: u32,
|
||||||
|
/// Delivery loss = `(host_bytes - recv_bytes) / host_bytes`, as a percentage (0 if unknown).
|
||||||
|
pub loss_pct: f32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Frames buffered between the data-plane pump and the embedder. Small: the embedder
|
/// Frames buffered between the data-plane pump and the embedder. Small: the embedder
|
||||||
/// (decoder) should drain at frame rate; when it falls behind, the newest frame is dropped
|
/// (decoder) should drain at frame rate; when it falls behind, the newest frame is dropped
|
||||||
@@ -62,7 +117,10 @@ pub struct NativeClient {
|
|||||||
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
||||||
/// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker.
|
/// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker.
|
||||||
rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>,
|
rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>,
|
||||||
reconfig_tx: tokio::sync::mpsc::UnboundedSender<Mode>,
|
/// Outbound control-stream requests (mode switch, speed test) → the worker's control task.
|
||||||
|
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
|
||||||
|
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||||
|
probe: Arc<Mutex<ProbeState>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
worker: Option<std::thread::JoinHandle<()>>,
|
worker: Option<std::thread::JoinHandle<()>>,
|
||||||
/// The currently active session mode (the Welcome's, then updated by every accepted
|
/// The currently active session mode (the Welcome's, then updated by every accepted
|
||||||
@@ -74,6 +132,10 @@ pub struct NativeClient {
|
|||||||
/// The virtual gamepad backend the host actually resolved ([`Welcome::gamepad`]).
|
/// The virtual gamepad backend the host actually resolved ([`Welcome::gamepad`]).
|
||||||
/// `Auto` = an older host that didn't say (assume X-Box 360, no DualSense feedback).
|
/// `Auto` = an older host that didn't say (assume X-Box 360, no DualSense feedback).
|
||||||
pub resolved_gamepad: GamepadPref,
|
pub resolved_gamepad: GamepadPref,
|
||||||
|
/// The encoder bitrate the host actually configured ([`Welcome::bitrate_kbps`], kbps): our
|
||||||
|
/// requested rate clamped to the host's range, or its default if we requested `0`. `0` = an
|
||||||
|
/// older host that didn't report it.
|
||||||
|
pub resolved_bitrate_kbps: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NativeClient {
|
impl NativeClient {
|
||||||
@@ -94,6 +156,7 @@ impl NativeClient {
|
|||||||
mode: Mode,
|
mode: Mode,
|
||||||
compositor: CompositorPref,
|
compositor: CompositorPref,
|
||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
|
bitrate_kbps: u32,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
identity: Option<(String, String)>,
|
identity: Option<(String, String)>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -105,15 +168,17 @@ impl NativeClient {
|
|||||||
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
||||||
let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec<u8>)>();
|
let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec<u8>)>();
|
||||||
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||||
let (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::<Mode>();
|
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>();
|
||||||
let (ready_tx, ready_rx) =
|
let (ready_tx, ready_rx) =
|
||||||
std::sync::mpsc::channel::<Result<(Mode, GamepadPref, [u8; 32])>>();
|
std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
||||||
|
let probe = Arc::new(Mutex::new(ProbeState::default()));
|
||||||
|
|
||||||
let host = host.to_string();
|
let host = host.to_string();
|
||||||
let shutdown_w = shutdown.clone();
|
let shutdown_w = shutdown.clone();
|
||||||
let mode_slot_w = mode_slot.clone();
|
let mode_slot_w = mode_slot.clone();
|
||||||
|
let probe_w = probe.clone();
|
||||||
let worker = std::thread::Builder::new()
|
let worker = std::thread::Builder::new()
|
||||||
.name("punktfunk-client".into())
|
.name("punktfunk-client".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -134,6 +199,7 @@ impl NativeClient {
|
|||||||
mode,
|
mode,
|
||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
|
bitrate_kbps,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
frame_tx,
|
frame_tx,
|
||||||
@@ -143,15 +209,17 @@ impl NativeClient {
|
|||||||
input_rx,
|
input_rx,
|
||||||
mic_rx,
|
mic_rx,
|
||||||
rich_input_rx,
|
rich_input_rx,
|
||||||
reconfig_rx,
|
ctrl_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown: shutdown_w,
|
shutdown: shutdown_w,
|
||||||
mode_slot: mode_slot_w,
|
mode_slot: mode_slot_w,
|
||||||
|
probe: probe_w,
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.map_err(PunktfunkError::Io)?;
|
.map_err(PunktfunkError::Io)?;
|
||||||
|
|
||||||
let (negotiated, resolved_gamepad, fingerprint) = match ready_rx.recv_timeout(timeout) {
|
let (negotiated, resolved_gamepad, fingerprint, resolved_bitrate_kbps) =
|
||||||
|
match ready_rx.recv_timeout(timeout) {
|
||||||
Ok(Ok(t)) => t,
|
Ok(Ok(t)) => t,
|
||||||
Ok(Err(e)) => return Err(e),
|
Ok(Err(e)) => return Err(e),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -168,12 +236,14 @@ impl NativeClient {
|
|||||||
input_tx,
|
input_tx,
|
||||||
mic_tx,
|
mic_tx,
|
||||||
rich_input_tx,
|
rich_input_tx,
|
||||||
reconfig_tx,
|
ctrl_tx,
|
||||||
|
probe,
|
||||||
shutdown,
|
shutdown,
|
||||||
worker: Some(worker),
|
worker: Some(worker),
|
||||||
mode: mode_slot,
|
mode: mode_slot,
|
||||||
host_fingerprint: fingerprint,
|
host_fingerprint: fingerprint,
|
||||||
resolved_gamepad,
|
resolved_gamepad,
|
||||||
|
resolved_bitrate_kbps,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,11 +350,61 @@ impl NativeClient {
|
|||||||
/// frames open with an IDR carrying new parameter sets) and [`NativeClient::mode`]
|
/// frames open with an IDR carrying new parameter sets) and [`NativeClient::mode`]
|
||||||
/// reflects it. A rejected request leaves the session unchanged.
|
/// reflects it. A rejected request leaves the session unchanged.
|
||||||
pub fn request_mode(&self, mode: Mode) -> Result<()> {
|
pub fn request_mode(&self, mode: Mode) -> Result<()> {
|
||||||
self.reconfig_tx
|
self.ctrl_tx
|
||||||
.send(mode)
|
.send(CtrlRequest::Mode(mode))
|
||||||
.map_err(|_| PunktfunkError::Closed)
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||||
|
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
||||||
|
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
||||||
|
/// `done` flag is set. Starting a probe resets any prior measurement. The host clamps both
|
||||||
|
/// fields (≤ 1 Gbps, ≤ 5 s).
|
||||||
|
pub fn request_probe(&self, target_kbps: u32, duration_ms: u32) -> Result<()> {
|
||||||
|
// Reset the accumulator so a fresh run doesn't blend into the previous one.
|
||||||
|
*self.probe.lock().unwrap() = ProbeState {
|
||||||
|
active: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
self.ctrl_tx
|
||||||
|
.send(CtrlRequest::Probe(ProbeRequest {
|
||||||
|
target_kbps,
|
||||||
|
duration_ms,
|
||||||
|
}))
|
||||||
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current speed-test measurement (partial until `done`, final once the host's
|
||||||
|
/// end-of-burst report lands). Derives goodput + loss from the accumulated probe bytes.
|
||||||
|
pub fn probe_result(&self) -> ProbeOutcome {
|
||||||
|
let p = self.probe.lock().unwrap();
|
||||||
|
let elapsed_ms = match (p.start, p.last) {
|
||||||
|
(Some(s), Some(l)) => l.duration_since(s).as_millis() as u32,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
// bytes × 8 / ms = kilobits/second.
|
||||||
|
let throughput_kbps = if elapsed_ms > 0 {
|
||||||
|
(p.recv_bytes.saturating_mul(8) / elapsed_ms as u64) as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let loss_pct = if p.host_bytes > 0 {
|
||||||
|
p.host_bytes.saturating_sub(p.recv_bytes) as f64 / p.host_bytes as f64 * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
} as f32;
|
||||||
|
ProbeOutcome {
|
||||||
|
done: p.done,
|
||||||
|
recv_bytes: p.recv_bytes,
|
||||||
|
recv_packets: p.recv_packets,
|
||||||
|
host_bytes: p.host_bytes,
|
||||||
|
host_packets: p.host_packets,
|
||||||
|
elapsed_ms,
|
||||||
|
throughput_kbps,
|
||||||
|
loss_pct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the next reassembled, FEC-recovered access unit; [`PunktfunkError::NoFrame`] on
|
/// Pull the next reassembled, FEC-recovered access unit; [`PunktfunkError::NoFrame`] on
|
||||||
/// timeout, [`PunktfunkError::Closed`]-class errors once the session ended.
|
/// timeout, [`PunktfunkError::Closed`]-class errors once the session ended.
|
||||||
///
|
///
|
||||||
@@ -373,6 +493,7 @@ struct WorkerArgs {
|
|||||||
mode: Mode,
|
mode: Mode,
|
||||||
compositor: CompositorPref,
|
compositor: CompositorPref,
|
||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
|
bitrate_kbps: u32,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
identity: Option<(String, String)>,
|
identity: Option<(String, String)>,
|
||||||
frame_tx: SyncSender<Frame>,
|
frame_tx: SyncSender<Frame>,
|
||||||
@@ -382,10 +503,11 @@ struct WorkerArgs {
|
|||||||
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||||
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
||||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||||
reconfig_rx: tokio::sync::mpsc::UnboundedReceiver<Mode>,
|
ctrl_rx: tokio::sync::mpsc::UnboundedReceiver<CtrlRequest>,
|
||||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, GamepadPref, [u8; 32])>>,
|
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||||
|
probe: Arc<Mutex<ProbeState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
||||||
@@ -397,6 +519,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
mode,
|
mode,
|
||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
|
bitrate_kbps,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
frame_tx,
|
frame_tx,
|
||||||
@@ -406,10 +529,11 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
mut input_rx,
|
mut input_rx,
|
||||||
mut mic_rx,
|
mut mic_rx,
|
||||||
mut rich_input_rx,
|
mut rich_input_rx,
|
||||||
mut reconfig_rx,
|
mut ctrl_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown,
|
shutdown,
|
||||||
mode_slot,
|
mode_slot,
|
||||||
|
probe,
|
||||||
} = args;
|
} = args;
|
||||||
let setup = async {
|
let setup = async {
|
||||||
let remote: std::net::SocketAddr = format!("{host}:{port}")
|
let remote: std::net::SocketAddr = format!("{host}:{port}")
|
||||||
@@ -448,6 +572,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
mode,
|
mode,
|
||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
|
bitrate_kbps,
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -491,6 +616,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
welcome.mode,
|
welcome.mode,
|
||||||
welcome.gamepad,
|
welcome.gamepad,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
|
welcome.bitrate_kbps,
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -502,6 +628,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
negotiated,
|
negotiated,
|
||||||
resolved_gamepad,
|
resolved_gamepad,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
|
resolved_bitrate_kbps,
|
||||||
) = match setup.await {
|
) = match setup.await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -509,7 +636,12 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = ready_tx.send(Ok((negotiated, resolved_gamepad, fingerprint)));
|
let _ = ready_tx.send(Ok((
|
||||||
|
negotiated,
|
||||||
|
resolved_gamepad,
|
||||||
|
fingerprint,
|
||||||
|
resolved_bitrate_kbps,
|
||||||
|
)));
|
||||||
|
|
||||||
// Input task: embedder events → QUIC datagrams.
|
// Input task: embedder events → QUIC datagrams.
|
||||||
let input_conn = conn.clone();
|
let input_conn = conn.clone();
|
||||||
@@ -536,30 +668,50 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Control task: the handshake stream stays open for mid-stream renegotiation. One
|
// Control task: the handshake stream stays open for mid-stream renegotiation + speed tests.
|
||||||
// request at a time — write Reconfigure, await Reconfigured, publish the active mode.
|
// Outbound requests (mode switch, probe) and inbound replies (Reconfigured, ProbeResult) are
|
||||||
|
// multiplexed with `select!`; a single outbound channel (`ctrl_rx`) keeps one writer so the
|
||||||
|
// two `&mut ctrl_send` borrows don't collide across branches.
|
||||||
{
|
{
|
||||||
let mode_slot = mode_slot.clone();
|
let mode_slot = mode_slot.clone();
|
||||||
|
let probe = probe.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(want) = reconfig_rx.recv().await {
|
loop {
|
||||||
if io::write_msg(&mut ctrl_send, &Reconfigure { mode: want }.encode())
|
tokio::select! {
|
||||||
.await
|
req = ctrl_rx.recv() => {
|
||||||
.is_err()
|
let Some(req) = req else { break }; // client dropped
|
||||||
{
|
let bytes = match req {
|
||||||
|
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
|
||||||
|
CtrlRequest::Probe(p) => p.encode(),
|
||||||
|
};
|
||||||
|
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let ack = match io::read_msg(&mut ctrl_recv).await {
|
}
|
||||||
Ok(b) => match Reconfigured::decode(&b) {
|
msg = io::read_msg(&mut ctrl_recv) => {
|
||||||
Ok(a) => a,
|
let Ok(msg) = msg else { break }; // stream closed
|
||||||
Err(_) => break, // protocol error — stop renegotiating
|
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||||
},
|
|
||||||
Err(_) => break, // stream closed
|
|
||||||
};
|
|
||||||
if ack.accepted {
|
if ack.accepted {
|
||||||
*mode_slot.lock().unwrap() = ack.mode;
|
*mode_slot.lock().unwrap() = ack.mode;
|
||||||
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(requested = ?want, active = ?ack.mode, "host rejected mode switch");
|
tracing::warn!(active = ?ack.mode, "host rejected mode switch");
|
||||||
|
}
|
||||||
|
} else if let Ok(result) = ProbeResult::decode(&msg) {
|
||||||
|
let mut p = probe.lock().unwrap();
|
||||||
|
p.host_bytes = result.bytes_sent;
|
||||||
|
p.host_packets = result.packets_sent;
|
||||||
|
p.done = true;
|
||||||
|
tracing::info!(
|
||||||
|
bytes_sent = result.bytes_sent,
|
||||||
|
packets_sent = result.packets_sent,
|
||||||
|
duration_ms = result.duration_ms,
|
||||||
|
"speed-test probe result"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::warn!("unknown control message — ignoring");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -607,11 +759,25 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
|
|
||||||
// Data-plane pump on a blocking thread: poll the session, hand frames to the embedder.
|
// Data-plane pump on a blocking thread: poll the session, hand frames to the embedder.
|
||||||
// try_send drops the newest frame when the embedder lags (freshness over completeness).
|
// try_send drops the newest frame when the embedder lags (freshness over completeness).
|
||||||
|
// Speed-test filler ([`FLAG_PROBE`]) is folded into the probe accumulator instead of the
|
||||||
|
// decoder queue — it isn't video.
|
||||||
let pump_shutdown = shutdown.clone();
|
let pump_shutdown = shutdown.clone();
|
||||||
|
let pump_probe = probe.clone();
|
||||||
let _ = tokio::task::spawn_blocking(move || {
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
while !pump_shutdown.load(Ordering::SeqCst) {
|
while !pump_shutdown.load(Ordering::SeqCst) {
|
||||||
match session.poll_frame() {
|
match session.poll_frame() {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
|
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||||
|
let mut p = pump_probe.lock().unwrap();
|
||||||
|
if p.active {
|
||||||
|
let now = Instant::now();
|
||||||
|
p.start.get_or_insert(now);
|
||||||
|
p.last = Some(now);
|
||||||
|
p.recv_bytes += frame.data.len() as u64;
|
||||||
|
p.recv_packets += 1;
|
||||||
|
}
|
||||||
|
continue; // not video — never enqueue for the decoder
|
||||||
|
}
|
||||||
let _ = frame_tx.try_send(frame);
|
let _ = frame_tx.try_send(frame);
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {
|
Err(PunktfunkError::NoFrame) => {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ pub const PUNKTFUNK_MAGIC: u8 = 0xC9;
|
|||||||
pub const FLAG_PIC: u8 = 0x1;
|
pub const FLAG_PIC: u8 = 0x1;
|
||||||
pub const FLAG_EOF: u8 = 0x2;
|
pub const FLAG_EOF: u8 = 0x2;
|
||||||
pub const FLAG_SOF: u8 = 0x4;
|
pub const FLAG_SOF: u8 = 0x4;
|
||||||
|
/// Bandwidth-probe filler, not decodable video: a [`crate::quic::ProbeRequest`] speed test makes
|
||||||
|
/// the host burst access units carrying this flag so the client measures throughput/loss without
|
||||||
|
/// feeding them to the decoder. Punktfunk/1 only (GameStream never sets it).
|
||||||
|
pub const FLAG_PROBE: u8 = 0x8;
|
||||||
|
|
||||||
/// Crypto framing overhead [`Session`](crate::session::Session) adds when encrypting:
|
/// Crypto framing overhead [`Session`](crate::session::Session) adds when encrypting:
|
||||||
/// an 8-byte sequence prefix plus the GCM tag.
|
/// an 8-byte sequence prefix plus the GCM tag.
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ pub struct Hello {
|
|||||||
/// [`Welcome::gamepad`]. Appended to the wire form — omitted by older clients (decodes
|
/// [`Welcome::gamepad`]. Appended to the wire form — omitted by older clients (decodes
|
||||||
/// to `Auto`).
|
/// to `Auto`).
|
||||||
pub gamepad: GamepadPref,
|
pub gamepad: GamepadPref,
|
||||||
|
/// The client's desired video encoder bitrate, in kilobits per second. `0` = no preference
|
||||||
|
/// (the host uses its default). The host clamps the request to a supported range and reports
|
||||||
|
/// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form —
|
||||||
|
/// omitted by older clients (decodes to `0`, i.e. host default).
|
||||||
|
pub bitrate_kbps: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `host → client`: the complete session offer.
|
/// `host → client`: the complete session offer.
|
||||||
@@ -77,6 +82,11 @@ pub struct Welcome {
|
|||||||
/// DualSense feedback (0xCD) can arrive at all. Appended to the wire form — `Auto` when an
|
/// DualSense feedback (0xCD) can arrive at all. Appended to the wire form — `Auto` when an
|
||||||
/// older host omitted it (i.e. "unknown, assume X-Box 360").
|
/// older host omitted it (i.e. "unknown, assume X-Box 360").
|
||||||
pub gamepad: GamepadPref,
|
pub gamepad: GamepadPref,
|
||||||
|
/// The encoder bitrate the host actually configured for this session, in kilobits per second
|
||||||
|
/// (the client's [`Hello::bitrate_kbps`] clamped to the host's supported range, or the host
|
||||||
|
/// default when the client requested `0`). Appended to the wire form — `0` when an older host
|
||||||
|
/// omitted it (i.e. "unknown").
|
||||||
|
pub bitrate_kbps: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `client → host`: data plane is bound, begin streaming.
|
/// `client → host`: data plane is bound, begin streaming.
|
||||||
@@ -107,10 +117,41 @@ pub struct Reconfigured {
|
|||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `client → host`, any time after [`Start`]: run a bandwidth speed test. The host bursts
|
||||||
|
/// filler access units (flagged [`crate::packet::FLAG_PROBE`]) over the data plane at
|
||||||
|
/// `target_kbps` of application goodput for `duration_ms`, *pausing video for the duration*, then
|
||||||
|
/// replies with [`ProbeResult`]. The client measures the received probe bytes + time to estimate
|
||||||
|
/// the link's sustainable rate (and the loss vs. the host's reported send count) so it can pick a
|
||||||
|
/// [`Hello::bitrate_kbps`]. The host clamps both fields to sane bounds.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ProbeRequest {
|
||||||
|
/// Goodput rate the host should send the probe at, in kilobits per second.
|
||||||
|
pub target_kbps: u32,
|
||||||
|
/// How long to burst, in milliseconds.
|
||||||
|
pub duration_ms: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host → client`: the probe burst is finished. Reports what the host actually sent so the
|
||||||
|
/// client can compute delivery ratio (loss) = `received / bytes_sent` and throughput =
|
||||||
|
/// `received_bytes * 8 / elapsed`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ProbeResult {
|
||||||
|
/// Total access-unit payload bytes the host emitted for the probe.
|
||||||
|
pub bytes_sent: u64,
|
||||||
|
/// Number of probe access units the host emitted.
|
||||||
|
pub packets_sent: u32,
|
||||||
|
/// The burst's actual duration in milliseconds (the host clamps/measures the request).
|
||||||
|
pub duration_ms: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Type byte of [`Reconfigure`] (first byte after the magic).
|
/// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||||
pub const MSG_RECONFIGURE: u8 = 0x01;
|
pub const MSG_RECONFIGURE: u8 = 0x01;
|
||||||
/// Type byte of [`Reconfigured`].
|
/// Type byte of [`Reconfigured`].
|
||||||
pub const MSG_RECONFIGURED: u8 = 0x02;
|
pub const MSG_RECONFIGURED: u8 = 0x02;
|
||||||
|
/// Type byte of [`ProbeRequest`].
|
||||||
|
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||||
|
/// Type byte of [`ProbeResult`].
|
||||||
|
pub const MSG_PROBE_RESULT: u8 = 0x21;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
// Pairing ceremony (typed control messages): instead of a session Hello, a client may open
|
// Pairing ceremony (typed control messages): instead of a session Hello, a client may open
|
||||||
@@ -379,6 +420,7 @@ impl Hello {
|
|||||||
b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes());
|
b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes());
|
||||||
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
|
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
|
||||||
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
|
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
|
||||||
|
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +445,11 @@ impl Hello {
|
|||||||
.get(21)
|
.get(21)
|
||||||
.map(|&v| GamepadPref::from_u8(v))
|
.map(|&v| GamepadPref::from_u8(v))
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
// Optional trailing 4 bytes (LE) — absent on an older client → `0` (host default).
|
||||||
|
bitrate_kbps: b
|
||||||
|
.get(22..26)
|
||||||
|
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
||||||
|
.unwrap_or(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,13 +476,15 @@ impl Welcome {
|
|||||||
b.extend_from_slice(&self.frames.to_le_bytes());
|
b.extend_from_slice(&self.frames.to_le_bytes());
|
||||||
b.push(self.compositor.to_u8()); // appended at offset 53 — older clients read [0..53] and skip it
|
b.push(self.compositor.to_u8()); // appended at offset 53 — older clients read [0..53] and skip it
|
||||||
b.push(self.gamepad.to_u8()); // appended at offset 54 — same back-compat discipline
|
b.push(self.gamepad.to_u8()); // appended at offset 54 — same back-compat discipline
|
||||||
|
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 55..59
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode(b: &[u8]) -> Result<Welcome> {
|
pub fn decode(b: &[u8]) -> Result<Welcome> {
|
||||||
// Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22]
|
// Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22]
|
||||||
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
||||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] (optional trailing bytes).
|
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||||
|
// (compositor/gamepad/bitrate are optional trailing bytes).
|
||||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||||
}
|
}
|
||||||
@@ -477,6 +526,11 @@ impl Welcome {
|
|||||||
.get(54)
|
.get(54)
|
||||||
.map(|&v| GamepadPref::from_u8(v))
|
.map(|&v| GamepadPref::from_u8(v))
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
// Optional trailing 4 bytes (LE) — absent on an older host → `0` (unknown).
|
||||||
|
bitrate_kbps: b
|
||||||
|
.get(55..59)
|
||||||
|
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
||||||
|
.unwrap_or(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,6 +621,53 @@ impl Reconfigured {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ProbeRequest {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
// magic[0..4] type[4] target_kbps[5..9] duration_ms[9..13]
|
||||||
|
let mut b = Vec::with_capacity(13);
|
||||||
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
|
b.push(MSG_PROBE_REQUEST);
|
||||||
|
b.extend_from_slice(&self.target_kbps.to_le_bytes());
|
||||||
|
b.extend_from_slice(&self.duration_ms.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<ProbeRequest> {
|
||||||
|
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PROBE_REQUEST {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad ProbeRequest"));
|
||||||
|
}
|
||||||
|
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||||
|
Ok(ProbeRequest {
|
||||||
|
target_kbps: u32at(5),
|
||||||
|
duration_ms: u32at(9),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeResult {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
// magic[0..4] type[4] bytes_sent[5..13] packets_sent[13..17] duration_ms[17..21]
|
||||||
|
let mut b = Vec::with_capacity(21);
|
||||||
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
|
b.push(MSG_PROBE_RESULT);
|
||||||
|
b.extend_from_slice(&self.bytes_sent.to_le_bytes());
|
||||||
|
b.extend_from_slice(&self.packets_sent.to_le_bytes());
|
||||||
|
b.extend_from_slice(&self.duration_ms.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<ProbeResult> {
|
||||||
|
if b.len() != 21 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PROBE_RESULT {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad ProbeResult"));
|
||||||
|
}
|
||||||
|
Ok(ProbeResult {
|
||||||
|
bytes_sent: u64::from_le_bytes(b[5..13].try_into().unwrap()),
|
||||||
|
packets_sent: u32::from_le_bytes(b[13..17].try_into().unwrap()),
|
||||||
|
duration_ms: u32::from_le_bytes(b[17..21].try_into().unwrap()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frame a message for the control stream: `u16 LE length || payload`.
|
/// Frame a message for the control stream: `u16 LE length || payload`.
|
||||||
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||||
let mut b = Vec::with_capacity(2 + payload.len());
|
let mut b = Vec::with_capacity(2 + payload.len());
|
||||||
@@ -1149,6 +1250,7 @@ mod tests {
|
|||||||
frames: 600,
|
frames: 600,
|
||||||
compositor: CompositorPref::Gamescope,
|
compositor: CompositorPref::Gamescope,
|
||||||
gamepad: GamepadPref::DualSense,
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 50_000,
|
||||||
};
|
};
|
||||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1266,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
compositor: CompositorPref::Kwin,
|
compositor: CompositorPref::Kwin,
|
||||||
gamepad: GamepadPref::DualSense,
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 25_000,
|
||||||
};
|
};
|
||||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||||
let s = Start {
|
let s = Start {
|
||||||
@@ -1227,18 +1330,26 @@ mod tests {
|
|||||||
},
|
},
|
||||||
compositor: CompositorPref::Mutter,
|
compositor: CompositorPref::Mutter,
|
||||||
gamepad: GamepadPref::DualSense,
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 80_000,
|
||||||
};
|
};
|
||||||
let enc = h.encode();
|
let enc = h.encode();
|
||||||
assert_eq!(enc.len(), 22);
|
assert_eq!(enc.len(), 26);
|
||||||
// Legacy (20-byte) Hello → both Auto, mode intact.
|
// Legacy (20-byte) Hello → both Auto, no bitrate, mode intact.
|
||||||
let legacy = Hello::decode(&enc[..20]).unwrap();
|
let legacy = Hello::decode(&enc[..20]).unwrap();
|
||||||
assert_eq!(legacy.compositor, CompositorPref::Auto);
|
assert_eq!(legacy.compositor, CompositorPref::Auto);
|
||||||
assert_eq!(legacy.gamepad, GamepadPref::Auto);
|
assert_eq!(legacy.gamepad, GamepadPref::Auto);
|
||||||
|
assert_eq!(legacy.bitrate_kbps, 0);
|
||||||
assert_eq!(legacy.mode, h.mode);
|
assert_eq!(legacy.mode, h.mode);
|
||||||
// Compositor-era (21-byte) Hello → compositor intact, gamepad Auto.
|
// Compositor-era (21-byte) Hello → compositor intact, gamepad Auto.
|
||||||
let mid = Hello::decode(&enc[..21]).unwrap();
|
let mid = Hello::decode(&enc[..21]).unwrap();
|
||||||
assert_eq!(mid.compositor, CompositorPref::Mutter);
|
assert_eq!(mid.compositor, CompositorPref::Mutter);
|
||||||
assert_eq!(mid.gamepad, GamepadPref::Auto);
|
assert_eq!(mid.gamepad, GamepadPref::Auto);
|
||||||
|
// Gamepad-era (22-byte) Hello → compositor + gamepad intact, bitrate 0 (host default).
|
||||||
|
let pre_bitrate = Hello::decode(&enc[..22]).unwrap();
|
||||||
|
assert_eq!(pre_bitrate.gamepad, GamepadPref::DualSense);
|
||||||
|
assert_eq!(pre_bitrate.bitrate_kbps, 0);
|
||||||
|
// Full message → bitrate intact.
|
||||||
|
assert_eq!(Hello::decode(&enc).unwrap().bitrate_kbps, 80_000);
|
||||||
|
|
||||||
let w = Welcome {
|
let w = Welcome {
|
||||||
abi_version: 2,
|
abi_version: 2,
|
||||||
@@ -1256,17 +1367,24 @@ mod tests {
|
|||||||
frames: 0,
|
frames: 0,
|
||||||
compositor: CompositorPref::Kwin,
|
compositor: CompositorPref::Kwin,
|
||||||
gamepad: GamepadPref::Xbox360,
|
gamepad: GamepadPref::Xbox360,
|
||||||
|
bitrate_kbps: 120_000,
|
||||||
};
|
};
|
||||||
let wenc = w.encode();
|
let wenc = w.encode();
|
||||||
assert_eq!(wenc.len(), 55);
|
assert_eq!(wenc.len(), 59);
|
||||||
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||||
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||||
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||||
|
assert_eq!(legacy_w.bitrate_kbps, 0);
|
||||||
assert_eq!(legacy_w.frames, 0);
|
assert_eq!(legacy_w.frames, 0);
|
||||||
assert_eq!(legacy_w.key, w.key);
|
assert_eq!(legacy_w.key, w.key);
|
||||||
let mid_w = Welcome::decode(&wenc[..54]).unwrap();
|
let mid_w = Welcome::decode(&wenc[..54]).unwrap();
|
||||||
assert_eq!(mid_w.compositor, CompositorPref::Kwin);
|
assert_eq!(mid_w.compositor, CompositorPref::Kwin);
|
||||||
assert_eq!(mid_w.gamepad, GamepadPref::Auto);
|
assert_eq!(mid_w.gamepad, GamepadPref::Auto);
|
||||||
|
// Gamepad-era (55-byte) Welcome → gamepad intact, bitrate 0 (unknown).
|
||||||
|
let pre_bitrate_w = Welcome::decode(&wenc[..55]).unwrap();
|
||||||
|
assert_eq!(pre_bitrate_w.gamepad, GamepadPref::Xbox360);
|
||||||
|
assert_eq!(pre_bitrate_w.bitrate_kbps, 0);
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1297,6 +1415,25 @@ mod tests {
|
|||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_messages_roundtrip() {
|
||||||
|
let req = ProbeRequest {
|
||||||
|
target_kbps: 250_000,
|
||||||
|
duration_ms: 2000,
|
||||||
|
};
|
||||||
|
assert_eq!(ProbeRequest::decode(&req.encode()).unwrap(), req);
|
||||||
|
let res = ProbeResult {
|
||||||
|
bytes_sent: 62_500_000,
|
||||||
|
packets_sent: 480,
|
||||||
|
duration_ms: 2003,
|
||||||
|
};
|
||||||
|
assert_eq!(ProbeResult::decode(&res.encode()).unwrap(), res);
|
||||||
|
// Type bytes keep the control messages disjoint from each other.
|
||||||
|
assert!(ProbeRequest::decode(&res.encode()).is_err());
|
||||||
|
assert!(Reconfigure::decode(&req.encode()).is_err());
|
||||||
|
assert!(ProbeResult::decode(&req.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn control_messages_disjoint_from_hello() {
|
fn control_messages_disjoint_from_hello() {
|
||||||
// A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at
|
// A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at
|
||||||
@@ -1311,6 +1448,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
compositor: CompositorPref::Auto,
|
compositor: CompositorPref::Auto,
|
||||||
gamepad: GamepadPref::Auto,
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
}
|
}
|
||||||
.encode();
|
.encode();
|
||||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||||
|
|||||||
+178
-22
@@ -25,10 +25,10 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
|
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_SOF};
|
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||||
use punktfunk_core::quic::{
|
use punktfunk_core::quic::{
|
||||||
endpoint, io, Hello, PairChallenge, PairProof, PairRequest, PairResult, Reconfigure,
|
endpoint, io, Hello, PairChallenge, PairProof, PairRequest, PairResult, ProbeRequest,
|
||||||
Reconfigured, Start, Welcome,
|
ProbeResult, Reconfigure, Reconfigured, Start, Welcome,
|
||||||
};
|
};
|
||||||
use punktfunk_core::transport::UdpTransport;
|
use punktfunk_core::transport::UdpTransport;
|
||||||
use punktfunk_core::Session;
|
use punktfunk_core::Session;
|
||||||
@@ -228,6 +228,27 @@ pub(crate) async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()>
|
|||||||
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
||||||
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Encoder bitrate (kbps) the host falls back to when the client expresses no preference
|
||||||
|
/// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its
|
||||||
|
/// link (e.g. after a speed test) requests an explicit rate instead.
|
||||||
|
const DEFAULT_BITRATE_KBPS: u32 = 20_000;
|
||||||
|
/// Bounds a client's requested bitrate to a sane range before configuring NVENC: a 500 kbps floor
|
||||||
|
/// keeps the stream above unusable, and a 500 Mbps ceiling guards against an absurd request
|
||||||
|
/// exhausting the encoder / link (GF(2¹⁶) FEC lifts the old ~1 Gbps wall, but 500 Mbps already
|
||||||
|
/// covers 5K@240). Resolved value is echoed in `Welcome::bitrate_kbps`.
|
||||||
|
const MIN_BITRATE_KBPS: u32 = 500;
|
||||||
|
const MAX_BITRATE_KBPS: u32 = 500_000;
|
||||||
|
|
||||||
|
/// Resolve a client's [`Hello::bitrate_kbps`] request to the rate the host will configure:
|
||||||
|
/// `0` → host default; anything else clamped into `[MIN, MAX]`.
|
||||||
|
fn resolve_bitrate_kbps(requested: u32) -> u32 {
|
||||||
|
if requested == 0 {
|
||||||
|
DEFAULT_BITRATE_KBPS
|
||||||
|
} else {
|
||||||
|
requested.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent audio-capturer slot, reused across sessions (same pattern as the GameStream
|
/// Persistent audio-capturer slot, reused across sessions (same pattern as the GameStream
|
||||||
/// path): keeps one warm PipeWire capture stream instead of a connect/negotiate cycle —
|
/// path): keeps one warm PipeWire capture stream instead of a connect/negotiate cycle —
|
||||||
/// and a daemon-side node churn — per session. (Drop now tears a capturer down cleanly.)
|
/// and a daemon-side node churn — per session. (Drop now tears a capturer down cleanly.)
|
||||||
@@ -391,6 +412,14 @@ async fn serve_session(
|
|||||||
// needed; the actual pads are created lazily by the input thread).
|
// needed; the actual pads are created lazily by the input thread).
|
||||||
let gamepad = resolve_gamepad(hello.gamepad);
|
let gamepad = resolve_gamepad(hello.gamepad);
|
||||||
|
|
||||||
|
// Resolve the encoder bitrate (client request clamped to a sane range, or host default).
|
||||||
|
let bitrate_kbps = resolve_bitrate_kbps(hello.bitrate_kbps);
|
||||||
|
tracing::info!(
|
||||||
|
requested_kbps = hello.bitrate_kbps,
|
||||||
|
resolved_kbps = bitrate_kbps,
|
||||||
|
"encoder bitrate"
|
||||||
|
);
|
||||||
|
|
||||||
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport).
|
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport).
|
||||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||||
let udp_port = probe.local_addr()?.port();
|
let udp_port = probe.local_addr()?.port();
|
||||||
@@ -422,6 +451,7 @@ async fn serve_session(
|
|||||||
.map(|c| c.as_pref())
|
.map(|c| c.as_pref())
|
||||||
.unwrap_or(CompositorPref::Auto),
|
.unwrap_or(CompositorPref::Auto),
|
||||||
gamepad,
|
gamepad,
|
||||||
|
bitrate_kbps,
|
||||||
};
|
};
|
||||||
io::write_msg(&mut send, &welcome.encode()).await?;
|
io::write_msg(&mut send, &welcome.encode()).await?;
|
||||||
|
|
||||||
@@ -444,17 +474,23 @@ async fn serve_session(
|
|||||||
"handshake complete — streaming"
|
"handshake complete — streaming"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Control task: the handshake stream stays open for mid-stream renegotiation. A
|
// Control task: the handshake stream stays open for mid-stream renegotiation and speed
|
||||||
// validated Reconfigure is acked, then handed to the data-plane thread, which rebuilds
|
// tests. A validated Reconfigure is acked, then handed to the data-plane thread, which
|
||||||
// capture/encoder/virtual output at the new mode (the data plane itself is untouched).
|
// rebuilds capture/encoder/virtual output at the new mode (the data plane itself is
|
||||||
|
// untouched). A ProbeRequest is handed to the data plane, which bursts FLAG_PROBE filler and
|
||||||
|
// hands back a ProbeResult that this task writes to the client. The two control directions
|
||||||
|
// (inbound requests, outbound probe results) are multiplexed with `select!`.
|
||||||
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
|
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
|
||||||
|
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||||
|
let (probe_result_tx, mut probe_result_rx) =
|
||||||
|
tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut active = hello.mode;
|
let mut active = hello.mode;
|
||||||
while let Ok(msg) = io::read_msg(&mut ctrl_recv).await {
|
loop {
|
||||||
let Ok(req) = Reconfigure::decode(&msg) else {
|
tokio::select! {
|
||||||
tracing::warn!("unknown control message — ignoring");
|
msg = io::read_msg(&mut ctrl_recv) => {
|
||||||
continue;
|
let Ok(msg) = msg else { break }; // stream closed
|
||||||
};
|
if let Ok(req) = Reconfigure::decode(&msg) {
|
||||||
let ok = req.mode.refresh_hz > 0
|
let ok = req.mode.refresh_hz > 0
|
||||||
&& crate::encode::validate_dimensions(
|
&& crate::encode::validate_dimensions(
|
||||||
crate::encode::Codec::H265,
|
crate::encode::Codec::H265,
|
||||||
@@ -468,16 +504,33 @@ async fn serve_session(
|
|||||||
} else {
|
} else {
|
||||||
tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)");
|
tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)");
|
||||||
}
|
}
|
||||||
let ack = Reconfigured {
|
let ack = Reconfigured { accepted: ok, mode: active };
|
||||||
accepted: ok,
|
|
||||||
mode: active,
|
|
||||||
};
|
|
||||||
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if ok && reconfig_tx.send(req.mode).is_err() {
|
if ok && reconfig_tx.send(req.mode).is_err() {
|
||||||
break; // data plane gone
|
break; // data plane gone
|
||||||
}
|
}
|
||||||
|
} else if let Ok(req) = ProbeRequest::decode(&msg) {
|
||||||
|
tracing::info!(
|
||||||
|
target_kbps = req.target_kbps,
|
||||||
|
duration_ms = req.duration_ms,
|
||||||
|
"speed-test probe requested"
|
||||||
|
);
|
||||||
|
if probe_tx.send(req).is_err() {
|
||||||
|
break; // data plane gone
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("unknown control message — ignoring");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = probe_result_rx.recv() => {
|
||||||
|
let Some(result) = result else { break }; // data plane gone
|
||||||
|
if io::write_msg(&mut ctrl_send, &result.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -593,6 +646,7 @@ async fn serve_session(
|
|||||||
let source = opts.source;
|
let source = opts.source;
|
||||||
let (seconds, frames) = (opts.seconds, opts.frames);
|
let (seconds, frames) = (opts.seconds, opts.frames);
|
||||||
let mode = hello.mode;
|
let mode = hello.mode;
|
||||||
|
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
|
||||||
let stop_stream = stop.clone();
|
let stop_stream = stop.clone();
|
||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
@@ -602,7 +656,13 @@ async fn serve_session(
|
|||||||
let mut session = Session::new(cfg, Box::new(transport))
|
let mut session = Session::new(cfg, Box::new(transport))
|
||||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||||
match source {
|
match source {
|
||||||
M3Source::Synthetic => synthetic_stream(&mut session, frames, &stop_stream),
|
M3Source::Synthetic => synthetic_stream(
|
||||||
|
&mut session,
|
||||||
|
frames,
|
||||||
|
&stop_stream,
|
||||||
|
&probe_rx,
|
||||||
|
&probe_result_tx,
|
||||||
|
),
|
||||||
M3Source::Virtual => {
|
M3Source::Virtual => {
|
||||||
let compositor = compositor
|
let compositor = compositor
|
||||||
.expect("the Virtual source resolves a compositor during the handshake");
|
.expect("the Virtual source resolves a compositor during the handshake");
|
||||||
@@ -613,6 +673,9 @@ async fn serve_session(
|
|||||||
&stop_stream,
|
&stop_stream,
|
||||||
&reconfig_rx,
|
&reconfig_rx,
|
||||||
compositor,
|
compositor,
|
||||||
|
bitrate_kbps,
|
||||||
|
&probe_rx,
|
||||||
|
&probe_result_tx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1107,12 +1170,20 @@ fn audio_thread(_conn: quinn::Connection, _stop: Arc<AtomicBool>, _audio_cap: Au
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn synthetic_stream(session: &mut Session, frames: u32, stop: &AtomicBool) -> Result<()> {
|
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>,
|
||||||
|
) -> Result<()> {
|
||||||
let interval = std::time::Duration::from_millis(1000 / 60);
|
let interval = std::time::Duration::from_millis(1000 / 60);
|
||||||
for idx in 0..frames {
|
for idx in 0..frames {
|
||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Service speed-test probes between synthetic frames (loopback bandwidth tests).
|
||||||
|
service_probes(session, stop, probe_rx, probe_result_tx);
|
||||||
let data = test_frame(idx, 64 * 1024);
|
let data = test_frame(idx, 64 * 1024);
|
||||||
session
|
session
|
||||||
.submit_frame(&data, now_ns(), (FLAG_PIC | FLAG_SOF) as u32)
|
.submit_frame(&data, now_ns(), (FLAG_PIC | FLAG_SOF) as u32)
|
||||||
@@ -1223,13 +1294,88 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
|||||||
Ok(chosen)
|
Ok(chosen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounds a speed-test [`ProbeRequest`] before bursting: a 1 Gbps / 5 s ceiling keeps a probe from
|
||||||
|
/// monopolizing the link or stalling the stream for too long. GF(2¹⁶) FEC makes ~1 Gbps reachable
|
||||||
|
/// on a LAN — ample headroom to find a session's sustainable bitrate.
|
||||||
|
const MAX_PROBE_KBPS: u32 = 1_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) -> ProbeResult {
|
||||||
|
let target_kbps = req.target_kbps.min(MAX_PROBE_KBPS);
|
||||||
|
let duration_ms = req.duration_ms.min(MAX_PROBE_MS);
|
||||||
|
if target_kbps == 0 || duration_ms == 0 {
|
||||||
|
return ProbeResult {
|
||||||
|
bytes_sent: 0,
|
||||||
|
packets_sent: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// kbps -> bytes/s (x1000/8).
|
||||||
|
let bytes_per_sec = target_kbps as u64 * 125;
|
||||||
|
// ~240 AUs/s for smooth pacing, each capped so one submit_frame stays a bounded burst (a large
|
||||||
|
// AU fragments into many UDP shards via sendmmsg).
|
||||||
|
let chunk = (bytes_per_sec / 240).clamp(1200, 256 * 1024) as usize;
|
||||||
|
let filler = vec![0u8; chunk];
|
||||||
|
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;
|
||||||
|
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 (UdpTransport returns Ok) — that loss is part
|
||||||
|
// of what the probe measures, so count what we offered and keep going.
|
||||||
|
let _ = session.submit_frame(&filler, now_ns(), FLAG_PROBE as u32);
|
||||||
|
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;
|
||||||
|
tracing::info!(
|
||||||
|
target_kbps,
|
||||||
|
duration_ms = actual_ms,
|
||||||
|
bytes_sent,
|
||||||
|
packets_sent,
|
||||||
|
"speed-test probe burst complete"
|
||||||
|
);
|
||||||
|
ProbeResult {
|
||||||
|
bytes_sent,
|
||||||
|
packets_sent,
|
||||||
|
duration_ms: actual_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn service_probes(
|
||||||
|
session: &mut Session,
|
||||||
|
stop: &AtomicBool,
|
||||||
|
probe_rx: &std::sync::mpsc::Receiver<ProbeRequest>,
|
||||||
|
probe_result_tx: &tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||||
|
) {
|
||||||
|
while let Ok(req) = probe_rx.try_recv() {
|
||||||
|
let result = run_probe_burst(session, req, stop);
|
||||||
|
let _ = probe_result_tx.send(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Real capture→encode→punktfunk/1: a native virtual output at the client's mode, NVENC AUs
|
/// Real capture→encode→punktfunk/1: a native virtual output at the client's mode, NVENC AUs
|
||||||
/// stamped with the capture wall clock (the client derives per-frame pipeline latency).
|
/// stamped with the capture wall clock (the client derives per-frame pipeline latency).
|
||||||
///
|
///
|
||||||
/// `reconfig` delivers accepted mid-stream mode switches: the capture/encode pipeline is
|
/// `reconfig` delivers accepted mid-stream mode switches: the capture/encode pipeline is
|
||||||
/// rebuilt at the new mode (capturer drop tears down the PipeWire stream and, via its
|
/// rebuilt at the new mode (capturer drop tears down the PipeWire stream and, via its
|
||||||
/// keepalive, the virtual output) while the data-plane `session` continues untouched —
|
/// keepalive, the virtual output) while the data-plane `session` continues untouched —
|
||||||
/// the rebuilt encoder opens with an IDR + in-band parameter sets.
|
/// the rebuilt encoder opens with an IDR + in-band parameter sets. `probe_rx`/`probe_result_tx`
|
||||||
|
/// carry speed-test bursts (see [`service_probes`]).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn virtual_stream(
|
fn virtual_stream(
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
@@ -1237,20 +1383,26 @@ fn virtual_stream(
|
|||||||
stop: &AtomicBool,
|
stop: &AtomicBool,
|
||||||
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||||
compositor: crate::vdisplay::Compositor,
|
compositor: crate::vdisplay::Compositor,
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
probe_rx: &std::sync::mpsc::Receiver<ProbeRequest>,
|
||||||
|
probe_result_tx: &tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
compositor = compositor.id(),
|
compositor = compositor.id(),
|
||||||
?mode,
|
?mode,
|
||||||
|
bitrate_kbps,
|
||||||
"punktfunk/1 virtual display"
|
"punktfunk/1 virtual display"
|
||||||
);
|
);
|
||||||
let mut vd = crate::vdisplay::open(compositor)?;
|
let mut vd = crate::vdisplay::open(compositor)?;
|
||||||
let (mut capturer, mut enc, mut frame, mut interval) =
|
let (mut capturer, mut enc, mut frame, mut interval) =
|
||||||
build_pipeline_with_retry(&mut vd, mode)?;
|
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps)?;
|
||||||
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
|
||||||
let mut next = std::time::Instant::now();
|
let mut next = std::time::Instant::now();
|
||||||
let mut sent: u64 = 0;
|
let mut sent: u64 = 0;
|
||||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||||
|
// Service speed-test probes between frames (each burst pauses video for its duration).
|
||||||
|
service_probes(session, stop, probe_rx, probe_result_tx);
|
||||||
// Drain to the NEWEST requested mode (a resize drag queues many) so we rebuild once,
|
// Drain to the NEWEST requested mode (a resize drag queues many) so we rebuild once,
|
||||||
// not once per stale intermediate mode.
|
// not once per stale intermediate mode.
|
||||||
let mut want = None;
|
let mut want = None;
|
||||||
@@ -1262,7 +1414,7 @@ fn virtual_stream(
|
|||||||
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
// 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
|
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
||||||
// healthy session — keep streaming the current mode and log instead.
|
// healthy session — keep streaming the current mode and log instead.
|
||||||
match build_pipeline(&mut vd, new_mode) {
|
match build_pipeline(&mut vd, new_mode, bitrate_kbps) {
|
||||||
Ok(next_pipe) => {
|
Ok(next_pipe) => {
|
||||||
(capturer, enc, frame, interval) = next_pipe;
|
(capturer, enc, frame, interval) = next_pipe;
|
||||||
next = std::time::Instant::now();
|
next = std::time::Instant::now();
|
||||||
@@ -1321,11 +1473,12 @@ type Pipeline = (
|
|||||||
fn build_pipeline_with_retry(
|
fn build_pipeline_with_retry(
|
||||||
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
|
bitrate_kbps: u32,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
const MAX_ATTEMPTS: u32 = 4;
|
const MAX_ATTEMPTS: u32 = 4;
|
||||||
let mut backoff = std::time::Duration::from_millis(500);
|
let mut backoff = std::time::Duration::from_millis(500);
|
||||||
for attempt in 1..=MAX_ATTEMPTS {
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
match build_pipeline(vd, mode) {
|
match build_pipeline(vd, mode, bitrate_kbps) {
|
||||||
Ok(pipe) => {
|
Ok(pipe) => {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
tracing::info!(attempt, "pipeline up after retry");
|
tracing::info!(attempt, "pipeline up after retry");
|
||||||
@@ -1382,6 +1535,7 @@ fn is_permanent_build_error(chain: &str) -> bool {
|
|||||||
fn build_pipeline(
|
fn build_pipeline(
|
||||||
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
|
bitrate_kbps: u32,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
let vout = vd.create(mode).context("create virtual output")?;
|
let vout = vd.create(mode).context("create virtual output")?;
|
||||||
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
|
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
|
||||||
@@ -1410,7 +1564,7 @@ fn build_pipeline(
|
|||||||
frame.width,
|
frame.width,
|
||||||
frame.height,
|
frame.height,
|
||||||
effective_hz,
|
effective_hz,
|
||||||
20_000_000,
|
bitrate_kbps as u64 * 1000,
|
||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
)
|
)
|
||||||
.context("open NVENC")?;
|
.context("open NVENC")?;
|
||||||
@@ -1762,6 +1916,7 @@ mod tests {
|
|||||||
mode,
|
mode,
|
||||||
CompositorPref::Auto,
|
CompositorPref::Auto,
|
||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
|
0,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
timeout
|
timeout
|
||||||
@@ -1786,6 +1941,7 @@ mod tests {
|
|||||||
mode,
|
mode,
|
||||||
CompositorPref::Auto,
|
CompositorPref::Auto,
|
||||||
GamepadPref::Auto,
|
GamepadPref::Auto,
|
||||||
|
0,
|
||||||
Some(host_fp),
|
Some(host_fp),
|
||||||
Some((cert.clone(), key.clone())),
|
Some((cert.clone(), key.clone())),
|
||||||
timeout,
|
timeout,
|
||||||
|
|||||||
@@ -137,6 +137,11 @@
|
|||||||
|
|
||||||
#define FLAG_SOF 4
|
#define FLAG_SOF 4
|
||||||
|
|
||||||
|
// Bandwidth-probe filler, not decodable video: a [`crate::quic::ProbeRequest`] speed test makes
|
||||||
|
// the host burst access units carrying this flag so the client measures throughput/loss without
|
||||||
|
// feeding them to the decoder. Punktfunk/1 only (GameStream never sets it).
|
||||||
|
#define FLAG_PROBE 8
|
||||||
|
|
||||||
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
||||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||||
#define MAX_DATAGRAM_BYTES 2048
|
#define MAX_DATAGRAM_BYTES 2048
|
||||||
@@ -151,6 +156,16 @@
|
|||||||
#define MSG_RECONFIGURED 2
|
#define MSG_RECONFIGURED 2
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ProbeRequest`].
|
||||||
|
#define MSG_PROBE_REQUEST 32
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ProbeResult`].
|
||||||
|
#define MSG_PROBE_RESULT 33
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Type byte of [`PairRequest`].
|
// Type byte of [`PairRequest`].
|
||||||
#define MSG_PAIR_REQUEST 16
|
#define MSG_PAIR_REQUEST 16
|
||||||
@@ -408,6 +423,26 @@ typedef struct {
|
|||||||
} PunktfunkRichInput;
|
} PunktfunkRichInput;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||||
|
// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||||
|
// measured goodput to drive a bitrate choice from; `loss_pct` is the delivery loss at that rate.
|
||||||
|
typedef struct {
|
||||||
|
// 1 once the host's end-of-burst report arrived (measurement final); else 0 (partial).
|
||||||
|
uint8_t done;
|
||||||
|
// Probe payload bytes / packets the client received.
|
||||||
|
uint64_t recv_bytes;
|
||||||
|
uint32_t recv_packets;
|
||||||
|
// Probe payload bytes / packets the host reported sending.
|
||||||
|
uint64_t host_bytes;
|
||||||
|
uint32_t host_packets;
|
||||||
|
// Client-measured receive window (first→last probe AU), milliseconds.
|
||||||
|
uint32_t elapsed_ms;
|
||||||
|
// Measured goodput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
|
||||||
|
uint32_t throughput_kbps;
|
||||||
|
// Delivery loss `(host_bytes - recv_bytes) / host_bytes` as a percentage (0 if unknown).
|
||||||
|
float loss_pct;
|
||||||
|
} PunktfunkProbeResult;
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#endif // __cplusplus
|
#endif // __cplusplus
|
||||||
@@ -562,6 +597,30 @@ PunktfunkConnection *punktfunk_connect_ex2(const char *host,
|
|||||||
uint32_t timeout_ms);
|
uint32_t timeout_ms);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Like [`punktfunk_connect_ex2`], but additionally requests the video encoder `bitrate_kbps`
|
||||||
|
// (kilobits per second). `0` lets the host pick its default; any other value is clamped to the
|
||||||
|
// host's supported range. After a speed test ([`punktfunk_connection_speed_test`]) a client can
|
||||||
|
// reconnect (or pick at connect time) with the measured rate. The value the host actually
|
||||||
|
// configured is readable via [`punktfunk_connection_bitrate`].
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// Same as [`punktfunk_connect`].
|
||||||
|
PunktfunkConnection *punktfunk_connect_ex3(const char *host,
|
||||||
|
uint16_t port,
|
||||||
|
uint32_t width,
|
||||||
|
uint32_t height,
|
||||||
|
uint32_t refresh_hz,
|
||||||
|
uint32_t compositor,
|
||||||
|
uint32_t gamepad,
|
||||||
|
uint32_t bitrate_kbps,
|
||||||
|
const uint8_t *pin_sha256,
|
||||||
|
uint8_t *observed_sha256_out,
|
||||||
|
const char *client_cert_pem,
|
||||||
|
const char *client_key_pem,
|
||||||
|
uint32_t timeout_ms);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Generate a persistent client identity: a self-signed certificate + private key, both
|
// Generate a persistent client identity: a self-signed certificate + private key, both
|
||||||
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
|
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
|
||||||
@@ -715,6 +774,16 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c,
|
|||||||
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
|
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// The video encoder bitrate (kilobits per second) the host actually configured for this session
|
||||||
|
// — the [`punktfunk_connect_ex3`] request clamped to the host's range, or its default when `0`
|
||||||
|
// was requested. `0` = an older host that didn't report it. Safe any time after connect.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; `bitrate_kbps` is writable (NULL is skipped).
|
||||||
|
PunktfunkStatus punktfunk_connection_bitrate(const PunktfunkConnection *c, uint32_t *bitrate_kbps);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
|
||||||
// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
|
||||||
@@ -731,6 +800,30 @@ PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c,
|
|||||||
uint32_t refresh_hz);
|
uint32_t refresh_hz);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||||
|
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 1 Gbps / ≤ 5 s),
|
||||||
|
// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
|
||||||
|
// `done` field is 1. Starting a probe resets any prior measurement.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle.
|
||||||
|
PunktfunkStatus punktfunk_connection_speed_test(const PunktfunkConnection *c,
|
||||||
|
uint32_t target_kbps,
|
||||||
|
uint32_t duration_ms);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Read the current speed-test measurement into `*out` (partial until `out->done == 1`). Safe to
|
||||||
|
// poll repeatedly after [`punktfunk_connection_speed_test`]; before any probe it reports zeros.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; `out` is writable for one `PunktfunkProbeResult` (NULL is an
|
||||||
|
// error).
|
||||||
|
PunktfunkStatus punktfunk_connection_probe_result(const PunktfunkConnection *c,
|
||||||
|
PunktfunkProbeResult *out);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user