fix: eliminate per-request global mutexes in Yaegi hot paths

The v1.0.14 fix replaced one contended sync.RWMutex (RefreshCoordinator.
refreshMutex) with sync.Map. Production showed the same death-spiral
signature recurring ~2 hours later — same shape, different mutex:
65 goroutines stuck on a sync.(*RWMutex).Lock at one address, pod
pinned at 1000m CPU, identical Yaegi runCfg/reflect.Value.Call stack
pattern. The mutex was RefreshCoordinator.attemptsMutex.

Generalising: under Yaegi (interpreted Go for traefik plugins), any
per-request global mutex acquisition is a latent serialization point.
reflect.Value.Call dispatch on a held lock turns a microsecond
critical section into a multi-millisecond one, and on a GOMAXPROCS=1
pod the queue is unbounded.

This commit removes every per-request global mutex on the hot path:

1. RefreshCoordinator.attemptsMutex (sync.RWMutex)
   sessionRefreshAttempts: map -> sync.Map.
   refreshAttemptTracker: all fields atomic (int32, int64 UnixNano,
   cooldownEndNano == 0 as the not-in-cooldown sentinel, replacing
   the inCooldown bool).
   isInCooldown / recordRefreshAttempt / recordRefreshSuccess /
   recordRefreshFailure all become lock-free. Cooldown entry uses
   CompareAndSwapInt64 so only one goroutine logs the transition.

2. RefreshCircuitBreaker.mutex (sync.RWMutex)
   lastFailureTime / lastSuccessTime -> atomic.Int64 UnixNano.
   state and failures already atomic.
   AllowRequest / RecordSuccess / RecordFailure now pure atomic ops.

3. TraefikOidc.firstRequestMutex (sync.Mutex)
   firstRequestReceived bool -> firstRequestStarted int32.
   metadataRefreshStarted bool -> metadataRefreshStartedAtomic int32.
   ServeHTTP bootstrap path uses CompareAndSwapInt32 — fires once,
   zero steady-state cost. Previously the mutex was acquired on
   every non-health request forever.

4. TraefikOidc.metadataRetryMutex (sync.Mutex)
   lastMetadataRetryTime time.Time -> lastMetadataRetryNano int64.
   The 30-second retry throttle is now a CAS on lastMetadataRetryNano.

cleanupStaleEntries iterates via sync.Map.Range; eviction is a
CompareAndDelete by pointer identity so a tracker freshly re-used by
a concurrent caller is not lost.

Empirical evidence (3 specialist-agent analysis of the v1.0.14 spike,
profiles in /tmp/traefik-spike-1779511683/):
  * mutex profile: 97% delay in sync.(*Mutex).Unlock via
    HTTPHandlerSwitcher -> accesslog -> metrics -> backoff.RetryNotify
  * 65 stuck goroutines at one RWMutex address (0x40022eb648),
    identical Yaegi CFG pointer, all on rc.attemptsMutex via
    recordRefreshAttempt + isInCooldown
  * traffic driver: long-lived in-cluster Go-http-client doing
    ~5.4 req/s POST embeddings via OIDC cookie session → same
    sessionID → contention all funnels to one tracker entry

Yaegi support for sync/atomic confirmed at
github.com/traefik/yaegi@v0.16.1/stdlib/go1_22_sync_atomic.go:
AddInt32/Int64, LoadInt32/Int64, StoreInt32/Int64,
CompareAndSwapInt32/Int64 all exposed via reflect.ValueOf. Yaegi
dispatches each call through reflect.Value.Call to the COMPILED
atomic.* function, which executes a single hardware CAS/LOCK-XADD
instruction. Each atomic op still pays Yaegi dispatch cost but
cannot block — no queueing, no death spiral.

Trade-off acknowledged: v1.0.15 issues ~6-8 atomic/sync.Map ops per
leader-path request vs the 4 mutex ops of v1.0.14. Under low
contention this is a modest CPU bump. Under high contention it's
an unbounded → bounded transformation. Net win.

