Multipath UDP for QWFWD
PR: Add multipath upstream routing with automatic failover (ExitLag/AntiLag-style)
Problem
When the single upstream path between qwfwd and the QW server becomes
congested or fails, every client behind the proxy is disconnected.
There is no way to survive transient route failures without dropping sessions.
Solution
This PR adds Multipath UDP support (src/multipath.c / src/multipath.h).
Up to 4 independent UDP sockets are opened to the server (or to relay
endpoints). Each path is continuously probed for RTT and packet loss.
Game traffic flows on the single best-scored path at any given moment.
If that path degrades or dies, traffic seamlessly switches to the next
best path — the client never sees a disconnect.
┌─────────────────────────────────────────┐
Client (ezQuake) │ qwfwd proxy │ QW Server
│ │
─── UDP ─────────▶│ peer_t │
│ └─ mp_ctx_t │
│ ├─ path0 [ACTIVE ★] ──UDP──────▶│ port 27500
│ ├─ path1 [ACTIVE ] ──UDP──────▶│ port 27500
│ ├─ path2 [PROBING ] ──UDP──────▶│ port 27500
│ └─ path3 [DEAD ] │
└─────────────────────────────────────────┘
How it works
Path states
DISABLED ──configure──▶ PROBING ──first RTT──▶ ACTIVE
│
┌───────────────────┤
│ loss > 30% │ no response > 3s
▼ ▼
DEGRADED DEAD ──10s──▶ REVIVING
│ │
│ loss < 10% first RTT ─────┘
└──────▶ ACTIVE
RTT measurement
A tiny 8-byte probe packet is sent on every path every 500 ms
(configurable via mp_probe_interval).
The server echoes it back unchanged.
RTT is computed using RFC 6298 EWMA:
SRTT = (1 - 0.125) * SRTT + 0.125 * sample
RTTVAR = (1 - 0.25) * RTTVAR + 0.25 * |SRTT - sample|
Path selection (score)
score = SRTT_ms + 200 * loss_rate
| Bonus/penalty |
Value |
| DEGRADED path |
+150 ms |
| PROBING path (unknown) |
fixed 500 ms |
| DEAD / DISABLED |
∞ (excluded) |
The active path switches when:
- The current path dies, or
- An alternative path score < current score × 0.75 (25% improvement)
Deduplication
Each path has its own socket fd. When the active path switches, a brief
window exists where packets from the old path may still arrive. A
64-slot circular cache of server→client sequence numbers discards
duplicates before they reach the client.
Configuration
qwfwd.cfg / server cvars
# Disable (default – identical to current behaviour)
cl_multipath 0
# Auto mode: open MP_MAX_PATHS direct sockets to the server.
# Useful when the proxy host has multiple NICs or routing policies.
cl_multipath 1
# Explicit relay list (comma-separated ip:port)
# Each entry becomes one upstream path.
cl_multipath "10.0.0.1:27500,10.0.0.2:27500,10.0.1.1:27500"
# Tuning (optional)
mp_probe_interval 500 # ms between RTT probes
mp_switch_threshold 0.75 # switch factor (lower = more aggressive switching)
mp_dead_timeout 3000 # ms without response -> DEAD
mp_revive_interval 10000 # ms before retrying a DEAD path
ezQuake client side
No changes required on the client. The client still connects normally
via cl_proxyaddr. The multipath logic is entirely server-side inside
qwfwd.
Console commands
mp_status – print per-peer path state, RTT, loss, packet counters
mp_reset <ip> – force-reinitialise multipath for a specific peer
Example output:
Peer 203.0.113.42:
Multipath: 3 path(s), switches=2
[0] path0 ACTIVE srtt= 24.3ms rttvar= 1.2ms loss= 0% tx=1420 rx=1418 <-- ACTIVE
[1] path1 ACTIVE srtt= 31.7ms rttvar= 3.4ms loss= 2% tx=1420 rx=1391
[2] path2 DEAD srtt= 0.0ms rttvar= 0.0ms loss= 0% tx= 24 rx= 0
Files changed
| File |
Change |
src/multipath.h |
New – data structures, public API |
src/multipath.c |
New – full implementation |
src/peer.h |
Add mp_ctx_t mp and mp_configured to peer_t |
src/peer.c |
Call MP_Init() on new peer, MP_Shutdown() on remove, MP_Frame() in peer loop + secondary socket drain |
src/clc.c |
Configure multipath on S2C_CONNECTION, switch NET_SendPacket to NET_SendPacketMP |
src/net.c |
Add NET_SendPacketMP() wrapper |
src/cmd.c |
Register mp_status and mp_reset commands |
src/cvar.c |
Register cl_multipath and tuning cvars |
CMakeLists.txt |
Add multipath.c to sources, link -lm |
Testing
# Build
cmake -B build && cmake --build build
# Run with 2 explicit paths (both pointing at same server = duplicate
# routing; replace IPs with actual relay/tunnel endpoints in production)
./build/qwfwd +set cl_multipath "qw.server.com:27500,relay.example.com:27500"
# In another terminal, watch path state
./build/qwfwd_console mp_status
Simulating path failure
# Drop traffic on a specific path using iptables (Linux)
iptables -A OUTPUT -d <path1_ip> -p udp --dport 27500 -j DROP
# qwfwd should detect DEAD within mp_dead_timeout ms and switch
# Remove the rule to trigger REVIVING -> ACTIVE
iptables -D OUTPUT -d <path1_ip> -p udp --dport 27500 -j DROP
Backward compatibility
- When
cl_multipath 0 (default), the code path is identical to the
pre-PR behaviour. Zero overhead.
- No protocol changes visible to the QW server or to clients.
- Probe packets are directed at the server's UDP port; any server that
does not recognise the probe magic simply discards them (8 bytes,
no side effects).
Future work
- DNS resolution in
MP_ParseConfig (currently requires dotted-decimal IPs)
- Per-path bandwidth throttling
- QUIC-style connection migration (send migration hint to server on path switch)
- Expose path stats via QTV status API
multipath.c
qwfwd-multipath.patch
qwfwd-multipath.patch
Multipath UDP for QWFWD
Problem
When the single upstream path between
qwfwdand the QW server becomescongested or fails, every client behind the proxy is disconnected.
There is no way to survive transient route failures without dropping sessions.
Solution
This PR adds Multipath UDP support (
src/multipath.c/src/multipath.h).Up to 4 independent UDP sockets are opened to the server (or to relay
endpoints). Each path is continuously probed for RTT and packet loss.
Game traffic flows on the single best-scored path at any given moment.
If that path degrades or dies, traffic seamlessly switches to the next
best path — the client never sees a disconnect.
How it works
Path states
RTT measurement
A tiny 8-byte probe packet is sent on every path every 500 ms
(configurable via
mp_probe_interval).The server echoes it back unchanged.
RTT is computed using RFC 6298 EWMA:
Path selection (score)
The active path switches when:
Deduplication
Each path has its own socket fd. When the active path switches, a brief
window exists where packets from the old path may still arrive. A
64-slot circular cache of server→client sequence numbers discards
duplicates before they reach the client.
Configuration
qwfwd.cfg / server cvars
ezQuake client side
No changes required on the client. The client still connects normally
via
cl_proxyaddr. The multipath logic is entirely server-side insideqwfwd.Console commands
Example output:
Files changed
src/multipath.hsrc/multipath.csrc/peer.hmp_ctx_t mpandmp_configuredtopeer_tsrc/peer.cMP_Init()on new peer,MP_Shutdown()on remove,MP_Frame()in peer loop + secondary socket drainsrc/clc.cS2C_CONNECTION, switchNET_SendPackettoNET_SendPacketMPsrc/net.cNET_SendPacketMP()wrappersrc/cmd.cmp_statusandmp_resetcommandssrc/cvar.ccl_multipathand tuning cvarsCMakeLists.txtmultipath.cto sources, link-lmTesting
Simulating path failure
Backward compatibility
cl_multipath 0(default), the code path is identical to thepre-PR behaviour. Zero overhead.
does not recognise the probe magic simply discards them (8 bytes,
no side effects).
Future work
MP_ParseConfig(currently requires dotted-decimal IPs)multipath.c
qwfwd-multipath.patch
qwfwd-multipath.patch