Files
punktfunk/crates/punktfunk-core/src/session.rs
T
enricobuehlerandClaude Fable 5 d36bec6e9d
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Failing after 13s
audit / cargo-audit (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Successful in 10m1s
windows-host / package (push) Successful in 11m20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m39s
feat(core+host): negotiate ChaCha20-Poly1305 as the session cipher for soft-AES clients
Lifts the ~100 Mbps decrypt ceiling on clients without hardware AES — the
armv7 soft-AES targets (webOS TVs), where AES-128-GCM resolves to fixsliced
software AES + software GHASH (~50-100 cpb) while ChaCha20-Poly1305's ARX
construction runs ~10-17 cpb portable, a 4-7x lift that PyroWave-on-TV needs
(design/chacha20-session-cipher.md).

Phase 1 (core crypto, no wire change): SessionKey merges cipher choice and
key material (invalid combinations unrepresentable, zeroize + redacted-Debug
discipline kept); SessionCrypto dispatches both aead-0.5 ciphers per call —
the salt||seq nonce scheme, per-direction salts, seq-as-AAD and replay
window carry over verbatim (same 96-bit nonce / 16-byte tag, const-asserted).
Config.key becomes SessionKey; validate's zero-key rejection follows the
active variant. The C ABI keeps its fixed 16-byte key mapped to AES — no
ABI_VERSION bump.

Phase 2 (negotiation): VIDEO_CAP_CHACHA20 (0x40) — support-plus-request in
one bit, the VIDEO_CAP_444 precedent. Welcome grows cipher@68 +
key_chacha@69..101, emitted only when non-zero so an AES session's Welcome
stays byte-identical to the pre-cipher form; decode is fail-closed (short
key or unknown id -> Err, never a silent AES fallback). No WIRE_VERSION
bump; downgrade resistance inherited from the pinned-TLS control channel.

Phase 3 (host): grant only when the client advertised the bit and the
PUNKTFUNK_CHACHA20 kill-switch (default on, documented) allows; fresh
32-byte per-session key from the same RNG discipline, legacy key field
stays independently random; resolved cipher logged at session start.

Verification: seal/open suites parameterized over both ciphers + a
cross-cipher tamper case; Welcome roundtrip/truncation/fail-closed tests;
ChaCha lossy-loopback soak (loss/replay is cipher-independent); bench
gains _chacha20 series (AES ids unchanged for CI history) — host-side
sealing line-rate-trivial on both x86 (~640 MiB/s) and Apple Silicon
(~535 MiB/s). punktfunk-probe drives the interop matrix via
PUNKTFUNK_CLIENT_CHACHA20=1 and logs the negotiated cipher.

Phase 4 (pf-webos pin bump + unconditional cap bit) follows the next
core release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:50:00 +02:00

1011 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Session lifecycle and the two hot-path state machines.
//!
//! - **Host** ([`Session::submit_frame`]): encoded access unit → FEC + packetize →
//! optional AES-GCM seal → transport send.
//! - **Client** ([`Session::poll_frame`]): transport recv → optional open → reorder +
//! FEC recover + reassemble → whole access unit.
//!
//! Both directions also carry input: a client [`Session::send_input`]s events; the host
//! drains them with [`Session::poll_input`].
use crate::config::{Config, Role};
use crate::crypto::SessionCrypto;
use crate::error::{PunktfunkError, Result};
use crate::fec::{coder_for, ErasureCoder};
use crate::input::InputEvent;
use crate::packet::{
PacketHeader, Packetizer, Reassembler, ReassemblerLimits, StreamedAu, MAX_DATAGRAM_BYTES,
};
use crate::stats::{Stats, StatsCounters};
use crate::transport::Transport;
use zerocopy::IntoBytes;
/// A reassembled, FEC-recovered access unit, ready to hand to the platform decoder.
pub struct Frame {
pub data: Vec<u8>,
pub frame_index: u32,
pub pts_ns: u64,
pub flags: u32,
/// `false` = a partial delivery: the frame aged out of the loss window with shards
/// missing, and the session opted into receiving it anyway
/// ([`Session::set_deliver_partial_frames`]). Only chunk-aligned AUs
/// ([`crate::packet::USER_FLAG_CHUNK_ALIGNED`]) are ever delivered partial; missing
/// shard ranges are zero-filled at their exact offsets.
pub complete: bool,
/// Wall-clock instant (ns since the Unix epoch, CLOCK_REALTIME basis — the same clock the
/// skew handshake compares and the host stamps `pts_ns` with) at which this AU finished
/// reassembly, stamped by [`Session::poll_frame`] as the frame leaves the session. Embedders
/// that previously stamped receipt themselves at the hand-off pull should use this instead:
/// the pull stamp additionally contains the pre-decode queue wait, silently folding any
/// client-side standing backlog into the apparent NETWORK latency. The reassembler itself
/// leaves this 0 (it owns no clock — the stamp is the session boundary's job).
pub received_ns: u64,
}
/// One end of a stream. Constructed for a single [`Role`]; calling the other role's
/// methods returns [`PunktfunkError::InvalidArg`].
///
/// Anti-replay: the receive path runs each opened datagram's AEAD-authenticated sequence through a
/// sliding-window filter ([`ReplayWindow`]), so a captured, validly-sealed datagram can't be replayed
/// by an on-path attacker — closing the input-replay gap that previously rested solely on the
/// LAN/VPN transport assumption (plan §1). Genuine reordering within the window is still accepted;
/// video additionally benefits from the reassembler's per-frame dedup.
pub struct Session {
config: Config,
coder: Box<dyn ErasureCoder>,
/// `Arc` so the second seal lane (Phase 1.5) can share the cipher; uncontended otherwise.
crypto: Option<std::sync::Arc<SessionCrypto>>,
/// Anti-replay window over the peer's authenticated sequence (receive side). `Some` exactly when
/// `crypto` is — the plaintext probe path carries no sequence to filter on.
replay: Option<ReplayWindow>,
transport: Box<dyn Transport>,
packetizer: Packetizer,
reassembler: Reassembler,
stats: StatsCounters,
/// Monotonic wire sequence, also the AES-GCM nonce counter.
next_seq: u64,
/// Client recv ring (reused across [`poll_frame`](Self::poll_frame)): `recvmmsg` drains a batch
/// of datagrams into `recv_scratch` in one syscall, and poll_frame consumes them one at a time
/// across calls (`recv_idx`..`recv_count`), refilling when drained. Allocated lazily on the
/// first client poll so host sessions don't carry it. No per-packet recv alloc at line rate.
recv_scratch: Vec<Vec<u8>>,
recv_lens: Vec<usize>,
recv_count: usize,
recv_idx: usize,
/// Host send pool: reused wire buffers (`seal_frame` seals in place into these, the caller sends
/// then returns them via [`reclaim_wires`](Self::reclaim_wires)). After warmup each buffer keeps
/// its capacity, so the per-packet ciphertext + wire `Vec` allocations vanish from the hot path.
wire_pool: Vec<Vec<u8>>,
/// Receive-path stage timing (`PUNKTFUNK_PERF`), read+reset via [`take_pump_perf`]
/// (Self::take_pump_perf). `None` when disabled — the hot path then pays one branch per stage.
perf: Option<PumpPerf>,
/// Send-path stage timing (`PUNKTFUNK_PERF`), read+reset via [`take_seal_perf`]
/// (Self::take_seal_perf). Same arming + branch-cost contract as `perf`.
seal_perf: Option<SealPerf>,
/// The second seal lane (plan Phase 1.5), lazily spawned by the first frame that crosses
/// [`TWO_LANE_MIN_PACKETS`]. Host sessions only (client sessions never seal frames).
seal_lane: Option<SealLane>,
/// Two-lane sealing enabled (default). `PUNKTFUNK_SEAL_LANES=1` forces single-lane.
seal_two_lane: bool,
/// Reused header-Vec for the lane hand-off (the worker's half round-trips through this,
/// so steady-state two-lane frames move `n/2` Vec headers with zero allocation).
lane_scratch: Vec<Vec<u8>>,
}
/// Stamp [`Frame::received_ns`] as the frame crosses the session boundary in
/// [`Session::poll_frame`] — completed frames return the moment their last shard lands, so
/// stamping at return IS stamping at reassembly completion (µs apart). CLOCK_REALTIME to match
/// `pts_ns` / the skew handshake (deliberately not monotonic — cross-machine latency math).
fn stamp_received(mut f: Frame) -> Frame {
f.received_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
f
}
mod perf;
mod replay;
mod seal;
pub use perf::{PumpPerf, SealPerf};
use perf::TimedCoder;
use replay::{seq_of, ReplayWindow};
use seal::{seal_wire_slice, SealJob, SealLane, TWO_LANE_MIN_PACKETS};
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). 128 keeps
/// the syscall rate ≤ ~3.4k/s even at the ~430k pkt/s the post-2026-07-14 receive path delivers
/// (~4.8 Gbps wire), and gives the kernel buffer a deeper drain per pump iteration; the buffers
/// cost `RECV_BATCH × RECV_BUF` (~256 KB, client sessions only).
const RECV_BATCH: usize = 128;
impl Session {
pub fn new(config: Config, transport: Box<dyn Transport>) -> Result<Session> {
config.validate()?;
let coder = coder_for(config.fec.scheme);
let crypto = config.encrypt.then(|| {
std::sync::Arc::new(SessionCrypto::new(&config.key, config.salt, config.role))
});
// A receive-side replay window exists exactly when the datagrams are sealed (they carry the
// authenticated sequence the window keys on). Both roles receive from their peer.
let replay = config.encrypt.then(ReplayWindow::new);
let packetizer = Packetizer::new(&config);
let reassembler = Reassembler::new(ReassemblerLimits::from_config(&config));
Ok(Session {
coder,
crypto,
replay,
transport,
packetizer,
reassembler,
stats: StatsCounters::default(),
next_seq: 0,
recv_scratch: Vec::new(),
recv_lens: Vec::new(),
recv_count: 0,
recv_idx: 0,
wire_pool: Vec::new(),
// Same opt-in the host's stage logs use; read once — set it before connecting.
perf: std::env::var("PUNKTFUNK_PERF")
.is_ok_and(|v| v != "0")
.then(PumpPerf::default),
seal_perf: std::env::var("PUNKTFUNK_PERF")
.is_ok_and(|v| v != "0")
.then(SealPerf::default),
seal_lane: None,
// Two-lane sealing of large frames is the default; =1 forces single-lane (the
// escape hatch — behavior is byte-identical, this only changes who seals).
seal_two_lane: std::env::var("PUNKTFUNK_SEAL_LANES")
.map(|v| v != "1")
.unwrap_or(true),
lane_scratch: Vec::new(),
config,
})
}
/// Drain the receive-path stage timings accumulated since the last call (window semantics —
/// the pump reads this once per report interval). `None` when `PUNKTFUNK_PERF` is off.
pub fn take_pump_perf(&mut self) -> Option<PumpPerf> {
self.perf.as_mut().map(std::mem::take)
}
/// Drain the send-path stage timings accumulated since the last call (window semantics —
/// the host send loop reads this once per perf window). `None` when `PUNKTFUNK_PERF` is off.
pub fn take_seal_perf(&mut self) -> Option<SealPerf> {
self.seal_perf.as_mut().map(std::mem::take)
}
/// Fold externally-timed socket time into [`SealPerf::sock_ns`] — the paced video path
/// times its own `send_sealed` chunk calls (they happen behind a `&self` borrow inside the
/// pacing closure, where the session can't self-time). No-op when perf is off.
pub fn note_sock_ns(&mut self, ns: u64) {
if let Some(p) = self.seal_perf.as_mut() {
p.sock_ns += ns;
}
}
pub fn role(&self) -> Role {
self.config.role
}
pub fn stats(&self) -> Stats {
self.stats.snapshot()
}
/// Wrap a packet for the wire: when encrypting, prepend the 8-byte big-endian
/// sequence (the receiver derives the GCM nonce from it) then the ciphertext.
/// Seal one plaintext packet into the reused `wire` buffer in place (no allocation): the wire is
/// `seq(8) || ciphertext || tag` with crypto on, or just the packet with crypto off (probe).
/// Byte-identical to the previous `seal` + concat path; `clear()` keeps the buffer's capacity.
fn seal_into(&mut self, packet: &[u8], wire: &mut Vec<u8>) -> Result<()> {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
wire.clear();
match &self.crypto {
Some(c) => {
wire.extend_from_slice(&seq.to_be_bytes()); // [0..8] plaintext seq prefix
wire.extend_from_slice(packet); // [8..8+n] plaintext to encrypt
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0); // tag scratch
c.seal_in_place(seq, &mut wire[8..])?; // encrypt [8..] in place, tag written at the end
}
None => wire.extend_from_slice(packet),
}
Ok(())
}
/// Unwrap a wire datagram back into a plaintext packet.
fn open_from_wire(&self, wire: &[u8]) -> Result<Vec<u8>> {
match &self.crypto {
Some(c) => {
if wire.len() < 8 {
return Err(PunktfunkError::BadPacket);
}
let seq = u64::from_be_bytes(wire[..8].try_into().unwrap());
c.open(seq, &wire[8..])
}
None => Ok(wire.to_vec()),
}
}
/// Feed an opened datagram's authenticated sequence to the anti-replay window: `true` = fresh
/// (accept), `false` = a replay or older than the window (drop). Returns `true` when the session
/// isn't encrypting (no window, and no sequence on the wire to key on).
fn accept_seq(&mut self, seq: u64) -> bool {
match self.replay.as_mut() {
Some(w) => w.accept(seq),
None => true,
}
}
// -- Host path --------------------------------------------------------
/// Host: FEC-protect, packetize, and seal one encoded access unit into wire packets WITHOUT
/// sending them. Counts the frame + its packets/bytes as submitted; the caller transmits the
/// returned packets via [`send_sealed`](Self::send_sealed) — in one call, or in chunks paced
/// over the frame interval so a real NIC doesn't drop the whole frame as a line-rate burst (the
/// 1 Gbps+ freeze fix). The nonce counter advances per packet, in order, so seal once and send
/// the result intact. (Holding the `Vec<Vec<u8>>` also keeps the buffers alive for the batch.)
pub fn seal_frame(
&mut self,
data: &[u8],
pts_ns: u64,
user_flags: u32,
) -> Result<Vec<Vec<u8>>> {
self.seal_frame_inner(data, pts_ns, user_flags, None)
}
/// [`seal_frame`](Self::seal_frame) with the caller's **explicit** `frame_index` instead of the
/// packetizer's internal counter. The punktfunk/1 encode loop owns the video numbering (one
/// session-lifetime counter, stamped per AU) so the encoder's reference-frame-invalidation
/// bookkeeping stays 1:1 with the wire across encoder rebuilds/resets — see
/// [`Packetizer::packetize_each`]. A session must use ONE numbering style per index space.
pub fn seal_frame_at(
&mut self,
data: &[u8],
pts_ns: u64,
user_flags: u32,
frame_index: u32,
) -> Result<Vec<Vec<u8>>> {
self.seal_frame_inner(data, pts_ns, user_flags, Some(frame_index))
}
fn seal_frame_inner(
&mut self,
data: &[u8],
pts_ns: u64,
user_flags: u32,
frame_index: Option<u32>,
) -> Result<Vec<Vec<u8>>> {
self.seal_run(true, |p, coder, emit| {
p.packetize_each(data, pts_ns, user_flags, frame_index, coder, emit)
})
}
/// Host: open a **streamed** access unit ([`crate::quic::VIDEO_CAP_STREAMED_AU`] — only
/// toward a client that advertised it; anyone else must get the whole-AU
/// [`seal_frame_at`](Self::seal_frame_at) path). The AU's bytes are fed in with
/// [`seal_streamed_chunk`](Self::seal_streamed_chunk) as the encoder produces them and
/// closed with [`seal_streamed_finish`](Self::seal_streamed_finish); the three calls'
/// returned wire batches are ONE frame, and the nonce order is emission order across the
/// calls — send each batch as it is returned, before sealing the next.
pub fn begin_streamed_frame_at(
&mut self,
pts_ns: u64,
user_flags: u32,
frame_index: u32,
) -> Result<StreamedAu> {
if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg(
"seal_frame called on a client session",
));
}
Ok(self
.packetizer
.begin_streamed(pts_ns, user_flags, Some(frame_index)))
}
/// Feed one encoder chunk into a streamed AU, sealing every FEC block it completes under
/// sentinel headers (see [`begin_streamed_frame_at`](Self::begin_streamed_frame_at)). The
/// returned batch is often EMPTY (the chunk is buffered until a block fills) — that's
/// normal, not an error.
pub fn seal_streamed_chunk(
&mut self,
au: &mut StreamedAu,
chunk: &[u8],
) -> Result<Vec<Vec<u8>>> {
self.seal_run(false, |p, coder, emit| {
p.push_streamed(au, chunk, coder, emit)
})
}
/// Close a streamed AU: seal the final block with the real totals (+ `FLAG_EOF`), which
/// retro-validate the frame at the receiver. Counts the frame as submitted.
pub fn seal_streamed_finish(&mut self, au: StreamedAu) -> Result<Vec<Vec<u8>>> {
self.seal_run(true, |p, coder, emit| p.finish_streamed(au, coder, emit))
}
/// The shared packetize → pooled-wire → seal machinery behind [`seal_frame`](Self::seal_frame)
/// and the streamed sealers: `run` drives the packetizer against an emit sink that writes
/// each packet's plaintext at its final wire offset; the seal pass (two-lane for large runs)
/// then encrypts in place. `count_frame` gates the per-frame stats — a streamed AU counts
/// once, at its finish call.
fn seal_run(
&mut self,
count_frame: bool,
run: impl FnOnce(
&mut Packetizer,
&dyn ErasureCoder,
&mut dyn FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()>,
) -> Result<Vec<Vec<u8>>> {
if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg(
"seal_frame called on a client session",
));
}
// Packetize straight into the pooled wire buffers (reused across frames via
// `reclaim_wires`) and seal each in place: the plaintext `header ++ shard` is written
// once, at its final wire offset — no intermediate per-packet Vec at all. Byte-identical
// to the wrapper (`packetize` + seal) path: same plaintext, same emission order, and the
// nonce counter advances per emitted packet exactly as before (pinned by the
// wire-equivalence tests below). Destructure into disjoint field borrows first — the
// emit closure needs `crypto`/`next_seq`/the pool while `packetizer` is `&mut`.
let perf_armed = self.seal_perf.is_some();
let fec_ns = std::sync::atomic::AtomicU64::new(0);
let mut seal_ns = 0u64;
let two_lane = self.seal_two_lane;
let Session {
packetizer,
coder,
crypto,
next_seq,
wire_pool,
seal_lane,
lane_scratch,
..
} = self;
// Stage timing (SealPerf): the coder shim times FEC, the seal phase times itself.
let timed_coder;
let coder_ref: &dyn ErasureCoder = if perf_armed {
timed_coder = TimedCoder {
inner: coder.as_ref(),
ns: &fec_ns,
};
&timed_coder
} else {
coder.as_ref()
};
let mut wires = std::mem::take(wire_pool);
let mut used = 0usize;
// Phase 1 — packetize: write each packet's plaintext at its final wire offset
// (`seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16)` with crypto on; `header ‖ shard`
// off). The nonce counter advances per packet in emission order exactly as before;
// sealing itself is a separate pass so it can split across lanes.
let seq_base = *next_seq;
let encrypting = crypto.is_some();
let result = {
let wires = &mut wires;
let used = &mut used;
let mut emit = move |hdr: &PacketHeader, body: &[u8]| -> Result<()> {
if *used == wires.len() {
wires.push(Vec::new());
}
let wire = &mut wires[*used];
*used += 1;
let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1);
wire.clear();
if encrypting {
wire.extend_from_slice(&seq.to_be_bytes());
wire.extend_from_slice(hdr.as_bytes());
wire.extend_from_slice(body);
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
} else {
wire.extend_from_slice(hdr.as_bytes());
wire.extend_from_slice(body);
}
Ok(())
};
run(packetizer, coder_ref, &mut emit)
};
result?;
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
// as the previous `resize_with(packets.len(), ..)` did. (Before the seal phase, so a
// two-lane split hands the worker exactly the frame's back half.)
wires.truncate(used);
// Phase 2 — seal. Large frames split across two lanes (plan Phase 1.5): the worker
// seals the back half under nonces `seq_base + i` while this thread seals the front —
// byte-identical output to the sequential pass (pinned by the wire-equivalence test).
if let Some(c) = crypto {
if two_lane && used >= TWO_LANE_MIN_PACKETS && seal_lane.is_none() {
*seal_lane = SealLane::spawn(c.clone()); // stays None if spawn fails → single-lane
}
let mut split_done = false;
if two_lane && used >= TWO_LANE_MIN_PACKETS {
// Take the lane for the frame: a healthy round-trip puts it back; either
// failure arm drops the corpse so the next large frame respawns a fresh one
// instead of retrying a dead channel forever.
if let Some(lane) = seal_lane.take() {
let half = used / 2;
let mut tail = std::mem::take(lane_scratch);
tail.extend(wires.drain(half..));
let job = SealJob {
bufs: tail,
seq_base: seq_base.wrapping_add(half as u64),
timed: perf_armed,
ns: 0,
result: Ok(()),
};
match lane.to_worker.send(job) {
Ok(()) => {
// Seal the front half while the worker runs; collect BOTH results
// before erroring so the lane is always drained and reusable.
let t0 = perf_armed.then(std::time::Instant::now);
let front = seal_wire_slice(c, &mut wires, seq_base);
if let Some(t0) = t0 {
seal_ns += t0.elapsed().as_nanos() as u64;
}
match lane.from_worker.recv() {
Ok(mut done) => {
*seal_lane = Some(lane);
seal_ns += done.ns;
wires.append(&mut done.bufs);
*lane_scratch = done.bufs;
front?;
done.result?;
split_done = true;
}
Err(_) => {
// The worker died holding the back half — the frame is
// unrecoverable (its packets are gone), but the error now
// SURFACES instead of `Ok` with half an access unit.
front?;
return Err(PunktfunkError::Unsupported("seal lane died"));
}
}
}
Err(std::sync::mpsc::SendError(job)) => {
// The worker is gone but the channel hands the job back: reclaim
// the back half so the single-lane pass below seals the WHOLE
// frame — previously this fall-through sealed and returned only
// the front half, silently, as `Ok`.
wires.extend(job.bufs);
}
}
}
}
if !split_done {
let t0 = perf_armed.then(std::time::Instant::now);
seal_wire_slice(c, &mut wires, seq_base)?;
if let Some(t0) = t0 {
seal_ns += t0.elapsed().as_nanos() as u64;
}
}
}
if let Some(p) = self.seal_perf.as_mut() {
p.fec_ns += fec_ns.load(std::sync::atomic::Ordering::Relaxed);
p.seal_ns += seal_ns;
p.frames += count_frame as u64;
p.packets += used as u64;
}
if count_frame {
StatsCounters::add(&self.stats.frames_submitted, 1);
}
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
StatsCounters::add(&self.stats.bytes_sent, bytes);
Ok(wires)
}
/// Return the wire buffers from [`seal_frame`](Self::seal_frame) to the reuse pool once the caller
/// has finished sending them, so the next frame reseals in place with no allocation. Optional —
/// dropping the buffers instead just forfeits the reuse (correctness is unaffected).
pub fn reclaim_wires(&mut self, wires: Vec<Vec<u8>>) {
self.wire_pool = wires;
}
/// Host: transmit one chunk of already-[`seal_frame`](Self::seal_frame)ed packets in a single
/// batched `sendmmsg`, returning how many the kernel accepted. The rest (`packets.len() - n`)
/// are counted as send-buffer drops. Call once for the whole frame, or per paced chunk.
pub fn send_sealed(&self, packets: &[&[u8]]) -> Result<usize> {
// GSO when enabled (UdpTransport/Linux), else sendmmsg — same short-count drop contract.
let sent = self.transport.send_gso(packets)?;
if sent < packets.len() {
StatsCounters::add(
&self.stats.packets_send_dropped,
(packets.len() - sent) as u64,
);
}
Ok(sent)
}
/// Host: FEC-protect, packetize, seal, and send one encoded access unit (the whole frame in one
/// batched send). Convenience composition of [`seal_frame`](Self::seal_frame) +
/// [`send_sealed`](Self::send_sealed) for callers that don't pace (synthetic source, probe).
pub fn submit_frame(&mut self, data: &[u8], pts_ns: u64, user_flags: u32) -> Result<()> {
let wires = self.seal_frame(data, pts_ns, user_flags)?;
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
let t0 = self.seal_perf.is_some().then(std::time::Instant::now);
let r = self.send_sealed(&refs);
drop(refs); // release the borrow of `wires` before returning the buffers to the pool
if let Some(t0) = t0 {
self.note_sock_ns(t0.elapsed().as_nanos() as u64);
}
self.reclaim_wires(wires);
r.map(|_| ())
}
/// Host: seal + send one **speed-test probe filler** access unit in the probe index space
/// (its own frame counter + the [`crate::packet::FLAG_PROBE`] user-flag) so a burst never
/// consumes video `frame_index`es — the client reassembles probe frames in a separate window
/// and its gap detectors never see them. Only call this against a client that advertised
/// [`crate::quic::VIDEO_CAP_PROBE_SEQ`]; an older client's single-window reassembler would
/// drop probe-space indexes as stale against the video stream.
pub fn submit_probe_frame(&mut self, data: &[u8], pts_ns: u64) -> Result<()> {
let idx = self.packetizer.alloc_probe_index();
let wires =
self.seal_frame_inner(data, pts_ns, crate::packet::FLAG_PROBE as u32, Some(idx))?;
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
let t0 = self.seal_perf.is_some().then(std::time::Instant::now);
let r = self.send_sealed(&refs);
drop(refs);
if let Some(t0) = t0 {
self.note_sock_ns(t0.elapsed().as_nanos() as u64);
}
self.reclaim_wires(wires);
r.map(|_| ())
}
/// Host: live-adjust the FEC recovery percentage (adaptive FEC). Affects the next
/// [`submit_frame`](Self::submit_frame)/[`seal_frame`](Self::seal_frame); the receiver needs no
/// notification (each packet's header carries its block's data/recovery shard counts).
pub fn set_fec_percent(&mut self, pct: u8) {
self.packetizer.set_fec_percent(pct);
}
/// The current FEC recovery percentage (host side).
pub fn fec_percent(&self) -> u8 {
self.packetizer.fec_percent()
}
/// Host: drain one pending input event from the client, if any.
pub fn poll_input(&mut self) -> Result<Option<InputEvent>> {
if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg(
"poll_input called on a client session",
));
}
while let Some(wire) = self.transport.recv()? {
let pkt = match self.open_from_wire(&wire) {
Ok(p) => p,
Err(_) => continue, // drop undecryptable noise
};
// Anti-replay: a captured input datagram replayed by an on-path attacker opens cleanly
// (its sequence + tag are still valid) — the window is what rejects the second copy.
// `len >= 8` is guaranteed because the sealed-path open above succeeded.
if self.replay.is_some() && !self.accept_seq(seq_of(&wire)) {
StatsCounters::add(&self.stats.packets_dropped, 1);
continue;
}
StatsCounters::add(&self.stats.packets_received, 1);
if let Some(ev) = InputEvent::decode(&pkt) {
return Ok(Some(ev));
}
// Not an input datagram (e.g. stray video) — ignore and keep draining.
}
Ok(None)
}
// -- Client path ------------------------------------------------------
/// Client: drain the transport until a whole access unit is recovered, or no more
/// packets are pending ([`PunktfunkError::NoFrame`]).
/// Client opt-in: deliver aged-out incomplete chunk-aligned frames as
/// [`Frame`]`{ complete: false }` instead of only dropping them (the PyroWave
/// datagram-aligned mode, plan §4.4 — a lost datagram costs a few blocks of blur,
/// not the frame). No effect on other codecs' AUs (they never carry the flag).
pub fn set_deliver_partial_frames(&mut self, on: bool) {
self.reassembler.set_deliver_partial(on);
}
/// The session's negotiated wire shard payload size (bytes of AU per datagram) —
/// the window size for chunk-aligned AUs (`USER_FLAG_CHUNK_ALIGNED`).
pub fn shard_payload(&self) -> usize {
self.config.shard_payload
}
pub fn poll_frame(&mut self) -> Result<Frame> {
if self.config.role != Role::Client {
return Err(PunktfunkError::InvalidArg(
"poll_frame called on a host session",
));
}
// Lazily allocate the recv ring on first client poll (host sessions never get here).
if self.recv_scratch.is_empty() {
// Each buffer holds a max datagram + 1 (an oversized read fills it → reassembler rejects).
self.recv_scratch = (0..RECV_BATCH)
.map(|_| vec![0u8; MAX_DATAGRAM_BYTES + 1])
.collect();
self.recv_lens = vec![0usize; RECV_BATCH];
}
loop {
// Refill the ring with one `recvmmsg` batch when the current one is drained.
if self.recv_idx >= self.recv_count {
let t0 = self.perf.is_some().then(std::time::Instant::now);
self.recv_count = self
.transport
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
if let (Some(p), Some(t0)) = (self.perf.as_mut(), t0) {
p.recv_ns += t0.elapsed().as_nanos() as u64;
p.batches += 1;
}
self.recv_idx = 0;
if self.recv_count == 0 {
// Nothing new on the wire — hand over an aged-out partial if one is
// waiting (it can only get staler).
if let Some(p) = self.reassembler.take_partial() {
return Ok(stamp_received(p));
}
return Err(PunktfunkError::NoFrame);
}
}
let i = self.recv_idx;
self.recv_idx += 1;
let len = self.recv_lens[i];
// An oversized datagram fills the whole buffer (recvmmsg truncates + caps msg_len at the
// buffer size) — drop it rather than hand up a truncated, corrupt packet, mirroring the
// scalar `recv`'s `n >= RECV_BUF` check.
if len > MAX_DATAGRAM_BYTES {
continue;
}
// Open in place inside the ring buffer — no per-datagram allocation at line rate
// (~125k pkt/s at 1 Gbps; the recv ring killed the recv alloc, this kills the decrypt
// one). The plaintext lands at [8..8+n] of the sealed wire (behind the seq prefix); an
// unencrypted (probe) datagram IS the packet. Field-precise borrows keep the slice into
// `recv_scratch` alive across the replay/reassembler calls below.
// Perf note: the two `continue`s below (short / undecryptable noise) skip the decrypt
// accounting — they are the exception path, not line-rate traffic.
let t_dec = self.perf.is_some().then(std::time::Instant::now);
let (pkt_range, seq) = match &self.crypto {
Some(c) => {
// A sealed datagram is at least seq prefix + tag; anything shorter is noise.
if len < 8 + crate::crypto::TAG_LEN {
continue;
}
let seq = u64::from_be_bytes(self.recv_scratch[i][..8].try_into().unwrap());
match c.open_in_place(seq, &mut self.recv_scratch[i][8..len]) {
Ok(n) => (8..8 + n, Some(seq)),
Err(_) => continue, // undecryptable noise — drop, keep draining
}
}
None => (0..len, None),
};
if let (Some(p), Some(t)) = (self.perf.as_mut(), t_dec) {
p.decrypt_ns += t.elapsed().as_nanos() as u64;
}
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
// sequence was already seen. Video also dedups per-frame downstream, but filtering here
// is uniform and cheap.
if let (Some(w), Some(seq)) = (self.replay.as_mut(), seq) {
if !w.accept(seq) {
StatsCounters::add(&self.stats.packets_dropped, 1);
continue;
}
}
let pkt = &self.recv_scratch[i][pkt_range];
StatsCounters::add(&self.stats.packets_received, 1);
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
// The reassembler validates the packet via its parsed header (`magic`),
// ignoring anything that isn't a well-formed video packet.
let t_push = self.perf.is_some().then(std::time::Instant::now);
let pushed = self
.reassembler
.push(pkt, self.coder.as_ref(), &self.stats)?;
if let (Some(p), Some(t)) = (self.perf.as_mut(), t_push) {
p.reasm_ns += t.elapsed().as_nanos() as u64;
// Counts datagrams that reached the reassembler (replay-rejected ones don't).
p.packets += 1;
}
if let Some(frame) = pushed {
StatsCounters::add(&self.stats.frames_completed, 1);
return Ok(stamp_received(frame));
}
// A push that completed nothing may still have aged a partial out — deliver it
// ahead of further draining (its successors are already arriving).
if let Some(p) = self.reassembler.take_partial() {
return Ok(stamp_received(p));
}
}
}
/// Client: discard the ENTIRE pending receive backlog — the current recv ring plus everything
/// queued in the kernel socket buffer — and reset the reassembler. Returns how many datagrams
/// were thrown away (counted into `packets_dropped`).
///
/// This is the latency-bound escape hatch: the receive path has no other way to skip ahead.
/// Packets arrive strictly in order, so once a standing queue forms (the pump transiently
/// slower than the wire, a Wi-Fi stall, power-save delivery clumping), the client plays that
/// far behind FOREVER — it consumes at exactly the arrival rate, so the backlog never shrinks
/// (observed live: a stream stuck 67 s behind, socket buffers full end to end). Discarding
/// is memcpy-speed (no decrypt/reassembly/allocation), so this empties even a 32 MB buffer in
/// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration
/// cap (1024 batches ≈ 131k datagrams ≈ 190 MB at the 128-deep ring) only guards against a
/// line-rate sender outpacing the discard loop indefinitely.
pub fn flush_backlog(&mut self) -> Result<u64> {
if self.config.role != Role::Client {
return Err(PunktfunkError::InvalidArg(
"flush_backlog called on a host session",
));
}
// The undelivered tail of the current ring is backlog too.
let mut flushed = self.recv_count.saturating_sub(self.recv_idx) as u64;
self.recv_count = 0;
self.recv_idx = 0;
if !self.recv_scratch.is_empty() {
for _ in 0..1024 {
let n = self
.transport
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
if n == 0 {
break;
}
flushed += n as u64;
}
}
self.reassembler.reset();
StatsCounters::add(&self.stats.packets_dropped, flushed);
Ok(flushed)
}
/// Client: serialize and send one input event to the host.
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
if self.config.role != Role::Client {
return Err(PunktfunkError::InvalidArg(
"send_input called on a host session",
));
}
let pkt = event.encode();
let mut wire = Vec::new(); // input is rare + per-event; no pool needed
self.seal_into(&pkt, &mut wire)?;
StatsCounters::add(&self.stats.packets_sent, 1);
StatsCounters::add(&self.stats.bytes_sent, wire.len() as u64);
if !self.transport.send(&wire)? {
StatsCounters::add(&self.stats.packets_send_dropped, 1);
}
Ok(())
}
}
#[cfg(test)]
mod wire_equivalence_tests {
use super::*;
use crate::config::{FecConfig, FecScheme, ProtocolPhase};
use crate::crypto::SessionKey;
use crate::transport::loopback_pair;
fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config {
Config {
role: Role::Host,
phase: match scheme {
FecScheme::Gf8 => ProtocolPhase::P1GameStream,
FecScheme::Gf16 => ProtocolPhase::P2Punktfunk,
},
fec: FecConfig {
scheme,
fec_percent,
max_data_per_block: 8,
},
shard_payload: 64,
max_frame_bytes: 8 * 1024 * 1024,
encrypt,
key: SessionKey::Aes128Gcm([7u8; 16]),
salt: [3, 1, 4, 1],
loopback_drop_period: 0,
}
}
fn host_session(cfg: Config) -> Session {
let (h, _c) = loopback_pair(0, 0);
Session::new(cfg, Box::new(h)).unwrap()
}
/// The reference wire path: build owned packets via the `packetize` wrapper, then seal
/// each into its own buffer — the pre-zero-copy implementation of `seal_frame`, spelled
/// out with the session's own private pieces so the two paths share nothing but state.
fn seal_via_wrapper(sess: &mut Session, frame: &[u8], pts_ns: u64, flags: u32) -> Vec<Vec<u8>> {
let packets = sess
.packetizer
.packetize(frame, pts_ns, flags, sess.coder.as_ref())
.unwrap();
let mut wires = Vec::new();
for pkt in &packets {
let mut wire = Vec::new();
sess.seal_into(pkt, &mut wire).unwrap();
wires.push(wire);
}
wires
}
/// `seal_frame`'s packetize-straight-into-the-wire-pool path must produce byte-identical
/// sealed output to the wrapper path (same plaintext = header ++ shard, same nonce
/// sequence) — for multi-block frames, partial tail shards, exact-multiple frames, the
/// empty frame, fec 0%/50%, both schemes, crypto on and off (plan §1.4).
#[test]
fn zero_copy_seal_matches_wrapper_path() {
for scheme in [FecScheme::Gf8, FecScheme::Gf16] {
for fec_percent in [0u8, 50] {
for encrypt in [true, false] {
let mut opt = host_session(host_cfg(scheme, fec_percent, encrypt));
let mut refr = host_session(host_cfg(scheme, fec_percent, encrypt));
// shard_payload 64 × max_data_per_block 8: >512 bytes spans FEC blocks.
let frames: Vec<Vec<u8>> = vec![
pattern(3000), // multi-block + partial tail shard
pattern(1024), // exact multiple (2 full blocks)
pattern(100), // single block, partial tail
Vec::new(), // empty frame → 1 zeroed shard
pattern(64), // exactly one full shard
pattern(20000), // > TWO_LANE_MIN_PACKETS wire packets → two-lane seal
];
for (i, frame) in frames.iter().enumerate() {
let got = opt.seal_frame(frame, 1000 * i as u64, i as u32).unwrap();
let want = seal_via_wrapper(&mut refr, frame, 1000 * i as u64, i as u32);
assert_eq!(
got, want,
"wire mismatch: scheme={scheme:?} fec={fec_percent}% encrypt={encrypt} frame#{i}"
);
// Return the buffers so later frames exercise the pooled-reuse path
// (including a bigger frame after a smaller one and vice versa).
opt.reclaim_wires(got);
}
// The 20000-byte frame (~469 wire packets at shard 64) crosses
// TWO_LANE_MIN_PACKETS: the equality above must have held THROUGH the
// two-lane split, not via a silent single-lane fallback.
if encrypt {
assert!(
opt.seal_lane.is_some(),
"two-lane seal lane should have spawned for the large frame"
);
}
}
}
}
}
fn pattern(len: usize) -> Vec<u8> {
(0..len).map(|i| (i * 31 + 7) as u8).collect()
}
/// A dead seal lane (worker gone, channels dangling) must degrade to a single-lane seal of
/// the WHOLE frame — the old fall-through sealed and returned only the front half as `Ok` —
/// and the corpse must be dropped so the next large frame respawns a fresh lane.
#[test]
fn dead_seal_lane_falls_back_to_single_lane_whole_frame() {
let mut opt = host_session(host_cfg(FecScheme::Gf16, 20, true));
let mut refr = host_session(host_cfg(FecScheme::Gf16, 20, true));
// A lane whose worker has already exited: both far ends dropped, so `send` fails
// immediately and hands the job (with the frame's back half) back.
let (to_worker, jobs) = std::sync::mpsc::sync_channel::<SealJob>(1);
let (done_tx, from_worker) = std::sync::mpsc::sync_channel::<SealJob>(1);
drop(jobs);
drop(done_tx);
opt.seal_lane = Some(SealLane {
to_worker,
from_worker,
});
let frame = pattern(20000); // > TWO_LANE_MIN_PACKETS wire packets → takes the split path
let got = opt.seal_frame(&frame, 7, 0).unwrap();
let want = seal_via_wrapper(&mut refr, &frame, 7, 0);
assert_eq!(got, want, "fallback must seal the whole frame, not half");
assert!(
opt.seal_lane.is_none(),
"the dead lane must be dropped, not retried forever"
);
// The next large frame respawns a fresh, working lane.
opt.reclaim_wires(got);
let got2 = opt.seal_frame(&frame, 8, 1).unwrap();
let want2 = seal_via_wrapper(&mut refr, &frame, 8, 1);
assert_eq!(got2, want2);
assert!(
opt.seal_lane.is_some(),
"a fresh lane respawns on the next large frame"
);
}
/// Partial delivery (plan §4.4): a chunk-aligned frame that loses shards past FEC's
/// reach is DELIVERED once it ages out — `complete: false`, received shards at their
/// exact offsets, missing ranges zero-filled — instead of silently dropping. Plain
/// AUs (no flag) keep the drop behavior even with the opt-in enabled.
#[test]
fn partial_delivery_of_chunk_aligned_frames() {
use crate::packet::USER_FLAG_CHUNK_ALIGNED;
let mk = |role| Config {
role,
phase: ProtocolPhase::P2Punktfunk,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 0, // no parity — any drop leaves a hole
max_data_per_block: 64,
},
shard_payload: 1024,
max_frame_bytes: 8 * 1024 * 1024,
encrypt: false,
key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4],
loopback_drop_period: 0,
};
// Drop exactly one datagram (the 3rd) out of the first frame's shards.
let (h, c) = crate::transport::loopback_pair(3, 1);
let mut host = Session::new(mk(Role::Host), Box::new(h)).unwrap();
let mut client = Session::new(mk(Role::Client), Box::new(c)).unwrap();
client.set_deliver_partial_frames(true);
// 8 shards of chunk-aligned payload.
let frame = pattern(8 * 1024);
host.submit_frame(&frame, 1_000, USER_FLAG_CHUNK_ALIGNED)
.unwrap();
// The incomplete frame ages out on the HARD index window — push enough newer
// (complete) frames past it. Collect everything the client emits.
let mut got_partial = None;
let mut completes = 0;
for i in 0..80u64 {
let filler = pattern(1024);
host.submit_frame(&filler, 2_000 + i, USER_FLAG_CHUNK_ALIGNED)
.unwrap();
loop {
match client.poll_frame() {
Ok(f) if !f.complete => got_partial = Some(f),
Ok(_) => completes += 1,
Err(PunktfunkError::NoFrame) => break,
Err(e) => panic!("unexpected: {e}"),
}
}
}
let p = got_partial.expect("the lossy frame must be delivered partial");
assert_eq!(p.pts_ns, 1_000);
assert_eq!(p.data.len(), frame.len());
assert!(p.flags & USER_FLAG_CHUNK_ALIGNED != 0);
// Exactly one 1024-byte shard is zeroed; every other offset matches the original.
let mut zero_windows = 0;
for w in 0..8 {
let win = &p.data[w * 1024..(w + 1) * 1024];
if win.iter().all(|&b| b == 0) {
zero_windows += 1;
} else {
assert_eq!(win, &frame[w * 1024..(w + 1) * 1024], "window {w} corrupt");
}
}
// loopback_pair(3, _) drops every 3rd datagram, so several of the 8 shards are
// gone — the exact count depends on phase; what matters is that SOME are zeroed
// and every survivor is intact.
assert!(
(1..8).contains(&zero_windows),
"dropped shards zero-filled (got {zero_windows})"
);
assert!(completes > 40, "surviving filler frames flow normally");
// Control: WITHOUT the flag the same loss is a plain drop, opt-in or not.
let (h2, c2) = crate::transport::loopback_pair(3, 1);
let mut host2 = Session::new(mk(Role::Host), Box::new(h2)).unwrap();
let mut client2 = Session::new(mk(Role::Client), Box::new(c2)).unwrap();
client2.set_deliver_partial_frames(true);
host2.submit_frame(&pattern(8 * 1024), 1_000, 0).unwrap();
let mut saw_partial = false;
for i in 0..80u64 {
host2.submit_frame(&pattern(1024), 2_000 + i, 0).unwrap();
loop {
match client2.poll_frame() {
Ok(f) => saw_partial |= !f.complete,
Err(PunktfunkError::NoFrame) => break,
Err(e) => panic!("unexpected: {e}"),
}
}
}
assert!(
!saw_partial,
"unflagged AUs must never be delivered partial"
);
}
}