Files
nebula/overlay/tio/tio.go
T
Caleb JasikandClaude Opus 5.5 61947ff1c2 overlay/darwin: batch tun writes with sendmsg_x
Passthrough.Flush hands its whole lane to a new tio.BatchWriter in one
WriteBatch call when the queue implements it, and NewSingleQueue
exposes WriteBatch when its source does. On darwin, tun.WriteBatch
writes up to 64 packets per sendmsg_x on the utun control socket,
which is a connected datagram socket, so xnu takes the batched
sendit_x path rather than one sendit per message. utun registers no
send_list handler, so the kernel still passes each packet to utun on
its own: this saves syscalls and fd locking, not per-packet kernel work.

Error handling follows what sendmsg_x reports:

- A short count with no error (a packet past the send buffer
  mid-call, or a later send failing with ENOBUFS): the remainder goes
  out in the next sendmsg_x.
- EMSGSIZE: only returned when the first packet is too large, before
  the kernel takes any, so that packet alone goes through Write, which
  reports it, and the rest are batched again.
- ENOSYS, EPERM, EOPNOTSUPP: sendmsg_x is private API, so WriteBatch
  logs once and falls back to writev per packet for good.
- Anything else: sendmsg_x returns the error without the count it
  took, so the call is reported rather than retried, which could
  deliver packets twice.

One loss mode has no userspace fix. sendit_x counts each packet as
sent once it is copied into an mbuf; if a later allocation fails, it
frees the copied prefix unsent, returns its length, and sendmsg_x
drops the ENOBUFS because the count is nonzero. That looks exactly
like a real short count, so under mbuf exhaustion WriteBatch silently
drops packets that writev would have delivered.

The iOS tun path (tun_ios.go) is unchanged.

Inside a macOS Network Extension, download goes from 640 to 832 Mbps
against the recvmsg_x change alone in three paired rounds, at 15% less
CPU per Mbps; the likely cause is that its utun charges more per write
than a raw one. Raw dnclient already runs download near line rate,
and there the CPU difference is within noise.

TestTunWriteBatch* round-trip through a connected AF_UNIX datagram
socketpair, which takes sendmsg_x the same way, over both the batched
and the fallback path: order, AF prefixes, skipped invalid packets,
and an oversize packet mid-call and first-in-call. Dropping Namelen
from msghdrX fails them. Mutation testing with gremlins leaves only
equivalent survivors in WriteBatch, plus which error is reported first
when several fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:04:03 -05:00

159 lines
6.4 KiB
Go

