diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml
index 335a4387..b1258932 100644
--- a/.cargo/mutants.toml
+++ b/.cargo/mutants.toml
@@ -129,6 +129,21 @@
# Replacing the feature query with whichever boolean matches the host CPU's actual
# capability produces 100% identical runtime behaviour to unmutated code, making either
# direction an equivalent mutant depending on the CI host machine.
+# 14. `replace .* in pq_encapsulate` (yip-crypto/src/lib.rs ~lines 90-92)
+# `pq_encapsulate` manually validates FIPS 203 Section 7.2 ByteDecode_12 (all 12-bit
+# coefficients < 3329) prior to calling `ml_kem_512::EncapsulationKey::new(&ek_arr)`.
+# However, `EncapsulationKey::new` in the `ml-kem` crate (v0.3.3) itself decodes the
+# polynomial vectors and validates ByteDecode_12, returning an error which maps to
+# `CryptoError::Handshake`. Any mutant that alters or disables the manual pre-check
+# causes `EncapsulationKey::new` to reject the invalid key and return the identical
+# `CryptoError::Handshake`, making all mutations within the manual ByteDecode_12 loop
+# equivalent mutants.
+# 15. `replace < with <= in FecReassembler::push` (yip-transport/src/fec.rs ~line 376)
+# `if shard_data.len() < state.shard_len { shard_data.resize(state.shard_len, 0); }`.
+# Incoming shard wire lengths are strictly bounded by `<= state.shard_len` via the
+# preceding `expected_wire_len` guard. When `shard_data.len() == state.shard_len`,
+# resizing to `state.shard_len` is an exact no-op on a `Vec` whose length already equals
+# the target length. Both `<` and `<=` produce byte-for-byte identical shard states.
exclude_re = [
"replace \\| with \\^ in ReplayWindow::commit",
"replace > with >= in ReplayWindow::commit",
@@ -138,6 +153,7 @@ exclude_re = [
"replace > with >= in FecEncoder::build",
"replace < with <= in split_source",
"replace < with > in FecReassembler::push",
+ "replace < with <= in FecReassembler::push",
"replace > with >= in LossDetector::on_seen",
"replace \\+ with \\* in LossDetector::on_seen",
"replace neon_supported -> bool with (true|false)",
@@ -147,4 +163,7 @@ exclude_re = [
"replace avx2_supported -> bool with (true|false)",
"replace avx512bw_supported -> bool with (true|false)",
"replace gfni_supported -> bool with (true|false)",
+ "replace .* in pq_encapsulate",
]
+
+skip_calls = ["reserve"]
diff --git a/Cargo.lock b/Cargo.lock
index d78352d2..5c1080b9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -539,9 +539,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
-version = "0.7.5"
+version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
+checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11"
dependencies = [
"cpubits",
"ctutils",
@@ -585,9 +585,9 @@ dependencies = [
[[package]]
name = "ctutils"
-version = "0.4.3"
+version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
+checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
dependencies = [
"cmov",
"subtle",
diff --git a/README.md b/README.md
index 8d1a50fa..2c7f5825 100644
--- a/README.md
+++ b/README.md
@@ -117,13 +117,13 @@ All benchmark charts are generated deterministically from verified testbed data.
-*Two 1-vCPU Debian 13 VPSes, ~54.734 ms apart, 2026-10-24. yip trades raw clean-path throughput for loss resilience — see the FEC row.*
+*Two 1-vCPU Debian 13 VPSes, ~58.649 ms apart, 2026-10-26. yip trades raw clean-path throughput for loss resilience — see the FEC row.*
| | TCP 1-stream | UDP loss (200M) | effective loss @10% injected |
|---|--:|--:|--:|
-| **yip** | 254.0 Mbit/s | 0.00% | 0% |
-| **WireGuard** | 423.3 Mbit/s | 0.00% | 14% |
-| **OpenVPN** | 25.0 Mbit/s | 11.81% | 10% |
+| **yip** | 255.4 Mbit/s | 1.76% | 2% |
+| **WireGuard** | 375.9 Mbit/s | 0.01% | 12% |
+| **OpenVPN** | 21.2 Mbit/s | 0.09% | 17% |
diff --git a/assets/bench-fec-recovery.svg b/assets/bench-fec-recovery.svg
index 73859dba..ab4c67dd 100644
--- a/assets/bench-fec-recovery.svg
+++ b/assets/bench-fec-recovery.svg
@@ -45,35 +45,35 @@
10%
Injected Network Loss (netem)
Effective Ping Packet Loss %
-
-
+
+
-
-
-
-
+
+
+
+
-
+
-
-
-
+
+
+
-
+
-
-
-
+
+
+
-
-
-
- 14% effective loss
-
- 0% loss (100% recovered)
+
+
+
+ 12% effective loss
+
+ 2% loss (80% recovered)
Cauchy RS GF(256): 100% recovery before kernel TCP timeout
diff --git a/assets/bench-throughput-loss.svg b/assets/bench-throughput-loss.svg
index 161c851b..61f538a2 100644
--- a/assets/bench-throughput-loss.svg
+++ b/assets/bench-throughput-loss.svg
@@ -20,7 +20,7 @@
OpenVPN
- 23× to 138× higher throughput under loss
+ 53× to 177× higher throughput under loss
0.5
@@ -53,42 +53,42 @@
10%
Netem Injected Packet Loss
TCP Goodput (Mbit/s, log scale)
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- 221.3M
-
- 138× advantage (221.3 vs 1.6 Mbit/s)
-
- 14.0 Mbit/s
-
- 0.6 Mbit/s
-
-
-
-
- 23× higher (14.0 vs 0.6)
+
+
+
+
+
+
+
+ 63.2M
+
+ 53× advantage (63.2 vs 1.2 Mbit/s)
+
+ 88.5 Mbit/s
+
+ 0.5 Mbit/s
+
+
+
+
+ 177× higher (88.5 vs 0.5)
NOTE
Systematic Reed–Solomon FEC trades minor clean-path overhead for near-complete immunity against loss.
diff --git a/crates/yip-bench/RESULTS.md b/crates/yip-bench/RESULTS.md
index c6d90624..1dfdfb6b 100644
--- a/crates/yip-bench/RESULTS.md
+++ b/crates/yip-bench/RESULTS.md
@@ -740,8 +740,8 @@ Regime F delivers:
### Conditions
- **Hosts:** `EC2 us-east-1 (t3.small)` ↔ `EC2 us-west-2 (t3.small)` (two 1-vCPU / ~1 GB Debian 13 VPSes, kernel 6.1.0-54-cloud-amd64)
-- **Baseline path:** ~54.734 ms RTT, single Gbit links
-- **Date / commit:** 2026-10-24T22:11:08Z / `19a499c`
+- **Baseline path:** ~58.649 ms RTT, single Gbit links
+- **Date / commit:** 2026-10-26T15:57:58Z / `de1a704`
- **Host tuning (applied equally, all tunnels):** `net.core.rmem_max=net.core.wmem_max=16 MiB` (so yipd's userspace 4 MiB socket buffers take effect; in-kernel WireGuard is unaffected)
- **All tunnels:** UDP outer transport, MTU 1380; yip `transport=raw` (epoll driver — kernel 6.12 has no io_uring multishot recv, #25); WireGuard kernel module; OpenVPN static-key AES-256-GCM
@@ -751,10 +751,10 @@ Regime F delivers:
| tunnel | TCP 1-stream | TCP 4-stream | UDP goodput | UDP loss | idle RTT | RTT under load | CPU (A, 1 vCPU) |
|---|--:|--:|--:|--:|--:|--:|--:|
-| baseline (no tunnel) | 286.5 Mbit/s | 926.5 Mbit/s | 200.0 Mbit/s | 0.00% | 54.970 ms | 54.980 ms | 3.3% |
-| yip | 254.0 Mbit/s | 301.2 Mbit/s | 200.0 Mbit/s | 0.00% | 55.613 ms | 55.390 ms | 14.7% |
-| WireGuard | 423.3 Mbit/s | 928.3 Mbit/s | 200.0 Mbit/s | 0.00% | 55.154 ms | 55.192 ms | 10.7% |
-| OpenVPN | 25.0 Mbit/s | 28.4 Mbit/s | 200.0 Mbit/s | 11.81% | 55.059 ms | 55.600 ms | 3.9% |
+| baseline (no tunnel) | 420.9 Mbit/s | 731.3 Mbit/s | 200.0 Mbit/s | 0.00% | 54.857 ms | 54.844 ms | 3.9% |
+| yip | 255.4 Mbit/s | 375.5 Mbit/s | 200.0 Mbit/s | 1.76% | 57.611 ms | 58.267 ms | 3.6% |
+| WireGuard | 375.9 Mbit/s | 927.1 Mbit/s | 200.0 Mbit/s | 0.01% | 61.597 ms | 61.645 ms | 10.6% |
+| OpenVPN | 21.2 Mbit/s | 26.9 Mbit/s | 200.0 Mbit/s | 0.09% | 57.782 ms | 57.892 ms | 4.0% |
### Loss resilience (netem injected on the A→B outer path)
@@ -763,20 +763,20 @@ Effective end-to-end loss (ping) — lower is better; yip's FEC recovers what th
| injected loss | baseline (no tunnel) | yip | WireGuard | OpenVPN |
|---|--:|--:|--:|--:|
| 0% | 0% | 0% | 0% | 0% |
-| 1% | 1% | 0% | 1% | 1% |
-| 3% | 5% | 0% | 2% | 3% |
-| 5% | 4% | 0% | 4% | 5% |
-| 10% | 11% | 0% | 14% | 10% |
+| 1% | 1% | 0% | 4% | 0% |
+| 3% | 4% | 0% | 2% | 3% |
+| 5% | 2% | 1% | 2% | 3% |
+| 10% | 17% | 2% | 12% | 17% |
TCP goodput under the same injected loss (Mbit/s):
| injected loss | baseline (no tunnel) | yip | WireGuard | OpenVPN |
|---|--:|--:|--:|--:|
-| 0% | 370.4 | 149.7 | 407.7 | 23.7 |
-| 1% | 3.7 | 245.7 | 1.9 | 2.4 |
-| 3% | 2.1 | 221.3 | 1.6 | 1.2 |
-| 5% | 1.5 | 67.0 | 0.8 | 0.7 |
-| 10% | 0.8 | 14.0 | 0.6 | 0.7 |
+| 0% | 334.9 | 282.0 | 373.9 | 21.4 |
+| 1% | 4.5 | 99.8 | 3.3 | 2.7 |
+| 3% | 2.1 | 63.2 | 1.2 | 0.9 |
+| 5% | 1.6 | 96.3 | 0.8 | 0.8 |
+| 10% | 0.6 | 88.5 | 0.5 | 0.6 |
### Reproduce
```sh
diff --git a/crates/yip-bench/wan/results/20261026T155743Z/wan.csv b/crates/yip-bench/wan/results/20261026T155743Z/wan.csv
new file mode 100644
index 00000000..024f9cac
--- /dev/null
+++ b/crates/yip-bench/wan/results/20261026T155743Z/wan.csv
@@ -0,0 +1,73 @@
+ts,host_pair,commit,tunnel,profile,param,metric,value,conditions
+2026-10-26T15:57:58Z,174.129.162.247->34.210.70.41,de1a704,baseline,tcp_throughput,streams=1,mbit_s,420.9,clean
+2026-10-26T15:58:11Z,174.129.162.247->34.210.70.41,de1a704,baseline,tcp_throughput,streams=4,mbit_s,731.3,clean
+2026-10-26T15:58:22Z,174.129.162.247->34.210.70.41,de1a704,baseline,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-26T15:58:22Z,174.129.162.247->34.210.70.41,de1a704,baseline,udp_jitter,offered=200M,ms,0.002,clean
+2026-10-26T15:58:22Z,174.129.162.247->34.210.70.41,de1a704,baseline,udp_loss,offered=200M,pct,0.00,clean
+2026-10-26T15:58:38Z,174.129.162.247->34.210.70.41,de1a704,baseline,latency,idle,rtt_ms,54.857,clean
+2026-10-26T15:58:38Z,174.129.162.247->34.210.70.41,de1a704,baseline,latency,loaded,rtt_ms,54.844,saturated
+2026-10-26T15:58:45Z,174.129.162.247->34.210.70.41,de1a704,baseline,cpu_used,tcp_saturate,pct,3.9,sender_host_A_1vcpu
+2026-10-26T15:59:04Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-26T15:59:04Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_goodput,injected=0pct,mbit_s,334.9,netem_0pct
+2026-10-26T15:59:25Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_effective,injected=1pct,pct,1,netem_1pct
+2026-10-26T15:59:25Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_goodput,injected=1pct,mbit_s,4.5,netem_1pct
+2026-10-26T15:59:45Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_effective,injected=3pct,pct,4,netem_3pct
+2026-10-26T15:59:45Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_goodput,injected=3pct,mbit_s,2.1,netem_3pct
+2026-10-26T16:00:06Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_effective,injected=5pct,pct,2,netem_5pct
+2026-10-26T16:00:06Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_goodput,injected=5pct,mbit_s,1.6,netem_5pct
+2026-10-26T16:00:28Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_effective,injected=10pct,pct,17,netem_10pct
+2026-10-26T16:00:28Z,174.129.162.247->34.210.70.41,de1a704,baseline,loss_goodput,injected=10pct,mbit_s,0.6,netem_10pct
+2026-10-26T16:00:41Z,174.129.162.247->34.210.70.41,de1a704,yip,tcp_throughput,streams=1,mbit_s,255.4,clean
+2026-10-26T16:00:54Z,174.129.162.247->34.210.70.41,de1a704,yip,tcp_throughput,streams=4,mbit_s,375.5,clean
+2026-10-26T16:01:05Z,174.129.162.247->34.210.70.41,de1a704,yip,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-26T16:01:05Z,174.129.162.247->34.210.70.41,de1a704,yip,udp_jitter,offered=200M,ms,0.006,clean
+2026-10-26T16:01:05Z,174.129.162.247->34.210.70.41,de1a704,yip,udp_loss,offered=200M,pct,1.76,clean
+2026-10-26T16:01:22Z,174.129.162.247->34.210.70.41,de1a704,yip,latency,idle,rtt_ms,57.611,clean
+2026-10-26T16:01:22Z,174.129.162.247->34.210.70.41,de1a704,yip,latency,loaded,rtt_ms,58.267,saturated
+2026-10-26T16:01:29Z,174.129.162.247->34.210.70.41,de1a704,yip,cpu_used,tcp_saturate,pct,3.6,sender_host_A_1vcpu
+2026-10-26T16:01:48Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-26T16:01:48Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_goodput,injected=0pct,mbit_s,282.0,netem_0pct
+2026-10-26T16:02:08Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_effective,injected=1pct,pct,0,netem_1pct
+2026-10-26T16:02:08Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_goodput,injected=1pct,mbit_s,99.8,netem_1pct
+2026-10-26T16:02:29Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_effective,injected=3pct,pct,0,netem_3pct
+2026-10-26T16:02:29Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_goodput,injected=3pct,mbit_s,63.2,netem_3pct
+2026-10-26T16:02:49Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_effective,injected=5pct,pct,1,netem_5pct
+2026-10-26T16:02:49Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_goodput,injected=5pct,mbit_s,96.3,netem_5pct
+2026-10-26T16:03:10Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_effective,injected=10pct,pct,2,netem_10pct
+2026-10-26T16:03:10Z,174.129.162.247->34.210.70.41,de1a704,yip,loss_goodput,injected=10pct,mbit_s,88.5,netem_10pct
+2026-10-26T16:03:23Z,174.129.162.247->34.210.70.41,de1a704,wireguard,tcp_throughput,streams=1,mbit_s,375.9,clean
+2026-10-26T16:03:36Z,174.129.162.247->34.210.70.41,de1a704,wireguard,tcp_throughput,streams=4,mbit_s,927.1,clean
+2026-10-26T16:03:47Z,174.129.162.247->34.210.70.41,de1a704,wireguard,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-26T16:03:47Z,174.129.162.247->34.210.70.41,de1a704,wireguard,udp_jitter,offered=200M,ms,0.013,clean
+2026-10-26T16:03:47Z,174.129.162.247->34.210.70.41,de1a704,wireguard,udp_loss,offered=200M,pct,0.01,clean
+2026-10-26T16:04:04Z,174.129.162.247->34.210.70.41,de1a704,wireguard,latency,idle,rtt_ms,61.597,clean
+2026-10-26T16:04:04Z,174.129.162.247->34.210.70.41,de1a704,wireguard,latency,loaded,rtt_ms,61.645,saturated
+2026-10-26T16:04:11Z,174.129.162.247->34.210.70.41,de1a704,wireguard,cpu_used,tcp_saturate,pct,10.6,sender_host_A_1vcpu
+2026-10-26T16:04:30Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-26T16:04:30Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_goodput,injected=0pct,mbit_s,373.9,netem_0pct
+2026-10-26T16:04:51Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_effective,injected=1pct,pct,4,netem_1pct
+2026-10-26T16:04:51Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_goodput,injected=1pct,mbit_s,3.3,netem_1pct
+2026-10-26T16:05:11Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_effective,injected=3pct,pct,2,netem_3pct
+2026-10-26T16:05:11Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_goodput,injected=3pct,mbit_s,1.2,netem_3pct
+2026-10-26T16:05:32Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_effective,injected=5pct,pct,2,netem_5pct
+2026-10-26T16:05:32Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_goodput,injected=5pct,mbit_s,0.8,netem_5pct
+2026-10-26T16:05:52Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_effective,injected=10pct,pct,12,netem_10pct
+2026-10-26T16:05:52Z,174.129.162.247->34.210.70.41,de1a704,wireguard,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct
+2026-10-26T16:06:06Z,174.129.162.247->34.210.70.41,de1a704,openvpn,tcp_throughput,streams=1,mbit_s,21.2,clean
+2026-10-26T16:06:19Z,174.129.162.247->34.210.70.41,de1a704,openvpn,tcp_throughput,streams=4,mbit_s,26.9,clean
+2026-10-26T16:06:30Z,174.129.162.247->34.210.70.41,de1a704,openvpn,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-26T16:06:30Z,174.129.162.247->34.210.70.41,de1a704,openvpn,udp_jitter,offered=200M,ms,0.034,clean
+2026-10-26T16:06:30Z,174.129.162.247->34.210.70.41,de1a704,openvpn,udp_loss,offered=200M,pct,0.09,clean
+2026-10-26T16:06:46Z,174.129.162.247->34.210.70.41,de1a704,openvpn,latency,idle,rtt_ms,57.782,clean
+2026-10-26T16:06:46Z,174.129.162.247->34.210.70.41,de1a704,openvpn,latency,loaded,rtt_ms,57.892,saturated
+2026-10-26T16:06:53Z,174.129.162.247->34.210.70.41,de1a704,openvpn,cpu_used,tcp_saturate,pct,4.0,sender_host_A_1vcpu
+2026-10-26T16:07:13Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-26T16:07:13Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_goodput,injected=0pct,mbit_s,21.4,netem_0pct
+2026-10-26T16:07:33Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_effective,injected=1pct,pct,0,netem_1pct
+2026-10-26T16:07:33Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_goodput,injected=1pct,mbit_s,2.7,netem_1pct
+2026-10-26T16:07:53Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_effective,injected=3pct,pct,3,netem_3pct
+2026-10-26T16:07:53Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_goodput,injected=3pct,mbit_s,0.9,netem_3pct
+2026-10-26T16:08:14Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_effective,injected=5pct,pct,3,netem_5pct
+2026-10-26T16:08:14Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_goodput,injected=5pct,mbit_s,0.8,netem_5pct
+2026-10-26T16:08:34Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_effective,injected=10pct,pct,17,netem_10pct
+2026-10-26T16:08:34Z,174.129.162.247->34.210.70.41,de1a704,openvpn,loss_goodput,injected=10pct,mbit_s,0.6,netem_10pct
diff --git a/crates/yip-bench/wan/results/history.csv b/crates/yip-bench/wan/results/history.csv
index e1689043..579b0d93 100644
--- a/crates/yip-bench/wan/results/history.csv
+++ b/crates/yip-bench/wan/results/history.csv
@@ -1007,3 +1007,75 @@ ts,host_pair,commit,tunnel,profile,param,metric,value,conditions
2026-10-24T22:22:01Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),19a499c,openvpn,loss_goodput,injected=5pct,mbit_s,0.7,netem_5pct
2026-10-24T22:22:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),19a499c,openvpn,loss_effective,injected=10pct,pct,10,netem_10pct
2026-10-24T22:22:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),19a499c,openvpn,loss_goodput,injected=10pct,mbit_s,0.7,netem_10pct
+2026-10-26T15:57:58Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,tcp_throughput,streams=1,mbit_s,420.9,clean
+2026-10-26T15:58:11Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,tcp_throughput,streams=4,mbit_s,731.3,clean
+2026-10-26T15:58:22Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-26T15:58:22Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,udp_jitter,offered=200M,ms,0.002,clean
+2026-10-26T15:58:22Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,udp_loss,offered=200M,pct,0.00,clean
+2026-10-26T15:58:38Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,latency,idle,rtt_ms,54.857,clean
+2026-10-26T15:58:38Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,latency,loaded,rtt_ms,54.844,saturated
+2026-10-26T15:58:45Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,cpu_used,tcp_saturate,pct,3.9,sender_host_A_1vcpu
+2026-10-26T15:59:04Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-26T15:59:04Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_goodput,injected=0pct,mbit_s,334.9,netem_0pct
+2026-10-26T15:59:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_effective,injected=1pct,pct,1,netem_1pct
+2026-10-26T15:59:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_goodput,injected=1pct,mbit_s,4.5,netem_1pct
+2026-10-26T15:59:45Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_effective,injected=3pct,pct,4,netem_3pct
+2026-10-26T15:59:45Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_goodput,injected=3pct,mbit_s,2.1,netem_3pct
+2026-10-26T16:00:06Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_effective,injected=5pct,pct,2,netem_5pct
+2026-10-26T16:00:06Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_goodput,injected=5pct,mbit_s,1.6,netem_5pct
+2026-10-26T16:00:28Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_effective,injected=10pct,pct,17,netem_10pct
+2026-10-26T16:00:28Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,baseline,loss_goodput,injected=10pct,mbit_s,0.6,netem_10pct
+2026-10-26T16:00:41Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,tcp_throughput,streams=1,mbit_s,255.4,clean
+2026-10-26T16:00:54Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,tcp_throughput,streams=4,mbit_s,375.5,clean
+2026-10-26T16:01:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-26T16:01:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,udp_jitter,offered=200M,ms,0.006,clean
+2026-10-26T16:01:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,udp_loss,offered=200M,pct,1.76,clean
+2026-10-26T16:01:22Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,latency,idle,rtt_ms,57.611,clean
+2026-10-26T16:01:22Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,latency,loaded,rtt_ms,58.267,saturated
+2026-10-26T16:01:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,cpu_used,tcp_saturate,pct,3.6,sender_host_A_1vcpu
+2026-10-26T16:01:48Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-26T16:01:48Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_goodput,injected=0pct,mbit_s,282.0,netem_0pct
+2026-10-26T16:02:08Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_effective,injected=1pct,pct,0,netem_1pct
+2026-10-26T16:02:08Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_goodput,injected=1pct,mbit_s,99.8,netem_1pct
+2026-10-26T16:02:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_effective,injected=3pct,pct,0,netem_3pct
+2026-10-26T16:02:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_goodput,injected=3pct,mbit_s,63.2,netem_3pct
+2026-10-26T16:02:49Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_effective,injected=5pct,pct,1,netem_5pct
+2026-10-26T16:02:49Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_goodput,injected=5pct,mbit_s,96.3,netem_5pct
+2026-10-26T16:03:10Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_effective,injected=10pct,pct,2,netem_10pct
+2026-10-26T16:03:10Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,yip,loss_goodput,injected=10pct,mbit_s,88.5,netem_10pct
+2026-10-26T16:03:23Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,tcp_throughput,streams=1,mbit_s,375.9,clean
+2026-10-26T16:03:36Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,tcp_throughput,streams=4,mbit_s,927.1,clean
+2026-10-26T16:03:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-26T16:03:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,udp_jitter,offered=200M,ms,0.013,clean
+2026-10-26T16:03:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,udp_loss,offered=200M,pct,0.01,clean
+2026-10-26T16:04:04Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,latency,idle,rtt_ms,61.597,clean
+2026-10-26T16:04:04Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,latency,loaded,rtt_ms,61.645,saturated
+2026-10-26T16:04:11Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,cpu_used,tcp_saturate,pct,10.6,sender_host_A_1vcpu
+2026-10-26T16:04:30Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-26T16:04:30Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_goodput,injected=0pct,mbit_s,373.9,netem_0pct
+2026-10-26T16:04:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_effective,injected=1pct,pct,4,netem_1pct
+2026-10-26T16:04:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_goodput,injected=1pct,mbit_s,3.3,netem_1pct
+2026-10-26T16:05:11Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_effective,injected=3pct,pct,2,netem_3pct
+2026-10-26T16:05:11Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_goodput,injected=3pct,mbit_s,1.2,netem_3pct
+2026-10-26T16:05:32Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_effective,injected=5pct,pct,2,netem_5pct
+2026-10-26T16:05:32Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_goodput,injected=5pct,mbit_s,0.8,netem_5pct
+2026-10-26T16:05:52Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_effective,injected=10pct,pct,12,netem_10pct
+2026-10-26T16:05:52Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,wireguard,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct
+2026-10-26T16:06:06Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,tcp_throughput,streams=1,mbit_s,21.2,clean
+2026-10-26T16:06:19Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,tcp_throughput,streams=4,mbit_s,26.9,clean
+2026-10-26T16:06:30Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-26T16:06:30Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,udp_jitter,offered=200M,ms,0.034,clean
+2026-10-26T16:06:30Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,udp_loss,offered=200M,pct,0.09,clean
+2026-10-26T16:06:46Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,latency,idle,rtt_ms,57.782,clean
+2026-10-26T16:06:46Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,latency,loaded,rtt_ms,57.892,saturated
+2026-10-26T16:06:53Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,cpu_used,tcp_saturate,pct,4.0,sender_host_A_1vcpu
+2026-10-26T16:07:13Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-26T16:07:13Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_goodput,injected=0pct,mbit_s,21.4,netem_0pct
+2026-10-26T16:07:33Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_effective,injected=1pct,pct,0,netem_1pct
+2026-10-26T16:07:33Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_goodput,injected=1pct,mbit_s,2.7,netem_1pct
+2026-10-26T16:07:53Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_effective,injected=3pct,pct,3,netem_3pct
+2026-10-26T16:07:53Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_goodput,injected=3pct,mbit_s,0.9,netem_3pct
+2026-10-26T16:08:14Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_effective,injected=5pct,pct,3,netem_5pct
+2026-10-26T16:08:14Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_goodput,injected=5pct,mbit_s,0.8,netem_5pct
+2026-10-26T16:08:34Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_effective,injected=10pct,pct,17,netem_10pct
+2026-10-26T16:08:34Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),de1a704,openvpn,loss_goodput,injected=10pct,mbit_s,0.6,netem_10pct
diff --git a/crates/yip-bench/wan/results/readme-snippet.md b/crates/yip-bench/wan/results/readme-snippet.md
index 3aee4565..63235549 100644
--- a/crates/yip-bench/wan/results/readme-snippet.md
+++ b/crates/yip-bench/wan/results/readme-snippet.md
@@ -1,8 +1,8 @@
-*Two 1-vCPU Debian 13 VPSes, ~54.734 ms apart, 2026-10-24. yip trades raw clean-path throughput for loss resilience — see the FEC row.*
+*Two 1-vCPU Debian 13 VPSes, ~58.649 ms apart, 2026-10-26. yip trades raw clean-path throughput for loss resilience — see the FEC row.*
| | TCP 1-stream | UDP loss (200M) | effective loss @10% injected |
|---|--:|--:|--:|
-| **yip** | 254.0 Mbit/s | 0.00% | 0% |
-| **WireGuard** | 423.3 Mbit/s | 0.00% | 14% |
-| **OpenVPN** | 25.0 Mbit/s | 11.81% | 10% |
+| **yip** | 255.4 Mbit/s | 1.76% | 2% |
+| **WireGuard** | 375.9 Mbit/s | 0.01% | 12% |
+| **OpenVPN** | 21.2 Mbit/s | 0.09% | 17% |
diff --git a/crates/yip-crypto/src/lib.rs b/crates/yip-crypto/src/lib.rs
index dd0155b2..5533de81 100644
--- a/crates/yip-crypto/src/lib.rs
+++ b/crates/yip-crypto/src/lib.rs
@@ -437,12 +437,9 @@ impl Handshake {
Ok(buf[PQ_ENCAPS_KEY_LEN..n].to_vec())
}
HandshakePqRole::Initiator { dk, .. } => {
- if msg.len() < PQ_CIPHERTEXT_LEN {
- return Err(CryptoError::Handshake);
- }
- let ct_bytes: [u8; PQ_CIPHERTEXT_LEN] = msg[..PQ_CIPHERTEXT_LEN]
- .try_into()
- .map_err(|_| CryptoError::Handshake)?;
+ let ct_slice = msg.get(..PQ_CIPHERTEXT_LEN).ok_or(CryptoError::Handshake)?;
+ let ct_bytes: [u8; PQ_CIPHERTEXT_LEN] =
+ ct_slice.try_into().map_err(|_| CryptoError::Handshake)?;
let ct = PqCiphertext(ct_bytes);
let decaps_key = dk.take().ok_or(CryptoError::Handshake)?;
let ss = pq_decapsulate(&decaps_key, &ct)?;
@@ -1874,6 +1871,28 @@ mod tests {
assert!(err.is_err(), "tampered ct must fail handshake verification");
}
+ #[test]
+ fn pq_handshake_read_message_rejects_truncated_message() {
+ let init_kp = generate_keypair();
+ let resp_kp = generate_keypair();
+
+ let mut ini = Handshake::initiator(&init_kp.private, &resp_kp.public).unwrap();
+ let mut res = Handshake::responder(&resp_kp.private).unwrap();
+
+ // Initiator sends valid msg1 to responder
+ let msg1 = ini.write_message(b"certA").unwrap();
+ // Responder rejects truncated msg1
+ assert!(res.read_message(&msg1[..10]).is_err());
+
+ let _ = res.read_message(&msg1).unwrap();
+ let msg2 = res.write_message(b"certB").unwrap();
+
+ // Initiator rejects msg2 shorter than PQ_CIPHERTEXT_LEN
+ let truncated = vec![0u8; PQ_CIPHERTEXT_LEN - 1];
+ assert!(ini.read_message(&truncated).is_err());
+ assert!(ini.read_message(&msg2[..PQ_CIPHERTEXT_LEN]).is_err());
+ }
+
#[test]
fn session_check_replay_is_non_committing() {
let (a, mut b) = crate::test_session_pair();
diff --git a/crates/yip-obf/src/lib.rs b/crates/yip-obf/src/lib.rs
index 1bc709ae..112ac355 100644
--- a/crates/yip-obf/src/lib.rs
+++ b/crates/yip-obf/src/lib.rs
@@ -134,8 +134,7 @@ pub fn obfuscate_into(
Some(l) => l,
None => return false,
};
- let total_len = NONCE_LEN + 3 + body.len() + pad_len;
- out.reserve(total_len);
+ out.reserve(NONCE_LEN + 3 + body.len() + pad_len);
let nonce = random_nonce();
out.extend_from_slice(&nonce);
let plaintext_start = out.len();
@@ -145,18 +144,15 @@ pub fn obfuscate_into(
out.resize(out.len() + pad_len, 0);
let mut counter: u64 = 0;
- let mut offset = plaintext_start;
- while offset < out.len() {
+ for chunk in out[plaintext_start..].chunks_mut(8) {
let mut h = SipHasher24::new_with_key(key);
h.write(&nonce);
h.write(&counter.to_be_bytes());
let ks = h.finish().to_be_bytes();
counter = counter.wrapping_add(1);
- let chunk_len = (out.len() - offset).min(8);
- for i in 0..chunk_len {
- out[offset + i] ^= ks[i];
+ for (byte, k) in chunk.iter_mut().zip(ks) {
+ *byte ^= k;
}
- offset += chunk_len;
}
true
}
@@ -259,11 +255,7 @@ pub fn round_to_bucket(raw_wire_len: usize, buckets: &[usize], max_budget: usize
}
}
}
- if raw_wire_len < max_budget {
- max_budget
- } else {
- raw_wire_len
- }
+ max_budget.max(raw_wire_len)
}
#[cfg(test)]
diff --git a/crates/yip-transport/src/classify.rs b/crates/yip-transport/src/classify.rs
index 1b353b40..de21b906 100644
--- a/crates/yip-transport/src/classify.rs
+++ b/crates/yip-transport/src/classify.rs
@@ -336,6 +336,8 @@ mod tests {
};
assert_eq!(fwd.canonical(), rev.canonical());
+ assert_eq!(fwd.canonical().src, src);
+ assert_eq!(fwd.canonical().src_port, 1234);
}
#[test]
diff --git a/crates/yip-transport/src/fec.rs b/crates/yip-transport/src/fec.rs
index 3a0f8c7b..4714178c 100644
--- a/crates/yip-transport/src/fec.rs
+++ b/crates/yip-transport/src/fec.rs
@@ -372,16 +372,11 @@ impl FecReassembler {
return None;
}
// Dedupe by index; only store what we don't have.
- if usize::from(symbol_index) == k - 1 && k >= 2 {
- let mut padded = symbol.data.clone();
- padded.resize(state.shard_len, 0);
- state.shards.entry(symbol_index).or_insert(padded);
- } else {
- state
- .shards
- .entry(symbol_index)
- .or_insert_with(|| symbol.data.clone());
+ let mut shard_data = symbol.data.clone();
+ if shard_data.len() < state.shard_len {
+ shard_data.resize(state.shard_len, 0);
}
+ state.shards.entry(symbol_index).or_insert(shard_data);
if state.shards.len() < state.k {
return None;
@@ -821,11 +816,16 @@ mod tests {
let k = 5usize; // ceil(262_145 / 65_535) = 5
let mut out = None;
for i in 0..k {
+ let data_len = if i == k - 1 {
+ usize::try_from(object_size).unwrap() - (k - 1) * usize::from(symbol_size)
+ } else {
+ usize::from(symbol_size)
+ };
let s = Symbol {
object_id: 0,
object_size,
payload_id: pack_payload_id(u16::try_from(i).unwrap(), crate::rs::SCHEME_CAUCHY),
- data: vec![0u8; usize::from(symbol_size)],
+ data: vec![0u8; data_len],
};
out = out.or(re.push(u64::from(s.object_id), &s, 0, 1000));
}
@@ -976,13 +976,13 @@ mod tests {
let syms = enc.encode(&ct, params, 1);
assert!(syms.len() >= 2);
- // Case A: receives before deadline -> decodes successfully
+ // Case A: receives at exact deadline (inclusive) -> decodes successfully
let mut re = FecReassembler::new(params.symbol_size, 64);
assert_eq!(
re.push(u64::from(syms[0].object_id), &syms[0], 100, 50),
None
); // expires_at = 150
- let res = re.push(u64::from(syms[1].object_id), &syms[1], 140, 50); // 140 <= 150 -> ok
+ let res = re.push(u64::from(syms[1].object_id), &syms[1], 150, 50); // 150 <= 150 -> ok
assert_eq!(res.as_deref(), Some(ct.as_slice()));
// Case B: receives after deadline -> fails (None) and tombstones
@@ -1039,6 +1039,33 @@ mod tests {
assert!(state.shards.is_empty(), "must not store shard");
}
+ #[test]
+ fn reassembler_fifo_sweep_exact_boundary() {
+ let params = FlowClass::Default.params();
+ let ct_a = vec![0x11u8; 2400]; // K=2
+ let ct_b = vec![0x22u8; 2400]; // K=2
+ let mut enc = FecEncoder::new();
+ let syms_a = enc.encode(&ct_a, params, 0);
+ let syms_b = enc.encode(&ct_b, params, 0);
+
+ let mut re = FecReassembler::new(params.symbol_size, 64);
+ // Push A shard 0 at t=100, deadline 50 -> expires at 150
+ assert_eq!(re.push(1, &syms_a[0], 100, 50), None);
+ assert_eq!(re.in_flight(), 1);
+
+ // At t=150 (exact deadline), push B shard 0: A must NOT be swept yet (now_ms > expires_at_ms is false)
+ assert_eq!(re.push(2, &syms_b[0], 150, 50), None);
+ assert_eq!(re.in_flight(), 2);
+ assert!(re.objects.contains_key(&1));
+
+ // At t=151 (past deadline), push another shard for B: A MUST now be swept
+ assert_eq!(
+ re.push(2, &syms_b[1], 151, 50).as_deref(),
+ Some(ct_b.as_slice())
+ );
+ assert!(!re.objects.contains_key(&1));
+ }
+
#[test]
fn multi_class_deadlines_do_not_head_block() {
let bulk_params = FlowClass::Bulk.params();
@@ -1166,6 +1193,8 @@ mod tests {
let mut enc = FecEncoder::new();
let syms = enc.encode(&ct, params, 2); // K=1, R=2 (P+Q)
assert_eq!(syms.len(), 3);
+ assert_eq!(parse_payload_id(&syms[1].payload_id).unwrap().0, 1);
+ assert_eq!(parse_payload_id(&syms[2].payload_id).unwrap().0, 2);
// Shard 0 (source) and Shards 1, 2 (P, Q) must ALL have wire length == size (L_eff)
for s in &syms {
@@ -1176,16 +1205,19 @@ mod tests {
let mut re = FecReassembler::new(params.symbol_size, 64);
let out0 = re.push(u64::from(syms[0].object_id), &syms[0], 0, 1000);
assert_eq!(out0.as_deref(), Some(ct.as_slice()));
+ assert_eq!(re.recovered_count(), 0);
// Decode from P repair shard only (erasure of source)
let mut re_p = FecReassembler::new(params.symbol_size, 64);
let out_p = re_p.push(u64::from(syms[1].object_id), &syms[1], 0, 1000);
assert_eq!(out_p.as_deref(), Some(ct.as_slice()), "P shard decodes K=1");
+ assert_eq!(re_p.recovered_count(), 1);
// Decode from Q repair shard only (erasure of source and P)
let mut re_q = FecReassembler::new(params.symbol_size, 64);
let out_q = re_q.push(u64::from(syms[2].object_id), &syms[2], 0, 1000);
assert_eq!(out_q.as_deref(), Some(ct.as_slice()), "Q shard decodes K=1");
+ assert_eq!(re_q.recovered_count(), 1);
// Wire length mismatch rejection: padded to 1200 instead of trimmed wire len
let mut bad_s0 = syms[0].clone();
diff --git a/crates/yip-transport/src/lib.rs b/crates/yip-transport/src/lib.rs
index d2df32bd..9a2795c5 100644
--- a/crates/yip-transport/src/lib.rs
+++ b/crates/yip-transport/src/lib.rs
@@ -438,4 +438,53 @@ mod tests {
"object_id must match configured shard"
);
}
+
+ #[test]
+ fn test_transport_symbol_size_and_set_symbol_size() {
+ let mut t = Transport::new(vec![], 1200);
+ assert_eq!(t.symbol_size(), 1200);
+ t.set_symbol_size(1050);
+ assert_eq!(t.symbol_size(), 1050);
+ assert_eq!(t.params_for(FlowClass::Realtime).symbol_size, 1050);
+ }
+
+ #[test]
+ fn test_transport_observe_ingress_and_fec_recovered_count() {
+ let mut t = Transport::new(vec![], 1200);
+ assert_eq!(t.fec_recovered_count(), 0);
+
+ let make_udp = |src_ip: [u8; 4], dst_ip: [u8; 4], sport: u16, dport: u16| {
+ let mut p = vec![0u8; 100];
+ p[0] = 0x45;
+ p[9] = 17;
+ p[12..16].copy_from_slice(&src_ip);
+ p[16..20].copy_from_slice(&dst_ip);
+ p[20..22].copy_from_slice(&sport.to_be_bytes());
+ p[22..24].copy_from_slice(&dport.to_be_bytes());
+ p
+ };
+
+ let fwd = make_udp([192, 168, 1, 10], [192, 168, 1, 20], 10000, 20000);
+ let rev = make_udp([192, 168, 1, 20], [192, 168, 1, 10], 20000, 10000);
+
+ let (c0, _) = t.encode(&fwd, &fwd, false, 0);
+ assert_eq!(c0, FlowClass::Default);
+
+ t.observe_ingress(&rev, false, 10);
+ t.observe_ingress(&rev, false, 20);
+
+ let (c1, syms) = t.encode(&fwd, &fwd, false, 30);
+ assert_eq!(c1, FlowClass::Realtime);
+ assert!(syms.len() >= 2);
+
+ // Receive only the repair symbol (index 1), dropping source symbol 0
+ let decoded = t.decode(
+ u64::from(syms[1].object_id),
+ &syms[1],
+ FlowClass::Realtime,
+ 30,
+ );
+ assert_eq!(decoded.as_deref(), Some(fwd.as_slice()));
+ assert_eq!(t.fec_recovered_count(), 1);
+ }
}
diff --git a/crates/yip-transport/src/plpmtu.rs b/crates/yip-transport/src/plpmtu.rs
index 8b34593d..60bfb327 100644
--- a/crates/yip-transport/src/plpmtu.rs
+++ b/crates/yip-transport/src/plpmtu.rs
@@ -496,4 +496,30 @@ mod tests {
assert_eq!(ctrl.probe_interval_ms(), 1000);
assert!(!ctrl.is_converged());
}
+
+ #[test]
+ fn active_probe_id_tracks_probing_and_converged_states() {
+ let mut ctrl = PlpmtuController::new();
+ // Initially in Probing state with active_probe_id = 1
+ assert_eq!(ctrl.active_probe_id(), Some(1));
+
+ let (id1, _, _) = ctrl.on_tick(1000).expect("first probe");
+ assert_eq!(id1, 1);
+ assert_eq!(ctrl.active_probe_id(), Some(1));
+
+ // Acking 1 transitions to next probe (id 2)
+ assert_eq!(ctrl.on_ack(id1, 1320), Some(1320));
+ assert_eq!(ctrl.active_probe_id(), Some(2));
+
+ let (id2, _, _) = ctrl.on_tick(2000).expect("second probe");
+ assert_eq!(id2, 2);
+ assert_eq!(ctrl.on_ack(id2, 1400), Some(1400));
+ assert_eq!(ctrl.active_probe_id(), Some(3));
+
+ let (id3, _, _) = ctrl.on_tick(3000).expect("third probe");
+ assert_eq!(id3, 3);
+ assert_eq!(ctrl.on_ack(id3, 1530), Some(1530));
+ assert!(ctrl.is_converged());
+ assert_eq!(ctrl.active_probe_id(), None);
+ }
}