Merge pull request 'feat(wire): mid-session shard-payload renegotiation — the black screen heals in seconds, jumbo behind an opt-in' (#41) from worktree-shard-payload-reneg into main
release / apple (push) Canceled after 2m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / canary-manifest (push) Successful in 16s
windows-host / package (push) Successful in 11m13s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 1m26s
android / android (push) Canceled after 3m42s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m45s
ci / rust (push) Canceled after 3m28s
ci / rust-arm64 (push) Canceled after 1m59s
ci / web (push) Canceled after 1m54s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
release / apple (push) Canceled after 2m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / canary-manifest (push) Successful in 16s
windows-host / package (push) Successful in 11m13s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 1m26s
android / android (push) Canceled after 3m42s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m45s
ci / rust (push) Canceled after 3m28s
ci / rust-arm64 (push) Canceled after 1m59s
ci / web (push) Canceled after 1m54s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
@@ -558,6 +558,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Like STREAMED_AU above: the shared-core reassembler pins geometry per-frame, so
|
||||
// the probe accepts a mid-session shard change (and jumbo growth) up to the
|
||||
// receive ceiling — and it's exactly the tool to measure both.
|
||||
max_shard_payload: punktfunk_core::config::max_shard_payload() as u16,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
|
||||
@@ -243,6 +243,38 @@ impl ControlTask {
|
||||
seq: offer.seq,
|
||||
kinds: offer.kinds,
|
||||
});
|
||||
} else if let Ok(chg) = crate::quic::ShardPayloadChanged::decode(&msg) {
|
||||
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the
|
||||
// host re-keys the sealed video geometry. Per-frame pinning means there
|
||||
// is nothing to re-key on the receive path — the reassembler follows
|
||||
// each frame's own header and every buffer is statically sized for the
|
||||
// ceiling — so the dispatch is validate + ack. The ack is telemetry for
|
||||
// a shrink and the GATE for a grow (the host emits nothing above the
|
||||
// old size until it lands). Validate against our own receive bounds —
|
||||
// the same ceiling we advertised in `Hello::max_shard_payload` — and
|
||||
// answer an out-of-bounds request with SILENCE, not an ack: a buggy
|
||||
// host must never read a granted grow out of garbage.
|
||||
let n = chg.shard_payload as usize;
|
||||
if (crate::config::MIN_SHARD_PAYLOAD..=crate::config::max_shard_payload())
|
||||
.contains(&n)
|
||||
&& n % 2 == 0
|
||||
{
|
||||
tracing::info!(
|
||||
shard_payload = n,
|
||||
"host re-keyed the wire shard payload — acking"
|
||||
);
|
||||
let ack = crate::quic::ShardPayloadAck {
|
||||
shard_payload: chg.shard_payload,
|
||||
};
|
||||
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
shard_payload = n,
|
||||
"out-of-bounds shard-payload change — ignoring (no ack)"
|
||||
);
|
||||
}
|
||||
} else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) {
|
||||
// Pointer bitmap changed (cursor channel, only when negotiated). try_send:
|
||||
// an overflowing ring drops the newest shape — the next change resends.
|
||||
|
||||
@@ -156,6 +156,12 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
// stop compositing the pointer, so only an embedder that actually renders the
|
||||
// cursor locally may set it (the embedder decides, we pass through).
|
||||
client_caps: args.client_caps,
|
||||
// Unconditional like STREAMED_AU: the shared reassembler pins geometry
|
||||
// per-frame and every receive buffer is sized from MAX_DATAGRAM_BYTES, so
|
||||
// every embedder accepts a mid-session shard change up to this ceiling
|
||||
// (design/shard-payload-reneg.md W0.3 — the host only renegotiates, and only
|
||||
// grows to jumbo, when this advertises it).
|
||||
max_shard_payload: crate::config::max_shard_payload() as u16,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
|
||||
@@ -373,16 +373,54 @@ pub fn shard_payload_for_udp_budget(udp_budget: usize, peer: core::net::IpAddr)
|
||||
p.clamp(MIN_SHARD_PAYLOAD, mtu1500_shard_payload_for(peer))
|
||||
}
|
||||
|
||||
/// The family's IP+UDP header bytes between an on-wire IP MTU and its UDP payload budget —
|
||||
/// 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
|
||||
fn ip_udp_overhead(peer: core::net::IpAddr) -> usize {
|
||||
match peer {
|
||||
core::net::IpAddr::V4(_) => 28,
|
||||
core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28,
|
||||
core::net::IpAddr::V6(_) => 48,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`shard_payload_for_udp_budget`] for an operator-supplied ON-WIRE IP MTU (the number
|
||||
/// `netsh interface ipv4 show subinterfaces` / `ip link` shows): subtracts the family's IP+UDP
|
||||
/// headers first — 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
|
||||
pub fn shard_payload_for_wire_mtu(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
|
||||
let ip_udp = match peer {
|
||||
core::net::IpAddr::V4(_) => 28,
|
||||
core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28,
|
||||
core::net::IpAddr::V6(_) => 48,
|
||||
};
|
||||
shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp), peer)
|
||||
shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp_overhead(peer)), peer)
|
||||
}
|
||||
|
||||
/// The operator's jumbo-frames opt-in (design/shard-payload-reneg.md Phase 2): the target
|
||||
/// on-wire IP MTU, or `None` = no opt-in (nothing above the 1500-default wire is ever probed
|
||||
/// or grown to). One knob, one code path: a `PUNKTFUNK_WIRE_MTU` above the standard 1500
|
||||
/// derives the target from the operator's number; `PUNKTFUNK_JUMBO=1` is the fixed 9000
|
||||
/// profile for operators who don't want to think in MTUs. Raising the wire above 1500 is
|
||||
/// only ever an ACK-GATED mid-session grow toward a client that advertised
|
||||
/// [`max_shard_payload`] headroom — sessions still START at the family default.
|
||||
pub fn jumbo_wire_mtu() -> Option<usize> {
|
||||
if let Ok(v) = std::env::var("PUNKTFUNK_WIRE_MTU") {
|
||||
if let Ok(mtu) = v.trim().parse::<usize>() {
|
||||
if mtu > 1500 {
|
||||
return Some(mtu);
|
||||
}
|
||||
}
|
||||
}
|
||||
match std::env::var("PUNKTFUNK_JUMBO") {
|
||||
Ok(v) if v.trim() == "1" => Some(9000),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The jumbo sibling of [`shard_payload_for_wire_mtu`]: the largest even shard payload whose
|
||||
/// sealed datagram fits `wire_mtu`, clamped to the RECEIVE ceiling ([`max_shard_payload`])
|
||||
/// instead of the family 1500-default — the up-leg's grow target. Still floored at
|
||||
/// [`MIN_SHARD_PAYLOAD`].
|
||||
pub fn jumbo_shard_payload_for(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
|
||||
let p = wire_mtu
|
||||
.saturating_sub(ip_udp_overhead(peer))
|
||||
.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD);
|
||||
let p = p - p % 2; // FEC requires even shards
|
||||
p.clamp(MIN_SHARD_PAYLOAD, max_shard_payload())
|
||||
}
|
||||
|
||||
/// Everything needed to construct a [`Session`](crate::session::Session).
|
||||
@@ -626,6 +664,29 @@ mod tests {
|
||||
assert_eq!(shard_payload_for_wire_mtu(1280, v6), 1168);
|
||||
}
|
||||
|
||||
/// Jumbo grow-target sizing (the up-leg, design/shard-payload-reneg.md): even, sealed
|
||||
/// fits the wire, clamped to the RECEIVE ceiling instead of the family 1500-default —
|
||||
/// and the standard 9000 profile lands on the exact documented value.
|
||||
#[test]
|
||||
fn jumbo_shard_payload_math() {
|
||||
use core::net::IpAddr;
|
||||
let v4: IpAddr = "192.168.1.50".parse().unwrap();
|
||||
let v6: IpAddr = "fd00::50".parse().unwrap();
|
||||
// 9000 − 28 (IPv4+UDP) − 64 (header+crypto) = 8908 even; sealed 8972 ≤ the 9216
|
||||
// datagram ceiling. The v6 sibling: 9000 − 48 − 64 = 8888.
|
||||
assert_eq!(jumbo_shard_payload_for(9000, v4), 8908);
|
||||
assert_eq!(sealed_datagram_bytes(8908), 8972);
|
||||
assert!(sealed_datagram_bytes(8908) <= MAX_DATAGRAM_BYTES);
|
||||
assert_eq!(jumbo_shard_payload_for(9000, v6), 8888);
|
||||
// An operator MTU larger than the receive path clamps to the ceiling, smaller ones
|
||||
// track the wire, and degenerate ones floor at MIN_SHARD_PAYLOAD.
|
||||
assert_eq!(jumbo_shard_payload_for(64_000, v4), max_shard_payload());
|
||||
let p = jumbo_shard_payload_for(4000, v4);
|
||||
assert_eq!(p % 2, 0);
|
||||
assert!(sealed_datagram_bytes(p) <= 4000 - 28);
|
||||
assert_eq!(jumbo_shard_payload_for(100, v4), MIN_SHARD_PAYLOAD);
|
||||
}
|
||||
|
||||
/// Family selection: genuine v6 remotes get the v6 size; v4 — including the IPv4-mapped v6
|
||||
/// form a dual-stack `[::]` socket reports for a v4 client — keeps the v4 size.
|
||||
#[test]
|
||||
|
||||
@@ -70,7 +70,17 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
|
||||
|
||||
/// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
||||
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||
pub const MAX_DATAGRAM_BYTES: usize = 2048;
|
||||
///
|
||||
/// Sized for **jumbo frames** (design/shard-payload-reneg.md W0.2): a 9000-MTU LAN carries
|
||||
/// ~8908-byte shards (sealed 8972-byte UDP payloads), and every receive path — the transport
|
||||
/// `RECV_BUF`, the session's `recvmmsg` ring — is sized from this constant, so a deployed
|
||||
/// client can accept a jumbo geometry the moment its host negotiates one. The ring cost is
|
||||
/// 128 × ~9 KiB ≈ 1.1 MiB per **client** session (lazily allocated on first poll; hosts never
|
||||
/// allocate it) — measured against the ~256 KiB it was at 2048, an acceptable static price
|
||||
/// for never having to resize buffers on a mid-session grow. Senders still derive their
|
||||
/// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
|
||||
/// this is the acceptance ceiling, not a transmit size.
|
||||
pub const MAX_DATAGRAM_BYTES: usize = 9216;
|
||||
|
||||
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
|
||||
#[repr(C)]
|
||||
|
||||
@@ -25,6 +25,10 @@ pub struct Packetizer {
|
||||
next_probe_index: u32,
|
||||
next_seq: u32,
|
||||
shard_payload: usize,
|
||||
/// The negotiated frame-size cap — kept so a live shard-payload swap
|
||||
/// ([`set_shard_payload`](Self::set_shard_payload)) can re-derive the per-frame block
|
||||
/// ceilings from the same formulas construction used.
|
||||
max_frame_bytes: usize,
|
||||
fec: crate::config::FecConfig,
|
||||
version: u8,
|
||||
/// Reusable zero-padded scratch for the frame's final data shard when the frame isn't an
|
||||
@@ -47,10 +51,12 @@ pub struct Packetizer {
|
||||
/// where every packet of the block is dropped wholesale, the frame never completes, and the
|
||||
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
|
||||
max_total_shards: usize,
|
||||
/// The peer's per-frame block ceiling, mirroring [`ReassemblerLimits::from_config`]'s
|
||||
/// `max_blocks` — the streamed path's bound on how many sentinel blocks it may emit (a
|
||||
/// streamed AU's size isn't known up front, so this is the only pre-emission guard against
|
||||
/// producing a frame the receiver must reject).
|
||||
/// The peer's per-frame block ceiling — the streamed path's bound on how many sentinel
|
||||
/// blocks it may emit (a streamed AU's size isn't known up front, so this is the only
|
||||
/// pre-emission guard against producing a frame the receiver must reject). The receiver
|
||||
/// derives the same ceiling per packet from the packet's own `shard_bytes`
|
||||
/// (`Reassembler::push` — geometry is per-frame), so this stays in step as long as it is
|
||||
/// computed from the shard size this packetizer actually stamps.
|
||||
max_blocks: usize,
|
||||
/// The streamed path's block-count ceiling in SLICE mode ([`USER_FLAG_SLICE_STREAM`]) —
|
||||
/// variable-K blocks, floored at `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` shards.
|
||||
@@ -105,15 +111,12 @@ impl StreamedAu {
|
||||
impl Packetizer {
|
||||
pub fn new(config: &Config) -> Self {
|
||||
let max_data = config.fec.max_data_per_block as usize;
|
||||
let total_data_max = config
|
||||
.max_frame_bytes
|
||||
.div_ceil(config.shard_payload.max(1))
|
||||
.max(1);
|
||||
Packetizer {
|
||||
let mut p = Packetizer {
|
||||
next_frame_index: 0,
|
||||
next_probe_index: 0,
|
||||
next_seq: 0,
|
||||
shard_payload: config.shard_payload,
|
||||
max_frame_bytes: config.max_frame_bytes,
|
||||
fec: config.fec,
|
||||
version: config.phase as u8,
|
||||
tail: Vec::new(),
|
||||
@@ -121,12 +124,37 @@ impl Packetizer {
|
||||
// Mirrors `ReassemblerLimits::from_config` — keep the two in step.
|
||||
max_total_shards: (max_data + config.fec.recovery_for(max_data))
|
||||
.min(config.fec.scheme.max_total_shards()),
|
||||
max_blocks: total_data_max.div_ceil(max_data).max(1),
|
||||
// Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)`
|
||||
// data shards (the flush floor, clamped by the block size), so a max-size frame
|
||||
// bounds the block count. Mirrors the receiver's slice firewall — keep in step.
|
||||
slice_block_cap: total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2,
|
||||
}
|
||||
// Derived from the shard size below (single source of truth for the formulas).
|
||||
max_blocks: 0,
|
||||
slice_block_cap: 0,
|
||||
};
|
||||
p.set_shard_payload(config.shard_payload);
|
||||
p
|
||||
}
|
||||
|
||||
/// Live-swap the wire shard payload (mid-session shard renegotiation,
|
||||
/// design/shard-payload-reneg.md Phase 1). Takes effect on the next packetized AU — call
|
||||
/// ONLY between AUs, never with a [`StreamedAu`] in flight: an open streamed AU's
|
||||
/// shard-aligned tiling derives from the size it began with, and re-keying under it would
|
||||
/// corrupt the frame's layout. The per-frame block ceilings follow the new size here; the
|
||||
/// receiver re-derives its side per packet from the header's own `shard_bytes` (geometry
|
||||
/// is per-frame there), so the two stay in step by construction. Bounds are the caller's
|
||||
/// contract — go through [`Session::set_shard_payload`](crate::session::Session::set_shard_payload),
|
||||
/// which enforces the `Config::validate` rules.
|
||||
pub fn set_shard_payload(&mut self, shard_payload: usize) {
|
||||
let max_data = self.fec.max_data_per_block as usize;
|
||||
let total_data_max = self.max_frame_bytes.div_ceil(shard_payload.max(1)).max(1);
|
||||
self.shard_payload = shard_payload;
|
||||
self.max_blocks = total_data_max.div_ceil(max_data).max(1);
|
||||
// Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)`
|
||||
// data shards (the flush floor, clamped by the block size), so a max-size frame
|
||||
// bounds the block count. Mirrors the receiver's slice firewall — keep in step.
|
||||
self.slice_block_cap = total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2;
|
||||
}
|
||||
|
||||
/// The wire shard payload AUs are currently packetized at.
|
||||
pub fn shard_payload(&self) -> usize {
|
||||
self.shard_payload
|
||||
}
|
||||
|
||||
/// Allocate the next **probe-space** frame index (speed-test filler). A separate counter from
|
||||
|
||||
@@ -76,6 +76,12 @@ struct BlockState {
|
||||
}
|
||||
|
||||
struct FrameBuf {
|
||||
/// The frame's PINNED shard payload — set by its first-arriving packet (bounds-checked by
|
||||
/// the firewall), matched by every later packet of the frame. Geometry is per-frame so a
|
||||
/// mid-session `shard_payload` change (design/shard-payload-reneg.md) is safe on an
|
||||
/// unordered wire: frames in flight complete under their own pin while new frames arrive
|
||||
/// under the new one, and no cross-geometry splice can land in one buffer.
|
||||
shard_bytes: usize,
|
||||
/// Exact AU size. 0 = unknown: the frame was opened by a streamed-AU SENTINEL packet
|
||||
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) and the final block's real totals haven't
|
||||
/// arrived yet — the frame can't complete before they do (and retro-validate).
|
||||
@@ -105,16 +111,28 @@ struct FrameBuf {
|
||||
/// Per-session bounds the reassembler enforces on every packet header *before*
|
||||
/// allocating, so a hostile or corrupt header cannot drive unbounded memory use. All
|
||||
/// derived from the negotiated [`Config`].
|
||||
///
|
||||
/// Shard geometry is PER-FRAME, not per-session (mid-session shard-payload renegotiation,
|
||||
/// design/shard-payload-reneg.md W0.1): a frame's first-arriving packet pins the frame's
|
||||
/// `shard_bytes` within `[min_shard_bytes, max_shard_bytes]`, later packets must match the
|
||||
/// pin, and the per-frame block ceiling derives from the pinned size (a shrunk shard needs
|
||||
/// more blocks for the same bytes). The reorder race between an ordered control-stream
|
||||
/// geometry change and the unordered video datagrams is thereby killed structurally — every
|
||||
/// frame is wholly one geometry, whichever order its packets and the change arrive in.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ReassemblerLimits {
|
||||
/// Expected shard payload length; every shard in the stream must match exactly.
|
||||
pub shard_bytes: usize,
|
||||
/// Floor for a frame's pinned shard payload — [`crate::config::MIN_SHARD_PAYLOAD`] in
|
||||
/// production (or the negotiated value when a session legitimately starts below it).
|
||||
pub min_shard_bytes: usize,
|
||||
/// Ceiling for a frame's pinned shard payload — what this receive path accepts and what
|
||||
/// the client advertises in `Hello::max_shard_payload`
|
||||
/// ([`crate::config::max_shard_payload`]): the transport recv buffers are sized for a
|
||||
/// sealed datagram of exactly this shard size.
|
||||
pub max_shard_bytes: usize,
|
||||
/// Max data shards per block (the negotiated `max_data_per_block`).
|
||||
pub max_data_shards: usize,
|
||||
/// Max total shards per block (data + recovery), capped by the FEC scheme ceiling.
|
||||
pub max_total_shards: usize,
|
||||
/// Max FEC blocks per frame.
|
||||
pub max_blocks: usize,
|
||||
/// Max accepted access-unit size.
|
||||
pub max_frame_bytes: usize,
|
||||
}
|
||||
@@ -135,12 +153,13 @@ impl ReassemblerLimits {
|
||||
// snapshot of it.
|
||||
let max_total =
|
||||
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards());
|
||||
let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1);
|
||||
ReassemblerLimits {
|
||||
shard_bytes: c.shard_payload,
|
||||
// `.min(c.shard_payload)`: never reject the session's own negotiated value — a
|
||||
// hand-configured session below the production floor still reassembles itself.
|
||||
min_shard_bytes: crate::config::MIN_SHARD_PAYLOAD.min(c.shard_payload),
|
||||
max_shard_bytes: crate::config::max_shard_payload(),
|
||||
max_data_shards: max_data,
|
||||
max_total_shards: max_total,
|
||||
max_blocks: total_data.div_ceil(max_data).max(1),
|
||||
max_frame_bytes: c.max_frame_bytes,
|
||||
}
|
||||
}
|
||||
@@ -179,6 +198,9 @@ const IN_FLIGHT_BUF_FACTOR: usize = 4;
|
||||
|
||||
/// Recovery-shard buffer pool ceiling (shard-sized buffers): enough for several max-recovery
|
||||
/// blocks in flight, small enough (~720 KB at a 1408-byte shard) to keep after a loss burst.
|
||||
/// Entries size themselves to the largest shard they ever held, so a jumbo session (opt-in,
|
||||
/// desktop-LAN — shards up to [`ReassemblerLimits::max_shard_bytes`]) retains proportionally
|
||||
/// more; it also needs ~6× fewer buffers per block, so the pool rarely fills there.
|
||||
const RECOVERY_POOL_MAX: usize = 512;
|
||||
|
||||
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
|
||||
@@ -295,11 +317,16 @@ impl Reassembler {
|
||||
// Bound every attacker-controllable header field against the negotiated limits
|
||||
// BEFORE allocating anything keyed on it — this is the firewall against a tiny
|
||||
// datagram triggering a huge `vec![None; total]` / `Vec::with_capacity`.
|
||||
// `shard_bytes` is bounds-checked (not equality-checked) because geometry is
|
||||
// per-frame — the frame-pin check below is what rejects a size CHANGE mid-frame;
|
||||
// the even requirement mirrors `Config::validate` (FEC requires even shards).
|
||||
let drop = |stats: &StatsCounters| {
|
||||
StatsCounters::add(&stats.packets_dropped, 1);
|
||||
};
|
||||
if hdr.magic != PUNKTFUNK_MAGIC
|
||||
|| shard_bytes != lim.shard_bytes
|
||||
|| shard_bytes < lim.min_shard_bytes
|
||||
|| shard_bytes > lim.max_shard_bytes
|
||||
|| shard_bytes % 2 != 0
|
||||
|| pkt.len() < HEADER_LEN + shard_bytes
|
||||
|| data_shards == 0
|
||||
|| data_shards > lim.max_data_shards
|
||||
@@ -330,6 +357,11 @@ impl Reassembler {
|
||||
// later pin — the maximum the negotiated limits allow (the design's "allocate at
|
||||
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
|
||||
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
|
||||
// The per-frame FEC-block ceiling under THIS packet's shard size (geometry is
|
||||
// per-frame: a shrunk shard needs more blocks for the same bytes, so a session-level
|
||||
// cap from the negotiated size would reject legitimate post-shrink frames). Mirrors
|
||||
// the sender's `Packetizer::new` for whatever size it currently packetizes at.
|
||||
let max_blocks = total_data_max.div_ceil(lim.max_data_shards).max(1);
|
||||
// The slice pipeline's per-frame block ceiling: every non-final slice block carries at
|
||||
// least `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` data shards (the sender's
|
||||
// flush floor, clamped by the block size), so a max-size frame bounds the block count
|
||||
@@ -350,9 +382,7 @@ impl Reassembler {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if sentinel {
|
||||
if frame_bytes != 0
|
||||
|| data_shards != lim.max_data_shards
|
||||
|| block_idx + 1 >= lim.max_blocks
|
||||
if frame_bytes != 0 || data_shards != lim.max_data_shards || block_idx + 1 >= max_blocks
|
||||
{
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
@@ -361,7 +391,7 @@ impl Reassembler {
|
||||
let block_cap = if slice_stream {
|
||||
slice_block_cap
|
||||
} else {
|
||||
lim.max_blocks
|
||||
max_blocks
|
||||
};
|
||||
if block_count > block_cap || block_idx >= block_count {
|
||||
drop(stats);
|
||||
@@ -513,6 +543,7 @@ impl Reassembler {
|
||||
}
|
||||
*in_flight_bytes += buf_len;
|
||||
e.insert(FrameBuf {
|
||||
shard_bytes,
|
||||
// A slice-stream sentinel's `frame_bytes` is its block's BASE offset, not a
|
||||
// frame size — the unpinned marker stays 0 until the final block's totals.
|
||||
frame_bytes: if sentinel { 0 } else { frame_bytes },
|
||||
@@ -527,6 +558,15 @@ impl Reassembler {
|
||||
})
|
||||
}
|
||||
};
|
||||
// Per-frame geometry pin: the frame's first packet pinned its shard size; a later
|
||||
// packet claiming a different (even in-bounds) size is dropped — otherwise two
|
||||
// geometries would compute different offsets into one buffer (a splice). This is
|
||||
// also what makes a mid-session `shard_payload` change safe against reorder: a
|
||||
// straggler of the old geometry can only ever land in ITS OWN frame's buffer.
|
||||
if frame.shard_bytes != shard_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// The slice marker must be frame-consistent: a mixed frame would firewall under one
|
||||
// placement rule and place under the other. The per-packet checks above and the
|
||||
// placement bounds guard below stay memory-safe without this — it's the tighter drop.
|
||||
@@ -883,6 +923,16 @@ impl Reassembler {
|
||||
// jump-to-live, exactly the stale content the flush existed to discard.
|
||||
self.pending_partial = None;
|
||||
}
|
||||
|
||||
/// Test-only: the current in-flight frame-buffer byte commitment (see
|
||||
/// [`IN_FLIGHT_BUF_FACTOR`]). The mixed-geometry budget tests assert it returns to
|
||||
/// exactly zero once every frame has terminated — the 0.23.0 lesson: geometry changes
|
||||
/// breed sizing bugs, and accounting drift here surfaces in the field as a permanent
|
||||
/// loss storm once the budget wedges.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn in_flight(&self) -> usize {
|
||||
self.in_flight_bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// The data shards of a terminating frame that only exist because parity restored them
|
||||
@@ -1024,10 +1074,10 @@ mod reset_tests {
|
||||
#[test]
|
||||
fn reset_drops_a_parked_partial() {
|
||||
let mut r = Reassembler::new(ReassemblerLimits {
|
||||
shard_bytes: 64,
|
||||
min_shard_bytes: 64,
|
||||
max_shard_bytes: 64,
|
||||
max_data_shards: 8,
|
||||
max_total_shards: 16,
|
||||
max_blocks: 4,
|
||||
max_frame_bytes: 4096,
|
||||
});
|
||||
r.pending_partial = Some(Frame {
|
||||
|
||||
@@ -7,11 +7,14 @@ use crate::stats::StatsCounters;
|
||||
use zerocopy::{FromBytes, IntoBytes};
|
||||
|
||||
fn limits() -> ReassemblerLimits {
|
||||
// `min == max` pins the whole stream to 16-byte shards — the strictest geometry, so the
|
||||
// firewall tests below exercise the bounds checks; per-frame-pinning tests build their own
|
||||
// limits with a real range. Derived per-frame block ceiling: 4096/16 = 256 shards → 32.
|
||||
ReassemblerLimits {
|
||||
shard_bytes: 16,
|
||||
min_shard_bytes: 16,
|
||||
max_shard_bytes: 16,
|
||||
max_data_shards: 8,
|
||||
max_total_shards: 12,
|
||||
max_blocks: 4,
|
||||
max_frame_bytes: 4096,
|
||||
}
|
||||
}
|
||||
@@ -840,7 +843,7 @@ fn streamed_sentinel_firewall_bounds() {
|
||||
.unwrap()
|
||||
.is_none());
|
||||
// Sits on the last block the limits allow (no room for the final block after it).
|
||||
let h = sentinel(|h| h.block_index = 3); // limits().max_blocks == 4
|
||||
let h = sentinel(|h| h.block_index = 31); // derived max_blocks == 32 (see `limits()`)
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
@@ -1769,3 +1772,408 @@ fn slice_streamed_in_flight_budget_matches_legacy() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame shard geometry (mid-session shard-payload renegotiation — W0.1,
|
||||
// design/shard-payload-reneg.md). The 0.23.0 lesson applies in full: geometry
|
||||
// changes breed sizing bugs, so the slice/sentinel suite re-runs at every
|
||||
// production shard size and mixed-geometry streams are tortured under reorder.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The shard sizes the renegotiation actually moves between: the clamp floor (512), a
|
||||
/// WARP/Tailscale-shaped 1280-MTU path (1216), the 1500-MTU default (1408), and 9000-MTU
|
||||
/// jumbo (8908 — sealed 8972, inside [`MAX_DATAGRAM_BYTES`]).
|
||||
const PRODUCTION_SHARDS: [usize; 4] = [512, 1216, 1408, 8908];
|
||||
|
||||
/// [`prod_slice_config`] at an arbitrary shard payload.
|
||||
fn geo_config(shard_payload: usize) -> Config {
|
||||
let mut c = prod_slice_config();
|
||||
c.shard_payload = shard_payload;
|
||||
c.validate().expect("geometry config must be valid");
|
||||
c
|
||||
}
|
||||
|
||||
/// Packetize one legacy AU at the packetizer's CURRENT shard payload with an explicit
|
||||
/// frame index, returning wire packets + source bytes.
|
||||
fn legacy_packets_with(
|
||||
pk: &mut Packetizer,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
len: usize,
|
||||
coder: &dyn crate::fec::ErasureCoder,
|
||||
) -> (Vec<Vec<u8>>, Vec<u8>) {
|
||||
let src: Vec<u8> = (0..len)
|
||||
.map(|i| (i * 131 + frame_index as usize * 7 + 3) as u8)
|
||||
.collect();
|
||||
let mut pkts: Vec<Vec<u8>> = Vec::new();
|
||||
pk.packetize_each(&src, pts_ns, 0, Some(frame_index), coder, |h, b| {
|
||||
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
|
||||
p.extend_from_slice(h.as_bytes());
|
||||
p.extend_from_slice(b);
|
||||
pkts.push(p);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(pkts, src)
|
||||
}
|
||||
|
||||
/// The slice-wire regression suite re-run at every production shard size (the design's
|
||||
/// non-negotiable verification): the exact-multiple sweep (the 0.23.0 filler-shard bug
|
||||
/// shape), lossy + reversed slice roundtrips, the legacy-streamed sentinel path, and the
|
||||
/// in-flight budget — each asserting DELIVERED byte-identical frames, never just an
|
||||
/// absence of errors.
|
||||
#[test]
|
||||
fn slice_wire_suite_at_production_shard_sizes() {
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
for &shard in &PRODUCTION_SHARDS {
|
||||
let cfg = geo_config(shard);
|
||||
|
||||
// Exact-shard-multiple AUs + the off-by-one sweep around one of them.
|
||||
for shards in [16usize, 30, 64] {
|
||||
for extra in 0..3usize {
|
||||
let n = shards * shard + extra;
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[n]);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("shard {shard}: {n}-byte slice AU must complete"));
|
||||
assert_eq!(
|
||||
f.data, src,
|
||||
"shard {shard}: {n}-byte AU must be byte-identical"
|
||||
);
|
||||
assert_eq!(
|
||||
r.in_flight(),
|
||||
0,
|
||||
"shard {shard}: budget must return to zero"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-slice AU under loss (one data shard of the first flushed block — within
|
||||
// its ≥ 20% parity) in both delivery orders. Reversed is the critical order: the
|
||||
// final block's totals arrive first and every sentinel validates against the pin.
|
||||
for reverse in [false, true] {
|
||||
let chunks = [20 * shard + 13, 7 * shard + 1, 17 * shard];
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &chunks);
|
||||
let killed = pkts
|
||||
.iter()
|
||||
.position(|p| {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
h.shard_index < h.data_shards && h.recovery_shards >= 1
|
||||
})
|
||||
.expect("suite frame must have a recoverable data shard");
|
||||
let mut delivery: Vec<Vec<u8>> = pkts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != killed)
|
||||
.map(|(_, p)| p.clone())
|
||||
.collect();
|
||||
if reverse {
|
||||
delivery.reverse();
|
||||
}
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &delivery).unwrap_or_else(|| {
|
||||
panic!("shard {shard} reverse={reverse}: lossy slice AU must complete")
|
||||
});
|
||||
assert_eq!(f.data, src, "shard {shard} reverse={reverse}");
|
||||
assert_eq!(r.in_flight(), 0);
|
||||
}
|
||||
|
||||
// Legacy-streamed (uniform full-K sentinel) path: one AU spanning a sentinel block
|
||||
// (K = 200) plus a final block.
|
||||
{
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 3, 3000, false, &[230 * shard]);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("shard {shard}: legacy-streamed AU must complete"));
|
||||
assert_eq!(f.data, src);
|
||||
assert_eq!(r.in_flight(), 0);
|
||||
}
|
||||
|
||||
// The budget regression at this size: 12 ordinary AUs opened concurrently, no drops.
|
||||
for slice in [false, true] {
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
for i in 0..12u32 {
|
||||
let (pkts, _) =
|
||||
streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
|
||||
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
stats
|
||||
.packets_dropped
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
0,
|
||||
"shard {shard} slice={slice}: 12 AUs in flight must fit the budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One packetizer, one reassembler, one continuous stream — the shard payload swapped
|
||||
/// live between AUs ([`Packetizer::set_shard_payload`], the Phase 1 host seam): every
|
||||
/// frame across shrink → grow-to-jumbo → shrink-again delivers byte-identically under its
|
||||
/// own per-frame pin, and the budget returns to zero.
|
||||
#[test]
|
||||
fn mid_stream_shard_swap_delivers_every_frame() {
|
||||
let cfg = geo_config(1408);
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
// (shard size to swap to, AU length) — swaps happen between AUs, as Phase 1 will.
|
||||
let schedule = [
|
||||
(1408usize, 3 * 1408 + 100),
|
||||
(1408, 9 * 1408),
|
||||
(512, 5 * 512 + 17), // shrink (the VPN heal)
|
||||
(512, 512),
|
||||
(8908, 12 * 8908 + 1), // grow (jumbo)
|
||||
(1216, 4 * 1216 + 9), // revert (a mis-proven jumbo hop self-corrects)
|
||||
];
|
||||
for (i, &(shard, len)) in schedule.iter().enumerate() {
|
||||
pk.set_shard_payload(shard);
|
||||
let pts = 1_000_000 * (i as u64 + 1);
|
||||
let (pkts, src) = legacy_packets_with(&mut pk, i as u32, pts, len, coder.as_ref());
|
||||
for p in &pkts {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
assert_eq!(
|
||||
h.shard_bytes as usize, shard,
|
||||
"sender must stamp the live size"
|
||||
);
|
||||
}
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("frame {i} at shard {shard} must complete"));
|
||||
assert_eq!(
|
||||
f.data, src,
|
||||
"frame {i} at shard {shard} must be byte-identical"
|
||||
);
|
||||
assert!(f.complete);
|
||||
}
|
||||
assert_eq!(
|
||||
r.in_flight(),
|
||||
0,
|
||||
"budget must be exact across geometry swaps"
|
||||
);
|
||||
assert_eq!(stats.snapshot().frames_dropped, 0);
|
||||
}
|
||||
|
||||
/// The reorder race the design kills structurally: an old-geometry frame still in flight
|
||||
/// when new-geometry frames start arriving completes under its OWN pin — its straggler
|
||||
/// lands in its own buffer, not the new geometry's.
|
||||
#[test]
|
||||
fn old_geometry_frame_completes_after_new_geometry_arrived() {
|
||||
let cfg = geo_config(1408);
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
// Frame 0 at 1408: 7 data shards + 2 parity (20% FEC), data-first wire order. Withhold
|
||||
// THREE data shards — more than parity can bridge — so the frame genuinely stays
|
||||
// incomplete until a straggler returns (fewer, and FEC would complete it early).
|
||||
let (pkts0, src0) = legacy_packets_with(&mut pk, 0, 1_000_000, 6 * 1408 + 50, coder.as_ref());
|
||||
assert_eq!(
|
||||
pkts0.len(),
|
||||
9,
|
||||
"expected geometry changed — update the split"
|
||||
);
|
||||
let head: Vec<Vec<u8>> = pkts0[..4].iter().chain(&pkts0[7..]).cloned().collect();
|
||||
let straggler = &pkts0[4];
|
||||
assert!(
|
||||
push_all(&mut r, coder.as_ref(), &stats, &head).is_none(),
|
||||
"frame 0 must still be incomplete"
|
||||
);
|
||||
|
||||
// The stream re-keys to 512: frames 1..=2 arrive whole and deliver.
|
||||
pk.set_shard_payload(512);
|
||||
for i in 1..=2u32 {
|
||||
let pts = 1_000_000 + 1_000_000 * i as u64;
|
||||
let (pkts, src) = legacy_packets_with(&mut pk, i, pts, 3 * 512 + 7, coder.as_ref());
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts).expect("new-geometry frame");
|
||||
assert_eq!(f.data, src);
|
||||
}
|
||||
|
||||
// Frame 0's old-geometry straggler arrives last — the frame completes byte-identically.
|
||||
let f = r
|
||||
.push(straggler, coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.expect("old-geometry frame must complete under its own pin");
|
||||
assert_eq!(f.data, src0);
|
||||
assert_eq!(f.frame_index, 0);
|
||||
assert_eq!(r.in_flight(), 0);
|
||||
assert_eq!(stats.snapshot().frames_dropped, 0);
|
||||
}
|
||||
|
||||
/// The anti-splice pin: a packet claiming a DIFFERENT (but in-bounds) shard size for an
|
||||
/// already-pinned frame is dropped — and the frame still completes from its real packets.
|
||||
#[test]
|
||||
fn cross_geometry_packet_for_a_pinned_frame_is_dropped() {
|
||||
let cfg = geo_config(1408);
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
let mut pk_a = Packetizer::new(&geo_config(1408));
|
||||
let mut pk_b = Packetizer::new(&geo_config(1216));
|
||||
let (pkts, src) = legacy_packets_with(&mut pk_a, 0, 1_000_000, 5 * 1408 + 9, coder.as_ref());
|
||||
// The impostor: the same frame index packetized at 1216 — self-consistent (it passes
|
||||
// the firewall standalone), wrong for THIS frame's pin.
|
||||
let (impostor, _) = legacy_packets_with(&mut pk_b, 0, 1_000_000, 5 * 1216, coder.as_ref());
|
||||
|
||||
assert!(r.push(&pkts[0], coder.as_ref(), &stats).unwrap().is_none());
|
||||
let before = stats.snapshot().packets_dropped;
|
||||
assert!(r
|
||||
.push(&impostor[1], coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
before + 1,
|
||||
"cross-geometry packet must be dropped by the frame pin"
|
||||
);
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts[1..])
|
||||
.expect("the pinned frame must still complete from its real packets");
|
||||
assert_eq!(f.data, src, "no impostor bytes may reach the frame");
|
||||
}
|
||||
|
||||
/// The firewall bounds on a frame's pinned size: below the floor, above the receive
|
||||
/// ceiling, or odd ⇒ dropped before any allocation; the exact floor and ceiling are
|
||||
/// accepted AND deliver (proving the rejections aren't vacuous).
|
||||
#[test]
|
||||
fn shard_size_firewall_bounds() {
|
||||
let cfg = geo_config(1408);
|
||||
let lim = ReassemblerLimits::from_config(&cfg);
|
||||
assert_eq!(lim.min_shard_bytes, crate::config::MIN_SHARD_PAYLOAD);
|
||||
assert_eq!(lim.max_shard_bytes, crate::config::max_shard_payload());
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut r = Reassembler::new(lim);
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
let single = |shard: usize, frame_index: u32| {
|
||||
let mut h = base_header();
|
||||
h.frame_index = frame_index;
|
||||
h.shard_bytes = shard as u16;
|
||||
h.frame_bytes = shard as u32;
|
||||
h
|
||||
};
|
||||
// Below the floor (even), above the ceiling (even), odd within bounds: all dropped.
|
||||
for (i, shard) in [510usize, 9154, 1409].into_iter().enumerate() {
|
||||
let before = stats.snapshot().packets_dropped;
|
||||
assert!(r
|
||||
.push(&packet(single(shard, i as u32)), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
before + 1,
|
||||
"shard {shard} must be firewalled"
|
||||
);
|
||||
}
|
||||
// The exact bounds deliver whole single-shard frames.
|
||||
for (i, shard) in [
|
||||
crate::config::MIN_SHARD_PAYLOAD,
|
||||
crate::config::max_shard_payload(),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let f = r
|
||||
.push(
|
||||
&packet(single(shard, 10 + i as u32)),
|
||||
coder.as_ref(),
|
||||
&stats,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("boundary shard {shard} must deliver"));
|
||||
assert_eq!(f.data.len(), shard);
|
||||
}
|
||||
}
|
||||
|
||||
mod geometry_proptests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// One generated frame: shard size, slice-vs-legacy wire, size factor, and whether to
|
||||
/// kill one recoverable data shard.
|
||||
type GenFrame = (usize, bool, usize, bool);
|
||||
|
||||
fn frame_strategy() -> impl Strategy<Value = GenFrame> {
|
||||
(
|
||||
proptest::sample::select(&PRODUCTION_SHARDS[..]),
|
||||
any::<bool>(),
|
||||
1usize..30,
|
||||
any::<bool>(),
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// Mixed-geometry reorder torture: frames of DIFFERENT shard sizes and wire shapes
|
||||
/// interleaved into one shuffled delivery, with per-frame recoverable loss — every
|
||||
/// frame must deliver byte-identically and the in-flight budget must return to
|
||||
/// exactly zero (the 0.23.0 budget-drift shape, now across geometries).
|
||||
#[test]
|
||||
fn mixed_geometry_reorder_torture(
|
||||
frames in proptest::collection::vec(frame_strategy(), 2..6),
|
||||
seed in any::<u64>(),
|
||||
) {
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&geo_config(1408)));
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
let mut all: Vec<(u64, u32, Vec<u8>)> = Vec::new(); // (shuffle key, frame, pkt)
|
||||
let mut sources: Vec<(u32, Vec<u8>)> = Vec::new();
|
||||
for (i, &(shard, slice, factor, kill)) in frames.iter().enumerate() {
|
||||
let cfg = geo_config(shard);
|
||||
let pts = 1_000_000 * (i as u64 + 1);
|
||||
let len = factor * shard + (factor % shard.min(7));
|
||||
let (mut pkts, src) = if slice {
|
||||
streamed_packets_with(&cfg, i as u32, pts, true, &[len.max(1)])
|
||||
} else {
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
legacy_packets_with(&mut pk, i as u32, pts, len.max(1), coder.as_ref())
|
||||
};
|
||||
if kill {
|
||||
if let Some(k) = pkts.iter().position(|p| {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
h.shard_index < h.data_shards && h.recovery_shards >= 1
|
||||
}) {
|
||||
pkts.remove(k);
|
||||
}
|
||||
}
|
||||
for (j, p) in pkts.into_iter().enumerate() {
|
||||
// Deterministic pseudo-shuffle key: interleaves frames and reorders
|
||||
// within a frame, differently per proptest case.
|
||||
let key = (seed | 1)
|
||||
.wrapping_mul(j as u64 + 1)
|
||||
.wrapping_add((i as u64) << 17)
|
||||
.rotate_left((j % 61) as u32);
|
||||
all.push((key, i as u32, p));
|
||||
}
|
||||
sources.push((i as u32, src));
|
||||
}
|
||||
all.sort_by_key(|(k, _, _)| *k);
|
||||
|
||||
let mut delivered: std::collections::HashMap<u32, Vec<u8>> =
|
||||
std::collections::HashMap::new();
|
||||
for (_, _, p) in &all {
|
||||
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
|
||||
prop_assert!(f.complete);
|
||||
prop_assert!(delivered.insert(f.frame_index, f.data).is_none(),
|
||||
"a frame must deliver exactly once");
|
||||
}
|
||||
}
|
||||
for (i, src) in &sources {
|
||||
let got = delivered.get(i);
|
||||
prop_assert!(got.is_some(), "frame {i} must be DELIVERED, not merely error-free");
|
||||
prop_assert_eq!(got.unwrap(), src, "frame {} must be byte-identical", i);
|
||||
}
|
||||
prop_assert_eq!(r.in_flight(), 0, "budget must be exact after all frames terminate");
|
||||
prop_assert_eq!(stats.snapshot().frames_dropped, 0u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,36 @@ pub struct RfiRequest {
|
||||
pub last_frame: u32,
|
||||
}
|
||||
|
||||
/// `host → client`, any time after [`Start`]: the video data plane's sealed shard payload
|
||||
/// changes mid-session (design/shard-payload-reneg.md Phase 1). Sent ONLY to a client whose
|
||||
/// [`Hello::max_shard_payload`] advertised per-frame geometry (0/absent = legacy — the host
|
||||
/// must never send this), and never above that advertised ceiling. Asymmetric semantics:
|
||||
///
|
||||
/// - **Shrink** (the mid-session MTU heal): the host may re-key its packetizer at the next
|
||||
/// AU boundary immediately after sending — per-frame pinning on the client makes the
|
||||
/// control-vs-datagram reorder race irrelevant and a smaller shard always fits existing
|
||||
/// buffers. The [`ShardPayloadAck`] is telemetry.
|
||||
/// - **Grow** (jumbo): the host must not emit a single sealed datagram above the OLD size
|
||||
/// until the ack arrives — the ack IS the gate, even when the client's buffers would
|
||||
/// happen to fit (the rule must not erode if the buffer strategy changes later).
|
||||
///
|
||||
/// No `effective_frame_index`: per-frame pinning makes it redundant — every video packet
|
||||
/// carries its own `shard_bytes` and the receiver follows each frame's pin.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ShardPayloadChanged {
|
||||
/// The new sealed shard payload in bytes (even, within the client's advertised bounds).
|
||||
pub shard_payload: u16,
|
||||
}
|
||||
|
||||
/// `client → host`: answer to [`ShardPayloadChanged`] — echoes the value the client applied.
|
||||
/// Only sent for an in-bounds request; an out-of-bounds one is dropped WITHOUT an ack (a
|
||||
/// buggy host must not read silence-then-garbage as a granted grow). The host treats the
|
||||
/// echoed value as the grant for a pending grow.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ShardPayloadAck {
|
||||
pub shard_payload: u16,
|
||||
}
|
||||
|
||||
/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to
|
||||
/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards
|
||||
/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report
|
||||
@@ -200,6 +230,10 @@ pub const MSG_SET_BITRATE: u8 = 0x05;
|
||||
pub const MSG_BITRATE_CHANGED: u8 = 0x06;
|
||||
/// Type byte of [`RfiRequest`].
|
||||
pub const MSG_RFI_REQUEST: u8 = 0x07;
|
||||
/// Type byte of [`ShardPayloadChanged`].
|
||||
pub const MSG_SHARD_PAYLOAD_CHANGED: u8 = 0x08;
|
||||
/// Type byte of [`ShardPayloadAck`].
|
||||
pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -306,6 +340,46 @@ impl RfiRequest {
|
||||
}
|
||||
}
|
||||
|
||||
impl ShardPayloadChanged {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] shard_payload[5..7]
|
||||
let mut b = Vec::with_capacity(7);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_SHARD_PAYLOAD_CHANGED);
|
||||
b.extend_from_slice(&self.shard_payload.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ShardPayloadChanged> {
|
||||
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_CHANGED {
|
||||
return Err(PunktfunkError::InvalidArg("bad ShardPayloadChanged"));
|
||||
}
|
||||
Ok(ShardPayloadChanged {
|
||||
shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ShardPayloadAck {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] shard_payload[5..7]
|
||||
let mut b = Vec::with_capacity(7);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_SHARD_PAYLOAD_ACK);
|
||||
b.extend_from_slice(&self.shard_payload.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ShardPayloadAck> {
|
||||
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_ACK {
|
||||
return Err(PunktfunkError::InvalidArg("bad ShardPayloadAck"));
|
||||
}
|
||||
Ok(ShardPayloadAck {
|
||||
shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LossReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] loss_ppm[5..9]
|
||||
@@ -1146,6 +1220,33 @@ mod tests {
|
||||
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shard_payload_messages_roundtrip() {
|
||||
for shard_payload in [512u16, 1216, 1408, 8908] {
|
||||
let chg = ShardPayloadChanged { shard_payload };
|
||||
assert_eq!(ShardPayloadChanged::decode(&chg.encode()).unwrap(), chg);
|
||||
let ack = ShardPayloadAck { shard_payload };
|
||||
assert_eq!(ShardPayloadAck::decode(&ack.encode()).unwrap(), ack);
|
||||
// Identical payload shape — the type byte alone must keep the pair disjoint (a
|
||||
// change echoed back must never re-decode as a change).
|
||||
assert!(ShardPayloadChanged::decode(&ack.encode()).is_err());
|
||||
assert!(ShardPayloadAck::decode(&chg.encode()).is_err());
|
||||
}
|
||||
// Exact length — no trailing bytes, no truncation.
|
||||
let bytes = ShardPayloadChanged { shard_payload: 512 }.encode();
|
||||
assert!(ShardPayloadChanged::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(ShardPayloadChanged::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
// Disjoint from the neighboring ids either side (0x07 RfiRequest / 0x20 ProbeRequest).
|
||||
assert!(ShardPayloadChanged::decode(
|
||||
&RfiRequest {
|
||||
first_frame: 1,
|
||||
last_frame: 2
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_messages_roundtrip() {
|
||||
let req = ProbeRequest {
|
||||
|
||||
@@ -59,7 +59,25 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfi
|
||||
// PINGs quinn already expects to lose above a constrained hop — a lost probe settles the
|
||||
// search lower, exactly as it did before.
|
||||
let mut mtud = quinn::MtuDiscoveryConfig::default();
|
||||
mtud.upper_bound(crate::config::video_datagram_udp_ceiling() as u16);
|
||||
// Jumbo opt-in (design/shard-payload-reneg.md Phase 2): with `PUNKTFUNK_JUMBO=1` /
|
||||
// `PUNKTFUNK_WIRE_MTU` > 1500 set, discovery probes up to the sealed JUMBO datagram
|
||||
// size so a settled connection can PROVE a jumbo path — the actual grow stays
|
||||
// client-ack-gated (`native/wire_mtu.rs`). The ceiling is per-ENDPOINT, not
|
||||
// per-connection: with the opt-in set, connections to non-jumbo peers spend a few extra
|
||||
// failed probes (one PTO each) settling lower; zero cost for anyone who doesn't opt in.
|
||||
// Derived with the IPv4 overhead — a v6 peer's sealed jumbo target is smaller, so the
|
||||
// ceiling covers it and discovery settles at the v6 path's own budget.
|
||||
let probe_ceiling = match crate::config::jumbo_wire_mtu() {
|
||||
Some(mtu) => {
|
||||
let shard = crate::config::jumbo_shard_payload_for(
|
||||
mtu,
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
);
|
||||
crate::config::sealed_datagram_bytes(shard) as u16
|
||||
}
|
||||
None => crate::config::video_datagram_udp_ceiling() as u16,
|
||||
};
|
||||
mtud.upper_bound(probe_ceiling);
|
||||
t.mtu_discovery_config(Some(mtud));
|
||||
Arc::new(t)
|
||||
}
|
||||
|
||||
@@ -90,8 +90,19 @@ pub struct Hello {
|
||||
/// disambiguated by REMAINING LENGTH at decode: fewer than `HDR_META_BODY_LEN` bytes after
|
||||
/// `preferred_codec` ⇒ no HDR block, the tail bytes are the post-HDR fields directly. This
|
||||
/// caps everything after `display_hdr` at `HDR_META_BODY_LEN − 1` bytes total — document any
|
||||
/// future field here and mind the budget. Omitted when zero and by older clients (→ `0`).
|
||||
/// future field here and mind the budget (`client_caps` 1 + `max_shard_payload` 2 = 3 of the
|
||||
/// 27 spent). Omitted when zero and by older clients (→ `0`).
|
||||
pub client_caps: u8,
|
||||
/// The largest video shard payload this client's receive path accepts — sealed datagrams for
|
||||
/// shards up to this size fit its transport buffers ([`crate::config::max_shard_payload`]).
|
||||
/// One field carries BOTH facts the host needs for mid-session shard renegotiation
|
||||
/// (design/shard-payload-reneg.md W0.3): non-zero ⇒ the client reassembles per-frame
|
||||
/// geometry (a mid-session `shard_payload` change is safe to send), and the value is the
|
||||
/// hard ceiling a jumbo grow may never exceed. Appended after `client_caps` as 2 trailing
|
||||
/// LE bytes (forcing the earlier placeholders). Omitted by older clients (decodes to `0`
|
||||
/// = legacy: the host must not change the sealed geometry mid-session, and never above
|
||||
/// the `Welcome` value).
|
||||
pub max_shard_payload: u16,
|
||||
}
|
||||
|
||||
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||
@@ -254,12 +265,14 @@ impl Hello {
|
||||
let pref_present = self.preferred_codec != 0;
|
||||
let hdr_present = self.display_hdr.is_some();
|
||||
let ccaps_present = self.client_caps != 0;
|
||||
let msp_present = self.max_shard_payload != 0;
|
||||
let need_placeholders = self.video_caps != 0
|
||||
|| ac_present
|
||||
|| vcodecs_present
|
||||
|| pref_present
|
||||
|| hdr_present
|
||||
|| ccaps_present;
|
||||
|| ccaps_present
|
||||
|| msp_present;
|
||||
match (&self.name, &self.launch) {
|
||||
(None, None) if !need_placeholders => {}
|
||||
(name, _) => {
|
||||
@@ -280,15 +293,21 @@ impl Hello {
|
||||
b.push(self.video_caps);
|
||||
}
|
||||
// audio_channels: emitted when non-stereo OR a later field follows.
|
||||
if ac_present || vcodecs_present || pref_present || hdr_present || ccaps_present {
|
||||
if ac_present
|
||||
|| vcodecs_present
|
||||
|| pref_present
|
||||
|| hdr_present
|
||||
|| ccaps_present
|
||||
|| msp_present
|
||||
{
|
||||
b.push(self.audio_channels);
|
||||
}
|
||||
// video_codecs: emitted when non-zero OR a later field follows.
|
||||
if vcodecs_present || pref_present || hdr_present || ccaps_present {
|
||||
if vcodecs_present || pref_present || hdr_present || ccaps_present || msp_present {
|
||||
b.push(self.video_codecs);
|
||||
}
|
||||
// preferred_codec: emitted when non-zero OR a later field follows.
|
||||
if pref_present || hdr_present || ccaps_present {
|
||||
if pref_present || hdr_present || ccaps_present || msp_present {
|
||||
b.push(self.preferred_codec);
|
||||
}
|
||||
// display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body; omitted when `None` even if
|
||||
@@ -297,10 +316,15 @@ impl Hello {
|
||||
if let Some(m) = &self.display_hdr {
|
||||
super::datagram::write_hdr_meta_body(m, &mut b);
|
||||
}
|
||||
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero.
|
||||
if ccaps_present {
|
||||
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero OR a
|
||||
// later field follows.
|
||||
if ccaps_present || msp_present {
|
||||
b.push(self.client_caps);
|
||||
}
|
||||
// max_shard_payload: 2 trailing LE bytes after client_caps. Emitted when non-zero.
|
||||
if msp_present {
|
||||
b.extend_from_slice(&self.max_shard_payload.to_le_bytes());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -386,6 +410,19 @@ impl Hello {
|
||||
};
|
||||
b.get(off).copied().unwrap_or(0)
|
||||
},
|
||||
// max_shard_payload: 2 LE bytes after client_caps (same post-HDR offset rule).
|
||||
// Absent on an older client → 0 = no mid-session renegotiation, no jumbo.
|
||||
max_shard_payload: {
|
||||
let off = if b.len().saturating_sub(tail + 4) >= super::datagram::HDR_META_BODY_LEN
|
||||
{
|
||||
tail + 4 + super::datagram::HDR_META_BODY_LEN
|
||||
} else {
|
||||
tail + 4
|
||||
};
|
||||
b.get(off + 1..off + 3)
|
||||
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -867,6 +904,7 @@ mod tests {
|
||||
preferred_codec: CODEC_H264,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
let enc = h.encode();
|
||||
let dec = Hello::decode(&enc).unwrap();
|
||||
@@ -944,6 +982,7 @@ mod tests {
|
||||
preferred_codec: CODEC_HEVC,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||
let s = Start {
|
||||
@@ -975,6 +1014,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
let enc = h.encode();
|
||||
assert_eq!(enc.len(), 26);
|
||||
@@ -1093,6 +1133,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
let enc = base.encode();
|
||||
assert_eq!(
|
||||
@@ -1145,6 +1186,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
||||
let with_launch = Hello {
|
||||
@@ -1205,6 +1247,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
// A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL).
|
||||
let vol = HdrMeta {
|
||||
@@ -1273,6 +1316,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
}
|
||||
.encode();
|
||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||
@@ -1306,6 +1350,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
let vol = HdrMeta {
|
||||
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
|
||||
@@ -1319,6 +1364,7 @@ mod tests {
|
||||
// fixed block length, so the decoder must NOT read it as a truncated HdrMeta).
|
||||
let caps_only = Hello {
|
||||
client_caps: CLIENT_CAP_CURSOR,
|
||||
max_shard_payload: 0,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(Hello::decode(&caps_only.encode()).unwrap(), caps_only);
|
||||
@@ -1326,6 +1372,7 @@ mod tests {
|
||||
let both = Hello {
|
||||
display_hdr: Some(vol),
|
||||
client_caps: CLIENT_CAP_CURSOR,
|
||||
max_shard_payload: 0,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
|
||||
@@ -1344,8 +1391,73 @@ mod tests {
|
||||
Hello::decode(&enc[..enc.len() - 1]).unwrap(),
|
||||
Hello {
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
..both.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// `max_shard_payload` (mid-session shard renegotiation, design/shard-payload-reneg.md
|
||||
/// W0.3): roundtrips, forces the earlier placeholders (deterministic offset), composes
|
||||
/// with the optional HDR block, and degrades to 0 = legacy in BOTH directions.
|
||||
#[test]
|
||||
fn hello_max_shard_payload_roundtrip_and_back_compat() {
|
||||
let base = Hello {
|
||||
abi_version: 2,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps: 0,
|
||||
name: None,
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
video_codecs: 0,
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
// The advertisement alone: every earlier trailing field is emitted as a placeholder
|
||||
// so the 2 LE bytes land at a deterministic offset — and the whole thing roundtrips.
|
||||
let adv = Hello {
|
||||
max_shard_payload: crate::config::max_shard_payload() as u16,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(Hello::decode(&adv.encode()).unwrap(), adv);
|
||||
// Composes with client_caps AND the fixed HDR block (the remaining-length
|
||||
// disambiguation must still find both fields after it).
|
||||
let vol = HdrMeta {
|
||||
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
|
||||
white_point: [15635, 16450],
|
||||
max_display_mastering_luminance: 8_000_000,
|
||||
min_display_mastering_luminance: 500,
|
||||
max_cll: 0,
|
||||
max_fall: 400,
|
||||
};
|
||||
let full = Hello {
|
||||
display_hdr: Some(vol),
|
||||
client_caps: CLIENT_CAP_CURSOR,
|
||||
max_shard_payload: 8908,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(Hello::decode(&full.encode()).unwrap(), full);
|
||||
// An older client (no trailing bytes at all) decodes to 0 = legacy: the host must
|
||||
// not change the sealed geometry mid-session.
|
||||
assert_eq!(Hello::decode(&base.encode()).unwrap().max_shard_payload, 0);
|
||||
// An older HOST reading an advertising Hello never looks past the fields it knows —
|
||||
// truncating the 2 trailing bytes yields the same Hello minus the advertisement.
|
||||
let enc = full.encode();
|
||||
assert_eq!(
|
||||
Hello::decode(&enc[..enc.len() - 2]).unwrap(),
|
||||
Hello {
|
||||
max_shard_payload: 0,
|
||||
..full.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,6 +603,31 @@ impl Session {
|
||||
self.packetizer.set_fec_percent(pct);
|
||||
}
|
||||
|
||||
/// Host: live-swap the wire shard payload between AUs (mid-session shard renegotiation,
|
||||
/// design/shard-payload-reneg.md). Affects the next sealed AU; call only between AUs
|
||||
/// (never with a `StreamedAu` in flight — see [`Packetizer::set_shard_payload`]). The new
|
||||
/// value must satisfy the exact bounds `Config::validate` imposed on the negotiated one
|
||||
/// (even, > 0, fits a datagram, block count fits the wire) — validated here against a
|
||||
/// probe of the session config. The PROTOCOL side is the caller's contract: a current
|
||||
/// client reassembles any in-bounds size per-frame, but a shrink may be sent immediately
|
||||
/// while a grow must be client-acked and never exceed the client's advertised
|
||||
/// `Hello::max_shard_payload` ceiling.
|
||||
pub fn set_shard_payload(&mut self, shard_payload: usize) -> Result<()> {
|
||||
if self.config.role != Role::Host {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
"set_shard_payload called on a client session",
|
||||
));
|
||||
}
|
||||
// Full `Config::validate` parity, zero drift: probe a copy (its key/salt copies are
|
||||
// zeroized on drop) rather than re-spelling the shard clauses here.
|
||||
let mut probe = self.config.clone();
|
||||
probe.shard_payload = shard_payload;
|
||||
probe.validate()?;
|
||||
self.config.shard_payload = shard_payload;
|
||||
self.packetizer.set_shard_payload(shard_payload);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The current FEC recovery percentage (host side).
|
||||
pub fn fec_percent(&self) -> u8 {
|
||||
self.packetizer.fec_percent()
|
||||
@@ -1060,4 +1085,147 @@ mod wire_equivalence_tests {
|
||||
"unflagged AUs must never be delivered partial"
|
||||
);
|
||||
}
|
||||
|
||||
/// The low-MTU PyroWave guarantee (design/shard-payload-reneg.md): mid-session
|
||||
/// renegotiation is gated OFF for chunk-aligned sessions, so a constrained path serves
|
||||
/// them through the leg-1 SESSION-START clamp instead — the learned budget (or
|
||||
/// `PUNKTFUNK_WIRE_MTU`) sizes `Welcome::shard_payload`, and everything chunk-aligned
|
||||
/// derives from that ONE number fixed at the handshake: the host packetizes at it, the
|
||||
/// client's parse window reads it back ([`Session::shard_payload`] → the C-ABI
|
||||
/// `punktfunk_connection_shard_payload` every embedder walks windows with), and partial
|
||||
/// delivery zero-fills exact windows of it. Pin that consistency at the clamp shapes a
|
||||
/// constrained path actually produces: the WARP/Tailscale budget (1216) and the floor
|
||||
/// (512) — chunk-aligned frames deliver, lose whole windows (never splice), and the
|
||||
/// window arithmetic matches the session value end to end.
|
||||
#[test]
|
||||
fn chunk_aligned_sessions_work_at_clamped_shard_sizes() {
|
||||
use crate::packet::USER_FLAG_CHUNK_ALIGNED;
|
||||
for shard in [1216usize, crate::config::MIN_SHARD_PAYLOAD] {
|
||||
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: shard,
|
||||
max_frame_bytes: 8 * 1024 * 1024,
|
||||
encrypt: true,
|
||||
key: SessionKey::Aes128Gcm([7u8; 16]),
|
||||
salt: [3, 1, 4, 1],
|
||||
loopback_drop_period: 0,
|
||||
};
|
||||
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);
|
||||
// The window every embedder parses with IS the clamped session value.
|
||||
assert_eq!(client.shard_payload(), shard);
|
||||
assert_eq!(host.shard_payload(), shard);
|
||||
|
||||
let frame = pattern(8 * shard);
|
||||
host.submit_frame(&frame, 1_000, USER_FLAG_CHUNK_ALIGNED)
|
||||
.unwrap();
|
||||
let mut got_partial = None;
|
||||
let mut completes = 0;
|
||||
for i in 0..80u64 {
|
||||
host.submit_frame(&pattern(shard), 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!("shard {shard}: unexpected: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = got_partial.expect("the lossy frame must be delivered partial");
|
||||
assert_eq!(p.data.len(), frame.len(), "shard {shard}");
|
||||
// Loss lands on exact `shard`-sized window boundaries: zeroed windows for the
|
||||
// dropped datagrams, byte-identical survivors — nothing spliced across windows.
|
||||
let mut zero_windows = 0;
|
||||
for w in 0..8 {
|
||||
let win = &p.data[w * shard..(w + 1) * shard];
|
||||
if win.iter().all(|&b| b == 0) {
|
||||
zero_windows += 1;
|
||||
} else {
|
||||
assert_eq!(
|
||||
win,
|
||||
&frame[w * shard..(w + 1) * shard],
|
||||
"shard {shard}: window {w} corrupt"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
(1..8).contains(&zero_windows),
|
||||
"shard {shard}: dropped shards zero-filled (got {zero_windows})"
|
||||
);
|
||||
assert!(
|
||||
completes > 40,
|
||||
"shard {shard}: surviving filler frames flow normally"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mid-session shard renegotiation end to end over the SEALED loopback wire
|
||||
/// (design/shard-payload-reneg.md): one host session re-keys its packetizer between AUs
|
||||
/// — shrink, jumbo grow, revert — through one continuous crypto/replay stream, and one
|
||||
/// client session must DELIVER every frame byte-identically (the vacuous-green lesson:
|
||||
/// assert delivered frames, never the absence of errors).
|
||||
#[test]
|
||||
fn mid_session_shard_swap_delivers_frames_over_the_sealed_wire() {
|
||||
let mk = |role: Role| {
|
||||
let mut c = host_cfg(FecScheme::Gf16, 20, true);
|
||||
c.role = role;
|
||||
c.shard_payload = 1408;
|
||||
c.fec.max_data_per_block = 64;
|
||||
c
|
||||
};
|
||||
let (ht, ct) = loopback_pair(0, 0);
|
||||
let mut host = Session::new(mk(Role::Host), Box::new(ht)).unwrap();
|
||||
let mut client = Session::new(mk(Role::Client), Box::new(ct)).unwrap();
|
||||
|
||||
let phases: [(usize, &[usize]); 4] = [
|
||||
(1408, &[3000, 3 * 1408]), // the negotiated default (incl. exact multiple)
|
||||
(512, &[2000, 5 * 512 + 17]), // shrink — the mid-session VPN heal
|
||||
(8908, &[100_000]), // grow — jumbo on a 9000-MTU LAN
|
||||
(1216, &[2 * 1216 + 9]), // revert — a mis-proven jumbo hop self-corrects
|
||||
];
|
||||
let mut pts = 0u64;
|
||||
let mut delivered = 0usize;
|
||||
for (shard, lens) in phases {
|
||||
host.set_shard_payload(shard).unwrap();
|
||||
assert_eq!(host.shard_payload(), shard);
|
||||
for &len in lens {
|
||||
pts += 1_000_000;
|
||||
let src = pattern(len);
|
||||
host.submit_frame(&src, pts, 0).unwrap();
|
||||
let f = client
|
||||
.poll_frame()
|
||||
.unwrap_or_else(|e| panic!("shard {shard}: frame must be DELIVERED ({e})"));
|
||||
assert_eq!(
|
||||
f.data, src,
|
||||
"shard {shard}: {len} B frame must be byte-identical"
|
||||
);
|
||||
assert!(f.complete);
|
||||
delivered += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(delivered, 6, "every submitted frame must be delivered");
|
||||
// The setter is host-side machinery: a client session must refuse it, and an
|
||||
// invalid size (odd / oversized) must be rejected without touching the live config.
|
||||
assert!(client.set_shard_payload(1408).is_err());
|
||||
assert!(
|
||||
host.set_shard_payload(1407).is_err(),
|
||||
"odd must be rejected"
|
||||
);
|
||||
assert!(
|
||||
host.set_shard_payload(crate::config::max_shard_payload() + 2)
|
||||
.is_err(),
|
||||
"oversized must be rejected"
|
||||
);
|
||||
assert_eq!(host.shard_payload(), 1216, "failed swaps must not stick");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,6 +1091,30 @@ async fn serve_session(
|
||||
// just never fires then.
|
||||
let (cursor_shape_tx, cursor_shape_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
|
||||
// Mid-session shard renegotiation (design/shard-payload-reneg.md Phase 2): the wire-MTU
|
||||
// watcher decides (constrained-path shrink / ack-gated jumbo grow), the control task
|
||||
// writes the `ShardPayloadChanged` and routes the acks back, and the data-plane loop
|
||||
// applies `Session::set_shard_payload` between AUs (drained next to `bitrate_rx`).
|
||||
// Channels are wired unconditionally (they just never fire); the DRIVER exists only for
|
||||
// a client that advertised `Hello::max_shard_payload` on a non-chunk-aligned session —
|
||||
// PyroWave clients parse chunk-aligned AUs in windows of the `Welcome` value pinned at
|
||||
// session start (read once over the C ABI), so those sessions keep the leg-1
|
||||
// next-session clamp instead of a mid-stream re-key.
|
||||
let (shard_change_tx, shard_change_rx) = tokio::sync::mpsc::unbounded_channel::<u16>();
|
||||
let (shard_ack_tx, shard_ack_rx) = tokio::sync::mpsc::unbounded_channel::<u16>();
|
||||
let (shard_apply_tx, shard_apply_rx) = std::sync::mpsc::channel::<usize>();
|
||||
let shard_reneg = (hello.max_shard_payload > 0 && codec != crate::encode::Codec::PyroWave)
|
||||
.then_some(wire_mtu::ShardReneg {
|
||||
client_ceiling: hello.max_shard_payload,
|
||||
change_tx: shard_change_tx,
|
||||
ack_rx: shard_ack_rx,
|
||||
apply_tx: shard_apply_tx,
|
||||
});
|
||||
// The session is real: watch this connection's MTU discovery settle and turn it into a
|
||||
// path verdict (WARN + learned clamp for the next session on a constrained path; clears
|
||||
// a stale clamp on a healthy one) — and, with the driver above, heal or grow THIS
|
||||
// session mid-stream. Bounded ~10 s task unless a jumbo grow leaves it as revert guard.
|
||||
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize, shard_reneg);
|
||||
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
|
||||
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
|
||||
// blend-capability gate — re-running it here could drift, and would re-probe).
|
||||
@@ -1146,6 +1170,8 @@ async fn serve_session(
|
||||
probe_result_rx,
|
||||
reconfig_result_rx,
|
||||
retarget_rx,
|
||||
shard_change_rx,
|
||||
shard_ack_tx,
|
||||
cursor_shape_rx,
|
||||
cursor_client_draws,
|
||||
clip_enabled,
|
||||
@@ -1588,6 +1614,7 @@ async fn serve_session(
|
||||
keyframe: keyframe_rx,
|
||||
rfi: rfi_rx,
|
||||
bitrate_rx,
|
||||
shard_rx: shard_apply_rx,
|
||||
compositor,
|
||||
gamescope_route,
|
||||
bitrate_kbps,
|
||||
|
||||
@@ -43,6 +43,11 @@ pub(super) async fn run(
|
||||
// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
|
||||
// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
|
||||
mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
|
||||
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher
|
||||
// asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer),
|
||||
// and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate.
|
||||
mut shard_change_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
|
||||
shard_ack_tx: tokio::sync::mpsc::UnboundedSender<u16>,
|
||||
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
|
||||
cursor_client_draws: Arc<AtomicBool>,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
@@ -56,6 +61,9 @@ pub(super) async fn run(
|
||||
// Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch
|
||||
// stops firing on a perpetually-ready `None`.
|
||||
let mut clip_offer_closed = false;
|
||||
// Same discipline for the wire-MTU watcher's channel — its bounded lifetime ends mid-session
|
||||
// on every healthy path.
|
||||
let mut shard_change_closed = false;
|
||||
let mut active = initial_mode;
|
||||
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
|
||||
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
|
||||
@@ -214,6 +222,16 @@ pub(super) async fn run(
|
||||
if bitrate_tx.send(resolved).is_err() {
|
||||
break; // data plane gone
|
||||
}
|
||||
} else if let Ok(ack) = punktfunk_core::quic::ShardPayloadAck::decode(&msg) {
|
||||
// Mid-session shard renegotiation: the client applied (or granted) a
|
||||
// geometry change. Forward to the wire-MTU watcher — for a grow this IS
|
||||
// the gate that lets the packetizer go above the old size. A dropped
|
||||
// send just means the watcher already ended (shrink acks are telemetry).
|
||||
tracing::info!(
|
||||
shard_payload = ack.shard_payload,
|
||||
"client acked shard-payload change"
|
||||
);
|
||||
let _ = shard_ack_tx.send(ack.shard_payload);
|
||||
} else if let Ok(req) = ProbeRequest::decode(&msg) {
|
||||
tracing::info!(
|
||||
target_kbps = req.target_kbps,
|
||||
@@ -317,6 +335,19 @@ pub(super) async fn run(
|
||||
break;
|
||||
}
|
||||
}
|
||||
n = shard_change_rx.recv(), if !shard_change_closed => {
|
||||
// Mid-session shard renegotiation: the wire-MTU watcher decided (shrink on a
|
||||
// constrained-path verdict / ack-gated jumbo grow). Only ever fires toward a
|
||||
// client that advertised `Hello::max_shard_payload` — the watcher owns that
|
||||
// gate. `None` = the watcher's bounded lifetime ended (normal, NOT a session
|
||||
// end): disable this branch, exactly the `clip_offer_closed` pattern — a
|
||||
// closed mpsc yields `None` perpetually and would busy-spin the select.
|
||||
let Some(n) = n else { shard_change_closed = true; continue };
|
||||
let msg = punktfunk_core::quic::ShardPayloadChanged { shard_payload: n };
|
||||
if io::write_msg(&mut ctrl_send, &msg.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
shape = cursor_shape_rx.recv() => {
|
||||
// Cursor-forward bridge (M2): the encode loop diffed a new pointer bitmap.
|
||||
// Rare (shape changes are human-paced); ≤ ~58 KiB fits the u16 frame by
|
||||
|
||||
@@ -734,10 +734,9 @@ pub(super) async fn negotiate(
|
||||
let start =
|
||||
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
||||
bringup.mark("start");
|
||||
// The session is real: watch this connection's MTU discovery settle and turn it into a
|
||||
// path verdict (WARN + learned clamp for the next session on a constrained path; clears a
|
||||
// stale clamp on a healthy one). Bounded ~10 s task, ends by itself.
|
||||
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize);
|
||||
// The wire-MTU watch (`wire_mtu::spawn_watch`) is spawned by `serve_session` after the
|
||||
// control-task channels exist — it now also DRIVES the mid-session shard renegotiation
|
||||
// (design/shard-payload-reneg.md), which needs the control stream's writer.
|
||||
Ok::<_, anyhow::Error>((
|
||||
hello,
|
||||
welcome,
|
||||
|
||||
@@ -762,6 +762,9 @@ fn send_loop(
|
||||
slice_wire: bool,
|
||||
burst_cap: Option<usize>,
|
||||
fec_target: Arc<AtomicU8>,
|
||||
// Mid-session shard-payload re-keys from the wire-MTU watcher (validated + ack-gated
|
||||
// there) — applied between AUs only (design/shard-payload-reneg.md Phase 1).
|
||||
shard_rx: std::sync::mpsc::Receiver<usize>,
|
||||
stats: SendStats,
|
||||
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
|
||||
// after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing).
|
||||
@@ -818,6 +821,25 @@ fn send_loop(
|
||||
}
|
||||
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
|
||||
apply_fec_target(&mut session, &fec_target);
|
||||
// Mid-session shard renegotiation: apply a re-key from the wire-MTU watcher — between
|
||||
// AUs only, NEVER with a streamed AU open (its shard-aligned tiling derives from the
|
||||
// size it began with; same gate as the probe burst above). Drain to the newest; the
|
||||
// protocol side (client advertisement, ack-gated grow) was enforced by the watcher.
|
||||
if streamed.is_none() {
|
||||
let mut want_shard = None;
|
||||
while let Ok(s) = shard_rx.try_recv() {
|
||||
want_shard = Some(s);
|
||||
}
|
||||
if let Some(s) = want_shard {
|
||||
match session.set_shard_payload(s) {
|
||||
Ok(()) => tracing::info!(shard_payload = s, "wire shard payload re-keyed"),
|
||||
// Can't fire for a watcher-driven value (it validates the same bounds) —
|
||||
// belt-and-suspenders for a future driver.
|
||||
Err(e) => tracing::warn!(shard_payload = s, error = ?e,
|
||||
"shard re-key refused by session validation"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
|
||||
match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) {
|
||||
Ok(send_msg) => {
|
||||
@@ -1171,6 +1193,11 @@ pub(super) struct SessionContext {
|
||||
/// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder
|
||||
/// alone is rebuilt in place at the new rate; capture + virtual output are untouched.
|
||||
pub(super) bitrate_rx: std::sync::mpsc::Receiver<u32>,
|
||||
/// Mid-session shard-payload changes from the wire-MTU watcher (already validated +
|
||||
/// protocol-gated there; a grow arrives only after the client's ack). Applied between
|
||||
/// AUs via [`Session::set_shard_payload`] — the packetizer re-keys, capture/encoder/
|
||||
/// virtual output are untouched (design/shard-payload-reneg.md Phase 1).
|
||||
pub(super) shard_rx: std::sync::mpsc::Receiver<usize>,
|
||||
/// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there).
|
||||
pub(super) compositor: crate::vdisplay::Compositor,
|
||||
/// This session's resolved gamescope sub-mode, or `None` for every other backend. Carried here
|
||||
@@ -1385,6 +1412,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
keyframe,
|
||||
rfi,
|
||||
bitrate_rx,
|
||||
shard_rx,
|
||||
compositor,
|
||||
gamescope_route,
|
||||
mut bitrate_kbps,
|
||||
@@ -1771,6 +1799,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
slice_wire,
|
||||
burst_cap,
|
||||
fec_target,
|
||||
shard_rx,
|
||||
send_stats,
|
||||
timing_conn,
|
||||
phase_send,
|
||||
|
||||
@@ -30,10 +30,30 @@ use std::net::IpAddr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use punktfunk_core::config::{
|
||||
mtu1500_shard_payload_for, sealed_datagram_bytes, shard_payload_for_udp_budget,
|
||||
shard_payload_for_wire_mtu, video_datagram_udp_ceiling,
|
||||
jumbo_shard_payload_for, jumbo_wire_mtu, mtu1500_shard_payload_for, sealed_datagram_bytes,
|
||||
shard_payload_for_udp_budget, shard_payload_for_wire_mtu, video_datagram_udp_ceiling,
|
||||
};
|
||||
|
||||
/// Everything the MID-SESSION renegotiation driver needs (design/shard-payload-reneg.md
|
||||
/// Phase 2) — `None` at [`spawn_watch`] makes the watcher observe-and-learn only (leg-1
|
||||
/// behavior). Constructed ONLY when the client's `Hello::max_shard_payload` advertised
|
||||
/// per-frame geometry AND the session's wire is not chunk-aligned: a PyroWave client parses
|
||||
/// chunk-aligned AUs in windows of the `Welcome` value pinned at session start (Apple
|
||||
/// `Stage2Pipeline` / `pf-client-core` video.rs read it once over the C ABI), so re-keying
|
||||
/// such a session mid-stream would corrupt its parse — those sessions keep the leg-1
|
||||
/// next-session clamp instead.
|
||||
pub(super) struct ShardReneg {
|
||||
/// The client's advertised receive ceiling (bytes of shard; > 0 by construction).
|
||||
pub client_ceiling: u16,
|
||||
/// → control task (the control stream's sole writer): send `ShardPayloadChanged{n}`.
|
||||
pub change_tx: tokio::sync::mpsc::UnboundedSender<u16>,
|
||||
/// ← control task: the client's `ShardPayloadAck`s (the grow gate).
|
||||
pub ack_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
|
||||
/// → data plane: apply [`Session::set_shard_payload`] between AUs
|
||||
/// (drained next to `bitrate_rx` in the encode loop).
|
||||
pub apply_tx: std::sync::mpsc::Sender<usize>,
|
||||
}
|
||||
|
||||
/// Measured UDP-payload budget per peer IP, learned from live control connections whose MTU
|
||||
/// discovery settled below the video-datagram ceiling. In-memory only: a host restart
|
||||
/// re-learns in one session, and entries self-correct (a later ceiling-hit erases, a lower
|
||||
@@ -96,15 +116,26 @@ fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: I
|
||||
}
|
||||
|
||||
/// Sample the control connection's discovered MTU after the search has settled and turn it
|
||||
/// into a verdict. Spawned once per negotiated session; the task ends by itself after the
|
||||
/// final sample (bounded ~10 s lifetime, holding only a cheap `Connection` handle).
|
||||
pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize) {
|
||||
/// into a verdict — and, with a [`ShardReneg`] driver, act on it MID-SESSION
|
||||
/// (design/shard-payload-reneg.md Phase 2): a below-ceiling verdict shrinks the live wire at
|
||||
/// the ~3–10 s mark (session 1 heals instead of staying black), and a settled-at-jumbo
|
||||
/// verdict grows it, ack-gated, when the operator opted in. Spawned once per negotiated
|
||||
/// session; without a grow the task ends after the final sample (bounded ~10 s lifetime,
|
||||
/// holding only a cheap `Connection` handle) — after a grow it stays as the revert guard
|
||||
/// until the connection closes.
|
||||
pub(super) fn spawn_watch(
|
||||
conn: quinn::Connection,
|
||||
session_shard_payload: usize,
|
||||
reneg: Option<ShardReneg>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let peer = conn.remote_address().ip();
|
||||
let ceiling = video_datagram_udp_ceiling() as u16;
|
||||
// Discovery finishes in a handful of RTTs on a LAN (well under the first sample) but
|
||||
// needs a loss timeout per failed probe on a constrained path — the second sample
|
||||
// covers that with margin. Max, because discovery only ever raises `current_mtu`.
|
||||
// covers that with margin. Max, because discovery only ever raises `current_mtu`
|
||||
// (the post-grow revert guard below re-reads it live, where blackhole detection CAN
|
||||
// lower it again).
|
||||
let mut settled = 0u16;
|
||||
for wait_s in [3u64, 7] {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait_s)).await;
|
||||
@@ -113,6 +144,9 @@ pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize)
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The wire this session is CURRENTLY sealed at — moves on a mid-session shrink/grow.
|
||||
let mut current = session_shard_payload;
|
||||
let mut reneg = reneg;
|
||||
if settled >= ceiling {
|
||||
// The path carries full-size video datagrams — erase any stale learned clamp so
|
||||
// the next session returns to the default wire.
|
||||
@@ -120,34 +154,116 @@ pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize)
|
||||
tracing::info!(peer = %peer,
|
||||
"wire MTU: path re-measured at full size — learned clamp cleared");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A closed connection stops discovering, so a session that ended before the final
|
||||
// sample proves nothing (a healthy high-RTT path could still be mid-search): learn
|
||||
// only from a connection that stayed alive through the whole window.
|
||||
if conn.close_reason().is_some() {
|
||||
return;
|
||||
}
|
||||
learned().lock().unwrap().insert(peer, settled);
|
||||
if sealed_datagram_bytes(session_shard_payload) <= settled as usize {
|
||||
// This session was already clamped small enough — the path is still constrained
|
||||
// (keep the record fresh) but video fits, so no alarm.
|
||||
tracing::info!(peer = %peer, discovered_udp_mtu = settled,
|
||||
"wire MTU: constrained path re-measured; this session's video is sized to fit");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
peer = %peer,
|
||||
discovered_udp_mtu = settled,
|
||||
needed_udp_mtu = ceiling,
|
||||
"wire MTU: this path CANNOT carry full-size video datagrams — the control \
|
||||
plane works but every video packet is oversized for a hop, which streams as \
|
||||
an endless black screen with zero reported loss. Typical cause: a VPN/overlay \
|
||||
adapter (Tailscale / Cloudflare WARP / ZeroTier) claiming the LAN route, or a \
|
||||
lowered NIC MTU — compare `ping <client> -f -l 1450` vs `-l 1200` and check \
|
||||
`netsh interface ipv4 show subinterfaces` (Windows) / `ip link` (Linux). The \
|
||||
measured budget is recorded: the NEXT session from this client sizes video to \
|
||||
fit automatically. To pin it for all sessions set PUNKTFUNK_WIRE_MTU."
|
||||
);
|
||||
// A closed connection stops discovering, so a session that ended before the final
|
||||
// sample proves nothing (a healthy high-RTT path could still be mid-search): learn
|
||||
// only from a connection that stayed alive through the whole window.
|
||||
if conn.close_reason().is_some() {
|
||||
return;
|
||||
}
|
||||
learned().lock().unwrap().insert(peer, settled);
|
||||
if sealed_datagram_bytes(current) <= settled as usize {
|
||||
// This session was already clamped small enough — the path is still constrained
|
||||
// (keep the record fresh) but video fits, so no alarm.
|
||||
tracing::info!(peer = %peer, discovered_udp_mtu = settled,
|
||||
"wire MTU: constrained path re-measured; this session's video is sized to fit");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
peer = %peer,
|
||||
discovered_udp_mtu = settled,
|
||||
needed_udp_mtu = ceiling,
|
||||
"wire MTU: this path CANNOT carry full-size video datagrams — the control \
|
||||
plane works but every video packet is oversized for a hop, which streams as \
|
||||
an endless black screen with zero reported loss. Typical cause: a VPN/overlay \
|
||||
adapter (Tailscale / Cloudflare WARP / ZeroTier) claiming the LAN route, or a \
|
||||
lowered NIC MTU — compare `ping <client> -f -l 1450` vs `-l 1200` and check \
|
||||
`netsh interface ipv4 show subinterfaces` (Windows) / `ip link` (Linux). The \
|
||||
measured budget is recorded: the NEXT session from this client sizes video to \
|
||||
fit automatically. To pin it for all sessions set PUNKTFUNK_WIRE_MTU."
|
||||
);
|
||||
// Phase 2 down-leg: heal THIS session at the verdict mark. Shrink is sent
|
||||
// and applied immediately — per-frame pinning on the client makes ordering
|
||||
// irrelevant and smaller always fits; the ack is telemetry. The learned
|
||||
// record above still makes session 2 START right.
|
||||
if let Some(r) = reneg.as_ref() {
|
||||
let target = shard_payload_for_udp_budget(settled as usize, peer);
|
||||
if target < current
|
||||
&& r.change_tx.send(target as u16).is_ok()
|
||||
&& r.apply_tx.send(target).is_ok()
|
||||
{
|
||||
tracing::info!(
|
||||
peer = %peer,
|
||||
shard_payload = target,
|
||||
was = current,
|
||||
"wire MTU: video re-keyed mid-session to fit the constrained path \
|
||||
— the stream heals now instead of on the next connect"
|
||||
);
|
||||
current = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 2 up-leg: jumbo grow — operator opt-in (PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU
|
||||
// > 1500, which also raised the endpoint's probe ceiling so `settled` can even reach
|
||||
// here), client-advertised headroom, and a settled-at-jumbo proof. The grow is
|
||||
// ACK-GATED: not one sealed datagram above the old size leaves before the client's
|
||||
// ack, even though its buffers are statically sized — the rule must not erode.
|
||||
let (Some(mtu), Some(r)) = (jumbo_wire_mtu(), reneg.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let target = jumbo_shard_payload_for(mtu, peer).min(r.client_ceiling as usize);
|
||||
let target = target - target % 2;
|
||||
if target <= current || (settled as usize) < sealed_datagram_bytes(target) {
|
||||
return;
|
||||
}
|
||||
if r.change_tx.send(target as u16).is_err() {
|
||||
return;
|
||||
}
|
||||
let acked = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while let Some(v) = r.ack_rx.recv().await {
|
||||
if v as usize == target {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !acked {
|
||||
tracing::warn!(peer = %peer, shard_payload = target,
|
||||
"wire MTU: jumbo grow not acked — staying at the current wire");
|
||||
return;
|
||||
}
|
||||
if r.apply_tx.send(target).is_err() {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
peer = %peer,
|
||||
shard_payload = target,
|
||||
was = current,
|
||||
wire_mtu = mtu,
|
||||
"wire MTU: jumbo grow acked and applied — packets-per-frame cut ~6×"
|
||||
);
|
||||
current = target;
|
||||
// Revert guard: a mis-proven jumbo hop must self-correct instead of blackholing.
|
||||
// quinn's PMTU blackhole detection lowers `current_mtu` when the big packets start
|
||||
// vanishing; sample it and shrink back through the same path the down-leg uses.
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if conn.close_reason().is_some() {
|
||||
return;
|
||||
}
|
||||
let mtu_now = conn.stats().path.current_mtu;
|
||||
if (mtu_now as usize) < sealed_datagram_bytes(current) {
|
||||
let back = shard_payload_for_udp_budget(mtu_now as usize, peer);
|
||||
tracing::warn!(peer = %peer, discovered_udp_mtu = mtu_now,
|
||||
shard_payload = back, was = current,
|
||||
"wire MTU: jumbo path stopped fitting — reverting the wire to match");
|
||||
if r.change_tx.send(back as u16).is_err() || r.apply_tx.send(back).is_err() {
|
||||
return;
|
||||
}
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,7 +488,17 @@
|
||||
|
||||
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||
#define MAX_DATAGRAM_BYTES 2048
|
||||
//
|
||||
// Sized for **jumbo frames** (design/shard-payload-reneg.md W0.2): a 9000-MTU LAN carries
|
||||
// ~8908-byte shards (sealed 8972-byte UDP payloads), and every receive path — the transport
|
||||
// `RECV_BUF`, the session's `recvmmsg` ring — is sized from this constant, so a deployed
|
||||
// client can accept a jumbo geometry the moment its host negotiates one. The ring cost is
|
||||
// 128 × ~9 KiB ≈ 1.1 MiB per **client** session (lazily allocated on first poll; hosts never
|
||||
// allocate it) — measured against the ~256 KiB it was at 2048, an acceptable static price
|
||||
// for never having to resize buffers on a mid-session grow. Senders still derive their
|
||||
// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
|
||||
// this is the acceptance ceiling, not a transmit size.
|
||||
#define MAX_DATAGRAM_BYTES 9216
|
||||
|
||||
// The slice-flush floor: a sentinel block below this many data shards costs disproportionate
|
||||
// per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush
|
||||
@@ -816,6 +826,16 @@
|
||||
#define MSG_RFI_REQUEST 7
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ShardPayloadChanged`].
|
||||
#define MSG_SHARD_PAYLOAD_CHANGED 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ShardPayloadAck`].
|
||||
#define MSG_SHARD_PAYLOAD_ACK 9
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ProbeRequest`].
|
||||
#define MSG_PROBE_REQUEST 32
|
||||
|
||||
Reference in New Issue
Block a user