Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project are documented here, following

## [Unreleased]

### Added
- Adaptive loss-feedback loop + reactive ARQ. The receiver detects post-FEC
residual loss as gaps in the object counter and reports it (with NACKs) in an
authenticated `Control` packet; the sender attributes loss per class and drives
the repair controller. ARQ-eligible (`Bulk`) flows on a clean link now decay
their repair ratio to **zero**, activating the FEC-encode bypass — clean-link
single-stream TCP rises from ~273–285 to ~457 Mbit/s. On loss the controller
re-arms FEC instantly and NACKed `Bulk` objects are retransmitted with fresh
RaptorQ repair symbols (reusing the original object id); `Realtime`/`Default`
flows keep a proactive floor and are not retransmitted. New `yip-transport`
modules: `feedback` (`LossReport`), `lossdetect` (`LossDetector`), `retxbuf`
(`RetxBuffer`), plus `Transport::repair_object`.

### Changed
- Data-plane throughput pass: yipd now batches egress sends (`sendmmsg`) and
ingress reads (`recvmmsg`) through yip-io's `PlainIo`, reuses framing buffers
Expand Down
2 changes: 2 additions & 0 deletions bin/yipd/src/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub enum PacketType {
HandshakeResp = 1,
/// Data-plane packet (used by later tunnel tasks).
Data = 2,
/// Loss-feedback control packet (receiver → sender).
Control = 3,
}

// ── established session ───────────────────────────────────────────────────────
Expand Down
446 changes: 401 additions & 45 deletions bin/yipd/src/tunnel.rs

Large diffs are not rendered by default.

196 changes: 196 additions & 0 deletions bin/yipd/tests/run-netns-tunnel-loss.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
#!/usr/bin/env bash
# End-to-end netns tunnel test for yipd under packet loss.
# Usage: run-netns-tunnel-loss.sh <path-to-yipd-binary>
#
# Creates two network namespaces (yipA, yipB) joined by a veth pair,
# applies 10% random packet loss via tc-netem on both veth interfaces,
# starts a yipd daemon in each, brings up TUN devices with tunnel IPs,
# then pings across the encrypted FEC+ARQ tunnel with a generous count.
#
# The test passes if all 10 pings succeed despite the underlying 10% loss,
# demonstrating that FEC + reactive ARQ together recover the missing packets.
set -euo pipefail

YIPD="${1:?Usage: $0 <yipd-binary>}"
TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-loss-test.XXXXXX)"

NS_A="yipA"
NS_B="yipB"
VETH_A="vethA"
VETH_B="vethB"
VETH_A_IP="10.0.0.1"
VETH_B_IP="10.0.0.2"
TUN_A_IP="10.9.0.1"
TUN_B_IP="10.9.0.2"
TUN_PREFIX="24"
VETH_PREFIX="24"
PORT_A="51820"
PORT_B="51821"
TUN_DEV="yip0"

PID_A=""
PID_B=""

