Five places where one fact lived twice now state it once (maintainability sweep) #415
@@ -260,22 +260,15 @@ static IR_UNSUPPORTED: std::sync::atomic::AtomicBool = std::sync::atomic::Atomic
|
||||
/// loss — the cascade). The session glue then rate-limits client keyframe requests
|
||||
/// ([`EncoderCaps::intra_refresh`](super::EncoderCaps)).
|
||||
fn intra_refresh_requested() -> bool {
|
||||
std::env::var("PUNKTFUNK_INTRA_REFRESH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
super::policy::intra_refresh_requested()
|
||||
&& !IR_UNSUPPORTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The intra-refresh wave length in frames — ffmpeg derives `intraRefreshPeriod`/`Cnt` from
|
||||
/// `gop_size` before forcing the real GOP infinite, so this is what `gop_size` is set to in IR
|
||||
/// mode. Default = half a second of frames (heals fast, spreads the intra cost to ~2-3% per
|
||||
/// frame); `PUNKTFUNK_IR_PERIOD_FRAMES` overrides.
|
||||
/// The intra-refresh wave length in frames ([`super::policy::intra_refresh_period`]) — ffmpeg
|
||||
/// derives `intraRefreshPeriod`/`Cnt` from `gop_size` before forcing the real GOP infinite, so
|
||||
/// this is what `gop_size` is set to in IR mode.
|
||||
fn intra_refresh_period(fps: u32) -> i32 {
|
||||
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.filter(|v| *v >= 2)
|
||||
.unwrap_or_else(|| (fps.max(16) / 2) as i32)
|
||||
super::policy::intra_refresh_period(fps) as i32
|
||||
}
|
||||
|
||||
impl NvencEncoder {
|
||||
|
||||
@@ -118,12 +118,7 @@ pub(crate) fn capture_modifiers(fourcc: u32) -> Vec<u64> {
|
||||
/// **Log-only** — see [`select_physical_device`] for why no oracle, this one included, is
|
||||
/// allowed to CHANGE the selection.
|
||||
fn capture_anchor_node() -> std::path::PathBuf {
|
||||
std::env::var("PUNKTFUNK_RENDER_NODE")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/dev/dri/renderD128"))
|
||||
pf_gpu::render_node_env().unwrap_or_else(|| std::path::PathBuf::from("/dev/dri/renderD128"))
|
||||
}
|
||||
|
||||
/// `(major, minor)` of a device node, split the way `VkPhysicalDeviceDrmPropertiesEXT` reports
|
||||
|
||||
@@ -369,7 +369,7 @@ fn handshake(mut link: Link, p: &Params, bitrate_bps: u64) -> Result<Handshake>
|
||||
let hello = ToWorker::Hello {
|
||||
proto: worker::PROTO_VERSION,
|
||||
workspace_version: worker::WORKSPACE_VERSION.to_string(),
|
||||
drm_node: std::env::var("PUNKTFUNK_RENDER_NODE").ok(),
|
||||
drm_node: pf_gpu::render_node_env().map(|p| p.to_string_lossy().into_owned()),
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
fps: p.fps,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Loss-recovery env-knob PARSING shared by the native backends (Linux NVENC/libav, Windows
|
||||
// AMF/QSV) — extracted from three hand-copies that had already diverged twice: QSV's
|
||||
// `ltr_disabled` dropped the trim + `yes`/`on` spellings (a `set VAR=1 ` with a trailing space
|
||||
// silently left LTR enabled on Intel while the identical value worked on AMD), and QSV's
|
||||
// `intra_refresh_period` ignored the env var entirely. Both were fixed in place — and stayed
|
||||
// three copies, so the next drift was a matter of time. Parse each knob ONCE.
|
||||
//
|
||||
// Parsing only: DEFAULTS stay with their backend where they differ (QSV marks LTR ~1/4 s, AMF
|
||||
// ~1/2 s — deliberate tuning, not drift), and API-bound clamps stay at the call site (QSV's
|
||||
// `mfxU16` 8..=240). Sibling of `rfi.rs`, which did the same for the slot-recovery policy.
|
||||
|
||||
/// Truthy env opt-in: `1` / `true` / `yes` / `on`, trimmed (see the QSV trailing-space incident
|
||||
/// above — every backend must accept the same spellings).
|
||||
pub(crate) fn env_flag(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_INTRA_REFRESH` — opt into the intra-refresh loss-recovery wave: a moving intra
|
||||
/// band with recovery-point signalling refreshes the whole picture every
|
||||
/// [`intra_refresh_period`] frames, so FEC-unrecoverable loss heals without the 20-40× full-IDR
|
||||
/// spike (which under loss causes more loss — the cascade). Linux ANDs its runtime
|
||||
/// `IR_UNSUPPORTED` latch on top; on Windows this is also the LTR↔IR selector (mutually
|
||||
/// exclusive — the wave sweeps the picture, LTR pins references).
|
||||
pub(crate) fn intra_refresh_requested() -> bool {
|
||||
env_flag("PUNKTFUNK_INTRA_REFRESH")
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_IR_PERIOD_FRAMES` — the intra-refresh wave length in frames (>= 2 to be a wave);
|
||||
/// default half a second of frames (heals fast, spreads the intra cost to ~2-3 % per frame).
|
||||
/// Backends narrow to their API's field type at the call site.
|
||||
pub(crate) fn intra_refresh_period(fps: u32) -> u32 {
|
||||
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.filter(|v| *v >= 2)
|
||||
.unwrap_or_else(|| fps.max(16) / 2)
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_LTR_INTERVAL_FRAMES` — explicit LTR mark-cadence override (>= 1 frame); `None`
|
||||
/// leaves the backend's tuned default in charge.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn ltr_interval_env() -> Option<i64> {
|
||||
std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.filter(|v| *v >= 1)
|
||||
}
|
||||
|
||||
/// Validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, spike-only): at `frame_idx == N` the encoder
|
||||
/// self-triggers its real `invalidate_ref_frames` path, so a headless spike run exercises LTR
|
||||
/// recovery end-to-end (mark → force → recovery-anchor tag) without a live client. `None`
|
||||
/// normally; N must be positive — frame 0 is the opening IDR. (QSV's hand-copy skipped that
|
||||
/// filter, so `=0` behaved differently per vendor.)
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn ltr_test_force_at() -> Option<i64> {
|
||||
std::env::var("PUNKTFUNK_LTR_FORCE_AT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
}
|
||||
@@ -50,6 +50,7 @@
|
||||
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use super::policy::{intra_refresh_period, intra_refresh_requested, ltr_test_force_at};
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
@@ -536,27 +537,6 @@ fn usage_from_env(codec: Codec) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this session should run the **intra-refresh** loss-recovery mode (`PUNKTFUNK_INTRA_REFRESH`
|
||||
/// truthy — the same opt-in the Linux NVENC path uses): a moving intra wave refreshes the whole
|
||||
/// picture every [`intra_refresh_period`] frames, so FEC-unrecoverable loss heals without the
|
||||
/// 20-40× full-IDR spike, and the session glue rate-limits client keyframe requests
|
||||
/// ([`EncoderCaps::intra_refresh`]).
|
||||
fn intra_refresh_requested() -> bool {
|
||||
std::env::var("PUNKTFUNK_INTRA_REFRESH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Intra-refresh wave length in frames (default half a second, `PUNKTFUNK_IR_PERIOD_FRAMES`
|
||||
/// overrides) — same knob and default as the Linux NVENC intra-refresh mode.
|
||||
fn intra_refresh_period(fps: u32) -> u32 {
|
||||
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|v| *v >= 2)
|
||||
.unwrap_or_else(|| (fps.max(16) / 2).max(2))
|
||||
}
|
||||
|
||||
/// Number of user-controlled LTR slots. AMD exposes up to 2; two rotating slots hold a sliding pair
|
||||
/// of recent long-term references, so a loss can re-reference the newest one *before* the loss point.
|
||||
const NUM_LTR_SLOTS: usize = 2;
|
||||
@@ -568,9 +548,7 @@ const NUM_LTR_SLOTS: usize = 2;
|
||||
/// LTR is mutually exclusive with it, so LTR wins). `PUNKTFUNK_NO_AMF_LTR=1` forces the old full-IDR
|
||||
/// recovery for debugging.
|
||||
fn ltr_disabled() -> bool {
|
||||
std::env::var("PUNKTFUNK_NO_AMF_LTR")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
super::policy::env_flag("PUNKTFUNK_NO_AMF_LTR")
|
||||
}
|
||||
|
||||
/// Cadence (frames) between LTR marks — a fresh long-term reference roughly every half second by
|
||||
@@ -578,22 +556,7 @@ fn ltr_disabled() -> bool {
|
||||
/// second of recent references, so a loss up to ~1 s old still has a known-good frame to force; a
|
||||
/// smaller interval means the forced reference is more recent (a smaller recovery-frame residual).
|
||||
fn ltr_mark_interval(fps: u32) -> i64 {
|
||||
std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|v| *v >= 1)
|
||||
.unwrap_or_else(|| (fps.max(2) / 2).max(1) as i64)
|
||||
}
|
||||
|
||||
/// Validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, spike-only): at `frame_idx == N` the encoder
|
||||
/// self-triggers its real [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) path, so a
|
||||
/// headless spike run can exercise LTR recovery end-to-end (mark → force → recovery-anchor tag)
|
||||
/// without a live client sending an [`RfiRequest`](punktfunk_core::quic::RfiRequest). `None` normally.
|
||||
fn ltr_test_force_at() -> Option<i64> {
|
||||
std::env::var("PUNKTFUNK_LTR_FORCE_AT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
super::policy::ltr_interval_env().unwrap_or_else(|| (fps.max(2) / 2).max(1) as i64)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
//! it stays behind the same gate and falls back to IDR wherever the driver declines. 4:4:4 stays
|
||||
//! `false` until probed on real hardware (design §8.6).
|
||||
|
||||
use super::policy::{intra_refresh_requested, ltr_test_force_at};
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use libvpl_sys as vpl;
|
||||
@@ -143,52 +144,19 @@ const NUM_LTR_SLOTS: usize = 2;
|
||||
/// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with
|
||||
/// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR.
|
||||
fn ltr_disabled() -> bool {
|
||||
// Same accepted spellings as AMF's `ltr_disabled` — this had dropped the `trim()` and the
|
||||
// `yes`/`on` forms, so a value with stray whitespace (easy to produce with `set VAR=1 `)
|
||||
// silently left LTR enabled on Intel while the identical value worked on AMD.
|
||||
std::env::var("PUNKTFUNK_NO_QSV_LTR")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
super::policy::env_flag("PUNKTFUNK_NO_QSV_LTR")
|
||||
}
|
||||
|
||||
/// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s
|
||||
/// Frames between LTR marks ([`super::policy::ltr_interval_env`] overrides); default ~1/4 s
|
||||
/// so a loss usually finds a slot only a few frames old.
|
||||
fn ltr_mark_interval(fps: u32) -> i64 {
|
||||
std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or_else(|| (fps as i64 / 4).max(1))
|
||||
super::policy::ltr_interval_env().unwrap_or_else(|| (fps as i64 / 4).max(1))
|
||||
}
|
||||
|
||||
/// Spike-only validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, shared with AMF): self-trigger the
|
||||
/// real `invalidate_ref_frames` path at frame N so a headless run exercises mark → force →
|
||||
/// recovery-anchor without a live client.
|
||||
fn ltr_test_force_at() -> Option<i64> {
|
||||
std::env::var("PUNKTFUNK_LTR_FORCE_AT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
}
|
||||
|
||||
/// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave
|
||||
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
|
||||
fn intra_refresh_requested() -> bool {
|
||||
// Spelling parity with AMF (see `ltr_disabled` above).
|
||||
std::env::var("PUNKTFUNK_INTRA_REFRESH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The wave period in frames (~0.5 s), `PUNKTFUNK_IR_PERIOD_FRAMES` overrides — the same knob and
|
||||
/// default as AMF / Linux NVENC. (This claimed parity while ignoring the env var entirely, so the
|
||||
/// knob silently did nothing on Intel; the clamp is kept because `mfxU16` bounds the field.)
|
||||
/// The intra-refresh wave period, narrowed to the `mfxU16` field's useful 8..=240
|
||||
/// ([`super::policy::intra_refresh_period`] parses the shared knob).
|
||||
fn intra_refresh_period(fps: u32) -> u16 {
|
||||
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.filter(|v| *v >= 2)
|
||||
.unwrap_or(fps / 2)
|
||||
.clamp(8, 240) as u16
|
||||
super::policy::intra_refresh_period(fps).clamp(8, 240) as u16
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -2039,6 +2039,12 @@ mod nvenc_core;
|
||||
))]
|
||||
#[path = "enc/rfi.rs"]
|
||||
mod rfi;
|
||||
// Loss-recovery env-knob parsing (IR/LTR opt-ins, periods, spike hooks) shared by the Linux and
|
||||
// Windows backends — rfi.rs's sibling: three hand-copies of the same env reads had diverged
|
||||
// twice before this extraction (see the module header). Defaults and API clamps stay per-backend.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "enc/policy.rs"]
|
||||
mod policy;
|
||||
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
|
||||
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
|
||||
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
||||
|
||||
@@ -616,6 +616,20 @@ pub fn manual_selection() -> Option<GpuInfo> {
|
||||
gpus.into_iter().nth(i)
|
||||
}
|
||||
|
||||
/// The raw `PUNKTFUNK_RENDER_NODE` override (the house DRM-node knob), parsed ONCE: trimmed,
|
||||
/// empty = unset. Three call sites used to read the env independently and disagreed on trim and
|
||||
/// empty handling. Callers that must NOT consult the console's manual GPU preference (PyroWave's
|
||||
/// oracle rules — no oracle may change ITS device selection) read this directly;
|
||||
/// [`linux_render_node`] layers the preference on top.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn render_node_env() -> Option<PathBuf> {
|
||||
std::env::var("PUNKTFUNK_RENDER_NODE")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// The VAAPI/DRM render node for this host: matched manual preference > `PUNKTFUNK_RENDER_NODE`
|
||||
/// (a deliberate live env read — see `config.rs` module docs) > `/dev/dri/renderD128`.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -625,11 +639,7 @@ pub fn linux_render_node() -> PathBuf {
|
||||
{
|
||||
return node;
|
||||
}
|
||||
std::env::var("PUNKTFUNK_RENDER_NODE")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
|
||||
render_node_env().unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
|
||||
}
|
||||
|
||||
/// NVIDIA-presence probe (same device-node check as `encode::nvidia_present` — duplicated two
|
||||
|
||||
@@ -2584,7 +2584,192 @@ fn clamp_device_name(s: &str) -> String {
|
||||
s[..end].to_string()
|
||||
}
|
||||
|
||||
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
||||
/// Every connect option in one **growable** struct — the terminal form of the
|
||||
/// [`punktfunk_connect`] … [`punktfunk_connect_ex11`] chain, consumed by
|
||||
/// [`punktfunk_connect_opts`]. Eleven generations each minted a new exported symbol to add a
|
||||
/// field or two (`ex11` over `ex10`: exactly `audio_rate_hz` + `audio_bits`, for a 24-parameter
|
||||
/// signature and a full forwarding shim). This struct ends that: a new option is a new field
|
||||
/// appended HERE, guarded by `struct_size` exactly like [`PunktfunkConfig`].
|
||||
///
|
||||
/// Usage: zero-initialize the whole struct, set `struct_size = sizeof(PunktfunkConnectOpts)`,
|
||||
/// then set the fields you mean. Every zero field keeps the auto/legacy behaviour of the `ex`
|
||||
/// chain (null pointer = absent, `0` = auto/unspecified) — `audio_rate_hz = 0` is `ex10`'s
|
||||
/// UNSPECIFIED audio format, an explicit pair is `ex11`'s hi-res request, so the load-bearing
|
||||
/// `ex11`-vs-`ex10` symbol choice becomes a field value.
|
||||
///
|
||||
/// Growth discipline (for this crate): append only — never reorder, never widen an existing
|
||||
/// field; a new field's zero value must mean "unspecified/auto"; keep the struct free of TAIL
|
||||
/// padding on both pointer widths (the const asserts below lock 96/68 bytes), so an appended
|
||||
/// field can never land inside bytes an older caller's `sizeof` already covered; bump
|
||||
/// [`crate::ABI_VERSION`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
pub struct PunktfunkConnectOpts {
|
||||
/// `sizeof(PunktfunkConnectOpts)` as THIS caller was compiled — the skew guard
|
||||
/// ([`punktfunk_connect_opts`] rejects smaller than the v26 introduction size, and when the
|
||||
/// struct grows, an older caller's shorter size defaults the tail instead of misreading it).
|
||||
pub struct_size: u32,
|
||||
/// Required: NUL-terminated UTF-8 IP or hostname (the one non-nullable pointer here).
|
||||
pub host: *const std::os::raw::c_char,
|
||||
/// Library id to auto-launch, or null ([`punktfunk_connect_ex4`]).
|
||||
pub launch_id: *const std::os::raw::c_char,
|
||||
/// Null (trust on first use) or the host certificate's expected 32-byte SHA-256
|
||||
/// ([`punktfunk_connect`]'s trust contract).
|
||||
pub pin_sha256: *const u8,
|
||||
/// TLS client identity: both null (anonymous) or both NUL-terminated PEM
|
||||
/// ([`punktfunk_generate_identity`]).
|
||||
pub client_cert_pem: *const std::os::raw::c_char,
|
||||
/// See `client_cert_pem`.
|
||||
pub client_key_pem: *const std::os::raw::c_char,
|
||||
/// The label this device knocks with, or null for the OS default
|
||||
/// ([`punktfunk_connect_ex10`]).
|
||||
pub device_name: *const std::os::raw::c_char,
|
||||
/// Requested mode ([`punktfunk_connect`]).
|
||||
pub width: u32,
|
||||
/// See `width`.
|
||||
pub height: u32,
|
||||
/// See `width`.
|
||||
pub refresh_hz: u32,
|
||||
/// `PUNKTFUNK_COMPOSITOR_*`; `0`/unrecognized = auto ([`punktfunk_connect_ex`]).
|
||||
pub compositor: u32,
|
||||
/// `PUNKTFUNK_GAMEPAD_*`; `0`/unrecognized = auto ([`punktfunk_connect_ex2`]).
|
||||
pub gamepad: u32,
|
||||
/// Session wire budget in kbps; `0` = the host default ([`punktfunk_connect_ex3`]).
|
||||
pub bitrate_kbps: u32,
|
||||
/// Audio format ask; `0`/`0` = UNSPECIFIED (the legacy Opus path), an explicit pair is a
|
||||
/// hi-res request that derives `PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES`
|
||||
/// ([`punktfunk_connect_ex11`] — including why explicit 48000/16 is a genuine lossless ask).
|
||||
pub audio_rate_hz: u32,
|
||||
/// Connect timeout in milliseconds.
|
||||
pub timeout_ms: u32,
|
||||
/// Required: the host's UDP port.
|
||||
pub port: u16,
|
||||
/// `PUNKTFUNK_VIDEO_CAP_*` bits ([`punktfunk_connect_ex5`]).
|
||||
pub video_caps: u8,
|
||||
/// Channel ask: 2 / 6 / 8; `0` = stereo ([`punktfunk_connect_ex6`]).
|
||||
pub audio_channels: u8,
|
||||
/// See `audio_rate_hz`.
|
||||
pub audio_bits: u8,
|
||||
/// `PUNKTFUNK_CODEC_*` bits the client can decode ([`punktfunk_connect_ex7`]).
|
||||
pub video_codecs: u8,
|
||||
/// The one `PUNKTFUNK_CODEC_*` bit to prefer; `0` = host's choice
|
||||
/// ([`punktfunk_connect_ex7`]).
|
||||
pub preferred_codec: u8,
|
||||
/// `PUNKTFUNK_CLIENT_CAP_*` bits ([`punktfunk_connect_ex8`]).
|
||||
pub client_caps: u8,
|
||||
}
|
||||
|
||||
// The no-tail-padding lock the growth contract rests on (see the struct doc): if either width's
|
||||
// size moves under an edit that meant to change nothing, fields were reordered/widened or tail
|
||||
// padding appeared — all append-contract breaks. On APPENDING a field: keep
|
||||
// `CONNECT_OPTS_MIN_SIZE` frozen at these v26 values and update these literals to the new
|
||||
// (still padding-free) sizes.
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
assert!(core::mem::size_of::<PunktfunkConnectOpts>() == 96);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
assert!(core::mem::size_of::<PunktfunkConnectOpts>() == 68);
|
||||
};
|
||||
|
||||
/// The v26 introduction size of [`PunktfunkConnectOpts`] — the MINIMUM `struct_size`
|
||||
/// [`punktfunk_connect_opts`] ever accepts. FROZEN: when the struct grows, this stays put so
|
||||
/// v26-built callers keep connecting; only the const asserts above move.
|
||||
#[cfg(all(feature = "quic", target_pointer_width = "64"))]
|
||||
const CONNECT_OPTS_MIN_SIZE: usize = 96;
|
||||
#[cfg(all(feature = "quic", target_pointer_width = "32"))]
|
||||
const CONNECT_OPTS_MIN_SIZE: usize = 68;
|
||||
|
||||
/// Connect with every option in one growable [`PunktfunkConnectOpts`] (ABI v26) — semantics are
|
||||
/// exactly [`punktfunk_connect_ex11`]'s, field for field. The `ex` chain stays supported and
|
||||
/// byte-identical forever, but it is CLOSED: new options land only in the struct.
|
||||
///
|
||||
/// `status_out` (nullable) is written on every path, like the whole connect family;
|
||||
/// `observed_sha256_out` (null or 32 bytes) receives the host certificate's fingerprint on
|
||||
/// success, per [`punktfunk_connect`]'s trust contract.
|
||||
///
|
||||
/// # Safety
|
||||
/// `opts` is null or points to at least `opts->struct_size` readable bytes laid out as its
|
||||
/// declared version of [`PunktfunkConnectOpts`]; its pointer fields follow
|
||||
/// [`punktfunk_connect_ex11`]'s contract; `observed_sha256_out` is null or valid for 32 bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_opts(
|
||||
opts: *const PunktfunkConnectOpts,
|
||||
observed_sha256_out: *mut u8,
|
||||
status_out: *mut i32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
let set_status = |s: crate::error::PunktfunkStatus| {
|
||||
if !status_out.is_null() {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
|
||||
// written once by value.
|
||||
unsafe { *status_out = s as i32 };
|
||||
}
|
||||
};
|
||||
if opts.is_null() {
|
||||
set_status(crate::error::PunktfunkStatus::NullPointer);
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// Read only the 4-byte size prefix first to bound the subsequent read — `config_from_ptr`'s
|
||||
// guard, with the growth direction added: older (shorter, but never shorter than v26)
|
||||
// callers get their missing tail defaulted instead of misread.
|
||||
// SAFETY: `addr_of!` forms a raw pointer WITHOUT creating a reference, which is the point:
|
||||
// the caller's struct may be a different size than ours, so the field is read by offset
|
||||
// rather than through a `&`.
|
||||
let declared = unsafe { std::ptr::addr_of!((*opts).struct_size).read_unaligned() } as usize;
|
||||
if declared < CONNECT_OPTS_MIN_SIZE {
|
||||
set_status(crate::error::PunktfunkStatus::InvalidArg);
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// Copy the known prefix over an all-zero struct: today that is the whole struct; once the
|
||||
// struct has grown past a caller's vintage, the copy stops at THEIR `struct_size` and the
|
||||
// appended fields stay zero = unspecified (zeroed raw pointers are null). A NEWER caller's
|
||||
// extra tail is ignored the same way.
|
||||
// SAFETY: all-zero is a valid `PunktfunkConnectOpts` — null pointers and zero scalars.
|
||||
let mut o: PunktfunkConnectOpts = unsafe { std::mem::zeroed() };
|
||||
let take = declared.min(std::mem::size_of::<PunktfunkConnectOpts>());
|
||||
// SAFETY: per the ABI contract `opts` is readable for `declared >= take` bytes; `o` is a
|
||||
// local of at least `take` bytes; a local cannot overlap a caller-owned region.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
opts.cast::<u8>(),
|
||||
std::ptr::addr_of_mut!(o).cast::<u8>(),
|
||||
take,
|
||||
);
|
||||
}
|
||||
// SAFETY: the pointer fields are forwarded UNCHANGED to the shared body, which applies the
|
||||
// same ABI contract to them; the copy above dereferenced nothing they point at.
|
||||
unsafe {
|
||||
connect_ex_impl(
|
||||
o.host,
|
||||
o.port,
|
||||
o.client_caps,
|
||||
o.width,
|
||||
o.height,
|
||||
o.refresh_hz,
|
||||
o.compositor,
|
||||
o.gamepad,
|
||||
o.bitrate_kbps,
|
||||
o.video_caps,
|
||||
o.audio_channels,
|
||||
o.video_codecs,
|
||||
o.preferred_codec,
|
||||
o.launch_id,
|
||||
o.pin_sha256,
|
||||
observed_sha256_out,
|
||||
o.client_cert_pem,
|
||||
o.client_key_pem,
|
||||
o.device_name,
|
||||
o.audio_rate_hz,
|
||||
o.audio_bits,
|
||||
o.timeout_ms,
|
||||
status_out,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared body of the whole connect family — [`punktfunk_connect`] through
|
||||
/// [`punktfunk_connect_ex11`] and [`punktfunk_connect_opts`]: `status_out`
|
||||
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. `device_name` (nullable,
|
||||
/// [`punktfunk_connect_ex10`]) is the label this device knocks with; null = the OS default.
|
||||
@@ -6044,6 +6229,36 @@ mod log_sink_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The [`PunktfunkConnectOpts`] size-prefix guard: null and undersized structs come back as
|
||||
/// status codes, not reads — and a well-sized struct takes the copy path all the way into
|
||||
/// the shared body (null `host` = `InvalidArg`, before any dialing). The growth half of the
|
||||
/// contract (an older, shorter caller gets its tail defaulted) cannot be exercised until the
|
||||
/// struct actually grows; what CAN regress today is this guard and the no-tail-padding
|
||||
/// layout the const asserts beside the struct lock.
|
||||
#[test]
|
||||
fn connect_opts_guards_size_prefix() {
|
||||
let mut status = 0i32;
|
||||
// SAFETY: null `opts` is the documented reported-not-UB case.
|
||||
let c =
|
||||
unsafe { punktfunk_connect_opts(std::ptr::null(), std::ptr::null_mut(), &mut status) };
|
||||
assert!(c.is_null());
|
||||
assert_eq!(status, PunktfunkStatus::NullPointer as i32);
|
||||
|
||||
// SAFETY: an all-zero struct is a valid value (null pointers, zero scalars).
|
||||
let mut o: PunktfunkConnectOpts = unsafe { std::mem::zeroed() };
|
||||
o.struct_size = 4; // an impossible, pre-v26 size
|
||||
// SAFETY: `o` outlives the call; out-params are null or a live local.
|
||||
let c = unsafe { punktfunk_connect_opts(&o, std::ptr::null_mut(), &mut status) };
|
||||
assert!(c.is_null());
|
||||
assert_eq!(status, PunktfunkStatus::InvalidArg as i32);
|
||||
|
||||
o.struct_size = std::mem::size_of::<PunktfunkConnectOpts>() as u32;
|
||||
// SAFETY: as above; the null `host` field is the documented InvalidArg path.
|
||||
let c = unsafe { punktfunk_connect_opts(&o, std::ptr::null_mut(), &mut status) };
|
||||
assert!(c.is_null());
|
||||
assert_eq!(status, PunktfunkStatus::InvalidArg as i32);
|
||||
}
|
||||
|
||||
/// The `ex10` device name is cut to the Hello's BYTE budget on a CHARACTER boundary — the
|
||||
/// naive `s[..HELLO_NAME_MAX]` panics on any multi-byte name that straddles it, and an
|
||||
/// operator naming a device in German or Japanese is not an edge case.
|
||||
|
||||
@@ -256,7 +256,16 @@ pub use stats::Stats;
|
||||
/// never sees it and [`WIRE_VERSION`] is unchanged. It relies on tracing's `log` feature, now
|
||||
/// declared explicitly by this crate (it was on transitively through quinn's defaults, which is
|
||||
/// not a thing an ABI promise should rest on).
|
||||
pub const ABI_VERSION: u32 = 25;
|
||||
/// **v26** adds [`abi::punktfunk_connect_opts`] + [`abi::PunktfunkConnectOpts`] — the whole
|
||||
/// connect surface in ONE size-prefixed, growable struct, closing the eleven-generation
|
||||
/// `punktfunk_connect_ex*` chain (each new option used to mint a new exported symbol plus a
|
||||
/// 20-something-parameter forwarding shim; `ex11` over `ex10` was two fields). ADDED, not
|
||||
/// widened: every `ex` variant keeps its symbol, signature and byte-identical behaviour, and an
|
||||
/// embedder that never calls the new form behaves exactly as on v25. New connect options land
|
||||
/// only in the struct from here on — appended behind its `struct_size` guard, zero meaning
|
||||
/// unspecified/auto — so they stop being ABI events at all. Client-local; [`WIRE_VERSION`] is
|
||||
/// unchanged.
|
||||
pub const ABI_VERSION: u32 = 26;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -32,6 +32,29 @@ static PunktfunkConfig make_config(uint32_t role, uint32_t drop_period) {
|
||||
int main(void) {
|
||||
printf("punktfunk-core C ABI harness (abi_version=%u)\n", punktfunk_abi_version());
|
||||
|
||||
/* PunktfunkConnectOpts (v26): the C compiler must agree with Rust's const-asserted layout —
|
||||
* 96 bytes on 64-bit / 68 on 32-bit, NO tail padding (the growth contract: an appended field
|
||||
* may never land in bytes an older caller's sizeof already covered) — and the size-prefix
|
||||
* guard must reject an undersized struct as a status, not a read. The declaration sits
|
||||
* behind the header's quic guard; the staticlib this harness links always carries quic
|
||||
* (see the -lopus/Security link line), so the check only needs the define. */
|
||||
#ifdef PUNKTFUNK_FEATURE_QUIC
|
||||
if (sizeof(PunktfunkConnectOpts) != (sizeof(void *) == 8 ? 96u : 68u)) {
|
||||
fprintf(stderr, "FAIL: PunktfunkConnectOpts is %zu bytes\n", sizeof(PunktfunkConnectOpts));
|
||||
return 1;
|
||||
}
|
||||
{
|
||||
PunktfunkConnectOpts o;
|
||||
int32_t st = 0;
|
||||
memset(&o, 0, sizeof(o));
|
||||
o.struct_size = 4; /* an impossible, pre-v26 size */
|
||||
if (punktfunk_connect_opts(&o, NULL, &st) != NULL || st != PUNKTFUNK_STATUS_INVALID_ARG) {
|
||||
fprintf(stderr, "FAIL: undersized connect opts accepted (st=%d)\n", (int)st);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
const uint32_t DROP_PERIOD = 8; /* drop 1 of every 8 packets */
|
||||
PunktfunkConfig host_cfg = make_config(0, DROP_PERIOD);
|
||||
PunktfunkConfig client_cfg = make_config(1, DROP_PERIOD);
|
||||
|
||||
@@ -43,14 +43,16 @@ fn native_libs() -> &'static [&'static str] {
|
||||
|
||||
fn ensure_staticlib(profile_dir: &Path) -> PathBuf {
|
||||
let staticlib = profile_dir.join("libpunktfunk_core.a");
|
||||
if !staticlib.exists() {
|
||||
// `cargo test` doesn't always emit the standalone staticlib; build it. The
|
||||
// outer cargo's build lock is released during test execution, so this is safe.
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let _ = Command::new(cargo)
|
||||
.args(["build", "-p", "punktfunk-core"])
|
||||
.status();
|
||||
}
|
||||
// `cargo test` doesn't always emit the standalone staticlib; build it — WITH `quic`, the
|
||||
// surface the harness declares (`-DPUNKTFUNK_FEATURE_QUIC`) and this test's link line
|
||||
// already pays for (`-lopus`, Security). Unconditional, because a featureless `.a` left by
|
||||
// an earlier plain `cargo build` would otherwise be reused and fail the link on the quic
|
||||
// symbols; when the artifact is already right this is an incremental no-op. The outer
|
||||
// cargo's build lock is released during test execution, so this is safe.
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let _ = Command::new(cargo)
|
||||
.args(["build", "-p", "punktfunk-core", "--features", "quic"])
|
||||
.status();
|
||||
staticlib
|
||||
}
|
||||
|
||||
@@ -84,8 +86,24 @@ fn c_abi_harness_round_trips() {
|
||||
|
||||
let mut compile = Command::new(&cc);
|
||||
compile
|
||||
.args(["-std=c11", "-Wall", "-Wextra", "-O2", "-I"])
|
||||
.arg(&include)
|
||||
// The staticlib is built with workspace-unified features, quic included (that's what the
|
||||
// -lopus / Security link line below pays for) — so expose the header's quic surface and
|
||||
// let the harness exercise it (the `PunktfunkConnectOpts` layout check).
|
||||
.args([
|
||||
"-std=c11",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-O2",
|
||||
"-DPUNKTFUNK_FEATURE_QUIC",
|
||||
"-I",
|
||||
])
|
||||
.arg(&include);
|
||||
// Apple Silicon: `cc` does not search homebrew's prefix on its own, and `-lopus` lives
|
||||
// there — without this the harness never linked on a Mac dev box at all.
|
||||
if cfg!(target_os = "macos") && Path::new("/opt/homebrew/lib").is_dir() {
|
||||
compile.arg("-L/opt/homebrew/lib");
|
||||
}
|
||||
compile
|
||||
.arg(&harness)
|
||||
.arg(&staticlib)
|
||||
.args(native_libs())
|
||||
|
||||
@@ -1785,21 +1785,21 @@ async fn serve_session(
|
||||
// `clip_offer_rx` shape). The sender lives in the access lifecycle task below; for a session
|
||||
// with no fingerprint it's dropped instead and the arm disables itself.
|
||||
let (access_tx, access_rx) = tokio::sync::mpsc::unbounded_channel::<AccessUpdate>();
|
||||
tokio::spawn(control::run(
|
||||
tokio::spawn(control::run(control::Task {
|
||||
ctrl_send,
|
||||
ctrl_recv,
|
||||
hello.mode,
|
||||
initial_mode: hello.mode,
|
||||
codec,
|
||||
live_reconfig_ok,
|
||||
adaptive_fec,
|
||||
session_bitrate_kbps,
|
||||
live_bitrate.clone(),
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
cadence_behind_score.clone(),
|
||||
client_packets_received_ctl,
|
||||
live_bitrate: live_bitrate.clone(),
|
||||
encoder_ceiling_kbps: encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded: cadence_degraded.clone(),
|
||||
cadence_behind_score: cadence_behind_score.clone(),
|
||||
client_packets_received: client_packets_received_ctl,
|
||||
fec_target_ctl,
|
||||
phase_ctl_control,
|
||||
phase_ctl: phase_ctl_control,
|
||||
reconfig_tx,
|
||||
keyframe_tx,
|
||||
rfi_tx,
|
||||
@@ -1813,11 +1813,11 @@ async fn serve_session(
|
||||
shard_ack_tx,
|
||||
cursor_shape_rx,
|
||||
cursor_client_draws,
|
||||
clip_enabled.clone(),
|
||||
clip_enabled: clip_enabled.clone(),
|
||||
clip,
|
||||
session_grants.clone(),
|
||||
session_grants: session_grants.clone(),
|
||||
access_rx,
|
||||
));
|
||||
}));
|
||||
// The access lifecycle task (WP3): owns the session's expiry deadline and folds every watch
|
||||
// edit into the live mask. Only sessions with a fingerprint have a record to watch; dropping
|
||||
// `access_tx` otherwise retires the control task's update arm.
|
||||
|
||||
@@ -9,67 +9,109 @@ use super::*;
|
||||
use pf_clipboard::ClipCoordCmd;
|
||||
use punktfunk_core::quic::{ClipControl, ClipOffer, ClipState};
|
||||
|
||||
/// Run the control task for one live session. Owns the control streams (`serve_session` hands them
|
||||
/// off after negotiation) plus every channel end that bridges to the data-plane thread, and the
|
||||
/// [`pf_clipboard::ClipCoord`] handle bridging to the clipboard coordinator. Returns when the
|
||||
/// control stream closes or a data-plane channel drops.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run(
|
||||
mut ctrl_send: quinn::SendStream,
|
||||
ctrl_recv: quinn::RecvStream,
|
||||
initial_mode: punktfunk_core::Mode,
|
||||
codec: crate::encode::Codec,
|
||||
live_reconfig_ok: bool,
|
||||
adaptive_fec: bool,
|
||||
session_bitrate_kbps: u32,
|
||||
// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied rate,
|
||||
// its discovered codec-level ceiling (0 = unknown), and the "encode can't hold cadence"
|
||||
// flag. Read at `SetBitrate`-resolve time so the ack — the base the client's controller
|
||||
// climbs from — never promises a rate the encoder won't run at.
|
||||
live_bitrate: Arc<AtomicU32>,
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
cadence_behind_score: Arc<AtomicU32>,
|
||||
// Delivery truth, published from every `DeliveryReport` for the data plane's stall diagnosis:
|
||||
// the packets the client says it has received all session (`u32::MAX` = a client too old to
|
||||
// send one, the pre-seeded value).
|
||||
client_packets_received: Arc<AtomicU32>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
|
||||
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
|
||||
phase_ctl: Arc<super::stream::PhaseCtl>,
|
||||
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||
keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||
rfi_tx: std::sync::mpsc::Sender<(u32, u32)>,
|
||||
bitrate_tx: std::sync::mpsc::Sender<u32>,
|
||||
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
|
||||
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
|
||||
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
|
||||
// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
|
||||
// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
|
||||
mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
|
||||
// Pipeline-gap announcements (see `gap_tx`): a rebuild that kept the session up stopped the
|
||||
// stream for this many ms, forwarded to the client as a `PipelineGap` so its bitrate
|
||||
// controller discards the report window that straddled our own stall.
|
||||
mut gap_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
|
||||
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher
|
||||
// asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer),
|
||||
// and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate.
|
||||
mut shard_change_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
|
||||
shard_ack_tx: tokio::sync::mpsc::UnboundedSender<u16>,
|
||||
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
|
||||
cursor_client_draws: Arc<AtomicBool>,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
clip: pf_clipboard::ClipCoord,
|
||||
// Per-client access (design/per-client-access.md §5): the session's LIVE grant mask — the
|
||||
// same atomic the datagram filter reads; the deadline/watch task folds console edits into
|
||||
// it, so a `ClipControl` or `ClipOffer` arriving after a mid-session revoke resolves against
|
||||
// the new mask.
|
||||
session_grants: Arc<AtomicU32>,
|
||||
// `AccessUpdate`s from the session's deadline/watch task (expiry warnings + mid-session
|
||||
// grant edits) — this task is the control stream's sole writer, so they cross here.
|
||||
mut access_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::AccessUpdate>,
|
||||
) {
|
||||
/// Everything [`run`] owns for one live session — the control streams (`serve_session` hands
|
||||
/// them off after negotiation), every channel end that bridges to the data-plane thread, and the
|
||||
/// [`pf_clipboard::ClipCoord`] handle bridging to the clipboard coordinator. A named-field
|
||||
/// struct rather than the parameter list it grew from: at ~30 positionals the spawn site had
|
||||
/// stopped saying anything, and its same-typed neighbours (`retarget_rx` / `gap_rx` both carry
|
||||
/// bare `u32`s) were one silent transposition away from a runtime puzzle.
|
||||
pub(super) struct Task {
|
||||
pub(super) ctrl_send: quinn::SendStream,
|
||||
pub(super) ctrl_recv: quinn::RecvStream,
|
||||
pub(super) initial_mode: punktfunk_core::Mode,
|
||||
pub(super) codec: crate::encode::Codec,
|
||||
pub(super) live_reconfig_ok: bool,
|
||||
pub(super) adaptive_fec: bool,
|
||||
pub(super) session_bitrate_kbps: u32,
|
||||
/// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied
|
||||
/// rate, its discovered codec-level ceiling (0 = unknown), and the "encode can't hold
|
||||
/// cadence" flag. Read at `SetBitrate`-resolve time so the ack — the base the client's
|
||||
/// controller climbs from — never promises a rate the encoder won't run at.
|
||||
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||
/// See [`Self::live_bitrate`].
|
||||
pub(super) encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
/// See [`Self::live_bitrate`].
|
||||
pub(super) cadence_degraded: Arc<AtomicBool>,
|
||||
/// See [`Self::live_bitrate`].
|
||||
pub(super) cadence_behind_score: Arc<AtomicU32>,
|
||||
/// Delivery truth, published from every `DeliveryReport` for the data plane's stall
|
||||
/// diagnosis: the packets the client says it has received all session (`u32::MAX` = a
|
||||
/// client too old to send one, the pre-seeded value).
|
||||
pub(super) client_packets_received: Arc<AtomicU32>,
|
||||
pub(super) fec_target_ctl: Arc<AtomicU8>,
|
||||
/// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode
|
||||
/// loop's controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
|
||||
pub(super) phase_ctl: Arc<super::stream::PhaseCtl>,
|
||||
pub(super) reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||
pub(super) keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||
pub(super) rfi_tx: std::sync::mpsc::Sender<(u32, u32)>,
|
||||
pub(super) bitrate_tx: std::sync::mpsc::Sender<u32>,
|
||||
pub(super) probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
|
||||
pub(super) probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
|
||||
pub(super) reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
|
||||
/// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
|
||||
/// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
|
||||
pub(super) retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
|
||||
/// Pipeline-gap announcements (see `gap_tx`): a rebuild that kept the session up stopped
|
||||
/// the stream for this many ms, forwarded to the client as a `PipelineGap` so its bitrate
|
||||
/// controller discards the report window that straddled our own stall.
|
||||
pub(super) gap_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
|
||||
/// Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher
|
||||
/// asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer),
|
||||
/// and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate.
|
||||
pub(super) shard_change_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
|
||||
pub(super) shard_ack_tx: tokio::sync::mpsc::UnboundedSender<u16>,
|
||||
pub(super) cursor_shape_rx:
|
||||
tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
|
||||
pub(super) cursor_client_draws: Arc<AtomicBool>,
|
||||
pub(super) clip_enabled: Arc<AtomicBool>,
|
||||
pub(super) clip: pf_clipboard::ClipCoord,
|
||||
/// Per-client access (design/per-client-access.md §5): the session's LIVE grant mask — the
|
||||
/// same atomic the datagram filter reads; the deadline/watch task folds console edits into
|
||||
/// it, so a `ClipControl` or `ClipOffer` arriving after a mid-session revoke resolves
|
||||
/// against the new mask.
|
||||
pub(super) session_grants: Arc<AtomicU32>,
|
||||
/// `AccessUpdate`s from the session's deadline/watch task (expiry warnings + mid-session
|
||||
/// grant edits) — this task is the control stream's sole writer, so they cross here.
|
||||
pub(super) access_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::AccessUpdate>,
|
||||
}
|
||||
|
||||
/// Run the control task for one live session (owning everything in its [`Task`]). Returns when
|
||||
/// the control stream closes or a data-plane channel drops.
|
||||
pub(super) async fn run(task: Task) {
|
||||
let Task {
|
||||
mut ctrl_send,
|
||||
ctrl_recv,
|
||||
initial_mode,
|
||||
codec,
|
||||
live_reconfig_ok,
|
||||
adaptive_fec,
|
||||
session_bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
cadence_behind_score,
|
||||
client_packets_received,
|
||||
fec_target_ctl,
|
||||
phase_ctl,
|
||||
reconfig_tx,
|
||||
keyframe_tx,
|
||||
rfi_tx,
|
||||
bitrate_tx,
|
||||
probe_tx,
|
||||
mut probe_result_rx,
|
||||
mut reconfig_result_rx,
|
||||
mut retarget_rx,
|
||||
mut gap_rx,
|
||||
mut shard_change_rx,
|
||||
shard_ack_tx,
|
||||
mut cursor_shape_rx,
|
||||
cursor_client_draws,
|
||||
clip_enabled,
|
||||
clip,
|
||||
session_grants,
|
||||
mut access_rx,
|
||||
} = task;
|
||||
let pf_clipboard::ClipCoord {
|
||||
available: clip_available,
|
||||
cmd_tx: clip_cmd_tx,
|
||||
|
||||
@@ -1640,6 +1640,32 @@ fn settle_portal_cursor(
|
||||
false
|
||||
}
|
||||
|
||||
/// The host-composite pair for the live compositor — `(gamescope_composite,
|
||||
/// metadata_composite)`: ONE derivation shared by bring-up and the mid-stream compositor
|
||||
/// retarget so the two cannot drift ([`settle_portal_cursor`]'s discipline). They had drifted:
|
||||
/// bring-up keyed the gamescope arm on the compositor alone, while the retarget read
|
||||
/// [`gamescope_cursor`] — which also folds in `gamescope_composites_cursor()`, the "the spawned
|
||||
/// gamescope paints the pointer into its node itself" capability. On such a gamescope the two
|
||||
/// answers differed at bring-up: a host composite planned for a pointer the node already
|
||||
/// carries, with no XFixes reader attached to feed it (`session_plan.rs` documents the pair
|
||||
/// contract — attach-without-blend wastes an X11 connection, blend-without-attach streams no
|
||||
/// pointer, and blending a self-painting node would draw the pointer twice).
|
||||
///
|
||||
/// `gamescope` is whether the LIVE compositor is gamescope — the retarget passes the
|
||||
/// newly-detected one, not the session's original.
|
||||
///
|
||||
/// [`gamescope_cursor`]: crate::session_plan::SessionPlan::gamescope_cursor
|
||||
fn composite_plan(
|
||||
plan: &crate::session_plan::SessionPlan,
|
||||
has_cursor_channel: bool,
|
||||
gamescope: bool,
|
||||
) -> (bool, bool) {
|
||||
(
|
||||
plan.gamescope_cursor && !has_cursor_channel,
|
||||
!has_cursor_channel && plan.cursor_blend && !gamescope,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDisplay>) -> Result<()> {
|
||||
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
||||
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
||||
@@ -1806,16 +1832,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
tracing::info!("cursor channel negotiated — forwarding shape/state, encoder blend off");
|
||||
}
|
||||
// gamescope (Phase C): no channel for a plain capture-mode client and no compositor-embedded
|
||||
// pointer, so the host ALWAYS composites the XFixes-sourced cursor into the video. Active only
|
||||
// when there's no cursor-forward channel (a future desktop-mode gamescope client takes the
|
||||
// `cursor_fwd` path instead). See `plan.gamescope_cursor`.
|
||||
// `mut`: a mid-stream Gaming↔Desktop switch (the capture-loss rebuild below) retargets the
|
||||
// compositor, so this is recomputed there against the live compositor.
|
||||
let mut gamescope_composite =
|
||||
compositor == pf_vdisplay::Compositor::Gamescope && cursor_fwd.is_none();
|
||||
if gamescope_composite {
|
||||
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
|
||||
}
|
||||
// pointer, so the host composites the XFixes-sourced cursor into the video — unless the
|
||||
// spawned gamescope paints the pointer into its node itself (`plan.gamescope_cursor` folds
|
||||
// that capability in; keying on the compositor alone here used to plan a composite the
|
||||
// reader never feeds on such a node). Active only when there's no cursor-forward channel (a
|
||||
// future desktop-mode gamescope client takes the `cursor_fwd` path instead).
|
||||
//
|
||||
// No-channel metadata composite: the client never draws the pointer (it did not advertise
|
||||
// the cursor channel — e.g. a capture-latched client, `console.rs` `latched_mouse`), and
|
||||
// the compositor-EMBEDS fallback is a fiction on a Mutter virtual stream (the software
|
||||
@@ -1824,10 +1846,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// schedules no update either, mutter#4939). So the plan asked the backend for
|
||||
// cursor-as-metadata and the HOST composites, permanently — the same arm a channel
|
||||
// session lands in after its capture-model flip, minus the channel.
|
||||
// `mut`: recomputed with `gamescope_composite` on a mid-stream compositor retarget.
|
||||
let mut metadata_composite = cursor_fwd.is_none()
|
||||
&& plan.cursor_blend
|
||||
&& compositor != pf_vdisplay::Compositor::Gamescope;
|
||||
//
|
||||
// `mut` on both: a mid-stream Gaming↔Desktop switch (the capture-loss rebuild below)
|
||||
// retargets the compositor and re-derives the pair through the same [`composite_plan`].
|
||||
let (mut gamescope_composite, mut metadata_composite) = composite_plan(
|
||||
&plan,
|
||||
cursor_fwd.is_some(),
|
||||
compositor == pf_vdisplay::Compositor::Gamescope,
|
||||
);
|
||||
if gamescope_composite {
|
||||
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
|
||||
}
|
||||
if metadata_composite {
|
||||
tracing::info!(
|
||||
"no cursor channel — compositing the metadata cursor into the video (embedded \
|
||||
@@ -3528,11 +3557,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
crate::session_plan::gamescope_cursor_for(
|
||||
c == crate::vdisplay::Compositor::Gamescope,
|
||||
);
|
||||
gamescope_composite =
|
||||
plan.gamescope_cursor && cursor_fwd.is_none();
|
||||
metadata_composite = cursor_fwd.is_none()
|
||||
&& plan.cursor_blend
|
||||
&& c != crate::vdisplay::Compositor::Gamescope;
|
||||
(gamescope_composite, metadata_composite) = composite_plan(
|
||||
&plan,
|
||||
cursor_fwd.is_some(),
|
||||
c == crate::vdisplay::Compositor::Gamescope,
|
||||
);
|
||||
// The retargeted backend starts with `hw_cursor`
|
||||
// unset — without re-applying the session's
|
||||
// out-of-band cursor request, the rebuilt display
|
||||
|
||||
@@ -253,9 +253,31 @@ and capture `observed_sha256_out` (trust-on-first-use), then pin it thereafter.
|
||||
|
||||
## 5. Connecting
|
||||
|
||||
There is a **ladder** of `connect` variants; each adds parameters and defaults the rest to the prior
|
||||
variant's behavior. Use the highest one you need — they all return `PunktfunkConnection *` (NULL on
|
||||
failure) and block up to `timeout_ms` for the handshake, so **call off your UI thread.**
|
||||
**The recommended call is `punktfunk_connect_opts` (ABI ≥ 26)** — every option in one
|
||||
size-prefixed struct, so a new option is a new field instead of a new symbol:
|
||||
|
||||
```c
|
||||
PunktfunkConnectOpts o = {0}; /* zero = auto/legacy for every option */
|
||||
o.struct_size = sizeof(PunktfunkConnectOpts);
|
||||
o.host = "192.168.1.50";
|
||||
o.port = 9777;
|
||||
o.width = 1920; o.height = 1080; o.refresh_hz = 60;
|
||||
o.bitrate_kbps = 20000;
|
||||
o.video_codecs = PUNKTFUNK_CODEC_HEVC; /* OR-in _H264 (software hosts) / _AV1 */
|
||||
o.preferred_codec = PUNKTFUNK_CODEC_HEVC;
|
||||
o.pin_sha256 = host_fp; /* or NULL for TOFU */
|
||||
o.client_cert_pem = cert; o.client_key_pem = key;
|
||||
o.timeout_ms = 8000;
|
||||
|
||||
int32_t status; uint8_t observed[32];
|
||||
PunktfunkConnection *c = punktfunk_connect_opts(&o, observed, &status);
|
||||
if (!c) { /* status says why — a PunktfunkStatus, including the typed host-rejection block */ }
|
||||
```
|
||||
|
||||
Before it there is a **ladder** of `connect` variants; each adds parameters and defaults the rest
|
||||
to the prior variant's behavior. Every rung keeps its symbol and behavior forever, but the ladder
|
||||
is **closed** — new options land only in the struct. All of these return `PunktfunkConnection *`
|
||||
(NULL on failure) and block up to `timeout_ms` for the handshake, so **call off your UI thread.**
|
||||
|
||||
| Variant | Adds |
|
||||
|------------------|------|
|
||||
@@ -266,7 +288,12 @@ failure) and block up to `timeout_ms` for the handshake, so **call off your UI t
|
||||
| `punktfunk_connect_ex4` | `launch_id` (auto-launch a library title) |
|
||||
| `punktfunk_connect_ex5` | `video_caps` (10-bit / HDR / 4:4:4 / host-timing) |
|
||||
| `punktfunk_connect_ex6` | `audio_channels` (2 / 6 / 8) |
|
||||
| **`punktfunk_connect_ex7`** | **`video_codecs` + `preferred_codec`** — the recommended full call |
|
||||
| `punktfunk_connect_ex7` | `video_codecs` + `preferred_codec` — the full positional call |
|
||||
| `punktfunk_connect_ex8` | `status_out` (typed WHY on a failed connect — `PunktfunkStatus`, incl. host rejections) |
|
||||
| `punktfunk_connect_ex9` | `client_caps` (client-drawn cursor, …) |
|
||||
| `punktfunk_connect_ex10` | `device_name` (the label this device knocks with) |
|
||||
| `punktfunk_connect_ex11` | `audio_rate_hz` + `audio_bits` (hi-res / lossless audio ask) |
|
||||
| **`punktfunk_connect_opts`** | **terminal: one growable struct** — new options stop being new symbols |
|
||||
|
||||
```c
|
||||
uint8_t caps = 0; // add PUNKTFUNK_VIDEO_CAP_10BIT | _HDR | _444 as supported
|
||||
|
||||
+103
-1
@@ -185,7 +185,16 @@
|
||||
// never sees it and [`WIRE_VERSION`] is unchanged. It relies on tracing's `log` feature, now
|
||||
// declared explicitly by this crate (it was on transitively through quinn's defaults, which is
|
||||
// not a thing an ABI promise should rest on).
|
||||
#define PUNKTFUNK_ABI_VERSION 25
|
||||
// **v26** adds [`abi::punktfunk_connect_opts`] + [`abi::PunktfunkConnectOpts`] — the whole
|
||||
// connect surface in ONE size-prefixed, growable struct, closing the eleven-generation
|
||||
// `punktfunk_connect_ex*` chain (each new option used to mint a new exported symbol plus a
|
||||
// 20-something-parameter forwarding shim; `ex11` over `ex10` was two fields). ADDED, not
|
||||
// widened: every `ex` variant keeps its symbol, signature and byte-identical behaviour, and an
|
||||
// embedder that never calls the new form behaves exactly as on v25. New connect options land
|
||||
// only in the struct from here on — appended behind its `struct_size` guard, zero meaning
|
||||
// unspecified/auto — so they stop being ABI events at all. Client-local; [`WIRE_VERSION`] is
|
||||
// unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 26
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -2258,6 +2267,81 @@ typedef struct {
|
||||
uint64_t bytes_received;
|
||||
} PunktfunkStats;
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Every connect option in one **growable** struct — the terminal form of the
|
||||
// [`punktfunk_connect`] … [`punktfunk_connect_ex11`] chain, consumed by
|
||||
// [`punktfunk_connect_opts`]. Eleven generations each minted a new exported symbol to add a
|
||||
// field or two (`ex11` over `ex10`: exactly `audio_rate_hz` + `audio_bits`, for a 24-parameter
|
||||
// signature and a full forwarding shim). This struct ends that: a new option is a new field
|
||||
// appended HERE, guarded by `struct_size` exactly like [`PunktfunkConfig`].
|
||||
//
|
||||
// Usage: zero-initialize the whole struct, set `struct_size = sizeof(PunktfunkConnectOpts)`,
|
||||
// then set the fields you mean. Every zero field keeps the auto/legacy behaviour of the `ex`
|
||||
// chain (null pointer = absent, `0` = auto/unspecified) — `audio_rate_hz = 0` is `ex10`'s
|
||||
// UNSPECIFIED audio format, an explicit pair is `ex11`'s hi-res request, so the load-bearing
|
||||
// `ex11`-vs-`ex10` symbol choice becomes a field value.
|
||||
//
|
||||
// Growth discipline (for this crate): append only — never reorder, never widen an existing
|
||||
// field; a new field's zero value must mean "unspecified/auto"; keep the struct free of TAIL
|
||||
// padding on both pointer widths (the const asserts below lock 96/68 bytes), so an appended
|
||||
// field can never land inside bytes an older caller's `sizeof` already covered; bump
|
||||
// [`crate::ABI_VERSION`].
|
||||
typedef struct {
|
||||
// `sizeof(PunktfunkConnectOpts)` as THIS caller was compiled — the skew guard
|
||||
// ([`punktfunk_connect_opts`] rejects smaller than the v26 introduction size, and when the
|
||||
// struct grows, an older caller's shorter size defaults the tail instead of misreading it).
|
||||
uint32_t struct_size;
|
||||
// Required: NUL-terminated UTF-8 IP or hostname (the one non-nullable pointer here).
|
||||
const char *host;
|
||||
// Library id to auto-launch, or null ([`punktfunk_connect_ex4`]).
|
||||
const char *launch_id;
|
||||
// Null (trust on first use) or the host certificate's expected 32-byte SHA-256
|
||||
// ([`punktfunk_connect`]'s trust contract).
|
||||
const uint8_t *pin_sha256;
|
||||
// TLS client identity: both null (anonymous) or both NUL-terminated PEM
|
||||
// ([`punktfunk_generate_identity`]).
|
||||
const char *client_cert_pem;
|
||||
// See `client_cert_pem`.
|
||||
const char *client_key_pem;
|
||||
// The label this device knocks with, or null for the OS default
|
||||
// ([`punktfunk_connect_ex10`]).
|
||||
const char *device_name;
|
||||
// Requested mode ([`punktfunk_connect`]).
|
||||
uint32_t width;
|
||||
// See `width`.
|
||||
uint32_t height;
|
||||
// See `width`.
|
||||
uint32_t refresh_hz;
|
||||
// `PUNKTFUNK_COMPOSITOR_*`; `0`/unrecognized = auto ([`punktfunk_connect_ex`]).
|
||||
uint32_t compositor;
|
||||
// `PUNKTFUNK_GAMEPAD_*`; `0`/unrecognized = auto ([`punktfunk_connect_ex2`]).
|
||||
uint32_t gamepad;
|
||||
// Session wire budget in kbps; `0` = the host default ([`punktfunk_connect_ex3`]).
|
||||
uint32_t bitrate_kbps;
|
||||
// Audio format ask; `0`/`0` = UNSPECIFIED (the legacy Opus path), an explicit pair is a
|
||||
// hi-res request that derives `PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES`
|
||||
// ([`punktfunk_connect_ex11`] — including why explicit 48000/16 is a genuine lossless ask).
|
||||
uint32_t audio_rate_hz;
|
||||
// Connect timeout in milliseconds.
|
||||
uint32_t timeout_ms;
|
||||
// Required: the host's UDP port.
|
||||
uint16_t port;
|
||||
// `PUNKTFUNK_VIDEO_CAP_*` bits ([`punktfunk_connect_ex5`]).
|
||||
uint8_t video_caps;
|
||||
// Channel ask: 2 / 6 / 8; `0` = stereo ([`punktfunk_connect_ex6`]).
|
||||
uint8_t audio_channels;
|
||||
// See `audio_rate_hz`.
|
||||
uint8_t audio_bits;
|
||||
// `PUNKTFUNK_CODEC_*` bits the client can decode ([`punktfunk_connect_ex7`]).
|
||||
uint8_t video_codecs;
|
||||
// The one `PUNKTFUNK_CODEC_*` bit to prefer; `0` = host's choice
|
||||
// ([`punktfunk_connect_ex7`]).
|
||||
uint8_t preferred_codec;
|
||||
// `PUNKTFUNK_CLIENT_CAP_*` bits ([`punktfunk_connect_ex8`]).
|
||||
uint8_t client_caps;
|
||||
} PunktfunkConnectOpts;
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// One audio packet pulled off a `punktfunk/1` connection — an Opus frame (48 kHz, 5 ms) on
|
||||
// every ordinary session, or one lossless PCM frame on a session that resolved the `0xD3` plane.
|
||||
@@ -3145,6 +3229,24 @@ PunktfunkConnection *punktfunk_connect_ex11(const char *host,
|
||||
int32_t *status_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Connect with every option in one growable [`PunktfunkConnectOpts`] (ABI v26) — semantics are
|
||||
// exactly [`punktfunk_connect_ex11`]'s, field for field. The `ex` chain stays supported and
|
||||
// byte-identical forever, but it is CLOSED: new options land only in the struct.
|
||||
//
|
||||
// `status_out` (nullable) is written on every path, like the whole connect family;
|
||||
// `observed_sha256_out` (null or 32 bytes) receives the host certificate's fingerprint on
|
||||
// success, per [`punktfunk_connect`]'s trust contract.
|
||||
//
|
||||
// # Safety
|
||||
// `opts` is null or points to at least `opts->struct_size` readable bytes laid out as its
|
||||
// declared version of [`PunktfunkConnectOpts`]; its pointer fields follow
|
||||
// [`punktfunk_connect_ex11`]'s contract; `observed_sha256_out` is null or valid for 32 bytes.
|
||||
PunktfunkConnection *punktfunk_connect_opts(const PunktfunkConnectOpts *opts,
|
||||
uint8_t *observed_sha256_out,
|
||||
int32_t *status_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user