feat(core+host): LN1 phase-2 — VIDEO_CAP_STREAMED_AU streamed access units

The wire half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 2): toward a client that
advertises the new cap bit (0x20), a chunked-poll encoder session ships each
AU's completed FEC blocks while the tail of the frame is still encoding —
the AU's last packet leaves the host as the encode finishes instead of
after it.

Wire semantics (negotiated; zero change for anyone else):
- Non-final blocks ride SENTINEL headers: block_count = 0 (a value no legacy
  sender emits) + frame_bytes = 0 + exactly max_data_per_block data shards,
  so the receiver's shard-offset formula needs no total.
- The final block's headers carry the real frame_bytes/block_count (+
  FLAG_EOF) and RETRO-VALIDATE the whole frame: totals under which a
  received sentinel block is out of range or not full-K kill the frame
  wholesale (no spliced delivery) and the index can't be resurrected.
- Firewall: sentinels are bounded by the negotiated limits (full-K exactly,
  never the last block the limits allow, no total to lie about); the exact
  derived-geometry check runs unchanged on every non-sentinel packet and
  retroactively at pinning. Sentinel opens commit a max_frame_bytes buffer,
  bounded by the existing IN_FLIGHT_BUF_FACTOR budget (amplification test).
- Order-agnostic like legacy: a reversed frame (final block first) opens
  legacy-shaped and still accepts its sentinels against the pinned totals.
- Small/empty streamed AUs degenerate to byte-identical legacy headers.

Host: Packetizer::{begin,push,finish}_streamed seal full-K blocks (data +
parity per block) as chunks arrive; Session::seal_streamed_* share the
pooled-wire + two-lane seal machinery via the new seal_run; the send thread
paces each flush under the frame's existing deadline (pace_sealed split out
of paced_submit) and runs the whole-AU accounting at the last chunk; the
encode pump forwards poll_chunk output as ChunkMsg when the client has the
cap AND the encoder chunks (re-queried per AU — an escalation falls back
seamlessly). Probes never run mid-AU. PUNKTFUNK_STREAMED_AU=0 = host escape
hatch. Client core ORs the cap into Hello (the shared reassembler carries
the support). Sampled first_slice_us vs encode_us PERF log measures the
overlap; the 0xCF stage-field extension stays a follow-up.

Core tests: streamed round-trip (clean/loss/reorder/duplicate, both orders),
sentinel firewall bounds, lying-final wholesale kill + no-resurrect,
open-amplification budget, header-shape pins. Gates still owed before
default-on: security review pass, loss-harness curve, GameStream smoke
(plane untouched structurally), bitrate A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 22:53:53 +02:00
co-authored by Claude Fable 5
parent 9d3b114fd6
commit c5402cb1f7
9 changed files with 1111 additions and 110 deletions
+74 -7
View File
@@ -13,7 +13,9 @@ 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::packet::{
PacketHeader, Packetizer, Reassembler, ReassemblerLimits, StreamedAu, MAX_DATAGRAM_BYTES,
};
use crate::stats::{Stats, StatsCounters};
use crate::transport::Transport;
use zerocopy::IntoBytes;
@@ -274,6 +276,68 @@ impl Session {
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(
@@ -320,10 +384,10 @@ impl Session {
// 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 result = {
let wires = &mut wires;
let used = &mut used;
move |hdr, body| {
let mut emit = move |hdr: &PacketHeader, body: &[u8]| -> Result<()> {
if *used == wires.len() {
wires.push(Vec::new());
}
@@ -342,8 +406,9 @@ impl Session {
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
@@ -421,10 +486,12 @@ impl Session {
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.frames += count_frame as u64;
p.packets += used as u64;
}
StatsCounters::add(&self.stats.frames_submitted, 1);
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);