package tio
import (
"io"
)
// QueueSet holds one or many Queue objects and helps close them in an orderly way.
type QueueSet interface {
io.Closer
Queues() []Queue
// Add takes a tun fd, adds it to the set, and prepares it for use as a Queue.
Add(fd int) error
}
// Capabilities advertises which kernel offload features a Queue successfully negotiated.
// Callers consult this to decide which coalescers to wire onto the write path.
type Capabilities struct {
// TSO means the FD was opened with IFF_VNET_HDR and the kernel agreed to TUN_F_TSO4|TSO6,
// and WriteGSO with GSOProtoTCP is safe.
TSO bool
// USO means the kernel additionally agreed to TUN_F_USO4|USO6,
// so WriteGSO with GSOProtoUDP is safe. Linux ≥ 6.2.
USO bool
}
// Queue is a readable/writable Poll queue.
// Concurrency contract: a single read goroutine drives Read; plain Write is safe for concurrent callers;
// WriteGSO (on Queues that implement GSOWriter) is single-writer per queue.
//
// Close on an individual Queue does NOT unblock a Read parked in poll — closing an fd
// never wakes its pollers. Orderly teardown goes through the owning QueueSet's Close,
// which first signals a shared shutdown eventfd every reader polls alongside its own fd.
// That eventfd is a set-wide kill switch: once signaled, every Queue in the set returns
// os.ErrClosed from Read, so it cannot be used to stop a single Queue.
type Queue interface {
io.Closer
// Read returns one or more packets.
// The returned Packet.Bytes slices are borrowed from the Queue's internal buffer and are only valid
// until the next Read or Close on this Queue.
// A Packet may carry a GSO/USO superpacket (see GSOInfo)
// Single-reader only: not safe for concurrent Reads (it reuses per-queue rx scratch each call).
Read() ([]Packet, error)
// Write emits a single packet on the plaintext (outside→inside) delivery path.
// Safe for concurrent use.
Write(p []byte) (int, error)
}
// Packet is the unit Queue.Read returns.
// Bytes points into the queue's internal buffer and is only valid until the next Read or Close on the queue that produced it.
// GSO is the zero value for an already-segmented IP datagram;
// when non-zero it describes a kernel-supplied TSO/USO superpacket the caller must segment before consuming.
type Packet struct {
Bytes []byte
GSO GSOInfo
}
// GSOInfo describes a kernel-supplied superpacket sitting in Packet.Bytes.
// The zero value means Bytes is one regular IP datagram and no segmentation is required.
type GSOInfo struct {
// Size is the GSO segment size: max payload bytes per segment
// (== TCP MSS for TSO, == UDP payload chunk for USO). Zero means not a superpacket.
Size uint16
// HdrLen is the total L3+L4 header length within Bytes (already corrected via correctHdrLen, so safe to slice on).
HdrLen uint16
// CsumStart is the L4 header offset inside Bytes (== L3 header length).
CsumStart uint16
// Proto picks the L4 protocol (TCP or UDP) so the segmenter knows which checksum/header layout to apply.
Proto GSOProto
}
// IsSuperpacket reports whether g describes a multi-segment GSO/USO
// superpacket that needs segmentation before its bytes can be encrypted and sent on the wire.
func (g GSOInfo) IsSuperpacket() bool { return g.Size > 0 }
// Clone returns a Packet whose Bytes is a freshly allocated copy of p.Bytes,
// safe to retain past the next Read or Close on the originating Queue.
// GSO metadata is copied verbatim.
// Use this only when a caller needs the data to outlive the borrowed-slice contract.
func (p Packet) Clone() Packet {
if p.Bytes == nil {
return p
}
cp := make([]byte, len(p.Bytes))
copy(cp, p.Bytes)
return Packet{Bytes: cp, GSO: p.GSO}
}
// CapsProvider is an optional interface implemented by Queues that negotiate kernel offload features at open time.
// Callers pick a write-path coalescer based on the result.
// Queues that don't implement it are treated as having no offload capability.
type CapsProvider interface {
Capabilities() Capabilities
}
// GSOProto selects the L4 protocol for a GSO superpacket.
// Determines which VIRTIO_NET_HDR_GSO_* type the writer stamps and which checksum offset
// inside the transport header virtio NEEDS_CSUM expects.
type GSOProto uint8
const (
GSOProtoUnknown GSOProto = iota
GSOProtoTCP
GSOProtoUDP
)
// GSOWriter is implemented by Queues that can emit a TCP or UDP superpacket
// assembled from a header prefix plus one or more borrowed payload fragments,
// in a single vectored write (writev with a leading virtio_net_hdr).
// This lets the coalescer avoid copying payload bytes between the caller's decrypt buffer and the TUN.
// Backends without GSO support do not implement this interface and coalescing is skipped.
//
// hdr contains the IPv4/IPv6 header prefix (mutable: callers will have filled in total length and IP csum).
// transportHdr is the TCP or UDP header
// (mutable: the L4 checksum field must hold the pseudo-header partial, single-fold not inverted, per virtio NEEDS_CSUM semantics).
// pays are non-overlapping payload fragments whose concatenation is the full superpacket payload.
// They are read-only from the writer's perspective and must remain valid until the call returns.
// Every segment in pays except possibly the last must be exactly the same size.
// proto picks the L4 protocol so the writer knows which gsoType / CsumOffset to set.
//
// Callers should also consult CapsProvider (via SupportsGSO) for the per-protocol negotiated capability:
// USO may not have been negotiated even when TSO was.
type GSOWriter interface {
io.Writer
CapsProvider
WriteGSO(hdr []byte, transportHdr []byte, pays [][]byte, proto GSOProto) error
}
// SupportsGSO reports whether w implements GSOWriter and the underlying
// queue advertises the negotiated capability for `want`.
func SupportsGSO(w io.Writer, want GSOProto) (GSOWriter, bool) {
gw, ok := w.(GSOWriter)
if !ok {
return nil, false
}
caps := gw.Capabilities()
switch want {
case GSOProtoTCP:
return gw, caps.TSO
case GSOProtoUDP:
return gw, caps.USO
default:
return gw, false
}
}
// BatchWriter is implemented by Queues that can emit several already-segmented packets in one call.
// Backends without a batched write do not implement it and callers loop over Write.
//
// pkts are borrowed: WriteBatch must not retain them past its return.
// A returned error doesn't say which packets were delivered, so callers must not retry the batch.
// Safe for concurrent use, like Write.
type BatchWriter interface {
io.Writer
WriteBatch(pkts [][]byte) error
}