mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-27 16:56:49 +09:00
The expirable LRU ran its sweeper forever, one goroutine per State. Purge first so pending waiters get ErrRegistrationExpired instead of hanging. The OIDC state cache is closed at server shutdown for the same reason.
151 lines
4.2 KiB
Go
151 lines
4.2 KiB
Go
package state
|
|
|
|
import (
|
|
"bytes"
|
|
"runtime"
|
|
"runtime/pprof"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hashicorp/golang-lru/v2/expirable"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func newTestAuthCache(t *testing.T, maxEntries int, ttl time.Duration) *expirable.LRU[types.AuthID, *types.AuthRequest] {
|
|
t.Helper()
|
|
|
|
cache := expirable.NewLRU[types.AuthID, *types.AuthRequest](
|
|
maxEntries,
|
|
func(_ types.AuthID, rn *types.AuthRequest) {
|
|
rn.FinishAuth(types.AuthVerdict{Err: ErrRegistrationExpired})
|
|
},
|
|
ttl,
|
|
)
|
|
t.Cleanup(cache.Close)
|
|
|
|
return cache
|
|
}
|
|
|
|
func requireExpiredVerdict(t *testing.T, entry *types.AuthRequest) {
|
|
t.Helper()
|
|
|
|
select {
|
|
case verdict := <-entry.WaitForAuth():
|
|
require.False(t, verdict.Accept(), "evicted entry must not signal Accept")
|
|
require.ErrorIs(t,
|
|
verdict.Err, ErrRegistrationExpired,
|
|
"evicted entry must surface ErrRegistrationExpired, got: %v",
|
|
verdict.Err,
|
|
)
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("eviction callback did not wake the parked AuthRequest")
|
|
}
|
|
}
|
|
|
|
// TestAuthCacheBoundedLRU verifies that the registration auth cache is
|
|
// bounded by a maximum entry count, that exceeding the maxEntries evicts the
|
|
// oldest entry, and that the eviction callback resolves the parked
|
|
// AuthRequest with ErrRegistrationExpired so any waiting goroutine wakes.
|
|
func TestAuthCacheBoundedLRU(t *testing.T) {
|
|
const maxEntries = 4
|
|
|
|
// Long TTL: eviction by size, not by time.
|
|
cache := newTestAuthCache(t, maxEntries, time.Hour)
|
|
|
|
entries := make([]*types.AuthRequest, 0, maxEntries+1)
|
|
ids := make([]types.AuthID, 0, maxEntries+1)
|
|
|
|
for range maxEntries + 1 {
|
|
id := types.MustAuthID()
|
|
entry := types.NewAuthRequest()
|
|
cache.Add(id, entry)
|
|
ids = append(ids, id)
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
// Cap should be respected.
|
|
assert.Equal(t, maxEntries, cache.Len(), "cache must not exceed the configured maxEntries")
|
|
|
|
// The oldest entry must have been evicted.
|
|
_, ok := cache.Get(ids[0])
|
|
assert.False(t, ok, "oldest entry must be evicted when maxEntries is exceeded")
|
|
|
|
requireExpiredVerdict(t, entries[0])
|
|
|
|
// All non-evicted entries must still be retrievable.
|
|
for i := 1; i <= maxEntries; i++ {
|
|
_, ok := cache.Get(ids[i])
|
|
assert.True(t, ok, "non-evicted entry %d should still be in the cache", i)
|
|
}
|
|
}
|
|
|
|
// TestAuthCacheTTLExpiry verifies the TTL sweeper evicts an entry that timed
|
|
// out and that the eviction callback wakes its waiter.
|
|
func TestAuthCacheTTLExpiry(t *testing.T) {
|
|
cache := newTestAuthCache(t, 4, 50*time.Millisecond)
|
|
|
|
id := types.MustAuthID()
|
|
entry := types.NewAuthRequest()
|
|
cache.Add(id, entry)
|
|
|
|
requireExpiredVerdict(t, entry)
|
|
|
|
_, ok := cache.Get(id)
|
|
assert.False(t, ok, "expired entry must be gone from the cache")
|
|
}
|
|
|
|
func goroutineID() string {
|
|
buf := make([]byte, 64)
|
|
n := runtime.Stack(buf, false)
|
|
|
|
// "goroutine 123 [running]:"
|
|
return strings.Fields(string(buf[:n]))[1]
|
|
}
|
|
|
|
// authCacheSweepers counts expirable LRU TTL sweeper goroutines started from
|
|
// goroutine gid, so caches other tests are still tearing down do not count.
|
|
func authCacheSweepers(t *testing.T, gid string) int {
|
|
t.Helper()
|
|
|
|
var buf bytes.Buffer
|
|
|
|
require.NoError(t, pprof.Lookup("goroutine").WriteTo(&buf, 2))
|
|
|
|
n := 0
|
|
|
|
for g := range strings.SplitSeq(buf.String(), "\n\n") {
|
|
if strings.Contains(g, "golang-lru/v2/expirable") &&
|
|
strings.Contains(g, " in goroutine "+gid+"\n") {
|
|
n++
|
|
}
|
|
}
|
|
|
|
return n
|
|
}
|
|
|
|
// TestStateCloseStopsAuthCacheSweeper verifies that closing a State stops
|
|
// the auth cache's TTL sweeper goroutine instead of leaking it, and that a
|
|
// request still pending at that point gets its expiry verdict rather than
|
|
// waiting forever for a sweep that never comes.
|
|
func TestStateCloseStopsAuthCacheSweeper(t *testing.T) {
|
|
gid := goroutineID()
|
|
|
|
s, err := NewState(persistTestConfig(t.TempDir() + "/headscale.db"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, authCacheSweepers(t, gid), "NewState must start a sweeper")
|
|
|
|
pending := types.NewAuthRequest()
|
|
s.SetAuthCacheEntry(types.MustAuthID(), pending)
|
|
|
|
require.NoError(t, s.Close())
|
|
|
|
requireExpiredVerdict(t, pending)
|
|
|
|
assert.Eventually(t, func() bool {
|
|
return authCacheSweepers(t, gid) == 0
|
|
}, 5*time.Second, 10*time.Millisecond, "sweeper must stop on State.Close")
|
|
}
|