Files
nebula/udp/udp_linux_writebatch.go
T
2026-07-30 17:32:00 -05:00

449 lines
15 KiB
Go

//go:build linux && !android && !e2e_testing
package udp
import (
"context"
"encoding/binary"
"errors"
"fmt"
"log/slog"
"net"
"net/netip"
"strconv"
"strings"
"unsafe"
"golang.org/x/sys/unix"
)
// batchWriter owns the sendmmsg(2)/UDP-GSO transmit path for a StdConn: the
// scratch WriteBatch packs mmsghdr entries into, plus the GSO capability
// state probed at socket creation. Each queue has its own StdConn and
// batchWriter, so no locking is needed.
//
// Terminology, smallest to largest:
//
// packet one element of bufs: a single UDP datagram. The unit of the
// returned written count.
// run consecutive packets planRun groups into one entry: same
// destination and outer ECN, equal sizes (a shorter packet only
// last), within maxGSOBytes and maxGSOSegments. Without GSO a run
// is always one packet. Runs are atomic: packed whole into one
// entry, or skipped whole if the socket cannot address their
// destination, leaving a hole (bufs indices covered by no entry).
// entry one mmsghdr slot of the sendmmsg array; the kernel's unit of
// success and failure. A multi-packet entry carries a UDP_SEGMENT
// cmsg and is sent as one superpacket the kernel segments into
// gso_size-byte datagrams. Entries never split.
// chunk the entries packed for one sendmmsg call, at most MaxWriteBatch.
// batch the caller's whole bufs/addrs/ecns triple, processed as one or
// more chunks.
type batchWriter struct {
fd int
isV4 bool
l *slog.Logger
// UDP GSO (sendmsg with UDP_SEGMENT cmsg) support, probed once at
// socket creation and cleared by WriteBatch if the kernel later rejects
// a GSO send (the setsockopt probe cannot see per-route limitations).
// When true, WriteBatch coalesces runs into UDP_SEGMENT entries;
// otherwise each packet is its own entry.
gsoSupported bool
maxGSOSegments int
// sendmmsg scratch, sized to MaxWriteBatch at construction; WriteBatch
// chunks larger inputs.
msgs []rawMessage
iovs []iovec
names [][]byte
// Per-entry cmsg scratch: one contiguous slab of
// MaxWriteBatch * cmsgSpace bytes holding two cmsg headers per entry
// (UDP_SEGMENT, then IP_TOS / IPV6_TCLASS). Layout in
// prepareWriteMessages.
cmsg []byte
cmsgSpace int
cmsgSegSpace int
cmsgEcnSpace int
// entryEnd[e] is the bufs index after the last packet packed into entry
// e. entryEnd[e]-entryPkts[e] recovers the bufs index the entry's run
// started at, used to rewind i for the GSO-disable replay.
entryEnd []int
// entryPkts[e] is the number of packets packed into entry e. Not
// derivable from entryEnd: skipped runs leave holes in the bufs index space.
entryPkts []int
// sendFn sends n prepared entries beginning at w.msgs[start]. The real
// syscall in production; tests inject partial-success and error scripts.
sendFn func(start, n int) (int, error)
}
func newBatchWriter(fd int, isV4 bool, l *slog.Logger) *batchWriter {
w := &batchWriter{fd: fd, isV4: isV4, l: l}
w.sendFn = w.sendmmsg
w.prepareWriteMessages(MaxWriteBatch)
w.prepareGSO()
return w
}
// prepareWriteMessages allocates the per-entry mmsghdr/iovec/sockaddr/cmsg
// scratch. Hdr.Iov/Iovlen/Control/Controllen are wired per call, since an
// entry spans a variable number of iovecs and may or may not carry cmsgs.
//
// Each entry's cmsg slot holds up to two headers at fixed offsets:
//
// [0 .. cmsgSegSpace) UDP_SEGMENT (gso_size, uint16)
// [cmsgSegSpace .. cmsgSpace) IP_TOS or IPV6_TCLASS (int32)
//
// The UDP_SEGMENT header is pre-filled here; only its payload is rewritten
// per call. The ECN header is written per entry by writeEntryCmsg because
// its Level/Type follow the destination's family. Hdr.Control/Controllen
// select whichever subset applies (none / segment / ecn / both).
func (w *batchWriter) prepareWriteMessages(n int) {
w.msgs = make([]rawMessage, n)
w.iovs = make([]iovec, n)
w.names = make([][]byte, n)
w.entryEnd = make([]int, n)
w.entryPkts = make([]int, n)
w.cmsgSegSpace = unix.CmsgSpace(2)
w.cmsgEcnSpace = unix.CmsgSpace(4)
w.cmsgSpace = w.cmsgSegSpace + w.cmsgEcnSpace
w.cmsg = make([]byte, n*w.cmsgSpace)
for k := 0; k < n; k++ {
base := k * w.cmsgSpace
seg := (*unix.Cmsghdr)(unsafe.Pointer(&w.cmsg[base]))
seg.Level = unix.SOL_UDP
seg.Type = unix.UDP_SEGMENT
setCmsgLen(seg, unix.CmsgLen(2))
}
for i := range w.msgs {
w.names[i] = make([]byte, unix.SizeofSockaddrInet6)
w.msgs[i].Hdr.Name = &w.names[i][0]
}
}
// maxGSOBytes bounds the total payload of one UDP_SEGMENT send. The kernel
// builds a single skb, which must fit the 16-bit UDP length field and
// sk_gso_max_size (65536 on most devices); 65000 leaves headroom for headers.
const maxGSOBytes = 65000
// prepareGSO probes UDP_SEGMENT support and sets w.gsoSupported on success.
// Best-effort; failure leaves it false.
func (w *batchWriter) prepareGSO() {
w.maxGSOSegments = 63 // pre-6.9 cap; see gsoMaxSegments
if err := unix.SetsockoptInt(w.fd, unix.IPPROTO_UDP, unix.UDP_SEGMENT, 0); err != nil {
w.l.Info("udp: GSO disabled", "reason", "rawconn control failed", "error", err)
recordCapability("udp.gso.enabled", false)
return
}
var un unix.Utsname
if err := unix.Uname(&un); err != nil {
w.l.Warn("udp: kernel version probe failed, capping GSO at 63 segments", "error", err)
} else {
w.maxGSOSegments = gsoMaxSegments(string(un.Release[:]))
}
w.gsoSupported = true
w.l.Info("udp: GSO enabled", "maxGSOSegments", w.maxGSOSegments)
recordCapability("udp.gso.enabled", true)
}
// gsoMaxSegments returns the most segments one UDP_SEGMENT send may carry:
// the kernel cap (UDP_MAX_SEGMENTS: 64 before 6.9, 128 after) minus one,
// because the kernel counts the 8-byte UDP header against the gso_size * UDP_MAX_SEGMENTS budget.
func gsoMaxSegments(release string) int {
major, minor := parseRelease(release)
if major > 6 || (major == 6 && minor >= 9) {
return 127
}
return 63
}
func parseRelease(r string) (major, minor int) {
// strip anything after the second dot or any non-digit
parts := strings.SplitN(r, ".", 3)
if len(parts) < 2 {
return 0, 0
}
major, _ = strconv.Atoi(parts[0])
// minor may have trailing junk like "15-generic"
mp := parts[1]
for i, c := range mp {
if c < '0' || c > '9' {
mp = mp[:i]
break
}
}
minor, _ = strconv.Atoi(mp)
return
}
// WriteBatch sends bufs via sendmmsg(2), coalescing runs into UDP_SEGMENT
// entries, so one syscall can mix GSO superpackets and plain datagrams.
// Without GSO support every packet is its own entry.
//
// Batches larger than the scratch take one sendmmsg per chunk. A partial
// success resumes the same prepared entries at the first unsent one — no
// repacking. A zero-sent error means the kernel rejected the first remaining
// entry: its packets are dropped and the rest of the chunk resumes in place.
//
// Returns the number of packets sent. An error means the call itself
// failed; a short count means some destinations were undeliverable.
func (w *batchWriter) WriteBatch(bufs [][]byte, addrs []netip.AddrPort, ecns []byte) (int, error) {
if len(bufs) != len(addrs) {
return 0, fmt.Errorf("WriteBatch: len(bufs)=%d != len(addrs)=%d", len(bufs), len(addrs))
}
if ecns != nil && len(ecns) != len(bufs) {
return 0, fmt.Errorf("WriteBatch: len(ecns)=%d != len(bufs)=%d", len(ecns), len(bufs))
}
// Callers deliver same-destination packets contiguously and in counter order, so we run the GSO planner directly without a pre-sort.
// A sorting pass measurably hurt throughput in microbenchmarks while providing no observed reordering benefit.
// A destination the kernel rejects results in us dropping that entry (one packet, or one same-destination GSO run).
// We count what actually made it out rather than returning an error.
written := 0
i := 0
for i < len(bufs) {
entry := 0
iovIdx := 0
for entry < len(w.msgs) && i < len(bufs) {
iovBudget := len(w.iovs) - iovIdx
if iovBudget < 1 {
break
}
runLen, segSize := w.planRun(bufs, addrs, ecns, i, iovBudget)
if runLen == 0 {
break
}
for k := 0; k < runLen; k++ {
b := bufs[i+k]
if len(b) == 0 {
w.iovs[iovIdx+k].Base = nil
setIovLen(&w.iovs[iovIdx+k], 0)
} else {
w.iovs[iovIdx+k].Base = &b[0]
setIovLen(&w.iovs[iovIdx+k], len(b))
}
}
nlen, err := writeSockaddr(w.names[entry], addrs[i], w.isV4)
if err != nil {
// The destination's address family does not match the socket
// (e.g. an IPv6 remote on a v4-bound socket). The packets are
// undeliverable and no entry is committed yet: skip the run.
if w.l.Enabled(context.Background(), slog.LevelDebug) {
w.l.Debug("skipping unroutable batch destination", "udpAddr", addrs[i], "packets", runLen, "error", err)
}
i += runLen
continue
}
hdr := &w.msgs[entry].Hdr
hdr.Iov = &w.iovs[iovIdx]
setMsgIovlen(hdr, runLen)
hdr.Namelen = uint32(nlen)
var ecn byte
if ecns != nil {
ecn = ecns[i]
}
// ECN cmsg family follows the destination, not the socket
dstIsV4 := addrs[i].Addr().Unmap().Is4()
w.writeEntryCmsg(entry, runLen, segSize, ecn, dstIsV4)
i += runLen
iovIdx += runLen
w.entryEnd[entry] = i
w.entryPkts[entry] = runLen
entry++
}
if entry == 0 {
// Every remaining packet was skipped; i reached len(bufs).
break
}
// Drain the packed entries without repacking: everything the packing
// loop wired (iovecs, names, cmsgs) stays intact until the next chunk
// overwrites it, so a partial success resumes the same sendmmsg array
// at the first unsent entry, and a rejected entry is skipped in place.
// Only the GSO-disable path replans, since its entries change shape.
done := 0
for done < entry {
sent, serr := w.sendFn(done, entry-done)
if sent > 0 {
// Count packets per entry; the bufs index span would
// overcount across holes left by skipped runs.
for e := done; e < done+sent; e++ {
written += w.entryPkts[e]
}
done += sent
continue
}
if serr == nil {
return written, fmt.Errorf("sendmmsg made no progress")
}
// sent<=0 means the first remaining entry itself failed. EIO on a
// superpacket means the route cannot carry a GSO send even though
// the setsockopt probe passed: udp_send_skb() returns EIO when the
// egress device lacks TX checksum offload (kernels through
// 6.10) or when an xfrm policy covers the route. Persistent, so
// disable GSO (socket-wide, though the kernel condition is
// per-route) and replay from the failed run as one-packet
// entries, still batched.
if w.gsoSupported && w.entryPkts[done] >= 2 && errors.Is(serr, unix.EIO) {
w.gsoSupported = false
w.l.Warn("udp: kernel rejected GSO send, disabling GSO", "error", serr)
recordCapability("udp.gso.enabled", false)
i = w.entryEnd[done] - w.entryPkts[done]
break
}
// TODO: a transient zero-sent errno (ENOBUFS under socket-memory
// pressure, or a theoretical EINTR) lands here too and drops
// the failed entry's run (up to 63/127 packets). The RX path
// retries EINTR; consider a bounded retry for those two before
// falling through to the per-entry drop.
//
// Any other zero-sent error is a per-entry failure:
// an unreachable destination, a firewall EPERM, or a PMTU shrink after a roam
// (EINVAL, or EMSGSIZE since kernel 6.14, once gso_size no longer fits the path).
// Retrying the packets individually cannot succeed where the entry did not, and
// disabling GSO cannot make oversized segments fit, so skip the entry and resume with the rest.
// Small-segment entries still pass, so the tunnel stays up while full-size packets drop.
if w.l.Enabled(context.Background(), slog.LevelDebug) {
w.l.Debug("sendmmsg rejected entry",
"error", serr,
"udpAddr", addrs[w.entryEnd[done]-w.entryPkts[done]],
"packets", w.entryPkts[done],
"gso", w.gsoSupported,
)
}
done++
}
// When the drain finished every entry, i already sits past the whole
// chunk (including any trailing skipped runs); the GSO-disable break
// above rewound it to the failed run for the replanned retry.
}
return written, nil
}
// planRun returns the length of the run starting at start and its segment
// size (len(bufs[start])). A run of length 1 carries no UDP_SEGMENT cmsg
// and is sent as a plain datagram; without GSO support planRun always
// returns 1. Outer ECN is a run boundary: the kernel stamps one codepoint per entry.
func (w *batchWriter) planRun(bufs [][]byte, addrs []netip.AddrPort, ecns []byte, start, iovBudget int) (int, int) {
if start >= len(bufs) || iovBudget < 1 {
return 0, 0
}
segSize := len(bufs[start])
if !w.gsoSupported || segSize == 0 || segSize > maxGSOBytes {
return 1, segSize
}
dst := addrs[start]
var ecn byte
if ecns != nil {
ecn = ecns[start]
}
maxLen := w.maxGSOSegments
if iovBudget < maxLen {
maxLen = iovBudget
}
runLen := 1
total := segSize
for runLen < maxLen && start+runLen < len(bufs) {
nextLen := len(bufs[start+runLen])
if nextLen == 0 || nextLen > segSize {
break
}
if addrs[start+runLen] != dst {
break
}
if ecns != nil && ecns[start+runLen] != ecn {
break
}
if total+nextLen > maxGSOBytes {
break
}
total += nextLen
runLen++
if nextLen < segSize {
// A short packet must be the last in the run.
break
}
}
return runLen, segSize
}
// writeEntryCmsg writes one entry's cmsgs: the UDP_SEGMENT payload when
// runLen >= 2, the IP_TOS/IPV6_TCLASS cmsg when ecn != 0, then points
// Hdr.Control at the smallest span covering the cmsgs in use.
//
// The ECN cmsg family must match the destination, not the socket: on the
// default dual-stack v6 bind, a v4-mapped destination takes the kernel's
// IPv4 path, which reads IP_TOS and ignores IPV6_TCLASS. The payload is a
// 4-byte int for both families, so the cmsg space is the same.
func (w *batchWriter) writeEntryCmsg(entry, runLen, segSize int, ecn byte, dstIsV4 bool) {
hdr := &w.msgs[entry].Hdr
useSeg := runLen >= 2
useEcn := ecn != 0
base := entry * w.cmsgSpace
if useSeg {
dataOff := base + unix.CmsgLen(0)
binary.NativeEndian.PutUint16(w.cmsg[dataOff:dataOff+2], uint16(segSize))
}
if useEcn {
ecnHdr := (*unix.Cmsghdr)(unsafe.Pointer(&w.cmsg[base+w.cmsgSegSpace]))
if dstIsV4 {
ecnHdr.Level = int32(unix.IPPROTO_IP)
ecnHdr.Type = int32(unix.IP_TOS)
} else {
ecnHdr.Level = int32(unix.IPPROTO_IPV6)
ecnHdr.Type = int32(unix.IPV6_TCLASS)
}
setCmsgLen(ecnHdr, unix.CmsgLen(4))
dataOff := base + w.cmsgSegSpace + unix.CmsgLen(0)
binary.NativeEndian.PutUint32(w.cmsg[dataOff:dataOff+4], uint32(ecn))
}
switch {
case useSeg && useEcn:
hdr.Control = &w.cmsg[base]
setMsgControllen(hdr, w.cmsgSpace)
case useSeg:
hdr.Control = &w.cmsg[base]
setMsgControllen(hdr, w.cmsgSegSpace)
case useEcn:
hdr.Control = &w.cmsg[base+w.cmsgSegSpace]
setMsgControllen(hdr, w.cmsgEcnSpace)
default:
hdr.Control = nil
setMsgControllen(hdr, 0)
}
}
// sendmmsg issues sendmmsg(2) against n entries of w.msgs starting at start.
func (w *batchWriter) sendmmsg(start, n int) (int, error) {
r1, _, errno := unix.Syscall6(unix.SYS_SENDMMSG, uintptr(w.fd),
uintptr(unsafe.Pointer(&w.msgs[start])), uintptr(n),
0, 0, 0,
)
sent := int(r1)
if errno != 0 {
return sent, &net.OpError{Op: "sendmmsg", Err: errno}
}
return sent, nil
}