Files
punktfunk/crates/punktfunk-core/src/session.rs
T
enricobuehlerandClaude Fable 5 705a8baddf feat(core,host,client): PyroWave datagram-aligned packets + partial-frame delivery (Phase 4, §4.4)
PyroWave AUs now packetize on the negotiated shard payload, so a lost datagram
costs a few wavelet blocks of localized blur rather than a whole frame — and the
client can render an aged-out lossy frame instead of freezing until the next one.

Host (opt-in, PyroWave only):
- The encoder packetizes at the shard payload behind a 4-byte window prefix
  (used-len u16 + kind u16). Whole packets pack into WIN_PACKED windows; a packet
  too large for one shard (PyroWave 32x32 blocks are atomic and can exceed a
  shard) rides a WIN_FRAG_FIRST/CONT/LAST chain. `set_wire_chunking()` joins the
  Encoder trait (forwarded through TrackedEncoder — the silent-no-op trap);
  EncodedFrame.chunk_aligned marks the AU.
- virtual_stream tags the AU with USER_FLAG_CHUNK_ALIGNED and re-applies chunking
  after every encoder (re)build, the adaptive-bitrate rebuild included.

Core:
- USER_FLAG_CHUNK_ALIGNED (0x40) wire bit. Reassembler opt-in
  (set_deliver_partial): a chunk-aligned frame that ages out with holes is handed
  over as Frame{complete:false} — received shards at their exact offsets, missing
  ranges zero-filled — instead of being dropped. Partials age out on a tight 30ms
  fuse (PARTIAL_WINDOW_NS) instead of the 120ms loss window: each frame is
  independently decodable, so an ancient partial has no value in a live stream.
  Newest-wins. A partial still counts as dropped for loss reporting.

Client (PyroWave decode):
- The session opts in when codec == PyroWave. The decoder walks the AU
  window-by-window, skipping zero (missing) windows and reassembling FRAG chains,
  then decodes whatever survived. A newest-decoded-index guard drops partials the
  pump has already moved past (no time-travel present).

Also fixes a redundant-closure clippy nit in the PyroWave planar-present path.

Validated on an RTX 5070 Ti under 2% netem loss with FEC pinned off: 60fps
sustained entirely via partials, e2e 43ms p50 (146ms before the fuse) vs 23ms
lossless, no keyframe-recovery chatter. Tests green: core 149, host 310 + the
GPU-gated encoder smoke (framed-window walk + FRAG reassembly + upstream
round-trip), client 26; clippy clean on the pyrowave feature combos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:14:24 +02:00

