Files
nebula/flow_tx_ring_test.go
T
Ryan Huber a784af4026 tun: Keep relayed data on the reader transmit path
The sticky gate only drains socket submissions, so moving relay data between reader and staged sender CPUs can reorder packets on independent NIC queues. Keep relayed batches on the reader with separate, shared counter reservations while preserving the direct gate and sender affinity.

Cover gate switches, oversized relay credit, mixed direct placement and stalled shared-tunnel reservations. Add Forgejo build, test, race, crypto, lint and artifact jobs on incus-docker.
2026-10-18 14:59:21 +00:00

232 lines
8.3 KiB
Go

//go:build linux && !android && !e2e_testing
package nebula
import (
"fmt"
"net/netip"
"sync"
"time"
"os"
"os/exec"
"testing"
"github.com/slackhq/nebula/overlay/tio"
"github.com/flynn/noise"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/udp"
"github.com/slackhq/nebula/util"
"github.com/stretchr/testify/require"
)
// queuedWire models two CPU-selected NIC queues with independent completion: the reader's
// queue is congested, while another CPU's queue drains immediately. WriteBatch returning only
// means the kernel accepted the packets; draining staged slots does not drain a NIC queue.
// The sampled affinity makes the schedule deterministic without a real socket or NIC. An
// unpinned sender alternates between allowed CPUs, even if it happened not to in this run.
func (c *affinityConn) queuedWire(readerCPU int) [][]byte {
var slow, fast [][]byte
wire := c.captured()
off := 0
for i, n := range c.sizes {
mask := c.masks[i]
if mask.IsSet(readerCPU) && (mask.Count() == 1 || i%2 == 0) {
slow = append(slow, wire[off:off+n]...)
} else {
fast = append(fast, wire[off:off+n]...)
}
off += n
}
return append(fast, slow...)
}
// TestFlowStagedTXRelayQueuedWire reproduces reorder after successful, ordered socket
// submissions. Mixed reads switch the sticky gate with an empty software pipeline; relayed
// data must stay on the reader throughout, without moving the direct sender. Each routine owns a flow.
func TestFlowStagedTXRelayQueuedWire(t *testing.T) {
if os.Getenv("NEBULA_FLOW_RING_TEST") != "1" {
cmd := exec.Command(os.Args[0], "-test.run=^TestFlowStagedTXRelayQueuedWire$")
cmd.Env = append(os.Environ(), "NEBULA_FLOW_RING_TEST=1")
out, err := cmd.CombinedOutput()
require.NoError(t, err, "%s", out)
return
}
allowed, err := util.AllowedCPUs()
require.NoError(t, err)
if len(allowed) < 2 {
t.Skip("requires two allowed CPUs")
}
for _, mode := range []string{"gate", "always-staged", "oversized"} {
for _, explicit := range []bool{false, true} {
for _, routines := range []int{1, 2} {
for q := range routines {
t.Run(fmt.Sprintf("%s/explicit=%t/routines=%d/q=%d", mode, explicit, routines, q), func(t *testing.T) {
sc := gated(stagedGateHold)
if mode != "gate" {
sc.inlineBelow = 0
}
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, sc)
f := sender.f
f.pinThreads, f.routines = true, routines
if explicit {
f.cpuAffinity = allowed[:2]
}
_, relayHI, relayPeer := addTestRelay(t, sender, ReplayWindow)
conn := &affinityConn{}
f.writers = make([]udp.Conn, routines)
for i := range f.writers {
f.writers[i] = conn
}
shape := append(repeatBool(false, 2), repeatBool(true, stagedGateHold+2)...)
shape = append(shape, repeatBool(false, stagedGateHold+2)...)
if mode != "gate" {
shape = []bool{false, true, false, true}
}
reads, want := gateReads(shape...)
if mode == "oversized" {
// A deliberately tiny outer budget alone forces the oversized fallback.
relayHI.ConnectionState.window = NewBits(16)
}
queue := &affinityQueue{flowBatchQueue: newFlowBatchQueue(reads), conn: conn}
// Each Read waits for all previous socket submissions, making software drain
// irrelevant to the independently queued packets in queuedWire.
f.listenIn(queue, q)
unwrap := func(wire [][]byte) [][]byte {
inner := make([][]byte, 0, len(wire))
for _, pkt := range wire {
inner = append(inner, pkt[header.Len:len(pkt)-relayPeer.dKey.Overhead()])
}
return inner
}
require.Equal(t, want, decryptWire(t, receiver, unwrap(conn.captured())), "socket submission order")
require.Equal(t, want, decryptWire(t, receiver, unwrap(conn.queuedWire(f.threadCPU(q)))), "NIC queue completion order")
})
}
}
}
}
}
// Mixed reads must keep direct sends on the original staged sender, including when relay data is present.
func TestFlowStagedTXMixedPlacement(t *testing.T) {
if os.Getenv("NEBULA_FLOW_MIXED_TEST") != "1" {
cmd := exec.Command(os.Args[0], "-test.run=^TestFlowStagedTXMixedPlacement$")
cmd.Env = append(os.Environ(), "NEBULA_FLOW_MIXED_TEST=1")
out, err := cmd.CombinedOutput()
require.NoError(t, err, "%s", out)
return
}
allowed, err := util.AllowedCPUs()
require.NoError(t, err)
if len(allowed) < 2 {
t.Skip("requires two allowed CPUs")
}
for _, explicit := range []bool{false, true} {
t.Run(fmt.Sprintf("explicit=%t", explicit), func(t *testing.T) {
sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
_, _, relayPeer := addTestRelay(t, sender, ReplayWindow)
direct, directReceiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
directAddr := netip.MustParseAddr("10.0.0.4")
direct.hostinfo.vpnAddrs = []netip.Addr{directAddr}
f := sender.f
f.hostMap.Hosts[directAddr] = direct.hostinfo
f.pinThreads, f.routines = true, 2
sendCPU := -1
if explicit {
f.cpuAffinity = allowed[:2]
sendCPU = allowed[1]
}
conn := &affinityConn{}
f.writers = []udp.Conn{conn, conn}
var reads [][]tio.Packet
var wantRelay, wantDirect []decodedSeg
for seq := range uint32(6) {
r := flowTCPSuperpacket(0, seq, 3)
d := flowTCPSuperpacket(1, seq, 9)
copy(d.Bytes[16:20], directAddr.AsSlice())
reads = append(reads, []tio.Packet{r, d})
for i := range 3 {
wantRelay = append(wantRelay, decodedSeg{seq: seq, idx: uint16(i)})
}
for i := range 9 {
wantDirect = append(wantDirect, decodedSeg{flow: 1, seq: seq, idx: uint16(i)})
}
}
queue := &affinityQueue{flowBatchQueue: newFlowBatchQueue(reads), conn: conn}
f.listenIn(queue, 0)
wire := conn.captured()
var directWire, relayWire [][]byte
off := 0
for i, n := range conn.sizes {
for _, pkt := range wire[off : off+n] {
var h header.H
require.NoError(t, h.Parse(pkt))
cpu := sendCPU
if h.Subtype == header.MessageRelay {
cpu = f.threadCPU(0)
require.NoError(t, relayPeer.VerifyRelay(nil, h.MessageCounter, pkt, make([]byte, 12)))
relayWire = append(relayWire, pkt[header.Len:len(pkt)-relayPeer.dKey.Overhead()])
} else {
directWire = append(directWire, pkt)
}
requireFlowCPU(t, conn.masks[i:i+1], cpu, allowed)
}
off += n
}
require.Equal(t, wantDirect, decryptWire(t, directReceiver, directWire))
require.Equal(t, wantRelay, decryptWire(t, receiver, relayWire))
})
}
}
// A relayed batch shares its outer tunnel with older staged direct data on the SAME routine. Inline completion
// must not refund that direct sender's credit while its oldest reservation is stalled.
func TestFlowStagedTXRelaySharedBudget(t *testing.T) {
sender, _ := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
_, via, _ := addTestRelay(t, sender, ReplayWindow)
// Give the relay host the same ordinary peer metadata as a direct tunnel.
direct := newFlowTestPeer(t, flowTestLocal, via.vpnAddrs[0], via.ConnectionState, 300, 400)
via.buildNetworks(sender.f.myVpnNetworksTable, direct.hostinfo.ConnectionState.peerCert.Certificate)
via.remoteIndexId = direct.hostinfo.remoteIndexId
ci := via.ConnectionState
ci.window = NewBits(32)
start := ci.messageCounter.Load()
entered, unblock := make(chan struct{}), make(chan struct{})
var release sync.Once
delayCrypto(direct, func(n uint64) {
if n == start+1 {
close(entered)
<-unblock
}
})
p := newStagedTx(sender.f, newFlowBatchQueue(nil), 0)
defer p.close()
defer release.Do(func() { close(unblock) })
d := flowTCPSuperpacket(1, 0, 9)
copy(d.Bytes[16:20], via.vpnAddrs[0].AsSlice())
p.send([]tio.Packet{d})
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("direct worker did not start")
}
for seq := range uint32(7) {
p.send([]tio.Packet{flowTCPSuperpacket(0, seq, 1)})
}
require.Equal(t, uint64(16), (ci.txBudget.state.Load()>>24)&txBudgetMask)
done := make(chan struct{})
go func() { defer close(done); p.send([]tio.Packet{flowTCPSuperpacket(0, 7, 1)}) }()
select {
case <-done:
t.Error("relay recycled credit around a stalled direct reservation")
case <-time.After(50 * time.Millisecond):
}
require.Equal(t, start+16, ci.messageCounter.Load())
release.Do(func() { close(unblock) })
<-done
p.drain()
require.Zero(t, ci.txBudget.state.Load())
}