drop ECN support for this release

This commit is contained in:
JackDoan
2026-07-31 13:38:16 -05:00
parent d3779b6a39
commit 93946faf7a
24 changed files with 148 additions and 1101 deletions
+24 -168
View File
@@ -8,10 +8,8 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"net"
"net/netip"
"strconv"
"sync/atomic"
"syscall"
"unsafe"
@@ -39,19 +37,6 @@ type StdConn struct {
// consecutive same-flow datagrams into a single recvmmsg entry; the
// delivered cmsg carries the gso_size used to split them back apart.
groSupported bool
// ecnRecvSupported is true when IP_RECVTOS / IPV6_RECVTCLASS was
// successfully enabled — the kernel will deliver the outer IP-ECN of
// each arriving datagram as a per-slot cmsg, and ListenOut passes
// the parsed value to the EncReader callback for RFC 6040 combine.
ecnRecvSupported bool
// ecnMarkThreshold holds tunnels.ecn_mark_threshold as float64 bits: the
// fraction of the socket receive buffer above which listenOutBatch flags
// the batch QueueCongested (decap then CE-marks ECT inner packets). Zero
// disables sampling entirely. Atomic because ReloadConfig may update it
// while the reader runs.
ecnMarkThreshold atomic.Uint64
}
func NewListener(l *slog.Logger, ip netip.Addr, port int, multi bool, batch int) (Conn, error) {
@@ -102,11 +87,6 @@ func NewListener(l *slog.Logger, ip netip.Addr, port int, multi bool, batch int)
if batch > 1 {
out.prepareGRO()
}
// Best-effort: ask the kernel to deliver outer IP-ECN as ancillary data
// on every recvmmsg slot so the decap side can apply RFC 6040 combine.
// On older kernels these may not exist; failing here just means we get
// 0 (Not-ECT) on every slot, which is the same as ecn_mode=disable.
out.prepareECNRecv()
return out, nil
}
@@ -138,34 +118,6 @@ func (u *StdConn) prepareGRO() {
recordCapability("udp.gro.enabled", true)
}
// prepareECNRecv turns on IP_RECVTOS / IPV6_RECVTCLASS so the outer IP-ECN
// field of each arriving datagram is delivered as ancillary data alongside
// the payload. ListenOut reads it via parseRecvCmsg and passes the codepoint
// through the EncReader for RFC 6040 combine on the decap side. Best-effort:
// we keep going on failure, and each family degrades independently — a peer
// whose family's probe failed just delivers no cmsg and lands as Not-ECT.
// Only a failure of every family the socket speaks turns the parsing off.
func (u *StdConn) prepareECNRecv() {
v4err := unix.SetsockoptInt(u.sysFd, unix.IPPROTO_IP, unix.IP_RECVTOS, 1)
var v6err error
if !u.isV4 {
v6err = unix.SetsockoptInt(u.sysFd, unix.IPPROTO_IPV6, unix.IPV6_RECVTCLASS, 1)
}
switch {
case v4err != nil && (u.isV4 || v6err != nil):
u.l.Info("udp: outer-ECN RX disabled", "reason", "kernel rejected probe", "error", errors.Join(v4err, v6err))
recordCapability("udp.ecn_rx.enabled", false)
return
case v4err != nil:
u.l.Debug("udp: outer-ECN RX degraded", "reason", "kernel rejected probe on IPv4", "error", v4err)
case v6err != nil:
u.l.Debug("udp: outer-ECN RX degraded", "reason", "kernel rejected probe on IPv6", "error", v6err)
}
u.ecnRecvSupported = true
u.l.Info("udp: outer-ECN RX enabled")
recordCapability("udp.ecn_rx.enabled", true)
}
// recordCapability registers (or updates) a boolean gauge for one of the
// kernel-feature probes. Gauges go to 1 when the feature is enabled, 0 when
// it is not — dashboards can show degraded state on partially-supported
@@ -312,12 +264,6 @@ func (u *StdConn) ListenOut(r EncReader, flush func()) error {
bufSize = udpGROBufferSize
cmsgSpace = unix.CmsgSpace(udpGROCmsgPayload)
}
if u.ecnRecvSupported {
// IP_TOS arrives as 1 byte; IPV6_TCLASS arrives as a 4-byte int.
// Reserve enough for the wider of the two so the same buffer fits
// either family alongside any UDP_GRO cmsg.
cmsgSpace += unix.CmsgSpace(4)
}
msgs, buffers, names, _ := prepareRawMessages(u.batch, bufSize, cmsgSpace)
for {
@@ -330,23 +276,6 @@ func (u *StdConn) ListenOut(r EncReader, flush func()) error {
}
}
// AQM sample: one getsockopt per recvmmsg batch (skipped entirely at
// threshold 0). Sampled BEFORE the read: a single recvmmsg can drain
// more than the whole receive buffer (64 GRO superpackets ≈ 4MB), so
// post-read residue is ~always zero; the pre-read depth is the
// backlog that accumulated while the previous batch was processed —
// the actual standing-queue signal. Depth beyond the configured
// fraction of the receive buffer flags every packet in the batch so
// decap CE-marks ECT inner packets: the ECN substitute for the
// tail-drop this queue otherwise regulates with.
congested := false
if frac := math.Float64frombits(u.ecnMarkThreshold.Load()); frac > 0 {
var mi [unix.SK_MEMINFO_VARS]uint32
if err := u.getMemInfo(&mi); err == nil {
congested = float64(mi[unix.SK_MEMINFO_RMEM_ALLOC]) >= frac*float64(mi[unix.SK_MEMINFO_RCVBUF])
}
}
n, err := u.recvmmsg(msgs)
if err != nil {
if errors.Is(err, unix.EINTR) {
@@ -362,12 +291,11 @@ func (u *StdConn) ListenOut(r EncReader, flush func()) error {
payload := buffers[i][:msgs[i].Len]
segSize := 0
outerECN := byte(0)
if cmsgSpace > 0 {
segSize, outerECN = parseRecvCmsg(&msgs[i].Hdr, u.groSupported, u.ecnRecvSupported)
segSize = parseRecvCmsg(&msgs[i].Hdr)
}
deliverSegments(r, from, payload, segSize, RxMeta{OuterECN: outerECN, QueueCongested: congested})
deliverSegments(r, from, payload, segSize)
}
flush()
@@ -375,9 +303,9 @@ func (u *StdConn) ListenOut(r EncReader, flush func()) error {
}
// deliverSegments hands a received superdatagram to r, splitting it back into pre-coalesce packets
func deliverSegments(r EncReader, from netip.AddrPort, payload []byte, segSize int, meta RxMeta) {
func deliverSegments(r EncReader, from netip.AddrPort, payload []byte, segSize int) {
if segSize <= 0 || segSize >= len(payload) { //avoid bogus values
r(from, payload, meta)
r(from, payload)
return
}
for off := 0; off < len(payload); off += segSize {
@@ -385,25 +313,16 @@ func deliverSegments(r EncReader, from netip.AddrPort, payload []byte, segSize i
if end > len(payload) {
end = len(payload)
}
r(from, payload[off:end], meta)
r(from, payload[off:end])
}
}
// parseRecvCmsg walks the per-slot ancillary buffer once and extracts up to
// two values of interest in a single pass: the UDP_GRO gso_size (when
// wantGRO is true) and the outer IP-level ECN codepoint stamped on the
// carrier (when wantECN is true). Returns zeros for whichever field is not
// requested or not present.
//
// The outer ECN is accepted from EITHER an IP_TOS (IPPROTO_IP, 1-byte) or an
// IPV6_TCLASS (IPPROTO_IPV6, 4-byte int) cmsg, regardless of the socket's
// family: a dual-stack v6 socket (isV4 == false) delivers IPv4 peers' outer
// ECN as an IP_TOS cmsg — gating on socket family here dropped v4-underlay
// ECN entirely. Whichever cmsg the kernel delivered carries the value.
func parseRecvCmsg(hdr *msghdr, wantGRO, wantECN bool) (gso int, ecn byte) {
// parseRecvCmsg walks the per-slot ancillary buffer and extracts the UDP_GRO
// gso_size, or 0 when no UDP_GRO cmsg is present.
func parseRecvCmsg(hdr *msghdr) (gso int) {
controllen := int(hdr.Controllen)
if controllen < unix.SizeofCmsghdr || hdr.Control == nil {
return 0, 0
return 0
}
ctrl := unsafe.Slice(hdr.Control, controllen)
off := 0
@@ -412,37 +331,25 @@ func parseRecvCmsg(hdr *msghdr, wantGRO, wantECN bool) (gso int, ecn byte) {
clen := int(ch.Len)
// Compare against the remaining bytes rather than off+clen
if clen < unix.SizeofCmsghdr || clen > len(ctrl)-off {
return gso, ecn
return gso
}
dataOff := off + unix.CmsgLen(0)
switch {
case wantGRO && ch.Level == unix.SOL_UDP && ch.Type == unix.UDP_GRO:
if ch.Level == unix.SOL_UDP && ch.Type == unix.UDP_GRO {
if dataOff+udpGROCmsgPayload <= len(ctrl) {
gso = int(int32(binary.NativeEndian.Uint32(ctrl[dataOff : dataOff+udpGROCmsgPayload])))
}
case wantECN && ch.Level == unix.IPPROTO_IP && ch.Type == unix.IP_TOS:
// IP_TOS arrives as a single byte; only the low 2 bits are ECN.
// A dual-stack v6 socket carries v4 peers' outer ECN here.
if dataOff+1 <= len(ctrl) {
ecn = ctrl[dataOff] & 0x03
}
case wantECN && ch.Level == unix.IPPROTO_IPV6 && ch.Type == unix.IPV6_TCLASS:
// IPV6_TCLASS arrives as a 4-byte int; ECN is the low 2 bits.
if dataOff+4 <= len(ctrl) {
ecn = byte(binary.NativeEndian.Uint32(ctrl[dataOff:dataOff+4])) & 0x03
}
}
// Advance by the aligned cmsg space.
off += unix.CmsgSpace(clen - unix.CmsgLen(0))
}
return gso, ecn
return gso
}
func (u *StdConn) WriteTo(b []byte, ip netip.AddrPort, ecn byte) error {
return sendmsg(u.sysFd, b, ip, u.isV4, ecn)
func (u *StdConn) WriteTo(b []byte, ip netip.AddrPort) error {
return sendto(u.sysFd, b, ip, u.isV4)
}
func sendmsg(fd int, b []byte, addr netip.AddrPort, isV4 bool, ecn byte) error {
func sendto(fd int, b []byte, addr netip.AddrPort, isV4 bool) error {
var rsa [unix.SizeofSockaddrInet6]byte
nlen, err := writeSockaddr(rsa[:], addr, isV4)
if err != nil {
@@ -452,43 +359,25 @@ func sendmsg(fd int, b []byte, addr netip.AddrPort, isV4 bool, ecn byte) error {
if len(b) > 0 {
base = &b[0]
}
var iov iovec
iov.Base = base
setIovLen(&iov, len(b))
var hdr msghdr
hdr.Name = &rsa[0]
hdr.Namelen = uint32(nlen)
hdr.Iov = &iov
setMsgIovlen(&hdr, 1)
// Stack scratch for the ECN cmsg, typed as uint64s so its base is cmsg-aligned on every arch.
// CmsgSpace(4) needs 24 bytes on 64-bit linux, 16 on 32-bit.
var ctrl [3]uint64
if ecn != 0 {
buf := (*[24]byte)(unsafe.Pointer(&ctrl[0]))[:]
writeECNCmsg(buf, addr.Addr().Unmap().Is4(), ecn)
hdr.Control = &buf[0]
setMsgControllen(&hdr, unix.CmsgSpace(4))
}
_, _, errno := unix.Syscall6(
unix.SYS_SENDMSG,
unix.SYS_SENDTO,
uintptr(fd),
uintptr(unsafe.Pointer(&hdr)),
0, 0, 0, 0,
uintptr(unsafe.Pointer(base)),
uintptr(len(b)),
0,
uintptr(unsafe.Pointer(&rsa[0])),
uintptr(nlen),
)
if errno != 0 {
return &net.OpError{Op: "sendmsg", Err: errno}
return &net.OpError{Op: "sendto", Err: errno}
}
return nil
}
// WriteBatch sends bufs via sendmmsg(2), coalescing same-destination runs into UDP-GSO superpackets when supported.
// See batchWriter in udp_linux_writebatch.go for the mechanics.
func (u *StdConn) WriteBatch(bufs [][]byte, addrs []netip.AddrPort, ecns []byte) (int, error) {
return u.bw.WriteBatch(bufs, addrs, ecns)
func (u *StdConn) WriteBatch(bufs [][]byte, addrs []netip.AddrPort) (int, error) {
return u.bw.WriteBatch(bufs, addrs)
}
// writeSockaddr encodes addr into buf (which must be at least SizeofSockaddrInet6 bytes).
@@ -522,8 +411,6 @@ func writeSockaddr(buf []byte, addr netip.AddrPort, isV4 bool) (int, error) {
}
func (u *StdConn) ReloadConfig(c *config.C) {
u.reloadECNMarkThreshold(c)
b := c.GetInt("listen.read_buffer", 0)
if b > 0 {
if err := u.SetRecvBuffer(b); err == nil {
@@ -565,37 +452,6 @@ func (u *StdConn) ReloadConfig(c *config.C) {
}
}
// reloadECNMarkThreshold parses tunnels.ecn_mark_threshold: the fraction
// (0..1] of the receive buffer above which decap CE-marks ECT inner packets.
// 0 (the default) disables the AQM sampling. Reloadable.
func (u *StdConn) reloadECNMarkThreshold(c *config.C) {
var frac float64
switch v := c.Get("tunnels.ecn_mark_threshold").(type) {
case nil:
case float64:
frac = v
case int:
frac = float64(v)
case string:
f, err := strconv.ParseFloat(v, 64)
if err != nil {
u.l.Warn("tunnels.ecn_mark_threshold is not a number; disabling", "value", v)
} else {
frac = f
}
default:
u.l.Warn("tunnels.ecn_mark_threshold is not a number; disabling", "value", v)
}
if frac < 0 || frac > 1 {
u.l.Warn("tunnels.ecn_mark_threshold must be within [0, 1]; disabling", "value", frac)
frac = 0
}
old := math.Float64frombits(u.ecnMarkThreshold.Swap(math.Float64bits(frac)))
if old != frac {
u.l.Info("tunnels.ecn_mark_threshold set", "fraction", frac)
}
}
func (u *StdConn) getMemInfo(meminfo *[unix.SK_MEMINFO_VARS]uint32) error {
var vallen uint32 = 4 * unix.SK_MEMINFO_VARS
_, _, err := unix.Syscall6(unix.SYS_GETSOCKOPT, uintptr(u.sysFd), uintptr(unix.SOL_SOCKET), uintptr(unix.SO_MEMINFO), uintptr(unsafe.Pointer(meminfo)), uintptr(unsafe.Pointer(&vallen)), 0)