Files
tailscale/net/netmon/default_interface_cache_test.go
James Tucker ae4ab7e7d5 net/netmon,net/netns: cache Windows default interface indices
Windows socket setup enumerates adapters and the routing table through
GetWindowsDefault for every netns socket. These calls can be expensive,
particularly on systems where security software adds syscall latency.

Cache nonzero default interface indices per monitor and address family,
and use them from netns controlC. Invalidate on native route, address,
and IP interface change notifications.

Coalesce concurrent lookups and retry results invalidated during lookup.
Bound lookup and wait attempts; once the budget is exhausted, fall back
to a single uncached lookup rather than failing socket setup, erring
toward a possibly stale interface index over no socket at all. Perform
fresh lookups for zero indices and errors, and retain the existing
unbound-socket fallback for a fresh zero result.

Use uncached lookups after monitor shutdown or when interface-change
notification registration fails.

Test cache reuse, independent address families, notification invalidation,
concurrent fills, invalidation during lookup, route restoration after a
zero result, churn and wait-budget fallbacks, and shutdown.

Fixes #21885

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-23 17:50:52 -04:00

276 lines
6.8 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
import (
"errors"
"sync/atomic"
"testing"
"testing/synctest"
)
func BenchmarkDefaultInterfaceCache(b *testing.B) {
var c defaultInterfaceCache
lookup := func() (uint32, error) { return 42, nil }
c.get(lookup)
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
c.get(lookup)
}
}
func TestDefaultInterfaceCache(t *testing.T) {
var c defaultInterfaceCache
var calls int
lookup := func() (uint32, error) {
calls++
return 42, nil
}
for range 3 {
if got, err := c.get(lookup); got != 42 || err != nil {
t.Fatalf("get = %v, %v; want 42, nil", got, err)
}
}
if calls != 1 {
t.Fatalf("lookups = %v; want 1", calls)
}
c.invalidate()
if got, err := c.get(lookup); got != 42 || err != nil {
t.Fatalf("get after invalidation = %v, %v; want 42, nil", got, err)
}
if calls != 2 {
t.Fatalf("lookups after invalidation = %v; want 2", calls)
}
}
func TestDefaultInterfaceCacheNoDefault(t *testing.T) {
var c defaultInterfaceCache
var calls int
index := uint32(0)
lookup := func() (uint32, error) {
calls++
return index, nil
}
for range 2 {
if got, err := c.get(lookup); got != 0 || err != nil {
t.Fatalf("get = %v, %v; want 0, nil", got, err)
}
}
if calls != 2 {
t.Fatalf("lookups with no default = %v; want 2", calls)
}
// The route returns before its notification is delivered. Without an
// explicit invalidation, the very next caller must see the restored route.
index = 42
for range 2 {
if got, err := c.get(lookup); got != 42 || err != nil {
t.Fatalf("get after route restoration = %v, %v; want 42, nil", got, err)
}
}
if calls != 3 {
t.Fatalf("lookups after route restoration = %v; want 3", calls)
}
}
func TestDefaultInterfaceCacheChurn(t *testing.T) {
var c defaultInterfaceCache
var calls int
// Every lookup races a network change, so no result is ever stable.
got, err := c.get(func() (uint32, error) {
calls++
c.invalidate()
return uint32(40 + calls), nil
})
if got != 43 || err != nil {
t.Fatalf("get during churn = %v, %v; want 43, nil", got, err)
}
if calls != defaultInterfaceCacheAttempts+1 {
t.Fatalf("lookups = %v; want %v", calls, defaultInterfaceCacheAttempts+1)
}
// The unstable result must not be published.
if got, err := c.get(func() (uint32, error) { return 44, nil }); got != 44 || err != nil {
t.Fatalf("get after churn = %v, %v; want 44, nil", got, err)
}
}
func TestDefaultInterfaceCacheWaitBudget(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var c defaultInterfaceCache
c.inFlight = make(chan struct{})
var calls atomic.Int32
done := make(chan struct{})
go func() {
defer close(done)
// Waiting for other callers' fills consumes the wait budget too.
// After exhausting it, get must do its own uncached lookup rather
// than fail without ever looking anything up.
got, err := c.get(func() (uint32, error) {
return uint32(41 + calls.Add(1)), nil
})
if got != 42 || err != nil {
t.Errorf("get = %v, %v; want 42, nil", got, err)
}
}()
for attempt := range defaultInterfaceCacheAttempts {
synctest.Wait()
// Simulate other callers taking successive fills.
c.mu.Lock()
previous := c.inFlight
c.inFlight = nil
if attempt+1 < defaultInterfaceCacheAttempts {
c.inFlight = make(chan struct{})
}
close(previous)
c.mu.Unlock()
}
<-done
if got := calls.Load(); got != 1 {
t.Fatalf("lookups = %v; want 1", got)
}
})
}
func TestDefaultInterfaceCacheError(t *testing.T) {
var c defaultInterfaceCache
wantErr := errors.New("lookup failed")
calls := 0
lookup := func() (uint32, error) {
calls++
if calls == 1 {
return 0, wantErr
}
return 42, nil
}
if _, err := c.get(lookup); err != wantErr {
t.Fatalf("get error = %v; want %v", err, wantErr)
}
for range 2 {
if got, err := c.get(lookup); got != 42 || err != nil {
t.Fatalf("get = %v, %v; want 42, nil", got, err)
}
}
if calls != 2 {
t.Fatalf("lookups = %v; want 2", calls)
}
}
func TestDefaultInterfaceCacheConcurrent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var c defaultInterfaceCache
var calls atomic.Int32
release := make(chan struct{})
lookup := func() (uint32, error) {
calls.Add(1)
<-release
return 42, nil
}
const n = 20
done := make(chan struct{}, n)
for range n {
go func() {
if got, err := c.get(lookup); got != 42 || err != nil {
t.Errorf("get = %v, %v; want 42, nil", got, err)
}
done <- struct{}{}
}()
}
synctest.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("concurrent lookups = %v; want 1", got)
}
close(release)
for range n {
<-done
}
if got := calls.Load(); got != 1 {
t.Fatalf("total lookups = %v; want 1", got)
}
})
}
func TestDefaultInterfaceCacheInvalidateDuringLookup(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var c defaultInterfaceCache
var calls atomic.Int32
release := make(chan struct{})
lookup := func() (uint32, error) {
if calls.Add(1) == 1 {
<-release
return 1, nil // obsolete result
}
return 2, nil
}
done := make(chan struct{})
go func() {
defer close(done)
if got, err := c.get(lookup); got != 2 || err != nil {
t.Errorf("get = %v, %v; want 2, nil", got, err)
}
}()
synctest.Wait()
// Must not block behind the expensive lookup.
c.invalidate()
close(release)
<-done
if got, err := c.get(lookup); got != 2 || err != nil {
t.Fatalf("cached get = %v, %v; want 2, nil", got, err)
}
if got := calls.Load(); got != 2 {
t.Fatalf("lookups = %v; want 2", got)
}
})
}
func TestDefaultInterfaceCacheDisableDuringLookup(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var c defaultInterfaceCache
var calls atomic.Int32
release := make(chan struct{})
lookup := func() (uint32, error) {
n := calls.Add(1)
if n == 1 {
<-release
}
return uint32(n), nil
}
done := make(chan struct{})
go func() {
defer close(done)
if got, err := c.get(lookup); got != 1 || err != nil {
t.Errorf("get = %v, %v; want 1, nil", got, err)
}
}()
synctest.Wait()
c.disable()
close(release)
<-done
// Even a later notification must not re-enable the cache.
c.invalidate()
for _, want := range []uint32{2, 3} {
if got, err := c.get(lookup); got != want || err != nil {
t.Fatalf("disabled get = %v, %v; want %v, nil", got, err, want)
}
}
})
}
func TestDefaultInterfaceCacheDisablePopulated(t *testing.T) {
var c defaultInterfaceCache
var calls uint32
lookup := func() (uint32, error) {
calls++
return calls, nil
}
if got, err := c.get(lookup); got != 1 || err != nil {
t.Fatalf("get = %v, %v; want 1, nil", got, err)
}
c.disable()
for _, want := range []uint32{2, 3} {
if got, err := c.get(lookup); got != want || err != nil {
t.Fatalf("disabled get = %v, %v; want %v, nil", got, err, want)
}
}
}