Files
tailscale/net/netmon/default_interface_cache.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

100 lines
2.7 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
import (
"sync"
)
// defaultInterfaceCacheAttempts is the number of lookup and wait attempts a
// single get may make, including waits for another caller's lookup, before
// falling back to one uncached lookup. Bounding them keeps network churn from
// keeping socket setup in this cache forever.
const defaultInterfaceCacheAttempts = 2
// defaultInterfaceCache caches a nonzero default interface index until an OS change
// notification invalidates it. Windows uses one cache per address family.
// It is independent of Windows APIs so its concurrency can be tested on any OS.
// The zero value is ready for use.
type defaultInterfaceCache struct {
mu sync.Mutex // guards following
inFlight chan struct{} // closed when the current lookup completes, or nil
generation uint64
index uint32
valid bool
disabled bool // notifications are no longer being delivered
}
func (c *defaultInterfaceCache) invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.generation++
c.valid = false
}
// disable stops caching when the notification subscription is closed.
func (c *defaultInterfaceCache) disable() {
c.mu.Lock()
defer c.mu.Unlock()
c.disabled = true
c.generation++
c.valid = false
}
func (c *defaultInterfaceCache) get(lookup func() (uint32, error)) (uint32, error) {
for attempt := 0; ; attempt++ {
c.mu.Lock()
if c.disabled {
c.mu.Unlock()
return lookup()
}
if c.valid {
index := c.index
c.mu.Unlock()
return index, nil
}
if attempt == defaultInterfaceCacheAttempts {
c.mu.Unlock()
// Churn prevented a stable result. Do one uncached lookup rather
// than fail: a possibly stale interface index beats no socket.
return lookup()
}
if done := c.inFlight; done != nil {
c.mu.Unlock()
<-done
continue
}
// Only one caller performs the expensive lookup. Invalidation must
// not wait for it, since the OS calls can be slow on some systems.
done := make(chan struct{})
c.inFlight = done
generation := c.generation
c.mu.Unlock()
index, err := lookup()
c.mu.Lock()
c.inFlight = nil
close(done)
if c.disabled {
c.mu.Unlock()
return index, err
}
if generation != c.generation {
// The network changed during the lookup. Don't publish the old
// result, even if the lookup succeeded.
c.mu.Unlock()
continue
}
if err == nil && index != 0 {
// Never cache "no default interface". Zero leaves a socket
// unconstrained, possibly routing it back into Tailscale. Each
// caller must check the OS again rather than reuse that absence
// while a route-restoration notification is still pending.
c.index, c.valid = index, true
}
c.mu.Unlock()
return index, err
}
}