diff --git a/refresh_cleanup_chunktrim_test.go b/refresh_cleanup_chunktrim_test.go new file mode 100644 index 0000000..efc75c8 --- /dev/null +++ b/refresh_cleanup_chunktrim_test.go @@ -0,0 +1,49 @@ +package traefikoidc + +import ( + "testing" + "time" + + "github.com/gorilla/sessions" +) + +// Cleanup must not evict a tracker whose cooldown is still running. +func TestRefreshCleanupKeepsTrackerInCooldown(t *testing.T) { + cfg := DefaultRefreshCoordinatorConfig() + cfg.MaxRefreshAttempts = 2 + cfg.RefreshAttemptWindow = 20 * time.Millisecond + cfg.RefreshCooldownPeriod = time.Hour + rc := NewRefreshCoordinator(cfg, GetSingletonNoOpLogger()) + defer rc.Shutdown() + + rc.recordRefreshAttempt("s") + rc.recordRefreshAttempt("s") + if !rc.isInCooldown("s") { + t.Fatal("precondition: session must enter cooldown") + } + time.Sleep(80 * time.Millisecond) + rc.cleanupStaleEntries() + if !rc.isInCooldown("s") { + t.Fatal("cleanup evicted a tracker still in cooldown") + } +} + +// Stale chunks above token_total must be dropped by key, even when the key +// set is sparse. +func TestChunkTrimDropsStaleSparseKeys(t *testing.T) { + cm := NewChunkManager(newNoOpLogger()) + mk := func(s string, total interface{}) *sessions.Session { + v := map[interface{}]interface{}{"token_chunk": s} + if total != nil { + v["token_total"] = total + } + return &sessions.Session{Values: v} + } + chunks := map[int]*sessions.Session{ + 0: mk("Zq8xK2mP", 3), 1: mk("r7TnB4wY", nil), 2: mk("c9LdG5sH", nil), 7: mk("stale", nil), + } + res := cm.processChunkedToken(chunks, RefreshTokenConfig) + if res.Error != nil || res.Token != "Zq8xK2mPr7TnB4wYc9LdG5sH" { + t.Fatalf("token=%q err=%v, want Zq8xK2mPr7TnB4wYc9LdG5sH", res.Token, res.Error) + } +} diff --git a/refresh_coordinator.go b/refresh_coordinator.go index d270f0d..35752dc 100644 --- a/refresh_coordinator.go +++ b/refresh_coordinator.go @@ -823,13 +823,16 @@ func (rc *RefreshCoordinator) cleanupRoutine() { // cleanupStaleEntries removes outdated tracking entries. Lock-free iteration // via sync.Map.Range; safe to race with concurrent reads/writes. func (rc *RefreshCoordinator) cleanupStaleEntries() { - cutoff := time.Now().Add(-2 * rc.config.RefreshAttemptWindow).UnixNano() + now := time.Now() + cutoff := now.Add(-2 * rc.config.RefreshAttemptWindow).UnixNano() + nowNano := now.UnixNano() rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool { tracker := trackerFromMapValue(value) if tracker == nil { return true } - if tracker.stateOf().lastAttemptNano < cutoff { + st := tracker.stateOf() + if st.lastAttemptNano < cutoff && st.cooldownEndNano <= nowNano { // 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) diff --git a/session_chunk_manager.go b/session_chunk_manager.go index 1f4ede5..1fbd1e9 100644 --- a/session_chunk_manager.go +++ b/session_chunk_manager.go @@ -445,9 +445,10 @@ func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, co // not clear cookies above n). token_total is the authoritative // count; drop the excess rather than failing — which forced a // re-auth and discarded the still-valid new token (R129). - n := len(chunks) - for i := total; i < n; i++ { - delete(chunks, i) + for i := range chunks { + if i >= total { + delete(chunks, i) + } } } if len(chunks) < total {