mirror of
https://github.com/slackhq/nebula.git
synced 2026-08-15 09:36:58 +02:00
913a37cfee
Device loses io.ReadWriteCloser + NewMultiQueueReader in favor of Queues(n), which returns up to n tio.Queue objects; platforms without multiqueue hand back their single queue and the interface sizes its reader routines to what it actually got. Queue.Read returns a batch of borrowed packets (single-element for every current backend) so a future backend can deliver more than one packet per syscall without another interface change. The Linux poll/eventfd machinery moves out of tun_linux.go into the new overlay/tio package: nonblocking fds, a shared shutdown eventfd owned by the queue set, and pollfd arrays built on the stack so concurrent writers parked in blockOnWrite no longer share Revents storage. Other platforms wrap their existing one-datagram Read/Write in a singleQueue adapter that owns a private scratch buffer, so multiqueue-by-sharing devices (user, disabled) no longer race concurrent readers on one buffer. This is the tun-interface subset of better-tun-interface-ordering, extracted at 18dc13b with none of the GSO/GRO offload mechanics and no udp/sendmmsg changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
209 lines
5.7 KiB
Go
209 lines
5.7 KiB
Go
//go:build linux && !android && !e2e_testing
|
|
// +build linux,!android,!e2e_testing
|
|
|
|
package tio
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// newReadPipe returns a read fd. The matching write fd is registered for cleanup.
|
|
// The caller takes ownership of the read fd (pass it into a QueueSet).
|
|
func newReadPipe(t *testing.T) int {
|
|
t.Helper()
|
|
var fds [2]int
|
|
if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
|
|
t.Fatalf("pipe2: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = unix.Close(fds[1]) })
|
|
return fds[0]
|
|
}
|
|
|
|
func TestPoll_WakeForShutdown_WakesFriends(t *testing.T) {
|
|
pipe1 := newReadPipe(t)
|
|
pipe2 := newReadPipe(t)
|
|
parent, err := NewPollQueueSet()
|
|
require.NoError(t, err)
|
|
require.NoError(t, parent.Add(pipe1))
|
|
require.NoError(t, parent.Add(pipe2))
|
|
t.Cleanup(func() {
|
|
_ = unix.Close(pipe1)
|
|
_ = unix.Close(pipe2)
|
|
})
|
|
|
|
readers := parent.Queues()
|
|
errs := make([]error, len(readers))
|
|
var wg sync.WaitGroup
|
|
for i, r := range readers {
|
|
wg.Add(1)
|
|
go func(i int, r Queue) {
|
|
defer wg.Done()
|
|
_, errs[i] = r.Read()
|
|
}(i, r)
|
|
}
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
if err := parent.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() { wg.Wait(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("readers did not wake")
|
|
}
|
|
|
|
for i, err := range errs {
|
|
if !errors.Is(err, os.ErrClosed) {
|
|
t.Errorf("reader %d: expected os.ErrClosed, got %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPoll_ConcurrentWrite_NoRace hammers a single Poll queue from two writer
|
|
// goroutines while a reader drains the other end of the pipe. The writers
|
|
// overflow the pipe buffer, so both repeatedly park in blockOnWrite at the same
|
|
// time — the exact scenario that raced on the old shared writePoll member
|
|
// array. Run under -race; a shared-array regression trips the detector here.
|
|
func TestPoll_ConcurrentWrite_NoRace(t *testing.T) {
|
|
var fds [2]int
|
|
require.NoError(t, unix.Pipe2(fds[:], unix.O_CLOEXEC))
|
|
readFd, writeFd := fds[0], fds[1]
|
|
|
|
shutdownFd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = unix.Close(shutdownFd) })
|
|
|
|
p, err := newPoll(writeFd, shutdownFd)
|
|
require.NoError(t, err)
|
|
|
|
const writers = 2
|
|
const perWriter = 4000
|
|
payload := make([]byte, 100)
|
|
total := writers * perWriter * len(payload)
|
|
|
|
// Reader: drain the read end (blocking) until every writer's bytes are
|
|
// consumed, so the writers keep making progress rather than wedging on a
|
|
// permanently full pipe.
|
|
readDone := make(chan struct{})
|
|
go func() {
|
|
defer close(readDone)
|
|
buf := make([]byte, 4096)
|
|
got := 0
|
|
for got < total {
|
|
n, rerr := unix.Read(readFd, buf)
|
|
got += n
|
|
if rerr != nil {
|
|
if rerr == unix.EINTR {
|
|
continue
|
|
}
|
|
return
|
|
}
|
|
if n == 0 { // EOF
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
var wg sync.WaitGroup
|
|
for w := 0; w < writers; w++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < perWriter; i++ {
|
|
if _, werr := p.Write(payload); werr != nil {
|
|
t.Errorf("write: %v", werr)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
select {
|
|
case <-readDone:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("reader did not drain")
|
|
}
|
|
|
|
require.NoError(t, p.Close())
|
|
_ = unix.Close(readFd)
|
|
}
|
|
|
|
// TestPoll_NewPoll_DoesNotCloseFdOnFailure pins the ownership rule: when
|
|
// newPoll fails, it must leave fd open so the caller (pollQueueSet.Add's
|
|
// callers in tun_linux.go) is the sole closer. If newPoll also closed fd,
|
|
// the poll path would double-close on Add error. We force the failure with
|
|
// an O_PATH descriptor: fcntl(F_SETFL) — which SetNonblock performs — is not
|
|
// permitted on O_PATH fds and fails with EBADF, while the fd itself stays
|
|
// open so we can observe that newPoll left it alone.
|
|
func TestPoll_NewPoll_DoesNotCloseFdOnFailure(t *testing.T) {
|
|
fd, err := unix.Open("/", unix.O_PATH|unix.O_CLOEXEC, 0)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = unix.Close(fd) })
|
|
|
|
p, err := newPoll(fd, 1)
|
|
require.Error(t, err, "SetNonblock on an O_PATH fd should fail")
|
|
require.Nil(t, p)
|
|
|
|
// If newPoll had closed fd, F_GETFD would report it closed. It staying
|
|
// open proves newPoll left the fd for the caller to close exactly once.
|
|
require.True(t, fdOpen(t, fd), "newPoll must not close fd on failure; caller is the sole closer")
|
|
}
|
|
|
|
func TestPoll_Close_Idempotent(t *testing.T) {
|
|
tf, err := newPoll(newReadPipe(t), 1)
|
|
require.NoError(t, err)
|
|
if err := tf.Close(); err != nil {
|
|
t.Fatalf("first Close: %v", err)
|
|
}
|
|
if err := tf.Close(); err != nil {
|
|
t.Fatalf("second Close should be a no-op, got %v", err)
|
|
}
|
|
}
|
|
|
|
// fdOpen reports whether fd currently refers to an open file description.
|
|
// A closed (or never-allocated) fd makes F_GETFD fail with EBADF.
|
|
func fdOpen(t *testing.T, fd int) bool {
|
|
t.Helper()
|
|
_, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
|
|
if err == nil {
|
|
return true
|
|
}
|
|
if errors.Is(err, unix.EBADF) {
|
|
return false
|
|
}
|
|
t.Fatalf("unexpected fcntl(F_GETFD) error on fd %d: %v", fd, err)
|
|
return false
|
|
}
|
|
|
|
// TestPollQueueSet_Close_ClosesShutdownFd is the regression test for the
|
|
// leaked shutdown eventfd: the container that owns shutdownFd must close it in
|
|
// Close, and a second Close must be a safe no-op.
|
|
func TestPollQueueSet_Close_ClosesShutdownFd(t *testing.T) {
|
|
qs, err := NewPollQueueSet()
|
|
require.NoError(t, err)
|
|
c, ok := qs.(*pollQueueSet)
|
|
require.True(t, ok)
|
|
require.NoError(t, qs.Add(newReadPipe(t)))
|
|
|
|
shutdownFd := c.shutdownFd
|
|
require.True(t, fdOpen(t, shutdownFd), "shutdown eventfd should be open before Close")
|
|
|
|
require.NoError(t, qs.Close())
|
|
require.False(t, fdOpen(t, shutdownFd), "shutdown eventfd should be closed after Close")
|
|
|
|
// Second Close must not touch fds (shutdownFd is now -1) and must return nil.
|
|
require.NoError(t, qs.Close())
|
|
}
|