All tests pass with -race; golangci-lint clean.
This commit is contained in:
2026-05-23 10:47:21 +01:00
parent ae4ccaa89d
commit 72e2b682bb
11 changed files with 265 additions and 247 deletions
+2 -2
View File
@@ -71,8 +71,8 @@ func makeBearerOIDC(t *testing.T, next http.Handler) *TraefikOidc {
logger: NewLogger("error"), logger: NewLogger("error"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sm, sessionManager: sm,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://issuer.example.com", issuerURL: "https://issuer.example.com",
audience: "https://api.example.com", audience: "https://api.example.com",
clientID: "https://api.example.com", clientID: "https://api.example.com",
+7 -6
View File
@@ -478,11 +478,10 @@ func TestRefreshCoordinatorIntegration(t *testing.T) {
// Test 3: Rate limiting // Test 3: Rate limiting
t.Run("RateLimiting", func(t *testing.T) { t.Run("RateLimiting", func(t *testing.T) {
// Reset circuit breaker to closed state for this test // Reset circuit breaker to closed state for this test. All fields are
coordinator.circuitBreaker.mutex.Lock() // atomic so we don't need any mutex.
atomic.StoreInt32(&coordinator.circuitBreaker.state, 0) // closed atomic.StoreInt32(&coordinator.circuitBreaker.state, 0) // closed
atomic.StoreInt32(&coordinator.circuitBreaker.failures, 0) atomic.StoreInt32(&coordinator.circuitBreaker.failures, 0)
coordinator.circuitBreaker.mutex.Unlock()
// Temporarily increase circuit breaker threshold to not interfere // Temporarily increase circuit breaker threshold to not interfere
oldMaxFailures := coordinator.circuitBreaker.config.MaxFailures oldMaxFailures := coordinator.circuitBreaker.config.MaxFailures
@@ -525,9 +524,11 @@ func TestRefreshCoordinatorIntegration(t *testing.T) {
time.Sleep(config.CleanupInterval * 3) time.Sleep(config.CleanupInterval * 3)
// Old sessions should be cleaned up // Old sessions should be cleaned up
coordinator.attemptsMutex.RLock() count := 0
count := len(coordinator.sessionRefreshAttempts) coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
coordinator.attemptsMutex.RUnlock() count++
return true
})
// Should have fewer sessions after cleanup // Should have fewer sessions after cleanup
if count > 10 { if count > 10 {
+4 -4
View File
@@ -415,8 +415,8 @@ func TestMiddlewareBackchannelLogoutRouting(t *testing.T) {
clientID: "test-client", clientID: "test-client",
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
logoutURLPath: "/logout", logoutURLPath: "/logout",
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -457,8 +457,8 @@ func TestMiddlewareFrontchannelLogoutRouting(t *testing.T) {
clientID: "test-client", clientID: "test-client",
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
logoutURLPath: "/logout", logoutURLPath: "/logout",
} }
close(oidc.initComplete) close(oidc.initComplete)
+14 -30
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
) )
@@ -484,9 +485,8 @@ func TestFirstRequestHandling(t *testing.T) {
defer server.Close() defer server.Close()
oidc := &TraefikOidc{ oidc := &TraefikOidc{
providerURL: server.URL, providerURL: server.URL,
firstRequestReceived: false, firstRequestStarted: 0,
firstRequestMutex: sync.Mutex{},
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
}, },
@@ -508,19 +508,13 @@ func TestFirstRequestHandling(t *testing.T) {
}, },
} }
// Simulate first request processing // Simulate first request processing — single-firing via CAS.
oidc.firstRequestMutex.Lock() if atomic.CompareAndSwapInt32(&oidc.firstRequestStarted, 0, 1) {
if !oidc.firstRequestReceived {
oidc.firstRequestReceived = true
oidc.firstRequestMutex.Unlock()
// This would normally be called asynchronously // This would normally be called asynchronously
go func() { go func() {
oidc.initializeMetadata(server.URL) oidc.initializeMetadata(server.URL)
// initComplete is closed internally by initializeMetadata // initComplete is closed internally by initializeMetadata
}() }()
} else {
oidc.firstRequestMutex.Unlock()
} }
// Wait for initialization // Wait for initialization
@@ -556,9 +550,8 @@ func TestFirstRequestHandling(t *testing.T) {
defer server.Close() defer server.Close()
oidc := &TraefikOidc{ oidc := &TraefikOidc{
providerURL: server.URL, providerURL: server.URL,
firstRequestReceived: false, firstRequestStarted: 0,
firstRequestMutex: sync.Mutex{},
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
}, },
@@ -580,31 +573,22 @@ func TestFirstRequestHandling(t *testing.T) {
}, },
} }
// Simulate multiple concurrent "first" requests // Simulate multiple concurrent "first" requests — only one CAS winner
// fires the bootstrap path.
const numRequests = 10 const numRequests = 10
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(numRequests) wg.Add(numRequests)
initStarted := 0 var initStarted int32
var initMu sync.Mutex
for i := 0; i < numRequests; i++ { for i := 0; i < numRequests; i++ {
go func() { go func() {
defer wg.Done() defer wg.Done()
oidc.firstRequestMutex.Lock() if atomic.CompareAndSwapInt32(&oidc.firstRequestStarted, 0, 1) {
if !oidc.firstRequestReceived { atomic.AddInt32(&initStarted, 1)
oidc.firstRequestReceived = true
oidc.firstRequestMutex.Unlock()
initMu.Lock()
initStarted++
initMu.Unlock()
// Only one should actually start initialization // Only one should actually start initialization
oidc.initializeMetadata(server.URL) oidc.initializeMetadata(server.URL)
} else {
oidc.firstRequestMutex.Unlock()
} }
}() }()
} }
@@ -612,8 +596,8 @@ func TestFirstRequestHandling(t *testing.T) {
wg.Wait() wg.Wait()
// Verify only one initialization was started // Verify only one initialization was started
if initStarted != 1 { if atomic.LoadInt32(&initStarted) != 1 {
t.Errorf("expected exactly 1 initialization, got %d", initStarted) t.Errorf("expected exactly 1 initialization, got %d", atomic.LoadInt32(&initStarted))
} }
// The metadata endpoint might be called once or not at all depending on timing // The metadata endpoint might be called once or not at all depending on timing
+28 -28
View File
@@ -61,8 +61,8 @@ func TestServeHTTP_ExcludedURLs(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", // Required for initialization check issuerURL: "https://provider.example.com", // Required for initialization check
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -92,8 +92,8 @@ func TestServeHTTP_EventStream(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -175,8 +175,8 @@ func TestServeHTTP_WebSocketUpgrade(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -272,8 +272,8 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close this to simulate timeout initComplete: make(chan struct{}), // Never close this to simulate timeout
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
} }
req := httptest.NewRequest("GET", "/protected", nil) req := httptest.NewRequest("GET", "/protected", nil)
@@ -307,8 +307,8 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -337,8 +337,8 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -367,8 +367,8 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -740,8 +740,8 @@ func TestMinimalHeaders(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
minimalHeaders: tt.minimalHeaders, minimalHeaders: tt.minimalHeaders,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -817,8 +817,8 @@ func TestMinimalHeaders_TokenHeaderNotSet(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
minimalHeaders: true, // Enable minimal headers minimalHeaders: true, // Enable minimal headers
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -903,8 +903,8 @@ func TestStripAuthCookies(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: tt.stripAuthCookies, stripAuthCookies: tt.stripAuthCookies,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -987,8 +987,8 @@ func TestStripAuthCookies_NoCookies(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: true, stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1034,8 +1034,8 @@ func TestStripAuthCookies_OnlyOIDCCookies(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: true, stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1085,8 +1085,8 @@ func TestStripAuthCookies_OnlyAppCookies(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: true, stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1148,8 +1148,8 @@ func TestStripAuthCookies_CustomPrefix(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sm, sessionManager: sm,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: true, stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
+4 -4
View File
@@ -16,6 +16,7 @@ import (
"net/url" "net/url"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -2685,10 +2686,9 @@ func TestMetadataRecoveryOnProviderFailure(t *testing.T) {
providerAvailable = true providerAvailable = true
mu.Unlock() mu.Unlock()
// Reset the retry timer to allow immediate retry // Reset the retry timer to allow immediate retry. The field is atomic
m.metadataRetryMutex.Lock() // now, so no lock is needed.
m.lastMetadataRetryTime = time.Time{} // Reset to zero time atomic.StoreInt64(&m.lastMetadataRetryNano, 0)
m.metadataRetryMutex.Unlock()
// Second request should trigger recovery attempt // Second request should trigger recovery attempt
req2 := httptest.NewRequest("GET", "/protected", nil) req2 := httptest.NewRequest("GET", "/protected", nil)
+16 -14
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/lukaszraczylo/traefikoidc/internal/utils" "github.com/lukaszraczylo/traefikoidc/internal/utils"
@@ -145,19 +146,20 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
if !strings.HasPrefix(req.URL.Path, "/health") { if !strings.HasPrefix(req.URL.Path, "/health") {
t.firstRequestMutex.Lock() // Lock-free one-shot bootstrap. The previous firstRequestMutex.Lock()
if !t.firstRequestReceived { // fired on EVERY non-health request forever (even after the boolean
t.firstRequestReceived = true // flipped true), which under Yaegi added a per-request serialization
// point. CAS gives single-firing semantics with zero steady-state cost.
if atomic.CompareAndSwapInt32(&t.firstRequestStarted, 0, 1) {
t.logger.Debug("Starting background tasks on first request") t.logger.Debug("Starting background tasks on first request")
t.startTokenCleanup() t.startTokenCleanup()
if !t.metadataRefreshStarted && t.providerURL != "" { if t.providerURL != "" &&
t.metadataRefreshStarted = true atomic.CompareAndSwapInt32(&t.metadataRefreshStartedAtomic, 0, 1) {
// Metadata refresh is handled by singleton resource manager // Metadata refresh is handled by singleton resource manager
t.startMetadataRefresh(t.providerURL) t.startMetadataRefresh(t.providerURL)
} }
} }
t.firstRequestMutex.Unlock()
} }
// Evaluate auth-bypass once, before waiting for initialization. Excluded // Evaluate auth-bypass once, before waiting for initialization. Excluded
@@ -213,14 +215,14 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
t.metadataMu.RUnlock() t.metadataMu.RUnlock()
if issuerURL == "" { if issuerURL == "" {
// Provider metadata initialization failed - try to recover // Provider metadata initialization failed - try to recover.
// Retry every 30 seconds to allow automatic recovery when provider comes back online // Retry every 30 seconds to allow automatic recovery. Lock-free
t.metadataRetryMutex.Lock() // throttle via CAS on lastMetadataRetryNano: one goroutine wins
shouldRetry := time.Since(t.lastMetadataRetryTime) >= 30*time.Second // the window, others see shouldRetry=false.
if shouldRetry { nowNano := time.Now().UnixNano()
t.lastMetadataRetryTime = time.Now() last := atomic.LoadInt64(&t.lastMetadataRetryNano)
} shouldRetry := time.Duration(nowNano-last) >= 30*time.Second &&
t.metadataRetryMutex.Unlock() atomic.CompareAndSwapInt64(&t.lastMetadataRetryNano, last, nowNano)
if shouldRetry && t.providerURL != "" { if shouldRetry && t.providerURL != "" {
t.logger.Info("Attempting to recover OIDC provider metadata...") t.logger.Info("Attempting to recover OIDC provider metadata...")
+14 -14
View File
@@ -13,8 +13,8 @@ func TestMiddlewareContextCancellation(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close to simulate waiting initComplete: make(chan struct{}), // Never close to simulate waiting
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
} }
// Create request with canceled context // Create request with canceled context
@@ -39,8 +39,8 @@ func TestMiddlewareSessionErrorRecovery(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -73,8 +73,8 @@ func TestMiddlewareAJAXRequestHandling(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -102,8 +102,8 @@ func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close to simulate provider unavailable initComplete: make(chan struct{}), // Never close to simulate provider unavailable
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
logoutURLPath: "/logout", logoutURLPath: "/logout",
postLogoutRedirectURI: "/", postLogoutRedirectURI: "/",
forceHTTPS: false, forceHTTPS: false,
@@ -142,8 +142,8 @@ func TestMiddlewareDomainRestrictions(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -187,8 +187,8 @@ func TestMiddlewareDomainRestrictions(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -236,8 +236,8 @@ func TestMiddlewareOpaqueTokenHandling(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestReceived: true, firstRequestStarted: 1,
metadataRefreshStarted: true, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
+147 -125
View File
@@ -21,16 +21,23 @@ type RefreshCoordinator struct {
// refreshMutex.Lock() was held for tens of milliseconds per request due // refreshMutex.Lock() was held for tens of milliseconds per request due
// to interpreter overhead on the work inside the critical section, // to interpreter overhead on the work inside the critical section,
// causing dozens of goroutines to stack up on it and pin one CPU core. // causing dozens of goroutines to stack up on it and pin one CPU core.
inFlightRefreshes sync.Map inFlightRefreshes sync.Map
// sessionRefreshAttempts maps sessionID -> *refreshAttemptTracker.
// sync.Map + atomic tracker fields means isInCooldown/recordRefreshAttempt/
// recordRefreshSuccess/recordRefreshFailure are lock-free. Previously
// these used attemptsMutex sync.RWMutex; under Yaegi every Lock() acquisition
// adds 10-50ms of dispatch overhead, and they were called twice per leader
// request (once for recordRefreshAttempt, once for isInCooldown). That
// serializing pattern caused the v1.0.15 death spiral after v1.0.14
// removed the refreshMutex (same architectural shape, different mutex).
sessionRefreshAttempts sync.Map
cleanupTimers map[string]*time.Timer cleanupTimers map[string]*time.Timer
sessionRefreshAttempts map[string]*refreshAttemptTracker
circuitBreaker *RefreshCircuitBreaker circuitBreaker *RefreshCircuitBreaker
metrics *RefreshMetrics metrics *RefreshMetrics
logger *Logger logger *Logger
stopChan chan struct{} stopChan chan struct{}
config RefreshCoordinatorConfig config RefreshCoordinatorConfig
wg sync.WaitGroup wg sync.WaitGroup
attemptsMutex sync.RWMutex
cleanupTimerMu sync.Mutex cleanupTimerMu sync.Mutex
} }
@@ -89,14 +96,22 @@ type refreshResult struct {
fromCache bool fromCache bool
} }
// refreshAttemptTracker tracks refresh attempts for a session // refreshAttemptTracker tracks refresh attempts for a session. All fields are
// accessed via sync/atomic so isInCooldown/recordRefreshAttempt/Success/Failure
// can run without holding any per-coordinator lock. Times are UnixNano so they
// fit in an int64 and can be read with a single atomic.LoadInt64.
//
// cooldownEndNano == 0 means "not in cooldown". This sentinel replaces the
// inCooldown bool that the previous implementation kept under attemptsMutex —
// under Yaegi any per-request global mutex turns into a serializing bottleneck
// (the v1.0.14 refreshMutex -> sync.Map fix removed only one such bottleneck;
// attemptsMutex was the next one in the queue).
type refreshAttemptTracker struct { type refreshAttemptTracker struct {
lastAttemptTime time.Time lastAttemptNano int64 // atomic, UnixNano of last attempt
windowStartTime time.Time windowStartNano int64 // atomic, UnixNano of attempt-window start
cooldownEndTime time.Time cooldownEndNano int64 // atomic, UnixNano; 0 = not in cooldown
attempts int32 attempts int32 // atomic
consecutiveFailures int32 consecutiveFailures int32 // atomic
inCooldown bool
} }
// RefreshMetrics tracks coordinator performance metrics // RefreshMetrics tracks coordinator performance metrics
@@ -111,14 +126,18 @@ type RefreshMetrics struct {
currentInFlightRefreshes int32 currentInFlightRefreshes int32
} }
// RefreshCircuitBreaker implements a circuit breaker specifically for refresh operations // RefreshCircuitBreaker implements a circuit breaker specifically for refresh
// operations. All mutable fields are atomic so AllowRequest/RecordSuccess/
// RecordFailure run without any mutex. The previous sync.RWMutex.RLock() was
// taken on every CoordinateRefresh — under Yaegi this added 10-50ms of
// interpreter dispatch per call, which compounded with attemptsMutex to keep
// the pod's single CPU core saturated.
type RefreshCircuitBreaker struct { type RefreshCircuitBreaker struct {
lastFailureTime time.Time lastFailureNano int64 // atomic, UnixNano of most recent failure
lastSuccessTime time.Time lastSuccessNano int64 // atomic, UnixNano of most recent success
config RefreshCircuitBreakerConfig config RefreshCircuitBreakerConfig
mutex sync.RWMutex state int32 // atomic: 0=closed, 1=open, 2=half-open
state int32 failures int32 // atomic
failures int32
} }
// RefreshCircuitBreakerConfig configures the refresh circuit breaker // RefreshCircuitBreakerConfig configures the refresh circuit breaker
@@ -135,13 +154,13 @@ func NewRefreshCoordinator(config RefreshCoordinatorConfig, logger *Logger) *Ref
} }
rc := &RefreshCoordinator{ rc := &RefreshCoordinator{
// inFlightRefreshes is a sync.Map; zero value is ready to use. // inFlightRefreshes and sessionRefreshAttempts are both sync.Map;
sessionRefreshAttempts: make(map[string]*refreshAttemptTracker), // their zero values are ready to use.
config: config, config: config,
metrics: &RefreshMetrics{}, metrics: &RefreshMetrics{},
logger: logger, logger: logger,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
cleanupTimers: make(map[string]*time.Timer), cleanupTimers: make(map[string]*time.Timer),
circuitBreaker: &RefreshCircuitBreaker{ circuitBreaker: &RefreshCircuitBreaker{
config: RefreshCircuitBreakerConfig{ config: RefreshCircuitBreakerConfig{
MaxFailures: 3, MaxFailures: 3,
@@ -415,86 +434,99 @@ func (rc *RefreshCoordinator) performCleanup(tokenHash string) {
} }
} }
// isInCooldown checks if a session is in cooldown after recording an attempt // getOrCreateTracker fetches the tracker for sessionID or atomically creates a
func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool { // fresh one. The sync.Map.LoadOrStore semantics make this lock-free even under
rc.attemptsMutex.Lock() // concurrent first-touch races: at most one tracker per sessionID survives.
defer rc.attemptsMutex.Unlock() //
// trackerFromMapValue centralizes the type assertion so the lint-mandated
// two-value form lives in one place; the stored type is always
// *refreshAttemptTracker by construction.
func trackerFromMapValue(v interface{}) *refreshAttemptTracker {
t, _ := v.(*refreshAttemptTracker)
return t
}
tracker, exists := rc.sessionRefreshAttempts[sessionID] func (rc *RefreshCoordinator) getOrCreateTracker(sessionID string) *refreshAttemptTracker {
if !exists { if v, ok := rc.sessionRefreshAttempts.Load(sessionID); ok {
return trackerFromMapValue(v)
}
fresh := &refreshAttemptTracker{
windowStartNano: time.Now().UnixNano(),
}
actual, _ := rc.sessionRefreshAttempts.LoadOrStore(sessionID, fresh)
return trackerFromMapValue(actual)
}
// isInCooldown checks if a session is in cooldown. Lock-free read with a
// best-effort cooldown-reset CAS on the cooldownEndNano sentinel. If the
// reset races with another goroutine we accept the loser's view (the winner's
// reset still happens). The attempt-window expiry and limit-exceeded paths
// are write-mostly but use atomic.StoreInt64/AddInt32 — never a held lock.
func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
v, ok := rc.sessionRefreshAttempts.Load(sessionID)
if !ok {
return false // No tracker means first attempt, not in cooldown return false // No tracker means first attempt, not in cooldown
} }
tracker := trackerFromMapValue(v)
now := time.Now() now := time.Now()
nowNano := now.UnixNano()
// Check if already in cooldown // Already in cooldown?
if tracker.inCooldown { if cooldownEnd := atomic.LoadInt64(&tracker.cooldownEndNano); cooldownEnd != 0 {
if now.After(tracker.cooldownEndTime) { if nowNano <= cooldownEnd {
// Cooldown expired, reset tracker return true // still in cooldown
tracker.inCooldown = false }
tracker.attempts = 1 // Already recorded one attempt // Cooldown expired. Best-effort reset (a concurrent caller may also
tracker.consecutiveFailures = 0 // reset; the result is equivalent — fresh window + one recorded
tracker.windowStartTime = now // attempt — so the CAS race is benign).
return false if atomic.CompareAndSwapInt64(&tracker.cooldownEndNano, cooldownEnd, 0) {
atomic.StoreInt32(&tracker.attempts, 1)
atomic.StoreInt32(&tracker.consecutiveFailures, 0)
atomic.StoreInt64(&tracker.windowStartNano, nowNano)
} }
return true // Still in cooldown
}
// Check if window expired
if now.Sub(tracker.windowStartTime) > rc.config.RefreshAttemptWindow {
// Reset window
tracker.attempts = 1 // Already recorded one attempt
tracker.windowStartTime = now
return false return false
} }
// Check if just exceeded attempt limit // Window expired?
if int(tracker.attempts) >= rc.config.MaxRefreshAttempts { if windowStart := atomic.LoadInt64(&tracker.windowStartNano); time.Duration(nowNano-windowStart) > rc.config.RefreshAttemptWindow {
// Enter cooldown now atomic.StoreInt32(&tracker.attempts, 1)
tracker.inCooldown = true atomic.StoreInt64(&tracker.windowStartNano, nowNano)
tracker.cooldownEndTime = now.Add(rc.config.RefreshCooldownPeriod) return false
rc.logger.Infof("Session %s entering refresh cooldown after %d attempts", }
sessionID, tracker.attempts)
// Just exceeded attempt limit?
if int(atomic.LoadInt32(&tracker.attempts)) >= rc.config.MaxRefreshAttempts {
end := now.Add(rc.config.RefreshCooldownPeriod).UnixNano()
// Only one CAS winner publishes the cooldown end + logs.
if atomic.CompareAndSwapInt64(&tracker.cooldownEndNano, 0, end) {
rc.logger.Infof("Session %s entering refresh cooldown after %d attempts",
sessionID, atomic.LoadInt32(&tracker.attempts))
}
return true return true
} }
return false return false
} }
// recordRefreshAttempt records a refresh attempt for rate limiting // recordRefreshAttempt records a refresh attempt for rate limiting. Lock-free:
// LoadOrStore for the tracker, atomic counters/timestamps for fields.
func (rc *RefreshCoordinator) recordRefreshAttempt(sessionID string) { func (rc *RefreshCoordinator) recordRefreshAttempt(sessionID string) {
rc.attemptsMutex.Lock() tracker := rc.getOrCreateTracker(sessionID)
defer rc.attemptsMutex.Unlock()
tracker, exists := rc.sessionRefreshAttempts[sessionID]
if !exists {
tracker = &refreshAttemptTracker{
windowStartTime: time.Now(),
}
rc.sessionRefreshAttempts[sessionID] = tracker
}
atomic.AddInt32(&tracker.attempts, 1) atomic.AddInt32(&tracker.attempts, 1)
tracker.lastAttemptTime = time.Now() atomic.StoreInt64(&tracker.lastAttemptNano, time.Now().UnixNano())
} }
// recordRefreshSuccess records a successful refresh // recordRefreshSuccess records a successful refresh. Lock-free.
func (rc *RefreshCoordinator) recordRefreshSuccess(sessionID string) { func (rc *RefreshCoordinator) recordRefreshSuccess(sessionID string) {
rc.attemptsMutex.Lock() if v, ok := rc.sessionRefreshAttempts.Load(sessionID); ok {
defer rc.attemptsMutex.Unlock() atomic.StoreInt32(&trackerFromMapValue(v).consecutiveFailures, 0)
if tracker, exists := rc.sessionRefreshAttempts[sessionID]; exists {
tracker.consecutiveFailures = 0
} }
} }
// recordRefreshFailure records a failed refresh // recordRefreshFailure records a failed refresh. Lock-free.
func (rc *RefreshCoordinator) recordRefreshFailure(sessionID string) { func (rc *RefreshCoordinator) recordRefreshFailure(sessionID string) {
rc.attemptsMutex.Lock() if v, ok := rc.sessionRefreshAttempts.Load(sessionID); ok {
defer rc.attemptsMutex.Unlock() atomic.AddInt32(&trackerFromMapValue(v).consecutiveFailures, 1)
if tracker, exists := rc.sessionRefreshAttempts[sessionID]; exists {
atomic.AddInt32(&tracker.consecutiveFailures, 1)
} }
} }
@@ -546,20 +578,22 @@ func (rc *RefreshCoordinator) cleanupRoutine() {
} }
} }
// cleanupStaleEntries removes outdated tracking entries // cleanupStaleEntries removes outdated tracking entries. Lock-free iteration
// via sync.Map.Range; safe to race with concurrent reads/writes.
func (rc *RefreshCoordinator) cleanupStaleEntries() { func (rc *RefreshCoordinator) cleanupStaleEntries() {
now := time.Now() cutoff := time.Now().Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool {
rc.attemptsMutex.Lock() tracker := trackerFromMapValue(value)
defer rc.attemptsMutex.Unlock() if tracker == nil {
return true
// Clean up old session trackers
for sessionID, tracker := range rc.sessionRefreshAttempts {
// Remove trackers that haven't been used recently
if now.Sub(tracker.lastAttemptTime) > 2*rc.config.RefreshAttemptWindow {
delete(rc.sessionRefreshAttempts, sessionID)
} }
} if atomic.LoadInt64(&tracker.lastAttemptNano) < cutoff {
// Compare-and-delete to avoid evicting a tracker that was just
// re-used by a concurrent caller. We compare by pointer identity.
rc.sessionRefreshAttempts.CompareAndDelete(key, value)
}
return true
})
} }
// GetMetrics returns current coordinator metrics // GetMetrics returns current coordinator metrics
@@ -592,63 +626,51 @@ func (rc *RefreshCoordinator) Shutdown() {
rc.wg.Wait() rc.wg.Wait()
} }
// AllowRequest checks if the circuit breaker allows a request // AllowRequest reports whether the circuit breaker allows a request. Lock-free.
func (cb *RefreshCircuitBreaker) AllowRequest() bool { func (cb *RefreshCircuitBreaker) AllowRequest() bool {
cb.mutex.RLock() switch atomic.LoadInt32(&cb.state) {
defer cb.mutex.RUnlock() case 0: // closed
state := atomic.LoadInt32(&cb.state)
switch state {
case 0: // Closed
return true return true
case 1: // Open case 1: // open
if time.Since(cb.lastFailureTime) > cb.config.OpenDuration { lastFail := atomic.LoadInt64(&cb.lastFailureNano)
// Try to transition to half-open if time.Duration(time.Now().UnixNano()-lastFail) > cb.config.OpenDuration {
// Transition to half-open; first CAS winner gets the probe.
if atomic.CompareAndSwapInt32(&cb.state, 1, 2) { if atomic.CompareAndSwapInt32(&cb.state, 1, 2) {
return true return true
} }
} }
return false return false
case 2: // Half-open case 2: // half-open
return true return true
default: default:
return false return false
} }
} }
// RecordSuccess records a successful operation // RecordSuccess records a successful operation. Lock-free.
func (cb *RefreshCircuitBreaker) RecordSuccess() { func (cb *RefreshCircuitBreaker) RecordSuccess() {
cb.mutex.Lock() switch atomic.LoadInt32(&cb.state) {
defer cb.mutex.Unlock() case 2: // half-open -> close
state := atomic.LoadInt32(&cb.state)
if state == 2 { // Half-open
// Close the circuit
atomic.StoreInt32(&cb.state, 0) atomic.StoreInt32(&cb.state, 0)
atomic.StoreInt32(&cb.failures, 0) atomic.StoreInt32(&cb.failures, 0)
} else if state == 0 { // Closed case 0: // closed
// Reset failure count on success
atomic.StoreInt32(&cb.failures, 0) atomic.StoreInt32(&cb.failures, 0)
} }
cb.lastSuccessTime = time.Now() atomic.StoreInt64(&cb.lastSuccessNano, time.Now().UnixNano())
} }
// RecordFailure records a failed operation // RecordFailure records a failed operation. Lock-free.
func (cb *RefreshCircuitBreaker) RecordFailure() { func (cb *RefreshCircuitBreaker) RecordFailure() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
failures := atomic.AddInt32(&cb.failures, 1) failures := atomic.AddInt32(&cb.failures, 1)
cb.lastFailureTime = time.Now() atomic.StoreInt64(&cb.lastFailureNano, time.Now().UnixNano())
state := atomic.LoadInt32(&cb.state) switch atomic.LoadInt32(&cb.state) {
case 0:
if state == 0 && int(failures) >= cb.config.MaxFailures { if int(failures) >= cb.config.MaxFailures {
// Open the circuit atomic.StoreInt32(&cb.state, 1)
atomic.StoreInt32(&cb.state, 1) }
} else if state == 2 { case 2:
// Half-open failed, return to open // Half-open probe failed -> back to open.
atomic.StoreInt32(&cb.state, 1) atomic.StoreInt32(&cb.state, 1)
} }
} }
+16 -15
View File
@@ -365,10 +365,12 @@ func TestMemoryLeakPrevention(t *testing.T) {
} }
} }
// Verify cleanup is working // Verify cleanup is working. sync.Map has no Len(); count via Range.
coordinator.attemptsMutex.RLock() sessionCount := 0
sessionCount := len(coordinator.sessionRefreshAttempts) coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
coordinator.attemptsMutex.RUnlock() sessionCount++
return true
})
// Should have cleaned up old sessions (only recent ones remain) // Should have cleaned up old sessions (only recent ones remain)
if sessionCount > numWorkers*2 { if sessionCount > numWorkers*2 {
@@ -650,24 +652,23 @@ func TestCleanupRoutine(t *testing.T) {
coordinator.recordRefreshAttempt(fmt.Sprintf("session_%d", i)) coordinator.recordRefreshAttempt(fmt.Sprintf("session_%d", i))
} }
// Verify sessions exist countSessions := func() int {
coordinator.attemptsMutex.RLock() n := 0
initialCount := len(coordinator.sessionRefreshAttempts) coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
coordinator.attemptsMutex.RUnlock() n++
return true
})
return n
}
if initialCount != 5 { if initialCount := countSessions(); initialCount != 5 {
t.Errorf("Expected 5 sessions, got %d", initialCount) t.Errorf("Expected 5 sessions, got %d", initialCount)
} }
// Wait for cleanup to run (2x window + cleanup interval) // Wait for cleanup to run (2x window + cleanup interval)
time.Sleep(2*config.RefreshAttemptWindow + 2*config.CleanupInterval) time.Sleep(2*config.RefreshAttemptWindow + 2*config.CleanupInterval)
// Verify sessions were cleaned up if finalCount := countSessions(); finalCount != 0 {
coordinator.attemptsMutex.RLock()
finalCount := len(coordinator.sessionRefreshAttempts)
coordinator.attemptsMutex.RUnlock()
if finalCount != 0 {
t.Errorf("Expected 0 sessions after cleanup, got %d", finalCount) t.Errorf("Expected 0 sessions after cleanup, got %d", finalCount)
} }
} }
+13 -5
View File
@@ -65,7 +65,19 @@ type ProviderMetadata struct {
// the complete authentication flow. It's designed to work seamlessly with Traefik's // the complete authentication flow. It's designed to work seamlessly with Traefik's
// plugin system and provides flexible configuration options. // plugin system and provides flexible configuration options.
type TraefikOidc struct { type TraefikOidc struct {
lastMetadataRetryTime time.Time // lastMetadataRetryNano is the UnixNano timestamp of the last metadata
// recovery attempt. Stored atomically so the hot ServeHTTP path can
// throttle retries without acquiring metadataRetryMutex on every request.
lastMetadataRetryNano int64
// firstRequestStarted is 0 until the very first non-health request fires
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
// firstRequestMutex + firstRequestReceived combo which previously took
// a write lock on every non-health request forever.
firstRequestStarted int32
// metadataRefreshStartedAtomic is the CAS-only variant of the old
// metadataRefreshStarted bool. Both flags live under the same atomic so
// concurrent first-request goroutines race exactly once.
metadataRefreshStartedAtomic int32
jwkCache JWKCacheInterface jwkCache JWKCacheInterface
jwtVerifier JWTVerifier jwtVerifier JWTVerifier
ctx context.Context ctx context.Context
@@ -130,17 +142,13 @@ type TraefikOidc struct {
maxRefreshTokenAge time.Duration maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex metadataMu sync.RWMutex
shutdownOnce sync.Once shutdownOnce sync.Once
metadataRetryMutex sync.Mutex
firstRequestMutex sync.Mutex
sessionInvalidationCache CacheInterface sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface refreshResultCache CacheInterface
minimalHeaders bool minimalHeaders bool
stripAuthCookies bool stripAuthCookies bool
enableBackchannelLogout bool enableBackchannelLogout bool
enableFrontchannelLogout bool enableFrontchannelLogout bool
firstRequestReceived bool
requireTokenIntrospection bool requireTokenIntrospection bool
metadataRefreshStarted bool
allowPrivateIPAddresses bool allowPrivateIPAddresses bool
disableReplayDetection bool disableReplayDetection bool
allowOpaqueTokens bool allowOpaqueTokens bool