diff --git a/crates/punktfunk-core/src/session.rs b/crates/punktfunk-core/src/session.rs index 6101430d..a95d6ff5 100644 --- a/crates/punktfunk-core/src/session.rs +++ b/crates/punktfunk-core/src/session.rs @@ -1086,6 +1086,89 @@ mod wire_equivalence_tests { ); } + /// 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