Files
tailscale/portlist/portlist_linux_bench_test.go
JeremiahM37 3dd55d6928 portlist: exercise Linux discovery backends with real sockets
Cover process attribution, loopback filtering, socket churn, transport
fallback and recovery, and namespace consistency. Reuse the existing
kernel-regression skip helper for live tests and leave namespace setup
to the caller. Probe all diagnostic handlers before tests depend on them,
skipping known capability failures such as missing UDP diagnostics.

Add warm and cold backend benchmarks with held TCP connections and
listener scaling. Assert that diagnostic polls do not silently fall back.

Updates #10430
Updates #15492

Signed-off-by: JeremiahM37 <JeremiahM37@users.noreply.github.com>
2026-09-14 19:16:36 -07:00

345 lines
9.0 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
package portlist
import (
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"sync"
"syscall"
"testing"
)
// portFixture creates real sockets in the caller's network namespace. The
// runner invokes this benchmark under `unshare -n`; no host routes or sockets
// are touched. TCP pairs are accepted and held open, so the kernel has both
// LISTEN and ESTABLISHED entries while the poller runs.
type portFixture struct {
closers []net.Conn
listeners []net.Listener
packets []net.PacketConn
expected map[uint16]bool
mu sync.Mutex
}
func (f *portFixture) close() {
f.mu.Lock()
closers := append([]net.Conn(nil), f.closers...)
f.mu.Unlock()
for _, c := range closers {
if tc, ok := c.(*net.TCPConn); ok {
_ = tc.SetLinger(0)
}
_ = c.Close()
}
for _, ln := range f.listeners {
_ = ln.Close()
}
for _, p := range f.packets {
_ = p.Close()
}
}
func makePortFixture(t testing.TB, pairs int) *portFixture {
t.Helper()
f := &portFixture{expected: map[uint16]bool{}}
addTCP := func(network, listenAddr string) {
ln, err := net.Listen(network, listenAddr)
if err != nil {
t.Skipf("%s unavailable: %v", network, err)
}
f.listeners = append(f.listeners, ln)
f.expected[uint16(ln.Addr().(*net.TCPAddr).Port)] = true
accepted := make(chan struct{}, pairs)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
f.mu.Lock()
f.closers = append(f.closers, c)
f.mu.Unlock()
accepted <- struct{}{}
}
}()
for i := 0; i < pairs; i++ {
c, err := net.Dial(network, ln.Addr().String())
if err != nil {
t.Fatalf("dial %s: %v", network, err)
}
f.mu.Lock()
f.closers = append(f.closers, c)
f.mu.Unlock()
}
for i := 0; i < pairs; i++ {
<-accepted
}
}
addTCP("tcp4", "127.0.0.1:0")
addTCP("tcp6", "[::1]:0")
for _, network := range []string{"udp4", "udp6"} {
for _, connected := range []bool{false, true} {
addr := "0.0.0.0:0"
peer := "127.0.0.1:9"
if network == "udp6" {
addr, peer = "[::]:0", "[::1]:9"
}
u, err := net.ListenPacket(network, addr)
if err != nil {
t.Skipf("%s unavailable: %v", network, err)
}
f.packets = append(f.packets, u)
f.expected[uint16(u.LocalAddr().(*net.UDPAddr).Port)] = true
if connected {
c, err := net.Dial(network, peer)
if err != nil {
t.Fatalf("dial %s: %v", network, err)
}
f.closers = append(f.closers, c)
}
}
}
return f
}
func makeManyPortFixture(t testing.TB, pairs, extra int) *portFixture {
f := makePortFixture(t, pairs)
for i := 0; i < extra; i++ {
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("extra listener: %v", err)
}
f.listeners = append(f.listeners, ln)
f.expected[uint16(ln.Addr().(*net.TCPAddr).Port)] = true
}
return f
}
// pollerFor bypasses Poller's constructor so each implementation can be A/B'd
// in one process while retaining Poll's change detection and metadata path.
func pollerFor(impl osImpl) *Poller {
p := &Poller{IncludeLocalhost: true, os: impl}
p.initOnce.Do(func() {})
return p
}
func BenchmarkPortlistBackends(b *testing.B) {
if runtime.GOOS != "linux" {
b.Skip("Linux-only")
}
maybeSkip(b)
factories := []struct {
name string
factory func(bool) *linuxImpl
}{
{"proc", func(include bool) *linuxImpl { return newLinuxImplWithDiag(include, false) }},
{"diag", func(include bool) *linuxImpl { return newLinuxImplWithDiag(include, true) }},
}
// Run both orderings. This matters on a shared machine because the first
// implementation can otherwise receive all cold page/cache effects.
for _, order := range [][]int{{0, 1}, {1, 0}} {
for _, pairs := range []int{0, 100, 500} {
for _, ix := range order {
tc := factories[ix]
b.Run(fmt.Sprintf("order%d/%s/pairs=%d", order[0], tc.name, pairs), func(b *testing.B) {
f := makePortFixture(b, pairs)
defer f.close()
impl := tc.factory(true)
if tc.name == "diag" {
defer impl.Close()
requireDiag(b, impl)
}
p := pollerFor(impl)
defer p.Close()
if _, _, err := p.Poll(); err != nil {
b.Fatal(err)
} // cold metadata pass
b.ReportAllocs()
var before, after syscall.Rusage
syscall.Getrusage(syscall.RUSAGE_SELF, &before)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, _, err := p.Poll(); err != nil {
b.Fatal(err)
}
if tc.name == "diag" && (impl.diagFD < 0 || impl.diagPermanent) {
b.Fatal("diag benchmark fell back to proc")
}
}
b.StopTimer()
syscall.Getrusage(syscall.RUSAGE_SELF, &after)
used := after.Utime.Nano() + after.Stime.Nano() - before.Utime.Nano() - before.Stime.Nano()
b.ReportMetric(float64(used)/float64(b.N), "cpu-ns/op")
})
}
}
for _, extra := range []int{16, 64} {
for _, tc := range factories {
b.Run(fmt.Sprintf("order%d/%s/listeners=%d", order[0], tc.name, extra), func(b *testing.B) {
f := makeManyPortFixture(b, 0, extra)
defer f.close()
impl := tc.factory(true)
if tc.name == "diag" {
defer impl.Close()
requireDiag(b, impl)
}
p := pollerFor(impl)
defer p.Close()
if _, _, err := p.Poll(); err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, _, err := p.Poll(); err != nil {
b.Fatal(err)
}
if tc.name == "diag" && (impl.diagFD < 0 || impl.diagPermanent) {
b.Fatal("diag listener benchmark fell back to proc")
}
}
})
}
}
}
}
// Cold includes construction, the first kernel dump, and first process-name
// attribution. Fixture setup is outside the timer; each iteration constructs a
// fresh poller against the same fixed sockets.
func BenchmarkPortlistCold(b *testing.B) {
maybeSkip(b)
for _, tc := range []struct {
name string
diag bool
}{{"proc", false}, {"diag", true}} {
b.Run(tc.name, func(b *testing.B) {
f := makePortFixture(b, 0)
defer f.close()
if tc.diag {
func() {
probe := newLinuxImplWithDiag(true, true)
defer probe.Close()
requireDiag(b, probe)
}()
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
impl := newLinuxImplWithDiag(true, tc.diag)
p := pollerFor(impl)
if _, _, err := p.Poll(); err != nil {
b.Fatal(err)
}
if tc.diag && (impl.diagFD < 0 || impl.diagPermanent) {
b.Fatal("diag cold benchmark fell back to proc")
}
p.Close()
}
})
}
}
func TestPortlistBackendParity(t *testing.T) {
maybeSkip(t)
f := makePortFixture(t, 8)
defer f.close()
proc := pollerFor(newLinuxImplWithDiag(true, false))
defer proc.Close()
diagImpl := newLinuxImplWithDiag(true, true)
defer diagImpl.Close()
requireDiag(t, diagImpl)
diag := pollerFor(diagImpl)
defer diag.Close()
a, _, err := proc.Poll()
if err != nil {
t.Fatal(err)
}
b, _, err := diag.Poll()
if err != nil {
t.Fatal(err)
}
if diagImpl.diagFD < 0 || diagImpl.diagPermanent {
t.Fatal("diag parity poll fell back to proc")
}
// A second pass exercises the warmed inode -> process metadata path. Poll
// returns nil when unchanged, so retain the first result for comparison.
if _, _, err = diag.Poll(); err != nil {
t.Fatal(err)
}
stableFixture := func(in []Port) map[string]bool {
out := map[string]bool{}
for _, p := range in {
if f.expected[p.Port] {
out[fmt.Sprintf("%s/%d", p.Proto, p.Port)] = true
}
}
return out
}
if fmt.Sprint(stableFixture(a)) != fmt.Sprint(stableFixture(b)) {
t.Fatalf("proc/diag mismatch: proc=%v diag=%v", stableFixture(a), stableFixture(b))
}
full := func(in []Port) map[string]Port {
out := map[string]Port{}
for _, p := range in {
if f.expected[p.Port] {
out[fmt.Sprintf("%s/%d", p.Proto, p.Port)] = p
}
}
return out
}
for k, p := range full(a) {
q, ok := full(b)[k]
if !ok || p.Process != q.Process || p.Pid != q.Pid {
t.Errorf("full parity %s: proc=%+v diag=%+v", k, p, q)
}
if p.Pid != os.Getpid() || p.Process != filepath.Base(os.Args[0]) {
t.Errorf("unexpected fixture owner %s: %+v", k, p)
}
}
if len(full(a)) != len(f.expected) {
t.Fatalf("proc did not report every fixture socket: got %d want %d", len(full(a)), len(f.expected))
}
}
func TestPortlistBackendChurn(t *testing.T) {
maybeSkip(t)
for _, diag := range []bool{false, true} {
f := makePortFixture(t, 4)
defer f.close()
li := newLinuxImplWithDiag(true, diag)
defer li.Close()
if diag {
requireDiag(t, li)
}
before, err := li.AppendListeningPorts(nil)
if err != nil {
t.Fatal(err)
}
if diag && (li.diagFD < 0 || li.diagPermanent) {
t.Fatal("diag churn poll fell back to proc")
}
if len(before) == 0 {
t.Fatal("empty initial listing")
}
_ = f.listeners[0].Close()
after, err := li.AppendListeningPorts(nil)
if err != nil {
t.Fatal(err)
}
if diag && (li.diagFD < 0 || li.diagPermanent) {
t.Fatal("diag churn poll fell back to proc")
}
if len(after) >= len(before) {
t.Fatalf("churn did not remove listener: before=%d after=%d", len(before), len(after))
}
}
}