1184 lines
54 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::{Packetizer, Reassembler, ReassemblerLimits, 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,
}
/// 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>>,
}
/// Wire-packet count at which a frame's sealing splits across two lanes (plan Phase 1.5):
/// below it the channel rendezvous (~µs) isn't worth it; at it the halved AES-GCM span
/// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps
/// at 60 fps — small frames and the probe's ~17-packet AUs stay strictly single-lane.
const TWO_LANE_MIN_PACKETS: usize = 256;
/// One two-lane seal hand-off: the frame's back-half wire buffers, sealed by the worker with
/// nonces `seq_base + i` (the nonce order is deterministic per shard index, which is what
/// makes the split sound). Round-trips through the channels so the buffers return to the pool.
struct SealJob {
bufs: Vec<Vec<u8>>,
seq_base: u64,
timed: bool,
/// Worker-lane CPU ns (when `timed`) and the seal outcome, filled in by the worker.
ns: u64,
result: Result<()>,
}
/// The persistent second seal lane: a worker thread that AES-GCM-seals the back half of a
/// large frame's packets while the send thread seals the front half. Rendezvous channels
/// (bound 1) — the send thread submits, seals its half, then waits; no per-frame spawn.
/// Dropping the struct closes the channel and the worker exits.
struct SealLane {
to_worker: std::sync::mpsc::SyncSender<SealJob>,
from_worker: std::sync::mpsc::Receiver<SealJob>,
}
impl SealLane {
fn spawn(crypto: std::sync::Arc<SessionCrypto>) -> Option<SealLane> {
let (to_worker, jobs) = std::sync::mpsc::sync_channel::<SealJob>(1);
let (done_tx, from_worker) = std::sync::mpsc::sync_channel::<SealJob>(1);
std::thread::Builder::new()
.name("punktfunk-seal2".into())
.spawn(move || {
while let Ok(mut job) = jobs.recv() {
let t0 = job.timed.then(std::time::Instant::now);
job.result = seal_wire_slice(&crypto, &mut job.bufs, job.seq_base);
if let Some(t0) = t0 {
job.ns = t0.elapsed().as_nanos() as u64;
}
if done_tx.send(job).is_err() {
break; // session gone mid-frame — nothing left to seal for
}
}
})
.ok()?;
Some(SealLane {
to_worker,
from_worker,
})
}
}
/// Seal a run of pre-written wire buffers in place: buffer `i` is `seq(8) ‖ plaintext ‖ tag
/// scratch` and seals over `[8..]` with sequence `seq_base + i` — the exact per-packet layout
/// and nonce order of the fused single-lane path. Shared by both lanes.
fn seal_wire_slice(c: &SessionCrypto, wires: &mut [Vec<u8>], seq_base: u64) -> Result<()> {
for (i, wire) in wires.iter_mut().enumerate() {
c.seal_in_place(seq_base.wrapping_add(i as u64), &mut wire[8..])?;
}
Ok(())
}
/// Accumulated client receive-path stage timings since the last [`Session::take_pump_perf`].
/// Answers "where does the pump core go" at line rate: kernel drain (`recv_ns`) vs AES-GCM
/// (`decrypt_ns`) vs reassembly+FEC (`reasm_ns`, the `Reassembler::push` round-trip including
/// shard copies and block reconstruction). 2026-07-14 sweep context: the pump pegs one core at
/// ~1.5 Gbps wire, ~85% of it userspace — this split is what Phase 2.1 (pooled reassembly) is
/// validated against.
#[derive(Debug, Default, Clone, Copy)]
pub struct PumpPerf {
/// ns inside `recv_batch` (recvmmsg / recvmsg_x), i.e. syscall + kernel copy.
pub recv_ns: u64,
/// ns inside `open_in_place` across all datagrams (AES-128-GCM + replay-window upkeep).
pub decrypt_ns: u64,
/// ns inside `Reassembler::push` (header parse, shard copy, FEC reconstruct, AU assembly).
pub reasm_ns: u64,
/// recv_batch calls (batches) and datagrams processed over the accumulation window.
pub batches: u64,
pub packets: u64,
}
/// Accumulated host send-path stage timings since the last [`Session::take_seal_perf`] (plan
/// Phase 0.4, host half). Answers "where does the send thread go" at rate: FEC parity
/// generation (`fec_ns`, inside [`ErasureCoder::encode_into`]) vs AES-GCM (`seal_ns`,
/// per-packet `seal_in_place`) vs the socket handoff (`sock_ns` — `send_gso`/`sendmmsg`
/// syscalls; the internal submit paths time it here, the paced video path folds its chunk
/// sends in via [`Session::note_sock_ns`]). The Phase 1.5 gate reads off this split: build
/// two-lane seal only if `seal_ns` exceeds ~15% of the send thread at 2 Gbps.
#[derive(Debug, Default, Clone, Copy)]
pub struct SealPerf {
/// ns inside `ErasureCoder::encode_into` (parity generation).
pub fec_ns: u64,
/// ns inside `seal_in_place` across all wire packets (AES-128-GCM).
pub seal_ns: u64,
/// ns inside `send_sealed` (socket syscalls), where the session can see it.
pub sock_ns: u64,
/// Frames sealed and wire packets sealed over the accumulation window.
pub frames: u64,
pub packets: u64,
}
/// [`ErasureCoder`] shim accumulating the time spent in `encode_into` (the send-path FEC
/// stage) — only constructed when `PUNKTFUNK_PERF` armed the session's [`SealPerf`]. The
/// counter is atomic purely to satisfy the trait's `Sync` bound; it lives on one thread.
struct TimedCoder<'a> {
inner: &'a dyn ErasureCoder,
ns: &'a std::sync::atomic::AtomicU64,
}
impl ErasureCoder for TimedCoder<'_> {
fn scheme(&self) -> crate::config::FecScheme {
self.inner.scheme()
}
fn encode(
&self,
data: &[&[u8]],
recovery_count: usize,
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
self.inner.encode(data, recovery_count)
}
fn encode_into(
&self,
data: &[&[u8]],
recovery_count: usize,
out: &mut Vec<Vec<u8>>,
) -> std::result::Result<(), crate::fec::FecError> {
let t0 = std::time::Instant::now();
let r = self.inner.encode_into(data, recovery_count, out);
self.ns.fetch_add(
t0.elapsed().as_nanos() as u64,
std::sync::atomic::Ordering::Relaxed,
);
r
}
fn reconstruct(
&self,
data_count: usize,
recovery_count: usize,
received: &mut [Option<Vec<u8>>],
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
self.inner.reconstruct(data_count, recovery_count, received)
}
fn reconstruct_into(
&self,
recovery_count: usize,
data: &mut [&mut [u8]],
have: &[bool],
recovery: &[(usize, &[u8])],
) -> std::result::Result<(), crate::fec::FecError> {
self.inner
.reconstruct_into(recovery_count, data, have, recovery)
}
}
/// 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>>> {
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 = packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder_ref, {
let wires = &mut wires;
let used = &mut used;
move |hdr, body| {
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(())
}
});
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 {
if let Some(lane) = seal_lane.as_ref() {
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(()),
};
if lane.to_worker.send(job).is_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;
}
let mut done = lane
.from_worker
.recv()
.map_err(|_| PunktfunkError::Unsupported("seal lane died"))?;
seal_ns += done.ns;
wires.append(&mut done.bufs);
*lane_scratch = done.bufs;
front?;
done.result?;
split_done = true;
}
// A failed send means the worker is gone — fall through to single-lane.
}
}
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 += 1;
p.packets += used as u64;
}
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(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(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(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(())
}
}
/// Extract the AEAD-authenticated 8-byte big-endian sequence prefix from a sealed wire datagram.
/// Only called on the encrypted receive path, where a preceding successful open has already
/// established `wire.len() >= 8`.
fn seq_of(wire: &[u8]) -> u64 {
u64::from_be_bytes(wire[..8].try_into().unwrap())
}
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
/// datagram, so this must cover the reassembler's 120 ms loss window
/// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
/// silently re-tightens the "late ≠ lost" fix: a Wi-Fi-retry-delayed shard the reassembler would
/// still use gets dropped here as "older than the window" first (4096 was only ~33 ms at the
/// ~125k pkt/s of a 1 Gbps stream; 32768 topped out around ~2 Gbps — which the client now
/// exceeds: the 2026-07-14 zero-copy + hardware-AES work measured ~4.8 Gbps wire ≈ 430k pkt/s
/// delivered). 131072 covers 120 ms up to ~1.09M pkt/s (≈12 Gbps wire) and is effectively
/// unbounded for the sparse input stream, while still bounding how far back a replay could
/// hide; the bitmap costs 16 KiB per session.
const REPLAY_WINDOW: u64 = 131072;
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
/// its datagrams from 0, and the protocol never legitimately re-sends a sequence (FEC recovery
/// shards get fresh ones), so a sequence seen twice is a replay. The AEAD tag already authenticates
/// the sequence — a forged one can't open — so this only has to reject *duplicates* of validly
/// sealed datagrams (and anything older than the window, which we can no longer prove is fresh).
/// Genuine reordering within the window is accepted. Bitmap-per-sequence, indexed `seq % WINDOW`.
struct ReplayWindow {
/// Highest sequence accepted so far; `seen` stays false until the first datagram.
highest: u64,
seen: bool,
/// One bit per in-window sequence in `(highest - WINDOW, highest]`.
bits: [u64; REPLAY_WORDS],
}
impl ReplayWindow {
fn new() -> ReplayWindow {
ReplayWindow {
highest: 0,
seen: false,
bits: [0; REPLAY_WORDS],
}
}
#[inline]
fn word_bit(seq: u64) -> (usize, u64) {
let idx = (seq % REPLAY_WINDOW) as usize;
(idx / 64, 1u64 << (idx % 64))
}
fn is_set(&self, seq: u64) -> bool {
let (w, b) = Self::word_bit(seq);
self.bits[w] & b != 0
}
fn set(&mut self, seq: u64) {
let (w, b) = Self::word_bit(seq);
self.bits[w] |= b;
}
fn unset(&mut self, seq: u64) {
let (w, b) = Self::word_bit(seq);
self.bits[w] &= !b;
}
/// Record `seq`, returning `true` if it's fresh (accept) or `false` if it's a replay / too old.
fn accept(&mut self, seq: u64) -> bool {
if !self.seen {
self.seen = true;
self.highest = seq;
self.set(seq);
return true;
}
if seq > self.highest {
// Advance the window. Sequences between the old and new high slide in unseen, so clear
// their (possibly stale, from a full window ago) slots — unless we jumped an entire
// window, in which case wipe the bitmap wholesale.
if seq - self.highest >= REPLAY_WINDOW {
self.bits = [0; REPLAY_WORDS];
} else {
let mut s = self.highest + 1;
while s < seq {
self.unset(s);
s += 1;
}
}
self.highest = seq;
self.set(seq);
true
} else if self.highest - seq >= REPLAY_WINDOW || self.is_set(seq) {
// Older than the window (can't prove it isn't a replay) or already seen (a duplicate) —
// either way, drop it.
false
} else {
self.set(seq); // in-window and not yet seen — a genuine reorder
true
}
}
}
#[cfg(test)]
mod wire_equivalence_tests {
use super::*;
use crate::config::{FecConfig, FecScheme, ProtocolPhase};
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: [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()
}
/// 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: [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"
);
}
}
#[cfg(test)]
mod replay_tests {
use super::*;
#[test]
fn accepts_in_order_and_rejects_duplicates() {
let mut w = ReplayWindow::new();
for seq in 0..1000 {
assert!(w.accept(seq), "fresh in-order seq {seq} must be accepted");
}
// Every one of those is now a replay.
for seq in 0..1000 {
assert!(!w.accept(seq), "replayed seq {seq} must be rejected");
}
}
#[test]
fn accepts_reorder_within_window_once() {
let mut w = ReplayWindow::new();
assert!(w.accept(100));
// Earlier-but-in-window sequences (a genuine reorder) are accepted exactly once.
assert!(w.accept(80));
assert!(!w.accept(80), "second copy of a reordered seq is a replay");
assert!(w.accept(99));
assert!(
!w.accept(100),
"the high-water seq itself can't be replayed"
);
}
#[test]
fn rejects_older_than_window() {
let mut w = ReplayWindow::new();
assert!(w.accept(REPLAY_WINDOW * 2));
// Anything a full window or more behind the high-water mark is dropped (can't prove fresh).
assert!(!w.accept(REPLAY_WINDOW * 2 - REPLAY_WINDOW));
assert!(!w.accept(0));
// But just inside the window is still accepted.
assert!(w.accept(REPLAY_WINDOW * 2 - (REPLAY_WINDOW - 1)));
}
#[test]
fn large_forward_jump_wipes_stale_bits() {
let mut w = ReplayWindow::new();
assert!(w.accept(5));
// Jump far forward (more than a window). The slot for an old seq that aliases 5 mod WINDOW
// must read as unseen afterward, i.e. the jump cleared it — so a NEW seq there is accepted.
let far = 10 * REPLAY_WINDOW + 5;
assert!(w.accept(far));
assert!(
!w.accept(5),
"the pre-jump seq is now far older than the window"
);
// A fresh seq aliasing 5 (mod WINDOW) but inside the new window is accepted, proving the
// stale bit was cleared rather than mistaken for a replay.
assert!(w.accept(far - REPLAY_WINDOW + 1));
}
#[test]
fn first_seq_need_not_be_zero() {
// Startup loss can mean the first datagram we ever open isn't seq 0.
let mut w = ReplayWindow::new();
assert!(w.accept(42));
assert!(!w.accept(42));
assert!(w.accept(43));
}
#[test]
fn seq_of_reads_the_big_endian_prefix() {
let mut wire = 0x0102_0304_0506_0708u64.to_be_bytes().to_vec();
wire.extend_from_slice(b"ciphertext-and-tag");
assert_eq!(seq_of(&wire), 0x0102_0304_0506_0708);
}
}