feat(pf-encode): PyroWave can stream its AU to the wire — and newest-wins was never in the way
PW6 was gated on one question: what happens to the client's newest-wins draining when a PyroWave AU arrives in pieces, given that `Session::set_deliver_frame_parts` refuses to combine with an all-intra stream. The answer is that the doc and the plan conflated two different axes, and the question never applied to this package. Host STREAMED_AU chunks change only the WIRE shape. The reassembler completes such a frame exactly like a whole one (`block_count != 0 && blocks_ok == block_count`) and hands up ONE Frame, so the frame channel still sees one entry per AU and the drain is untouched. What newest-wins genuinely cannot survive is the client's SEPARATE prefix delivery, and the mechanism is sharper than "assumes whole AUs" said: `FrameChannel::pop` counts QUEUE ENTRIES and takes one entry to be one AU. With parts on, one AU pushes several, so `len > 1` stops meaning "the consumer is behind" — the drain fires mid-AU, returns a SUFFIX and clears that same AU's prefixes. For PyroWave that is fatal rather than lossy: the sequence header lives in window 0 of every AU (`au_dims` reads it there), so every frame would arrive headerless, and `FramePart`'s own orphan contract would have a correct consumer abandon essentially all of them. Written into `pop`, `set_deliver_frame_parts` and the handshake, together with what a fix would take (skip whole SUPERSEDED AUs, never split one). That answer shrinks what this package may claim, so the code says so plainly. `encode_frame` is synchronous: the whole AU exists before the first chunk can be polled, so `poll_chunk` is not "emit as produced" and there is no encode/send overlap here (PW6 ⟂ PW5, confirmed). And with the client still receiving one whole Frame there is no decode-while-arriving either — the "~7 ms, decouple e2e latency from AU size" framing needs client work this commit does not do. What IS left is real and host-side: the whole-AU path FEC-protects, packetizes and seals the entire ~830 KB AU before its first datagram may leave the socket, while the streamed path seals and paces each FEC block as it completes. All of the cutting lives in the shared `pyrowave_wire` helper, which compiles and unit-tests on every platform, so both backends' `poll_chunk` / `supports_chunked_poll` are thin delegations — the Windows backend cannot be compiled from a Linux box, and logic written into it directly would ship unverified. Chunks are whole numbers of framing windows because `build_au` gives each window exactly ONE kind; that also makes them shard-aligned for free, which is what the sealer's sentinel bases require. Dense mode never streams (no window framing to cut on). `poll()` now errors while a chunk cursor is live — the trait's one-drain-method-per-AU contract, where double-emitting would put the same bytes on the wire twice under one frame index — and `reset()` drops the cursor so a rebuild cannot splice a dead AU's tail onto a fresh one. No new Encoder trait method, so neither the TrackedEncoder forwarding trap nor the EncoderCaps default trap is in play. Shipped OFF: `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` arms it, `PUNKTFUNK_PYROWAVE_CHUNK_KIB` tunes the 256 KiB target. The pre-registered partial-delivery trap is real and now has a named cost — an unpinned streamed frame (final block lost) is excluded from partial delivery, where the whole-AU path still hands the consumer a usable blur, and PyroWave clients opt into partials unconditionally. The netem loss-harness leg is the prerequisite for default-on and has not been run.
This commit is contained in:
@@ -128,6 +128,11 @@ pub struct PyroWaveEncoder {
|
||||
wire_budget: pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
/// The AU currently being handed out in streamed chunks (PW6 — `Some` strictly between a
|
||||
/// `first` chunk and its `last`). See [`pyrowave_wire::AuChunker`]: this backend's encode is
|
||||
/// synchronous, so the AU is COMPLETE before the first chunk leaves — the split is for the
|
||||
/// send side, never an encode/send overlap.
|
||||
chunker: Option<pyrowave_wire::AuChunker>,
|
||||
}
|
||||
|
||||
// SAFETY: used only from the single encode thread; the pyrowave handles are owned and only touched
|
||||
@@ -255,6 +260,7 @@ impl PyroWaveEncoder {
|
||||
wire_budget: pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
chunker: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -676,10 +682,55 @@ impl Encoder for PyroWaveEncoder {
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
// Trait contract: each AU is drained through ONE method. Erroring beats double-emitting
|
||||
// the bytes the chunk cursor already handed out (which would reach the wire twice, under
|
||||
// the same frame index, and fail the receiver's retro-validation).
|
||||
if self.chunker.is_some() {
|
||||
bail!("pyrowave: poll() on an AU already being drained through poll_chunk");
|
||||
}
|
||||
Ok(self.pending.pop_front())
|
||||
}
|
||||
|
||||
// --- streamed AU (PW6) — see `pyrowave_wire::AuChunker` for what this does and does NOT buy.
|
||||
// Byte-identical to the Linux twin BY CONSTRUCTION: all of the cutting lives in the shared
|
||||
// helper, which compiles and unit-tests on every platform. This file cannot be compiled from
|
||||
// a Linux/macOS dev box, so anything written here directly would ship unverified.
|
||||
fn supports_chunked_poll(&self) -> bool {
|
||||
pyrowave_wire::stream_chunk_step(self.wire_chunk).is_some()
|
||||
}
|
||||
|
||||
fn poll_chunk(&mut self) -> Result<Option<crate::AuChunk>> {
|
||||
// Finish the AU already in flight before opening the next one — the host's `handle_chunk`
|
||||
// keys begin/finish off `first`/`last` and cannot interleave two AUs.
|
||||
if let Some(c) = self.chunker.as_mut() {
|
||||
if let Some(chunk) = c.next() {
|
||||
return Ok(Some(chunk));
|
||||
}
|
||||
self.chunker = None;
|
||||
}
|
||||
let Some(f) = self.pending.pop_front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// No blocking wait here (the trait allows one): `submit` already ran the whole encode
|
||||
// synchronously, so an AU in `pending` is complete by construction.
|
||||
match pyrowave_wire::stream_chunk_step(self.wire_chunk) {
|
||||
Some(step) => Ok(self
|
||||
.chunker
|
||||
.insert(pyrowave_wire::AuChunker::new(f, step))
|
||||
.next()),
|
||||
// Unarmed / dense: the trait's own default shape, so a host that polls chunks anyway
|
||||
// still gets whole AUs.
|
||||
None => Ok(Some(crate::AuChunk::whole(f))),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) -> bool {
|
||||
// A rebuild forfeits every in-flight frame — including an AU only half-handed-out through
|
||||
// `poll_chunk`. Dropping the cursor here (ahead of every `pending.clear()` arm below) is
|
||||
// what keeps the next `poll_chunk` from splicing the tail of a dead AU onto a fresh one;
|
||||
// the host sees a `first` without the previous `last`, logs "streamed AU abandoned
|
||||
// mid-flight" and lets the client age that frame out.
|
||||
self.chunker = None;
|
||||
// Cheap in-place rebuild: recreate only the pyrowave encoder object (no rate-control /
|
||||
// reference state to preserve). The device, imported textures and fence survive.
|
||||
// SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder.
|
||||
|
||||
Reference in New Issue
Block a user