cleanup() {
echo "[cleanup] killing daemons and removing namespaces"
[ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true
[ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true
# Give them a moment to die
sleep 0.2
[ -n "$PID_A" ] && kill -9 "$PID_A" 2>/dev/null || true
[ -n "$PID_B" ] && kill -9 "$PID_B" 2>/dev/null || true
ip netns del "$NS_A" 2>/dev/null || true
ip netns del "$NS_B" 2>/dev/null || true
rm -rf "$TMPDIR_TEST"
}
trap cleanup EXIT

# ── 1. generate keypairs ──────────────────────────────────────────────────────
echo "[setup] generating keypairs"
GENKEY_A="$("$YIPD" --genkey)"
GENKEY_B="$("$YIPD" --genkey)"

PRIV_A="$(echo "$GENKEY_A" | grep '^private=' | cut -d= -f2)"
PUB_A="$(echo "$GENKEY_A" | grep '^public=' | cut -d= -f2)"
PRIV_B="$(echo "$GENKEY_B" | grep '^private=' | cut -d= -f2)"
PUB_B="$(echo "$GENKEY_B" | grep '^public=' | cut -d= -f2)"

# ── 2. write config files ─────────────────────────────────────────────────────
CFG_A="$TMPDIR_TEST/yipA.conf"
CFG_B="$TMPDIR_TEST/yipB.conf"

cat > "$CFG_A" <<EOF
# yipA — responder
local_private=${PRIV_A}
local_public=${PUB_A}
peer_public=${PUB_B}
listen=${VETH_A_IP}:${PORT_A}
peer_endpoint=${VETH_B_IP}:${PORT_B}
device=${TUN_DEV}
initiate=false
EOF

cat > "$CFG_B" <<EOF
# yipB — initiator
local_private=${PRIV_B}
local_public=${PUB_B}
peer_public=${PUB_A}
listen=${VETH_B_IP}:${PORT_B}
peer_endpoint=${VETH_A_IP}:${PORT_A}
device=${TUN_DEV}
initiate=true
EOF

# ── 3. create namespaces and veth pair ────────────────────────────────────────
echo "[setup] creating network namespaces"
ip netns add "$NS_A"
ip netns add "$NS_B"

echo "[setup] creating veth pair"
ip link add "$VETH_A" type veth peer name "$VETH_B"
ip link set "$VETH_A" netns "$NS_A"
ip link set "$VETH_B" netns "$NS_B"

ip netns exec "$NS_A" ip addr add "${VETH_A_IP}/${VETH_PREFIX}" dev "$VETH_A"
ip netns exec "$NS_A" ip link set "$VETH_A" up
ip netns exec "$NS_A" ip link set lo up

ip netns exec "$NS_B" ip addr add "${VETH_B_IP}/${VETH_PREFIX}" dev "$VETH_B"
ip netns exec "$NS_B" ip link set "$VETH_B" up
ip netns exec "$NS_B" ip link set lo up

# ── 4. apply 10% packet loss on both veth interfaces ─────────────────────────
echo "[setup] applying 10% netem loss on veth interfaces"
ip netns exec "$NS_A" tc qdisc add dev "$VETH_A" root netem loss 10%
ip netns exec "$NS_B" tc qdisc add dev "$VETH_B" root netem loss 10%

# ── 5. start daemons ─────────────────────────────────────────────────────────
LOG_A="$TMPDIR_TEST/yipA.log"
LOG_B="$TMPDIR_TEST/yipB.log"

echo "[start] starting yipA (responder)"
ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A" 2>&1 &
PID_A=$!

echo "[start] starting yipB (initiator)"
ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 &
PID_B=$!

# ── 6. wait for handshake and TUN device creation ────────────────────────────
# The daemons create the TUN device after a successful handshake.
# Poll for the TUN device to appear in each namespace (up to 30s under loss).
TUN_WAIT=30
INTERVAL=0.25

echo "[wait] waiting for TUN devices to appear (up to ${TUN_WAIT}s)"
elapsed=0
while true; do
A_UP=0
B_UP=0
ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1 && A_UP=1 || true
ip netns exec "$NS_B" ip link show "$TUN_DEV" >/dev/null 2>&1 && B_UP=1 || true

if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ]; then
echo "[wait] both TUN devices are up"
break
fi

# Check if either daemon died unexpectedly
if ! kill -0 "$PID_A" 2>/dev/null; then
echo "[error] yipA daemon died unexpectedly"
echo "=== yipA log ==="
cat "$LOG_A" || true
exit 1
fi
if ! kill -0 "$PID_B" 2>/dev/null; then
echo "[error] yipB daemon died unexpectedly"
echo "=== yipB log ==="
cat "$LOG_B" || true
exit 1
fi

elapsed=$(awk "BEGIN {print $elapsed + $INTERVAL}")
if awk "BEGIN {exit ($elapsed >= $TUN_WAIT) ? 0 : 1}"; then
echo "[error] timed out waiting for TUN devices"
echo "=== yipA log ==="
cat "$LOG_A" || true
echo "=== yipB log ==="
cat "$LOG_B" || true
exit 1
fi
sleep "$INTERVAL"
done

# ── 7. assign tunnel IPs ──────────────────────────────────────────────────────
echo "[setup] assigning tunnel IPs"
ip netns exec "$NS_A" ip addr add "${TUN_A_IP}/${TUN_PREFIX}" dev "$TUN_DEV"
ip netns exec "$NS_B" ip addr add "${TUN_B_IP}/${TUN_PREFIX}" dev "$TUN_DEV"

# The TUN device is already brought up by the daemon (bring_up in TunTap::create).
# Verify the link is up; if not, bring it up explicitly.
ip netns exec "$NS_A" ip link show "$TUN_DEV" | grep -q "UP" || \
ip netns exec "$NS_A" ip link set "$TUN_DEV" up
ip netns exec "$NS_B" ip link show "$TUN_DEV" | grep -q "UP" || \
ip netns exec "$NS_B" ip link set "$TUN_DEV" up

echo "[check] interface state in yipA:"
ip netns exec "$NS_A" ip addr show "$TUN_DEV"
echo "[check] interface state in yipB:"
ip netns exec "$NS_B" ip addr show "$TUN_DEV"

# Brief additional settle time to ensure the data loops are ready
sleep 0.5

# ── 8. ping across the tunnel (10 pings, 3s each) ────────────────────────────
echo "[test] pinging ${TUN_A_IP} from yipB across the tunnel under 10% loss"
if ip netns exec "$NS_B" ping -c 10 -W 3 "$TUN_A_IP"; then
echo "[PASS] ping succeeded under 10% netem loss"
else
PING_EXIT=$?
echo "[FAIL] ping failed under 10% loss (exit $PING_EXIT)"
echo "=== yipA log ==="
cat "$LOG_A" || true
echo "=== yipB log ==="
cat "$LOG_B" || true
exit "$PING_EXIT"
fi
22 changes: 22 additions & 0 deletions bin/yipd/tests/tunnel_netns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,25 @@ fn ping_across_yipd_tunnel() {
let status = Command::new("bash").arg(script).arg(yipd).status().unwrap();
assert!(status.success(), "netns tunnel ping failed");
}

#[test]
fn ping_across_yipd_tunnel_under_loss() {
let is_root = Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim() == "0")
.unwrap_or(false);
if !is_root {
eprintln!("SKIP ping_across_yipd_tunnel_under_loss: needs root");
return;
}
let yipd = env!("CARGO_BIN_EXE_yipd");
let script = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/run-netns-tunnel-loss.sh"
);
let status = Command::new("bash").arg(script).arg(yipd).status().unwrap();
assert!(status.success(), "netns tunnel ping under 10% loss failed");
}
48 changes: 48 additions & 0 deletions crates/yip-bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,51 @@ forfeit yip's loss-recovery thesis. **The clean-link throughput win (skip the en
datagram count) is therefore unlocked by the adaptive loss-feedback loop, not by this pass alone.** This pass
delivered the plumbing (batched I/O, buffers, a ready-and-tested bypass fast-path); activating the win is the
next milestone.

