feat(core/wire): per-frame shard geometry, jumbo ceiling, Hello advertisement

Phase 0 of mid-session shard-payload renegotiation (planning
design/shard-payload-reneg.md), stacked on the leg-1 MTU resilience. All
three legs are client-side and forward-compatible: deployed clients that
carry them accept a mid-session shard change the moment a future host
sends one, and nothing changes on the wire until then.

- W0.1 — the reassembler's strict shard_bytes firewall becomes per-frame
  pinning: a frame's first-arriving packet pins that frame's shard size
  (bounds-checked to [min_shard_bytes, max_shard_bytes], even), later
  packets must match the pin, and the per-frame block ceiling derives
  from the pinned size (a session-level cap would reject legitimate
  post-shrink frames). The reorder race between an ordered control
  message and unordered video dies structurally: old-geometry 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. The
  in-flight budget stays byte-based and exact.
- W0.2 — MAX_DATAGRAM_BYTES 2048 → 9216: every receive path (transport
  RECV_BUF, the recvmmsg ring) now accepts sealed jumbo datagrams
  (9000-MTU LAN ≈ 8908-byte shards). Static buffers over resize-on-ack:
  the ring delta is 128 × ~7 KiB ≈ 896 KiB per client session, lazily
  allocated, hosts unaffected. Grep verdict: no embedder uses the
  constant directly, so no C ABI bump — the regenerated header rides
  along (drift gate).
- W0.3 — trailing Hello field max_shard_payload: u16 (0/absent =
  legacy), the append-with-placeholder discipline of video_caps/
  client_caps. One field is both the renegotiation capability flag and
  the jumbo ceiling; core's pump advertises it for all client families,
  the probe too.
- Host seam for Phase 1, dead until wired: Packetizer::set_shard_payload
  (re-derives the block ceilings; construction delegates to it) +
  Session::set_shard_payload (host-only, Config::validate parity).

Verification (the 0.23.0 lesson — geometry changes breed sizing bugs):
the slice-wire suite re-runs at shard 512/1216/1408/8908 (exact-multiple
sweep, lossy + reversed roundtrips, sentinel path, in-flight budget);
mid-stream shrink→grow→revert delivery; the old-geometry reorder race;
cross-geometry splice rejection; firewall bounds non-vacuous both ways;
a 48-case mixed-geometry reorder-torture proptest asserting per-frame
byte-identical DELIVERY and an exactly-zero final budget; and a sealed
loopback session test (continuous crypto/replay) delivering frames
across live re-keys — every test asserts delivered frames, never the
absence of errors.

core: 294/294 --features quic + clippy -D warnings (macOS), fmt.
This commit is contained in:
2026-08-04 19:27:09 +02:00
parent 4bc7eecf05
commit 290d760ea4
9 changed files with 754 additions and 41 deletions
+11 -1
View File
@@ -481,7 +481,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