From d0f68cbbcd699b690c9a7cc0a9b4b184464fede0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 9 Jul 2026 11:35:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(core,host):=20per-family=20MTU=20shard=20s?= =?UTF-8?q?izing=20=E2=80=94=20the=20IPv6=20gating=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the dual-stack plan (design/client-parity-and-network-resilience.md, plan 5): the host sizes each session's shard_payload from the QUIC remote's address family instead of assuming IPv4 — 1408 over v4 (unchanged), 1388 over v6 (40-byte header). Rides the existing Welcome::shard_payload negotiation, so there is zero wire change and old clients simply follow. This has to land before any v6 data path exists: the v4-maximal 1408 makes every sealed video datagram overshoot a 1500-MTU IPv6 hop, and v6 routers never fragment — that's a blackhole (every datagram dropped), not the graceful-ish degradation of the b5c30df v4 fragmentation saga. IPv4-mapped v6 remotes (::ffff:a.b.c.d, what a dual-stack [::] socket reports for a v4 client) correctly keep the v4 size — they ride IPv4 on the wire. New mtu1500_shard_payload_v6()/mtu1500_shard_payload_for() in core config with the same pinned never-fragments/maximality tests as the v4 constant, plus a family-selection test. Verified: 82 core lib tests + loopback/c_abi green and host check/clippy clean on Linux (home-worker-2); core tests green on macOS. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-core/src/config.rs | 52 +++++++++++++++++++++++++ crates/punktfunk-host/src/punktfunk1.rs | 19 +++++---- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index cbe623ed..e733f413 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -269,6 +269,32 @@ pub const fn mtu1500_shard_payload() -> usize { p - p % 2 // FEC requires even shards } +/// The IPv6 sibling of [`mtu1500_shard_payload`]: largest **even** shard payload whose sealed wire +/// datagram fits an unfragmented IPv6/UDP packet on a standard 1500-byte MTU: +/// `1500 − 40 (IPv6) − 8 (UDP) − HEADER_LEN − CRYPTO_OVERHEAD` = 1388. The 20 extra header bytes +/// matter MORE here than on v4: IPv6 routers never fragment — an oversized datagram gets an ICMPv6 +/// Packet-Too-Big at best and a silent blackhole at worst — so streaming the v4 size (1408) to a +/// v6 client wouldn't degrade the way v4 fragmentation did (the b5c30df saga), it would drop every +/// video datagram on any 1500-MTU hop. +pub const fn mtu1500_shard_payload_v6() -> usize { + let p = 1500 - 40 - 8 - HEADER_LEN - CRYPTO_OVERHEAD; + p - p % 2 // FEC requires even shards +} + +/// The MTU-safe shard payload for a session streaming to `peer` (the QUIC remote — the data plane +/// dials the same address family): v6 sizing for a genuine IPv6 remote, v4 sizing otherwise — +/// including IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`, what a dual-stack `[::]` socket reports +/// for a v4 client), which ride IPv4 on the wire. Hosts pass this through +/// `Welcome::shard_payload`, so per-family sizing needs no wire change and old clients simply +/// follow the negotiated value. +pub fn mtu1500_shard_payload_for(peer: core::net::IpAddr) -> usize { + match peer { + core::net::IpAddr::V4(_) => mtu1500_shard_payload(), + core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => mtu1500_shard_payload(), + core::net::IpAddr::V6(_) => mtu1500_shard_payload_v6(), + } +} + /// Everything needed to construct a [`Session`](crate::session::Session). /// /// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized @@ -418,6 +444,32 @@ mod tests { assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1472, "not maximal"); } + /// Pin the IPv6 wire math the same way: the sealed datagram must fit 1452 (1500 − IPv6 40 − + /// UDP 8 — v6 routers don't fragment, so overshooting blackholes rather than degrades) and one + /// shard-step above must not. + #[test] + fn mtu1500_shard_payload_v6_never_blackholes() { + let p = mtu1500_shard_payload_v6(); + assert_eq!(p % 2, 0, "FEC requires even shards"); + assert!(p <= max_shard_payload()); + let wire = HEADER_LEN + p + CRYPTO_OVERHEAD; + assert!(wire <= 1452, "sealed datagram {wire} B exceeds a 1500-MTU IPv6 hop"); + assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1452, "not maximal"); + } + + /// 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] + fn shard_payload_follows_peer_family() { + use core::net::IpAddr; + let v4: IpAddr = "192.168.1.50".parse().unwrap(); + let v6: IpAddr = "fd00::50".parse().unwrap(); + let mapped: IpAddr = "::ffff:192.168.1.50".parse().unwrap(); + assert_eq!(mtu1500_shard_payload_for(v4), mtu1500_shard_payload()); + assert_eq!(mtu1500_shard_payload_for(mapped), mtu1500_shard_payload()); + assert_eq!(mtu1500_shard_payload_for(v6), mtu1500_shard_payload_v6()); + } + #[test] fn rejects_block_exceeding_scheme_ceiling() { let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255 diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 0e747e36..f64f225b 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -27,7 +27,7 @@ use anyhow::{anyhow, Context, Result}; use punktfunk_core::config::{ - mtu1500_shard_payload, CompositorPref, FecConfig, FecScheme, GamepadPref, Role, + mtu1500_shard_payload_for, CompositorPref, FecConfig, FecScheme, GamepadPref, Role, }; use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF}; @@ -977,13 +977,16 @@ async fn serve_session( max_data_per_block: 4096, }, // The largest even payload whose sealed datagram (header + shard + crypto) fits an - // unfragmented IPv4/UDP packet on a 1500 MTU — 1408, giving 1472 = the exact ceiling. - // The previous 1452 overshot it (its math forgot the header/crypto ride inside the UDP - // payload) and silently IP-fragmented EVERY video datagram, doubling per-datagram loss - // on Wi-Fi — the "100 Mbps badly fails on the phone" root cause. Negotiated, so the - // client follows. Jumbo (≈8900) is a future negotiated bump (needs MAX_DATAGRAM_BYTES - // raised + end-to-end 9000 MTU). - shard_payload: mtu1500_shard_payload() as u16, + // unfragmented UDP packet on a 1500 MTU for THIS client's address family — 1408 over + // IPv4 (1472 = the exact ceiling), 1388 over IPv6 (40-byte header, and v6 routers + // don't fragment: overshooting there blackholes instead of degrading). The data plane + // dials the same family as this QUIC connection, so the remote decides. The previous + // hardcoded 1452 overshot the v4 ceiling (its math forgot the header/crypto ride + // inside the UDP payload) and silently IP-fragmented EVERY video datagram, doubling + // per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause. + // Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs + // MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU). + shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16, encrypt: true, key, salt: *b"pkf1",