---

## Feedback loop — Phase A (the clean-link throughput unlock)

The throughput pass shipped a *dormant* zero-repair FEC bypass. The adaptive
loss-feedback loop (this milestone) activates it: the receiver reports post-FEC
residual loss in an authenticated `Control` packet, the sender feeds it to the
per-class controller, and an ARQ-eligible (`Bulk`) flow on a clean link decays
its repair ratio to **zero** — firing the bypass (skip the ~24 µs encode) and
halving the per-packet datagram count (1 source symbol, no repair).

Measured (release, kernel 6.18, Ryzen 5 7640U; clean-link single-stream TCP over
the netns tunnel):

| build | bulk repair ratio | clean-link TCP |
|-------|-------------------|----------------|
| throughput pass (bypass dormant) | 0.05 floor (≥1 repair, encode always runs) | ~273–285 Mbit/s |
| feedback loop (Phase A) | **0.0000** (converged; bypass fires) | **~457 Mbit/s** |

The bulk controller's repair ratio was confirmed at `0.0000` throughout a 40 s
run via yipd's diagnostic log — direct proof the loop converged, not merely
inferred from the throughput rise. The ~60 % jump is consistent with removing the
FEC encode (which the per-stage profile showed is ~80 % of egress CPU) and sending
one datagram per packet instead of two. Under loss the controller snaps repair
back up immediately (Phase B's ARQ then backstops the residual).

### Phase B — reactive ARQ + loss-recovery under the feedback loop

With the feedback loop active, loss recovery is unchanged — the controller
re-arms FEC the instant loss is reported. Measured UDP delivery under `tc netem`
(`run-fec-compare.sh`), feedback loop live:

| loss% | bare_recv% | yip_recv% |
|-------|------------|-----------|
| 0 | 100.0 | 100.0 |
| 5 | 94.7 | 99.7 |
| 10 | 89.9 | 99.0 |

Reactive ARQ retransmits `Bulk` objects the receiver NACKs, using fresh RaptorQ
repair symbols that carry the original object id (so the receiver tops up its
existing decoder). The retransmit codec is unit-proven (`repair_object` completes
an object delivered only one symbol short), the daemon wiring was reviewed for
object-id preservation and lock discipline, and the tunnel stays alive under 10 %
netem loss (netns ping 10/10). A dedicated end-to-end harness that forces
FEC-insufficiency and asserts ARQ-specific `Bulk` recovery (establishing the
tunnel *before* applying loss, with a retransmit buffer sized to the flow rate)
is a tracked follow-up.
Loading
Loading