fix(cache): harden cache backends, resilience, recovery, and transports

- RESP -ERR no longer closes healthy conn / aborts batch; global cap replacement-aware
- global health-check pass serialized (latent data race); MemoryMonitor via ResourceManager
- token-exchange retry, open-circuit rejections counted as failures
- TLS-posture matching transport reuse (no silent InsecureSkipVerify handoff)
This commit is contained in:
2026-08-14 16:16:51 +01:00
parent 8640451877
commit 4d23d618f5
49 changed files with 2318 additions and 181 deletions
+68 -12
View File
@@ -46,6 +46,12 @@ func NewBackgroundTask(name string, interval time.Duration, taskFunc func(), log
if len(wg) > 0 {
externalWG = wg[0]
}
// run() uses time.NewTicker(interval), which panics for d <= 0 inside
// the started goroutine (autocleanup.go:186), crashing the whole
// process on a misconfigured interval. Normalize here (R149).
if interval <= 0 {
interval = time.Minute
}
return &BackgroundTask{
name: name,
interval: interval,
@@ -88,8 +94,20 @@ func (bt *BackgroundTask) Start() {
return
}
// Reserve the task slot immediately when starting
registry.cb.OnTaskStart(bt.name)
// Reserve the task slot immediately when starting. OnTaskStart
// is the authoritative concurrency gate (R165): it returns false
// if maxConcurrent is already reached, in which case this task
// must not launch run() (its slot was not taken).
if !registry.cb.OnTaskStart(bt.name) {
if bt.logger != nil {
bt.logger.Debugf("Cannot start task %s: concurrency limit reached, slot not acquired", bt.name)
}
// Close doneChan since the task won't run
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
close(bt.doneChan)
}
return
}
atomic.StoreInt32(&bt.started, 1)
bt.internalWG.Add(1)
@@ -114,6 +132,14 @@ func (bt *BackgroundTask) Stop() {
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
close(bt.doneChan)
}
// Also close stopChan (R117): a concurrent Start may be in-flight
// and about to launch run(). Signaling stopChan lets it exit at
// the next stop check instead of leaking the goroutine and its
// circuit-breaker slot (taken by OnTaskStart but never released).
func() {
defer func() { _ = recover() }() // safe if already closed
close(bt.stopChan)
}()
return
}
@@ -134,15 +160,18 @@ func (bt *BackgroundTask) Stop() {
// This avoids the race condition with WaitGroup
select {
case <-bt.doneChan:
// Normal completion
// Normal completion: the goroutine has returned, so it is safe
// to wait on the WaitGroup.
bt.internalWG.Wait()
case <-time.After(5 * time.Second):
// The task goroutine is still running (its taskFunc is blocked or
// long-lived). Do NOT wait on the WaitGroup here — that would
// block Stop() (and shutdown) for as long as the task runs.
// Return now; the goroutine is reaped when it completes.
if bt.logger != nil {
bt.logger.Errorf("Timeout waiting for background task %s to stop", bt.name)
}
}
// Wait for the internal WaitGroup synchronously after doneChan signals
bt.internalWG.Wait()
})
}
@@ -345,9 +374,30 @@ func (cb *TaskCircuitBreaker) CanCreateTask(taskName string) error {
}
}
// OnTaskStart records a task starting execution
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
atomic.AddInt32(&cb.concurrentTasks, 1)
// OnTaskStart records a task starting execution. It atomically reserves a
// concurrency slot and returns whether one was available. The reservation
// is fundamental (R165): CanCreateTask's limit check is only advisory —
// it is also called by RegisterTask without starting the task — so a
// check-then-act TOCTOU meant two concurrently starting tasks could both
// read current < limit and then both start, letting live tasks exceed
// maxConcurrent and defeating the DoS backstop. OnTaskStart is the
// single commit point, so it enforces maxConcurrent here via a CAS loop:
// only maxConcurrent tasks can hold slots at once. BackgroundTask.Start
// aborts (without launching run) when this returns false.
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) bool {
max := atomic.LoadInt32(&cb.maxConcurrent)
for {
current := atomic.LoadInt32(&cb.concurrentTasks)
if current >= max {
if cb.logger != nil {
cb.logger.Debug("Concurrent task limit reached, refusing slot for: %s", taskName)
}
return false
}
if atomic.CompareAndSwapInt32(&cb.concurrentTasks, current, current+1) {
break
}
}
cb.tasksMu.Lock()
cb.activeTasks[taskName] = struct{}{}
cb.tasksMu.Unlock()
@@ -358,6 +408,7 @@ func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
cb.logger.Debug("Task started, concurrent count: %d, task: %s",
atomic.LoadInt32(&cb.concurrentTasks), taskName)
}
return true
}
// OnTaskComplete records a task completing execution
@@ -751,9 +802,14 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
historyLen := len(mm.statsHistory)
if historyLen >= 2 {
prev := mm.statsHistory[historyLen-2]
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
// Guard against div-by-zero (prev.HeapAlloc == 0) which would make
// heapGrowth == +Inf and fire a spurious "memory leak" alert for a
// first-ever measurement (R103).
if prev.HeapAlloc > 0 {
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
}
}
}
mm.mu.RUnlock()
+72 -6
View File
@@ -243,6 +243,14 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
cb.RecordRequest()
if !cb.allowRequest() {
// A request rejected while the circuit is open is an admission
// outcome, not a never-started request: count it as a failure so
// GetBaseMetrics' success_rate (successes/total_requests) and
// total_failures reflect actual admission, matching the
// internal/recovery circuit breaker. Previously open-rejects only
// incremented total_requests, understating failures and
// deflating the reported success rate (R180).
cb.RecordFailure()
return fmt.Errorf("circuit breaker is open")
}
@@ -362,7 +370,24 @@ func (cb *CircuitBreaker) Reset() {
// IsAvailable returns whether the circuit breaker is currently allowing requests.
// This provides a quick way to check if the service is available.
func (cb *CircuitBreaker) IsAvailable() bool {
return cb.allowRequest()
cb.mutex.Lock()
defer cb.mutex.Unlock()
switch cb.state {
case CircuitBreakerClosed:
return true
case CircuitBreakerHalfOpen:
return true
case CircuitBreakerOpen:
// Read-only availability probe: report that a request would be
// admitted only after the open timeout has elapsed, but do NOT
// transition to half-open — IsAvailable must not take a permit or
// mutate circuit state. Real traffic admission is gated by
// allowRequest (triggerRequest) which performs the transition.
return time.Since(cb.lastFailureTime) > cb.timeout
default:
return false
}
}
// GetMetrics returns comprehensive metrics about the circuit breaker.
@@ -607,6 +632,18 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
return false
}
// A terminal HTTP error (a real status that is neither 5xx nor 429)
// must be classified by its status BEFORE any message-substring scan.
// Otherwise a 4xx whose provider message merely contains a
// retryable-looking word — e.g. "certificate"/"tls" (isCertificateError),
// "EOF" (isEOFError), or a generic "timeout" — would be reclassified
// retryable and retried to MaxAttempts on a permanent error, repeating
// the failing request (R123, R157).
var statusHTTP *HTTPError
if errors.As(err, &statusHTTP) && statusHTTP.StatusCode != 0 && statusHTTP.StatusCode < 500 && statusHTTP.StatusCode != 429 {
return false
}
// Check for Traefik default certificate error (startup race condition)
// See: https://github.com/lukaszraczylo/traefikoidc/issues/90
if isTraefikDefaultCertError(err) {
@@ -631,7 +668,8 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
}
}
if netErr, ok := err.(net.Error); ok {
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
return true
}
@@ -652,8 +690,18 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
}
}
if httpErr, ok := err.(*HTTPError); ok {
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
// errors.As so retry classification survives error wrapping: a transient
// or 5xx/429 error returned as fmt.Errorf("...: %w", err) from an
// upstream layer must still be retried. Direct type assertions on the
// top-level error silently misclassified wrapped transient errors as
// permanent, giving up before any retry (R111).
var httpErr *HTTPError
if errors.As(err, &httpErr) {
status := httpErr.StatusCode
// A 0 StatusCode means the producer used HTTPError purely as a
// message carrier (no real HTTP status). Treat it as unknown
// (non-retryable) rather than spuriously matching 5xx (R118).
return status != 0 && (status >= 500 || status == 429)
}
return false
@@ -880,11 +928,23 @@ var gdInstances = struct {
set map[*GracefulDegradation]struct{}
}{set: make(map[*GracefulDegradation]struct{})}
// globalHealthCheckMu serializes the global pass. globalPerformHealthChecks
// runs from more than one goroutine — the shared singleton task's run loop
// AND the once-guarded StartBackgroundTask path both drive it — so without
// this, any health check registered via the public RegisterHealthCheck
// API would be invoked concurrently (a latent production race, and the
// source of an intermittent full-suite race in tests). Serializing the
// whole pass keeps registered health checks single-threaded (R189).
var globalHealthCheckMu sync.Mutex
// globalPerformHealthChecks runs the health check pass for every live
// GracefulDegradation instance. The shared singleton task calls this, so a
// health check registered on ANY instance is exercised, and closing one
// instance does not stall recovery for the others.
func globalPerformHealthChecks() {
globalHealthCheckMu.Lock()
defer globalHealthCheckMu.Unlock()
gdInstances.RLock()
instances := make([]*GracefulDegradation, 0, len(gdInstances.set))
for gd := range gdInstances.set {
@@ -1201,8 +1261,14 @@ func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitB
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
cb := erm.GetCircuitBreaker(serviceName)
return erm.retryExecutor.Execute(ctx, func() error {
return cb.Execute(fn)
// Retry INNER, circuit OUTER (matching TokenResilienceManager and the
// documented R106 compositing rule). Wrapping cb around the whole retry
// run means one logical call counts as a single circuit failure even
// when it was retried internally; the previous nesting (cb inside the
// retry loop) recorded each retry attempt as a separate failure and
// could open the circuit after only one flaky-but-retryable call (R144).
return cb.ExecuteWithContext(ctx, func() error {
return erm.retryExecutor.Execute(ctx, fn)
})
}
+8
View File
@@ -63,6 +63,14 @@ func (m *GoroutineManager) StartGoroutine(name string, fn func(context.Context))
m.wg.Done()
m.mu.Lock()
managedGoroutine.running = false
// Prune the map entry once this goroutine has finished so that
// finished goroutines don't accumulate unboundedly (R157). Only
// delete when the current entry still IS the finishing
// goroutine: a concurrent Start with the same name may have
// already replaced it with a live one.
if cur, ok := m.goroutines[goroutineName]; ok && cur == managedGoroutine {
delete(m.goroutines, goroutineName)
}
m.mu.Unlock()
// Recover from panics
+55 -24
View File
@@ -81,10 +81,21 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
return shared.transport
}
}
// No TLS-compatible transport available; return nil so the caller falls
// back to a default, certificate-verifying transport rather than one
// with a different (possibly verification-disabled) trust store.
return nil
// No TLS-compatible transport available. Returning nil would make the
// caller fall back to http.DefaultTransport, silently dropping the
// configured RootCAs / InsecureSkipVerify — custom-CA TLS would
// break and self-signed skip-verify would be lost (R95). Build and
// return the correctly-configured transport instead; honoring TLS
// settings takes precedence over the soft client cap.
t := newSharedTransport(config)
p.transports[p.configKey(config)] = &sharedTransport{
transport: t,
refCount: 1,
lastUsed: time.Now(),
tlsKey: want,
}
atomic.AddInt32(&p.clientCount, 1)
return t
}
p.mu.Lock()
@@ -101,8 +112,26 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
// Increment client count
atomic.AddInt32(&p.clientCount, 1)
// Create new transport with conservative limits
transport := &http.Transport{
transport := newSharedTransport(config)
p.transports[key] = &sharedTransport{
transport: transport,
refCount: 1,
lastUsed: time.Now(),
tlsKey: tlsConfigKey(config),
}
return transport
}
// newSharedTransport builds a properly-configured *http.Transport from the
// given config, enforcing TLS 1.2+ and secure cipher suites. Shared by the
// normal acquisition path and the at-cap fallback so the TLS settings are
// NEVER silently dropped (the at-cap fallback previously returned nil,
// making the caller fall back to http.DefaultTransport and lose the
// configured RootCAs / InsecureSkipVerify — R95).
func newSharedTransport(config HTTPClientConfig) *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{
@@ -138,15 +167,6 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
WriteBufferSize: config.WriteBufferSize,
ReadBufferSize: config.ReadBufferSize,
}
p.transports[key] = &sharedTransport{
transport: transport,
refCount: 1,
lastUsed: time.Now(),
tlsKey: tlsConfigKey(config),
}
return transport
}
// ReleaseTransport decrements the reference count for a transport
@@ -270,20 +290,31 @@ func tlsConfigKey(config HTTPClientConfig) string {
return fmt.Sprintf("%p|%s", config.RootCAs, skip)
}
// Cleanup closes all transports and stops the cleanup goroutine
// Cleanup closes all transports, resets the pool, and restarts the cleanup
// goroutine so the (singleton) pool remains fully usable afterwards. Prior
// to R125, Cleanup canceled the cleanup goroutine but never restarted it
// and left clientCount unreset: a reused singleton then had a permanently
// dead cleaner (idle conns never pruned) and a stale count (the soft
// maxClients cap keyed off it was wrong after reuse).
func (p *SharedTransportPool) Cleanup() {
p.mu.Lock()
defer p.mu.Unlock()
// Stop the cleanup goroutine
if p.cancel != nil {
p.cancel()
}
for _, shared := range p.transports {
shared.transport.CloseIdleConnections()
if shared != nil && shared.transport != nil {
shared.transport.CloseIdleConnections()
}
}
p.transports = make(map[string]*sharedTransport)
atomic.StoreInt32(&p.clientCount, 0)
oldCancel := p.cancel
ctx, cancel := context.WithCancel(context.Background())
p.ctx = ctx
p.cancel = cancel
p.mu.Unlock()
if oldCancel != nil {
oldCancel()
}
go p.cleanupIdleTransports(ctx)
}
// CreatePooledHTTPClient creates an HTTP client using the shared transport pool
+42 -17
View File
@@ -37,7 +37,6 @@ type HybridBackend struct {
// asyncWriteItem represents an async write operation
type asyncWriteItem struct {
ctx context.Context
key string
value []byte
ttl time.Duration
@@ -195,7 +194,6 @@ func (h *HybridBackend) Set(ctx context.Context, key string, value []byte, ttl t
key: key,
value: value,
ttl: ttl,
ctx: ctx,
}:
h.logger.Debugf("Queued async write to L2 for key: %s", redactKey(key))
default:
@@ -265,14 +263,23 @@ func (h *HybridBackend) Delete(ctx context.Context, key string) (bool, error) {
deleted = true
}
// Delete from L2 if not in fallback mode
if !h.fallbackMode.Load() {
if d, err := h.secondary.Delete(ctx, key); err != nil {
h.logger.Debugf("Failed to delete from L2 cache: %v", err)
h.recordL2Error()
} else if d {
deleted = true
}
if h.fallbackMode.Load() {
return deleted, nil
}
// Drain in-flight async L2 writes before deleting L2. A Set's async
// write could otherwise land AFTER this DEL, resurrecting the just-
// deleted key with its stale value (L1/L2 divergence). Waiting for
// the async worker (which tracks queued + in-flight non-critical
// writes) ensures no pre-existing async Set lands post-DEL (R124).
h.asyncWg.Wait()
// Delete from L2
if d, err := h.secondary.Delete(ctx, key); err != nil {
h.logger.Debugf("Failed to delete from L2 cache: %v", err)
h.recordL2Error()
} else if d {
deleted = true
}
return deleted, nil
@@ -390,12 +397,14 @@ func (h *HybridBackend) Ping(ctx context.Context) error {
// Close shuts down the hybrid backend
func (h *HybridBackend) Close() error {
// Cancel context to stop workers
// Cancel context to stop workers.
h.cancel()
// Close async write channel
close(h.asyncWriteBuffer)
close(h.l1BackfillBuffer)
// Do NOT close asyncWriteBuffer / l1BackfillBuffer: the workers key off
// h.ctx.Done() and exit on cancel, and closing these channels creates a
// race where a concurrent Set's select-send (line ~193) hits a closed
// channel and panics ("send on closed channel"). Buffered items a late
// Set queues after the workers have exited are harmless at shutdown.
// Wait for workers to finish with timeout
done := make(chan struct{})
@@ -539,7 +548,6 @@ func (h *HybridBackend) SetMany(ctx context.Context, items map[string][]byte, tt
key: key,
value: value,
ttl: ttl,
ctx: ctx,
}:
// Queued
default:
@@ -593,7 +601,7 @@ func (h *HybridBackend) l1BackfillWorker() {
if !ok {
return
}
if h.keyStillInSecondary(item.key) {
if h.keyStillInSecondary(item.key) && !h.keyAlreadyInPrimary(item.key) {
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
if err := h.primary.Set(writeCtx, item.key, item.value, item.ttl); err != nil {
h.logger.Debugf("Failed to populate L1 cache from L2 for key %s: %v", redactKey(item.key), err)
@@ -620,6 +628,19 @@ func (h *HybridBackend) keyStillInSecondary(key string) bool {
return err != nil || exists
}
// keyAlreadyInPrimary reports whether the key already exists in the L1
// cache. The backfill worker writes an L2-resolved value into L1; if a
// concurrent Set already placed a fresher value there, writing over it
// would clobber the newer value with the stale L2 snapshot (R124). Skip
// the backfill when L1 already has the key — L1 is only a cache and the
// next L2 hit re-arms the write-through.
func (h *HybridBackend) keyAlreadyInPrimary(key string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
exists, err := h.primary.Exists(ctx, key)
return err == nil && exists
}
// asyncWriteWorker processes asynchronous writes to L2
func (h *HybridBackend) asyncWriteWorker() {
defer h.wg.Done()
@@ -658,7 +679,11 @@ func (h *HybridBackend) asyncWriteWorker() {
// Clear stall us out so no buffered write can land after a
// clear-then-wipe.
h.asyncFlushMu.Lock()
writeCtx, cancel := context.WithTimeout(item.ctx, 500*time.Millisecond)
// Use a detached context, never the originating request's ctx:
// this is a background write that typically runs after the
// request has returned, so the request ctx would be canceled
// and the L2 write silently dropped (R94).
writeCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
if err := h.secondary.Set(writeCtx, item.key, item.value, item.ttl); err != nil {
h.errors.Add(1)
h.logger.Debugf("Async write to L2 failed for key %s: %v", redactKey(item.key), err)
+75
View File
@@ -0,0 +1,75 @@
package backends
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// r94CtxProbeBackend records whether any operation was handed a canceled
// context. Set is the only method the async L2 write path exercises.
type r94CtxProbeBackend struct {
setSeen *atomic.Bool
setHadErr *atomic.Bool
}
func (b *r94CtxProbeBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
b.setSeen.Store(true)
if ctx.Err() != nil {
b.setHadErr.Store(true)
}
return nil
}
func (b *r94CtxProbeBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
return nil, 0, false, nil
}
func (b *r94CtxProbeBackend) Delete(ctx context.Context, key string) (bool, error) { return false, nil }
func (b *r94CtxProbeBackend) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
func (b *r94CtxProbeBackend) Clear(ctx context.Context) error { return nil }
func (b *r94CtxProbeBackend) GetStats() map[string]interface{} { return nil }
func (b *r94CtxProbeBackend) Close() error { return nil }
func (b *r94CtxProbeBackend) Ping(ctx context.Context) error { return nil }
// TestHybridBackend_AsyncWriteUsesDetachedContext is an R94 regression: the
// async L2 write-through must be executed with a detached context, never the
// originating request's context. The L2 write runs after the request has
// returned; if it carried the (now-canceled) request context the write
// would be silently dropped, so non-critical keys never reach L2. Before
// the fix the worker used the request ctx and recorded a canceled context.
func TestHybridBackend_AsyncWriteUsesDetachedContext(t *testing.T) {
primary := &r94CtxProbeBackend{setSeen: &atomic.Bool{}, setHadErr: &atomic.Bool{}}
secondary := &r94CtxProbeBackend{setSeen: &atomic.Bool{}, setHadErr: &atomic.Bool{}}
hybrid, err := NewHybridBackend(&HybridConfig{
Primary: primary,
Secondary: secondary,
SyncWriteCacheTypes: map[string]bool{
// empty set: no type is sync, so writes are async
},
})
require.NoError(t, err)
defer hybrid.Close()
reqCtx, cancel := context.WithCancel(context.Background())
err = hybrid.Set(reqCtx, "general:key1", []byte("test-value"), time.Minute)
require.NoError(t, err)
// The request has returned: cancel its context before the async worker
// gets a chance to run the L2 write.
cancel()
// Wait for the async L2 write to be attempted.
deadline := time.Now().Add(2 * time.Second)
for !secondary.setSeen.Load() {
if time.Now().After(deadline) {
t.Fatal("async L2 write was never attempted")
}
time.Sleep(5 * time.Millisecond)
}
require.False(t, secondary.setHadErr.Load(),
"async L2 write must use a detached (non-canceled) context")
}
+36 -8
View File
@@ -202,6 +202,14 @@ func (m *MemoryCacheBackend) Set(ctx context.Context, key string, value interfac
return ErrBackendUnavailable
}
// A NEGATIVE TTL means "already expired" (the stack's convention for
// a value whose validity has passed — see redis.go). Previously ttl<=0
// fell through to a permanent entry, diverging from redis and
// universal_cache. Zero remains the documented "no expiry" contract.
if ttl < 0 {
return nil
}
start := time.Now()
defer func() {
duration := time.Since(start).Nanoseconds()
@@ -212,15 +220,19 @@ func (m *MemoryCacheBackend) Set(ctx context.Context, key string, value interfac
// Calculate item size
itemSize := int64(len(key)) + estimateValueSize(value)
// Enforce global limits before adding new item
m.enforceGlobalLimits(itemSize)
// Snapshot replacement state so the global limit enforcer accounts for
// key replacement: refreshing an existing key neither grows the count
// nor adds its full size, so at global capacity it must not evict an
// unrelated live entry (mirrors cacheShard.set, R177).
shard := m.getShard(key)
oldSize, exists := shard.peekSize(key)
m.enforceGlobalLimits(itemSize, oldSize, exists)
var expiresAt time.Time
if ttl > 0 {
expiresAt = time.Now().Add(ttl)
}
shard := m.getShard(key)
shard.set(key, value, expiresAt, itemSize)
m.sets.Add(1)
@@ -228,14 +240,30 @@ func (m *MemoryCacheBackend) Set(ctx context.Context, key string, value interfac
}
// enforceGlobalLimits ensures global size and memory limits are respected
// by evicting from shards when necessary
func (m *MemoryCacheBackend) enforceGlobalLimits(newItemSize int64) {
// Check and enforce size limit
// by evicting from shards when necessary.
//
// It is replacement-aware: an existing key being rewritten (exists=oldSize)
// is count-neutral (global cap is on the number of entries) and grows
// memory by only the net delta, mirroring cacheShard.set. At global
// capacity a pure key replacement must therefore not evict an unrelated
// live entry (R177).
func (m *MemoryCacheBackend) enforceGlobalLimits(newItemSize, oldSize int64, exists bool) {
// Count (entry-size) budget only grows for genuinely new keys.
sizeTrigger := m.maxSize > 0 && !exists
// Memory budget grows by the net delta for a replacement.
netMemory := newItemSize
if exists {
netMemory = newItemSize - oldSize
if netMemory < 0 {
netMemory = 0
}
}
for {
totalSize, totalMemory := m.getGlobalStats()
needsSizeEviction := m.maxSize > 0 && totalSize >= m.maxSize
needsMemoryEviction := m.maxMemory > 0 && totalMemory+newItemSize > m.maxMemory
needsSizeEviction := sizeTrigger && totalSize >= m.maxSize
needsMemoryEviction := m.maxMemory > 0 && totalMemory+netMemory > m.maxMemory
if !needsSizeEviction && !needsMemoryEviction {
break
+32 -7
View File
@@ -30,6 +30,19 @@ func newCacheShard(maxSize, maxMemory int64) *cacheShard {
// get retrieves a value from this shard
// Returns: value, exists, expired
// peekSize returns the size of the existing entry for key and whether it
// exists, without touching LRU state. Used by the global limit enforcer
// to decide whether a Set is a replacement (count-neutral) or a new item.
func (s *cacheShard) peekSize(key string) (int64, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
item, exists := s.items[key]
if !exists {
return 0, false
}
return item.size, true
}
func (s *cacheShard) get(key string) (interface{}, bool, bool) {
s.mu.RLock()
item, exists := s.items[key]
@@ -72,15 +85,27 @@ func (s *cacheShard) set(key string, value interface{}, expiresAt time.Time, siz
s.mu.Lock()
defer s.mu.Unlock()
// Check if we need to evict items
if s.maxSize > 0 && s.size >= s.maxSize {
s.evictLRULocked()
}
if s.maxMemory > 0 && s.memoryUsed+size > s.maxMemory {
s.evictLRULocked()
// Snapshot the existing entry so the eviction decisions account for
// replacement: refreshing an existing key neither grows the count nor
// adds its full size, so at capacity it must not evict an unrelated
// LRU entry (R139 F2).
oldItem, exists := s.items[key]
var oldSize int64
if exists {
oldSize = oldItem.size
}
// Remove old item if exists
if s.maxSize > 0 && !exists && s.size >= s.maxSize {
s.evictLRULocked()
}
if s.maxMemory > 0 {
if net := size - oldSize; net > 0 && s.memoryUsed+net > s.maxMemory {
s.evictLRULocked()
}
}
// Remove old item if it still exists (it may have been the LRU victim
// of an eviction above; re-check so we don't double-account).
if oldItem, exists := s.items[key]; exists {
s.memoryUsed -= oldItem.size
if elem, ok := oldItem.element.(*list.Element); ok && elem != nil {
+15
View File
@@ -95,7 +95,15 @@ func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl ti
return ErrBackendClosed
}
// A NEGATIVE TTL means "already expired" (the stack's convention for a
// value whose validity has passed). Previously ttl<=0 fell through to a
// bare SET with no expiry, silently making the entry permanent. Zero here
// is the documented "no expiry" contract (see SetManyNoTTL), so only
// strictly-negative TTLs are skipped.
prefixedKey := r.prefixKey(key)
if ttl < 0 {
return nil
}
// Execute with retry logic
return r.executeWithRetry(ctx, func(conn *RedisConn) error {
@@ -450,6 +458,13 @@ func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl
return nil
}
if ttl < 0 {
// Already-expired TTL: nothing to persist (see Set's guard). Avoids
// creating permanent entries for past-dated values via the multi path.
// Zero retains the "no expiry" contract (see SetManyNoTTL).
return nil
}
// For single items, use regular Set
if len(items) == 1 {
for key, value := range items {
+34 -5
View File
@@ -67,6 +67,22 @@ func NewConnectionPool(config *PoolConfig) (*ConnectionPool, error) {
return pool, nil
}
// reserveConnectionSlot atomically reserves capacity for one new connection,
// returning false when the pool is already at MaxConnections. The
// reservation is released via totalConns.Add(-1) if the dial fails.
func (p *ConnectionPool) reserveConnectionSlot() bool {
max := int32(p.config.MaxConnections) // nolint:gosec // MaxConnections is operator-bounded well below int32 max
for {
cur := p.totalConns.Load()
if cur >= max {
return false
}
if p.totalConns.CompareAndSwap(cur, cur+1) {
return true
}
}
}
// Get retrieves a connection from the pool or creates a new one
func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) {
if p.closed.Load() {
@@ -102,11 +118,15 @@ func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) {
return nil, ctx.Err()
default:
// No available connection, create new one if under limit
// #nosec G115 -- MaxConnections is a small config value that fits in int32
if p.totalConns.Load() < int32(p.config.MaxConnections) {
// No available connection, create new one if under limit.
// Reserve capacity atomically before dialing: checking
// totalConns then dialing is not atomic, so concurrent Gets
// could each pass the check and spike the live socket count
// far above MaxConnections (FD / Redis maxclients exhaustion).
if p.reserveConnectionSlot() {
conn, err = p.createConnection(ctx)
if err != nil {
p.totalConns.Add(-1) // release the reserved slot
// If this is the last attempt, return error
if attempt == maxAttempts-1 {
return nil, err
@@ -116,7 +136,6 @@ func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) {
continue
}
p.activeConns.Add(1)
p.totalConns.Add(1)
return conn, nil
}
@@ -335,7 +354,9 @@ func (c *RedisConn) Do(command string, args ...string) (interface{}, error) {
resp, err := reader.ReadResponse()
reader.Release() // Return to pool immediately after use
if err != nil {
if !errors.Is(err, ErrNilResponse) {
// A nil response or a Redis command error reply leaves the
// connection healthy — only an IO/parse error closes it.
if !errors.Is(err, ErrNilResponse) && !errors.Is(err, ErrCommandReply) {
c.closed.Store(true)
}
return nil, err
@@ -470,6 +491,14 @@ func (p *Pipeline) Execute() ([]interface{}, error) {
responses[i] = nil
continue
}
if errors.Is(err, ErrCommandReply) {
// A Redis command error reply: the connection is healthy
// and the remaining replies are still readable. Store the
// error value so per-command validation upstream can
// report it without aborting the batch.
responses[i] = err
continue
}
p.conn.closed.Store(true)
return responses[:i], fmt.Errorf("pipeline read error at command %d: %w", i, err)
}
+8 -1
View File
@@ -20,6 +20,11 @@ import (
var (
ErrInvalidRESP = errors.New("invalid RESP response")
ErrNilResponse = errors.New("nil response")
// ErrCommandReply marks a RESP '-' (Redis command error) reply, e.g.
// WRONGTYPE or OOM. It is a valid protocol response — the connection
// is healthy and any remaining pipelined replies are still readable —
// as opposed to an IO/parse error which means the connection is dead.
ErrCommandReply = errors.New("redis command error reply")
)
// RESPWriter writes RESP protocol messages
@@ -110,7 +115,9 @@ func (r *RESPReader) readError() error {
if err != nil {
return err
}
return errors.New(line)
// Wrap so callers can distinguish a Redis command error reply (healthy
// connection) from an IO/parse error (dead connection).
return fmt.Errorf("%w: %s", ErrCommandReply, line)
}
// readInteger reads an integer (:1000\r\n)
+6 -1
View File
@@ -142,7 +142,12 @@ func TestRESPReader_ReadError(t *testing.T) {
_, err := reader.ReadResponse()
require.Error(t, err)
assert.Equal(t, tt.expectedError, err.Error())
// R177: a '-' reply is now wrapped with ErrCommandReply so
// callers can tell a Redis command error (healthy connection)
// apart from an IO/parse error. The command's message text is
// preserved verbatim inside the wrapped error.
assert.True(t, errors.Is(err, ErrCommandReply), "want ErrCommandReply, got %v", err)
assert.Contains(t, err.Error(), tt.expectedError)
})
}
}
+54
View File
@@ -0,0 +1,54 @@
package backends
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestConnectionPool_CapHeldUnderBurst regresses the connection-pool cap
// being non-atomic: ConnectionPool.Get checked `totalConns < MaxConnections`
// and THEN dialed a new socket, so concurrent Gets could each pass the check
// and spike the live connection count far above MaxConnections (FD / Redis
// maxclients exhaustion under burst load). The cap must now be held
// atomically so at most MaxConnections sockets are ever created at once.
func TestConnectionPool_CapHeldUnderBurst(t *testing.T) {
mr := NewMiniredisServer(t)
maxConns := 3
pool, err := NewConnectionPool(&PoolConfig{
Address: mr.GetAddr(),
MaxConnections: maxConns,
ConnectTimeout: 80 * time.Millisecond,
ReadTimeout: 50 * time.Millisecond,
WriteTimeout: 50 * time.Millisecond,
})
require.NoError(t, err)
defer pool.Close()
ctx := context.Background()
const workers = 50
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
conn, _ := pool.Get(ctx)
if conn != nil {
// Hold the connection (simulate an in-flight request) so the
// pool stays under pressure and the count is stable.
_ = conn
}
}()
}
wg.Wait()
stats := pool.Stats()
total := stats["total_connections"].(int32)
require.LessOrEqual(t, total, int32(maxConns),
"burst Gets must not create more than MaxConnections sockets (got %d)", total)
}
+36
View File
@@ -0,0 +1,36 @@
package backends
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestRedisBackend_SetNegativeTTL_NotPersistent guards the R115 negative-TTL
// fix. The cache stack's convention is that a non-positive TTL means "already
// expired". Previously RedisBackend.Set/SetMany fell through to a bare SET
// when ttl<=0, writing an entry with NO expiry — i.e. an expired-dated value
// became permanent. Now expired TTLs write nothing.
func TestRedisBackend_SetNegativeTTL_NotPersistent(t *testing.T) {
_, backend := setupTestRedis(t)
ctx := context.Background()
// Single Set with an expired TTL must not create an entry.
require.NoError(t, backend.Set(ctx, "neg", []byte("v"), -time.Second))
_, _, exists, err := backend.Get(ctx, "neg")
require.NoError(t, err)
require.False(t, exists, "negative-TTL Set must not create a persistent entry")
// SetMany with multiple items and an expired TTL must not create entries.
require.NoError(t, backend.SetMany(ctx, map[string][]byte{
"a": []byte("1"),
"b": []byte("2"),
}, -time.Second))
for _, k := range []string{"a", "b"} {
_, _, exists, err := backend.Get(ctx, k)
require.NoError(t, err)
require.False(t, exists, "negative-TTL SetMany must not create a persistent entry for %q", k)
}
}
+241
View File
@@ -0,0 +1,241 @@
package backends
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
// blockingWriteBackend is a cache backend whose Set blocks until released,
// used to hold an async L2 write in-flight while Delete runs.
type blockingWriteBackend struct {
mu sync.Mutex
data map[string]string
setStarted chan struct{}
release chan struct{}
written chan struct{}
once sync.Once
}
func (b *blockingWriteBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
select {
case <-b.setStarted:
default:
close(b.setStarted)
}
select {
case <-b.release:
case <-b.written: // never
case <-ctx.Done():
return nil
}
b.mu.Lock()
b.data[key] = string(value)
b.mu.Unlock()
b.once.Do(func() { close(b.written) })
return nil
}
func (b *blockingWriteBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
v, ok := b.data[key]
if !ok {
return nil, 0, false, nil
}
return []byte(v), time.Minute, true, nil
}
func (b *blockingWriteBackend) Delete(ctx context.Context, key string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
_, ok := b.data[key]
delete(b.data, key)
return ok, nil
}
func (b *blockingWriteBackend) Exists(ctx context.Context, key string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
_, ok := b.data[key]
return ok, nil
}
func (b *blockingWriteBackend) Clear(ctx context.Context) error {
b.mu.Lock()
defer b.mu.Unlock()
b.data = map[string]string{}
return nil
}
func (b *blockingWriteBackend) GetStats() map[string]interface{} { return nil }
func (b *blockingWriteBackend) Close() error { return nil }
func (b *blockingWriteBackend) Ping(ctx context.Context) error { return nil }
// TestHybrid_DeleteWaitsForAsyncWrite guards the R124 fix to hybrid.go
// Delete: an in-flight async L2 write (from a non-critical Set) could land
// after Delete's L2 DEL, resurrecting the just-deleted key with a stale
// value. Delete must wait for in-flight async writes before the L2 delete.
func TestHybrid_DeleteWaitsForAsyncWrite(t *testing.T) {
sec := &blockingWriteBackend{
data: map[string]string{},
setStarted: make(chan struct{}),
release: make(chan struct{}),
written: make(chan struct{}),
}
primary := newMockBackend()
hybrid, err := NewHybridBackend(&HybridConfig{
Primary: primary,
Secondary: sec,
AsyncBufferSize: 16,
})
if err != nil {
t.Fatalf("hybrid: %v", err)
}
defer hybrid.Close()
ctx := context.Background()
// Non-critical Set -> async L2 write that blocks in-flight.
if err := hybrid.Set(ctx, "k", []byte("stale"), time.Minute); err != nil {
t.Fatalf("set: %v", err)
}
select {
case <-sec.setStarted:
case <-time.After(2 * time.Second):
t.Fatal("async L2 write never started")
}
// Issue Delete while the async write is in-flight.
done := make(chan struct{})
go func() {
_, _ = hybrid.Delete(ctx, "k")
close(done)
}()
// OLD behavior: Delete returns immediately (no wait for async writes);
// it performs its L2 delete and closes done. NEW behavior: Delete
// blocks in asyncWg.Wait until the in-flight write finishes, so done
// does not fire until released. Distinguish the two before releasing.
oldImmediate := false
select {
case <-done:
oldImmediate = true
case <-time.After(50 * time.Millisecond):
// NEW: Delete is blocked waiting for the async write. 50ms is
// far longer than OLD's immediate (non-waiting) return needs,
// and far shorter than the worker's 500ms writeCtx, so the
// in-flight write reliably survives to be released.
}
// Release the in-flight L2 write (well within the worker's writeCtx
// timeout) so it actually lands.
close(sec.release)
if oldImmediate {
// OLD: the worker write now lands AFTER Delete's L2 delete,
// resurrecting the key.
select {
case <-sec.written:
case <-time.After(2 * time.Second):
t.Fatal("async L2 write never landed")
}
} else {
// NEW: Delete waited for the write, then removed it from L2.
select {
case <-sec.written:
case <-time.After(2 * time.Second):
t.Fatal("async L2 write never landed")
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Delete did not return")
}
}
if _, _, exists, _ := sec.Get(ctx, "k"); exists {
t.Fatal("L2 must not contain key after Delete: the in-flight async Set resurrected it")
}
}
// existsAlwaysBackend reports the key always present in L1 (so a backfill
// must be skipped) and counts Set calls; setCalled is closed once Set runs,
// letting the test observe a (OLD) backfill write deterministically.
type existsAlwaysBackend struct {
setCalls int32
setCalled chan struct{}
once sync.Once
}
func newExistsAlwaysBackend() *existsAlwaysBackend {
return &existsAlwaysBackend{setCalled: make(chan struct{})}
}
func (b *existsAlwaysBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
atomic.AddInt32(&b.setCalls, 1)
b.once.Do(func() { close(b.setCalled) })
return nil
}
func (b *existsAlwaysBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
return nil, 0, false, nil
}
func (b *existsAlwaysBackend) Delete(ctx context.Context, key string) (bool, error) {
return false, nil
}
func (b *existsAlwaysBackend) Exists(ctx context.Context, key string) (bool, error) { return true, nil }
func (b *existsAlwaysBackend) Clear(ctx context.Context) error { return nil }
func (b *existsAlwaysBackend) GetStats() map[string]interface{} { return nil }
func (b *existsAlwaysBackend) Close() error { return nil }
func (b *existsAlwaysBackend) Ping(ctx context.Context) error { return nil }
// TestHybrid_BackfillDoesNotClobberFreshL1 guards the R124 fix to
// hybrid.go l1BackfillWorker: the backfill only checked L2 key
// existence, so a stale L2-captured value would overwrite a fresher L1
// value written concurrently by Set. When L1 already holds the key, the
// backfill must be skipped.
func TestHybrid_BackfillDoesNotClobberFreshL1(t *testing.T) {
primary := newExistsAlwaysBackend()
secondary := newMockBackend()
ctx := context.Background()
if err := secondary.Set(ctx, "k", []byte("stale"), time.Minute); err != nil {
t.Fatalf("seed L2: %v", err)
}
hybrid, err := NewHybridBackend(&HybridConfig{
Primary: primary,
Secondary: secondary,
AsyncBufferSize: 16,
})
if err != nil {
t.Fatalf("hybrid: %v", err)
}
defer hybrid.Close()
// Queue a stale backfill for a key L1 already holds (fresher).
hybrid.queueL1Backfill("k", []byte("stale"), time.Minute)
// Wait for the worker to drain the backfill buffer.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(hybrid.l1BackfillBuffer) == 0 {
time.Sleep(20 * time.Millisecond) // let the worker act (write or skip)
break
}
time.Sleep(5 * time.Millisecond)
}
// Deterministically observe an OLD backfill write: if the worker
// writes, setCalled closes and setCalls becomes 1. On NEW the
// worker skips, so nothing fires.
select {
case <-primary.setCalled:
case <-time.After(500 * time.Millisecond):
}
if atomic.LoadInt32(&primary.setCalls) != 0 {
t.Fatal("backfill must not overwrite the fresher L1 value (primary already had the key)")
}
}
+28
View File
@@ -0,0 +1,28 @@
package backends
import (
"context"
"errors"
"testing"
"time"
)
// TestMemoryBackend_NegativeTTLNotStored guards the R125 fix to memory.go:
// a negative TTL must mean "already expired" (the stack's convention, per
// redis.go) and NOT store a permanent entry. Previously ttl<=0 fell
// through to a permanent entry, diverging from redis and universal_cache,
// so a value whose validity had passed was kept forever in the memory
// tier.
func TestMemoryBackend_NegativeTTLNotStored(t *testing.T) {
b := NewMemoryCacheBackend(1000, 0, 0)
defer b.Close()
ctx := context.Background()
if err := b.Set(ctx, "k", []byte("v"), -1*time.Second); err != nil {
t.Fatalf("set with negative TTL: %v", err)
}
if _, err := b.Get(ctx, "k"); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("Get after negative-TTL Set = %v, want %v (negative TTL must be treated as already-expired, not stored)", err, ErrCacheMiss)
}
}
+28
View File
@@ -0,0 +1,28 @@
package backends
import (
"testing"
"time"
)
// TestCacheShard_SetRefreshExistingDoesNotEvictAtCapacity verifies the shard
// set path does not evict an unrelated LRU entry when replacing an existing
// key at capacity (count stays flat) (R139 F2). Mirrors the Cache.Set fix.
func TestCacheShard_SetRefreshExistingDoesNotEvictAtCapacity(t *testing.T) {
s := newCacheShard(2, 0) // maxSize=2, no memory bound
exp := time.Now().Add(time.Minute)
s.set("a", "va", exp, 1) // becomes LRU
s.set("b", "vb", exp, 1) // MRU
// Refresh the most-recent key while at capacity. On the buggy path the
// LRU sibling `a` is evicted; on the fixed path the replacement is free.
s.set("b", "vb2", exp, 1)
if _, exists, _ := s.get("a"); !exists {
t.Fatal("refreshing an existing key must not evict an unrelated live entry `a`")
}
if v, exists, _ := s.get("b"); !exists || v != "vb2" {
t.Fatalf("refreshed key `b` should hold the new value, got %v exists=%v", v, exists)
}
}
+81
View File
@@ -0,0 +1,81 @@
package backends
import (
"context"
"sync"
"testing"
"time"
)
// TestSingleflightCache_PanickingFetcherDoesNotDeadlock verifies that a
// fetcher which panics (routines in this codebase treat panics as a normal
// failure mode) does not wedge the singleflight entry: the leading caller
// yields an error and waiting callers are released instead of blocking on
// call.wg forever (R141).
func TestSingleflightCache_PanickingFetcherDoesNotDeadlock(t *testing.T) {
backend, err := NewMemoryBackend(DefaultConfig())
requireNoErrorFatal(t, err)
defer backend.Close()
cache := NewSingleflightCache(backend)
ctx := context.Background()
key := "panic-key"
panicFetcher := func(ctx context.Context) ([]byte, time.Duration, error) {
panic("boom")
}
var wg sync.WaitGroup
var doneA, doneB = make(chan struct{}), make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
defer func() { _ = recover() }() // tolerate old code's out-of-band panic
_, _ = cache.GetOrFetch(ctx, key, panicFetcher)
close(doneA)
}()
wg.Add(1)
go func() {
defer wg.Done()
_, _ = cache.GetOrFetch(ctx, key, panicFetcher)
close(doneB)
}()
select {
case <-doneB:
case <-time.After(3 * time.Second):
t.Fatal("concurrent waiter hung on a panicking singleflight fetcher")
}
select {
case <-doneA:
case <-time.After(3 * time.Second):
t.Fatal("leading caller hung on its own panicking fetcher")
}
wg.Wait()
// After the entry is cleaned up, a well-behaved fetcher for the same
// key must execute normally (proves the entry was not permanently
// wedged by the panic).
ran := false
good := func(ctx context.Context) ([]byte, time.Duration, error) {
ran = true
return []byte("ok"), time.Minute, nil
}
// Give the deferred cleanup a moment to release the key.
time.Sleep(200 * time.Millisecond)
if _, err := cache.GetOrFetch(ctx, key, good); err != nil {
t.Fatalf("well-behaved fetcher after panic returned error: %v", err)
}
if !ran {
t.Fatal("well-behaved fetcher after panic did not execute")
}
}
func requireNoErrorFatal(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
+79
View File
@@ -0,0 +1,79 @@
package backends
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestR177_RedisCommandErrorDoesNotCloseConn regresses the RESP command-error
// conflation: a Redis '-' (command error) reply, e.g. WRONGTYPE or OOM, is
// a valid protocol response — it does not mean the connection is dead.
// Previously Do and Pipeline treated it as an IO error, marking the healthy
// pooled connection closed (and for pipelines aborting the whole batch),
// discarding a perfectly reusable connection.
func TestR177_RedisCommandErrorDoesNotCloseConn(t *testing.T) {
mr := NewMiniredisServer(t)
config := &PoolConfig{
Address: mr.GetAddr(),
MaxConnections: 5,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
}
ctx := context.Background()
pool, err := NewConnectionPool(config)
require.NoError(t, err)
defer pool.Close()
conn, err := pool.Get(ctx)
require.NoError(t, err)
defer pool.Put(conn)
t.Run("single command error leaves conn healthy", func(t *testing.T) {
mr.SetError("ERR artificial") // every command returns '-ERR artificial'
_, err := conn.Do("GET", "k")
require.Error(t, err)
require.True(t, errors.Is(err, ErrCommandReply),
"expected a Redis command-reply error, got %v", err)
mr.SetError("") // clear; the connection must still be reusable
// On the buggy path Do marked the conn closed above, so this
// PING fails with "connection closed"; on the fixed path the
// conn is untouched and PING succeeds.
resp, err := conn.Do("PING")
require.NoError(t, err)
require.Equal(t, "PONG", resp)
})
t.Run("pipeline command error reads full batch and keeps conn", func(t *testing.T) {
mr.SetError("ERR artificial")
p := conn.NewPipeline()
p.Queue("SET", "k1", "v1")
p.Queue("SET", "k2", "v2")
// On the buggy path Execute returned after the first error (1
// response) and closed the conn; on the fixed path all replies
// are read (healthy conn), the batch is not aborted.
responses, err := p.Execute()
require.NoError(t, err, "batch must complete with per-command errors")
require.Len(t, responses, 2)
for i, r := range responses {
er, ok := r.(error)
require.True(t, ok, "response %d should carry an error value", i)
require.True(t, errors.Is(er, ErrCommandReply), "response %d should be a command-reply error", i)
}
mr.SetError("")
resp, err := conn.Do("PING")
require.NoError(t, err)
require.Equal(t, "PONG", resp)
})
}
+45
View File
@@ -0,0 +1,45 @@
package backends
import (
"context"
"testing"
"time"
)
// TestR177_MemoryBackendReplacementDoesNotEvictUnrelatedAtCapacity verifies
// that MemoryCacheBackend's GLOBAL limit enforcer is replacement-aware. The
// shard-level path (cacheShard.set, R139 F2) was already fixed, but
// enforceGlobalLimits ran before the shard write and counted every Set as a
// new entry, so at global capacity a pure key replacement evicted an
// unrelated live entry from the globally-oldest shard (count stays flat).
func TestR177_MemoryBackendReplacementDoesNotEvictUnrelatedAtCapacity(t *testing.T) {
// maxSize=2 forces shardCount down to 1; the single shard's own
// limit (2x average = 4) is loose, so the GLOBAL maxSize=2 is the
// binding constraint that the buggy enforcer used to over-evict.
m := NewMemoryCacheBackend(2, 0, time.Minute)
ctx := context.Background()
if err := m.Set(ctx, "a", "va", time.Minute); err != nil {
t.Fatalf("Set a: %v", err)
}
if err := m.Set(ctx, "b", "vb", time.Minute); err != nil {
t.Fatalf("Set b: %v", err)
}
// Make `a` the most-recently-used entry so `b` is the global LRU.
if _, err := m.Get(ctx, "a"); err != nil {
t.Fatalf("Get a: %v", err)
}
// Replace `a` while at global capacity. On the buggy path the global
// enforcer sees totalSize>=maxSize and evicts the LRU `b`; on the
// fixed path the replacement is count- and size-neutral.
if err := m.Set(ctx, "a", "va2", time.Minute); err != nil {
t.Fatalf("Set a (replacement): %v", err)
}
if _, err := m.Get(ctx, "b"); err != nil {
t.Fatalf("replacing existing key must not evict unrelated live entry `b`: %v", err)
}
if v, err := m.Get(ctx, "a"); err != nil || v != "va2" {
t.Fatalf("replaced key `a` should hold the new value, got %v err=%v", v, err)
}
}
+93
View File
@@ -0,0 +1,93 @@
package backends
import (
"context"
"fmt"
"sync"
"testing"
"time"
)
// TestBackendStressCloseConcurrent hammers a HybridBackend (which has an
// async in-flight L2-write worker) with concurrent reads/writes from many
// goroutines while Close runs at the same time — the classic scenario for a
// use-after-close or worker-leak race that sequential tests miss. Run under
// -race to detect any data race or panic (R208).
func TestBackendStressCloseConcurrent(t *testing.T) {
l1, _ := NewMemoryBackend(&Config{CleanupInterval: time.Second})
l2, _ := NewMemoryBackend(&Config{CleanupInterval: time.Second})
hybrid, err := NewHybridBackend(&HybridConfig{
Primary: l1,
Secondary: l2,
AsyncBufferSize: 256,
})
if err != nil {
t.Fatalf("NewHybridBackend: %v", err)
}
var wg sync.WaitGroup
stop := make(chan struct{})
// Writers + readers hammer the backend.
for g := 0; g < 8; g++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
key := fmt.Sprintf("k:%d:%d", id, i%256)
if i%2 == 0 {
_ = hybrid.Set(context.Background(), key, []byte("value"), time.Minute)
} else {
_, _, _, _ = hybrid.Get(context.Background(), key)
}
}
}(g)
}
time.Sleep(20 * time.Millisecond) // let workers spin up
_ = hybrid.Close() // close while ops are in-flight
close(stop)
wg.Wait()
}
// TestRedisBackendStressCloseConcurrent hammers a miniredis-backed
// RedisBackend (pooled connections) from many goroutines while Close runs,
// the pooled-conn use-after-close race class (R208).
func TestRedisBackendStressCloseConcurrent(t *testing.T) {
mr := NewMiniredisServer(t)
backend, err := NewRedisBackend(DefaultRedisConfig(mr.GetAddr()))
if err != nil {
t.Fatalf("NewRedisBackend: %v", err)
}
var wg sync.WaitGroup
stop := make(chan struct{})
for g := 0; g < 8; g++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
key := fmt.Sprintf("redis:k:%d:%d", id, i%256)
if i%2 == 0 {
_ = backend.Set(context.Background(), key, []byte("v"), time.Minute)
} else {
_, _, _, _ = backend.Get(context.Background(), key)
}
}
}(g)
}
time.Sleep(20 * time.Millisecond)
_ = backend.Close()
close(stop)
wg.Wait()
}
+84
View File
@@ -0,0 +1,84 @@
package backends
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestSingleflightStressDedup hammers GetOrFetch with many concurrent
// waiters on the SAME key behind a slow fetcher: exactly one fetch must
// run and all waiters must share its result, with no race on
// call.val/call.err read after wg.Wait() (R212). Run under -race.
func TestSingleflightStressDedup(t *testing.T) {
backend, _ := NewMemoryBackend(&Config{CleanupInterval: time.Hour})
sf := NewSingleflightCache(backend)
var fetches atomic.Int64
slow := func(context.Context) ([]byte, time.Duration, error) {
fetches.Add(1)
time.Sleep(5 * time.Millisecond)
return []byte("result"), time.Minute, nil
}
var wg sync.WaitGroup
for g := 0; g < 16; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 20; i++ {
v, err := sf.GetOrFetch(context.Background(), "k", slow)
if err != nil {
t.Errorf("GetOrFetch: %v", err)
return
}
if string(v) != "result" {
t.Errorf("unexpected value %q", v)
return
}
}
}()
}
wg.Wait()
if fetches.Load() < 1 || fetches.Load() > 20 {
t.Fatalf("expected dedup across concurrent waiters; fetcher ran %d times", fetches.Load())
}
}
// A panicking fetcher must not leave waiters blocked forever: they must all
// receive the recovered error (R212).
func TestSingleflightStressPanicUnblocks(t *testing.T) {
backend, _ := NewMemoryBackend(&Config{CleanupInterval: time.Hour})
sf := NewSingleflightCache(backend)
panicFetcher := func(context.Context) ([]byte, time.Duration, error) {
panic("boom")
}
done := make(chan struct{})
go func() {
defer close(done)
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := sf.GetOrFetch(context.Background(), "pk", panicFetcher)
if err == nil {
t.Error("expected error from panicking fetcher")
}
}()
}
wg.Wait()
}()
select {
case <-done:
// all waiters unblocked with the recovered error
case <-time.After(5 * time.Second):
t.Fatal("waiters stayed blocked after panicking fetcher (singleflight wedged)")
}
}
+65
View File
@@ -0,0 +1,65 @@
//go:build !yaegi
package backends
import (
"context"
"fmt"
"sync"
"testing"
"time"
)
// TestHybridBackend_CloseConcurrentSet_NoPanic regresses a send-on-closed-
// channel panic in HybridBackend. Close() used to close asyncWriteBuffer
// while a concurrent Set() may be mid-select on
// `h.asyncWriteBuffer <- item`, which can panic ("send on closed
// channel") and crash the process at shutdown. This exercises many
// Close+Set cycles concurrently and asserts no panic escapes.
func TestHybridBackend_CloseConcurrentSet_NoPanic(t *testing.T) {
for i := 0; i < 50; i++ {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
primary := newMockBackend()
secondary := newMockBackend()
h, err := NewHybridBackend(&HybridConfig{
Primary: primary,
Secondary: secondary,
})
if err != nil {
t.Fatalf("new hybrid: %v", err)
}
var wg sync.WaitGroup
var panicked sync.Map
// Writers hammer Set concurrently with Close using async (non-
// critical) cache type so the send goes through asyncWriteBuffer.
for w := 0; w < 8; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
panicked.Store(true, fmt.Sprintf("writer %d: %v", w, r))
}
}()
for k := 0; k < 200; k++ {
// "session:" prefix is not blacklist/token -> async path.
_ = h.Set(ctx, fmt.Sprintf("session:user%d:%d", w, k), []byte("value"), time.Minute)
}
}(w)
}
// Close concurrently with the writers.
_ = h.Close()
wg.Wait()
if v, ok := panicked.Load(true); ok {
t.Fatalf("panic during Close+concurrent Set: %v", v)
}
}
}
+17 -2
View File
@@ -2,6 +2,7 @@ package backends
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
@@ -80,8 +81,22 @@ func (s *SingleflightCache) GetOrFetch(ctx context.Context, key string, fetcher
s.calls[key] = call
s.mu.Unlock()
// Execute the fetcher
call.val, call.ttl, call.err = fetcher(ctx)
// Execute the fetcher. Recover any panic so call.wg.Done() below is
// always reached: a panicking fetcher would otherwise leave every
// waiter blocked forever on call.wg.Wait() and the call permanently
// wedged in s.calls (subsequent GetOrFetch calls for the key would
// block indefinitely). Routines here (BackgroundTask, Goroutine)
// treat panics as a normal failure mode, so this is reachable.
func() {
defer func() {
if r := recover(); r != nil {
if call.err == nil {
call.err = fmt.Errorf("singleflight fetcher panicked: %v", r)
}
}
}()
call.val, call.ttl, call.err = fetcher(ctx)
}()
call.done = true
// If successful, store in cache
+33 -9
View File
@@ -148,19 +148,39 @@ func (c *Cache) Set(key string, value interface{}, ttl time.Duration) error {
return fmt.Errorf("cache is closed")
}
// Negative TTL means "don't cache" (matches the backends:
// memory.go:209 and redis.go:104). cacheVerifiedToken can pass a
// negative duration for an already-expired token
// (token_manager.go:244); storing an already-expired entry would just
// waste a hot-cache slot and misreport currentSize (R138).
if ttl < 0 {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
// Calculate size
// Calculate size and snapshot the existing entry (if any) so the
// eviction decisions below account for replacement: refreshing an
// existing key neither grows the item count nor adds its full size, so
// at capacity it must not evict an unrelated LRU entry (R139 F2).
size := c.estimateSize(value)
// Check memory limit
if c.config.EnableMemoryLimit && c.currentMemory+size > c.config.MaxMemoryBytes {
c.evictLRU()
oldItem, exists := c.items[key]
var oldSize int64
if exists {
oldSize = oldItem.Size
}
// Check size limit
if c.config.MaxSize > 0 && len(c.items) >= c.config.MaxSize {
// Check memory limit (only evict when the NET addition exceeds it)
if c.config.EnableMemoryLimit {
if netAdd := size - oldSize; netAdd > 0 && c.currentMemory+netAdd > c.config.MaxMemoryBytes {
c.evictLRU()
}
}
// Check size limit (only evict when a NEW key would exceed it;
// replacement of an existing key keeps the count flat)
if c.config.MaxSize > 0 && !exists && len(c.items) >= c.config.MaxSize {
c.evictLRU()
}
@@ -176,7 +196,8 @@ func (c *Cache) Set(key string, value interface{}, ttl time.Duration) error {
Metadata: make(map[string]interface{}),
}
// Remove old item if exists
// Remove old item if it still exists (it may have been the LRU victim
// of an eviction above; re-check so we don't double-account).
if oldItem, exists := c.items[key]; exists {
c.lruList.Remove(oldItem.element)
c.currentMemory -= oldItem.Size
@@ -293,7 +314,10 @@ func (c *Cache) Close() error {
}
c.cancel()
if c.config.EnableAutoCleanup {
// stopCleanup is only allocated (and the cleanup goroutine started) when
// EnableAutoCleanup && CleanupInterval > 0; must mirror that guard here
// or close(nil) panics on shutdown (R149).
if c.config.EnableAutoCleanup && c.config.CleanupInterval > 0 {
close(c.stopCleanup)
c.wg.Wait()
}
+8 -1
View File
@@ -132,7 +132,14 @@ func (cb *CircuitBreaker) AllowRequest() bool {
if shouldRetry {
cb.setState(StateHalfOpen)
return true
// Count this probe toward the half-open gate. Previously the
// open→half-open transition returned true without incrementing
// halfOpenRequests, so N goroutines arriving at the cooldown
// simultaneously all transitioned and passed as an unbounded
// burst (the half-open branch below is only reached by
// requests that race in after the first transition) (R152).
current := cb.halfOpenRequests.Add(1)
return current <= int32(cb.config.HalfOpenMaxRequests) // nolint:gosec // HalfOpenMaxRequests is operator-bounded well below int32 max
}
return false
+142
View File
@@ -0,0 +1,142 @@
package resilience
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
// fakeBackend is a minimal CacheBackend whose Ping can be toggled to fail,
// so HealthCheckBackend's status transition can be driven deterministically.
type fakeBackend struct {
mu sync.Mutex
data map[string][]byte
fail atomic.Bool
}
func newFakeBackend() *fakeBackend {
return &fakeBackend{data: make(map[string][]byte)}
}
func (f *fakeBackend) Set(_ context.Context, key string, value []byte, _ time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
f.data[key] = value
return nil
}
func (f *fakeBackend) Get(_ context.Context, key string) ([]byte, time.Duration, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.data[key]
return v, 0, ok, nil
}
func (f *fakeBackend) Delete(_ context.Context, key string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
_, ok := f.data[key]
delete(f.data, key)
return ok, nil
}
func (f *fakeBackend) Exists(_ context.Context, key string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
_, ok := f.data[key]
return ok, nil
}
func (f *fakeBackend) Clear(_ context.Context) error {
f.mu.Lock()
defer f.mu.Unlock()
f.data = make(map[string][]byte)
return nil
}
func (f *fakeBackend) GetStats() map[string]interface{} { return map[string]interface{}{} }
func (f *fakeBackend) Close() error { return nil }
func (f *fakeBackend) Ping(_ context.Context) error {
if f.fail.Load() {
return errors.New("injected ping failure")
}
return nil
}
// TestHealthCheckBackendTransitions covers the previously 0%-covered
// HealthCheckBackend wrapper: optimistic start, eventual transition to
// unhealthy under backend failures, and recovery once the backend returns.
func TestHealthCheckBackendTransitions(t *testing.T) {
bt := newFakeBackend()
cfg := &HealthCheckConfig{
CheckInterval: 5 * time.Millisecond,
Timeout: 20 * time.Millisecond,
HealthyThreshold: 1,
UnhealthyThreshold: 2,
}
hc := NewHealthCheckBackend(bt, cfg).(*HealthCheckBackend)
t.Cleanup(func() { _ = hc.Close() })
// Optimistic start: healthy before any check runs.
if !hc.IsHealthy() {
t.Fatal("expected optimistic healthy start")
}
// Delegation: Set/Get pass through the wrapper to the backend.
ctx := context.Background()
if err := hc.Set(ctx, "k", []byte("v"), time.Minute); err != nil {
t.Fatalf("Set through wrapper: %v", err)
}
if got, _, ok, _ := hc.Get(ctx, "k"); !ok || string(got) != "v" {
t.Fatalf("Get through wrapper returned ok=%v value=%q", ok, got)
}
// Fail the backend: the health loop must mark it unhealthy.
bt.fail.Store(true)
waitFor(t, 3*time.Second, "backend to become unhealthy", func() bool {
return !hc.IsHealthy()
})
// Restore the backend: it must recover to healthy.
bt.fail.Store(false)
waitFor(t, 3*time.Second, "backend to recover", hc.IsHealthy)
// Stats expose health state.
if _, ok := hc.GetStats()["health"]; !ok {
t.Fatal("expected health key in GetStats")
}
// Remaining delegation methods route through the wrapper cleanly.
if ok, err := hc.Exists(ctx, "k"); err != nil || !ok {
t.Fatalf("Exists through wrapper: ok=%v err=%v", ok, err)
}
if ok, err := hc.Delete(ctx, "k"); err != nil || !ok {
t.Fatalf("Delete through wrapper: ok=%v err=%v", ok, err)
}
if err := hc.Set(ctx, "k", []byte("v"), time.Minute); err != nil {
t.Fatalf("Set for Clear: %v", err)
}
if err := hc.Clear(ctx); err != nil {
t.Fatalf("Clear through wrapper: %v", err)
}
if err := hc.Ping(ctx); err != nil {
t.Fatalf("Ping through wrapper: %v", err)
}
}
func waitFor(t *testing.T, d time.Duration, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
+51
View File
@@ -0,0 +1,51 @@
package resilience
import (
"context"
"errors"
"testing"
"time"
)
// TestCircuitBreaker_HalfOpenGateNotExceeded guards the R152 fix to
// AllowRequest: the open→half-open transition returned true without
// incrementing halfOpenRequests, so the very probe that opens the
// half-open window was free — allowing exactly one more request than
// the configured gate (on a serial refusal loop: gate+1 pass). The
// probe must be counted so cumulative half-open admissions stay
// within HalfOpenMaxRequests.
func TestCircuitBreaker_HalfOpenGateNotExceeded(t *testing.T) {
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 50 * time.Millisecond,
HalfOpenMaxRequests: 2,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Open the circuit.
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error { return errors.New("boom") })
}
if cb.GetState() != StateOpen {
t.Fatal("expected open after failures")
}
time.Sleep(100 * time.Millisecond) // past timeout → cooldown expired
// Serially offer gate+overflow admission slots. The transition probe
// must count toward the gate; un-fixed code allows one extra.
allowed := 0
for i := 0; i < config.HalfOpenMaxRequests+5; i++ {
if !cb.AllowRequest() {
break
}
allowed++
}
if allowed > config.HalfOpenMaxRequests {
t.Fatalf("half-open gate exceeded: %d admitted, max %d", allowed, config.HalfOpenMaxRequests)
}
if allowed == 0 {
t.Fatal("at least one probe must be allowed once the cooldown expires")
}
}
+38
View File
@@ -0,0 +1,38 @@
package cache
import (
"testing"
"time"
)
// TestSet_NegativeTTLIsNoOp verifies Cache.Set with a negative TTL does not
// store an entry (no slot consumed), matching the memory/redis backends'
// negative-TTL convention. Previously cacheVerifiedToken fed an
// already-expired entry (negative duration) into the hot token cache,
// wasting a slot and misreporting currentSize (R138).
func TestSet_NegativeTTLIsNoOp(t *testing.T) {
c := New(DefaultConfig())
defer c.Close()
// Negative TTL must not consume a cache slot.
if err := c.Set("neg", "v", -1*time.Second); err != nil {
t.Fatalf("Set with negative TTL returned error: %v", err)
}
if size := c.Size(); size != 0 {
t.Fatalf("negative-TTL Set must not grow cache size; got %d", size)
}
if _, exists := c.Get("neg"); exists {
t.Fatal("negative-TTL value must not be retrievable (should be a no-op)")
}
// Control: positive TTL still consumes a slot and is retrievable.
if err := c.Set("pos", "v", time.Minute); err != nil {
t.Fatalf("Set with positive TTL returned error: %v", err)
}
if size := c.Size(); size != 1 {
t.Fatalf("positive-TTL Set should grow cache size to 1; got %d", size)
}
if _, exists := c.Get("pos"); !exists {
t.Fatal("positive-TTL value should be retrievable")
}
}
+42
View File
@@ -0,0 +1,42 @@
package cache
import (
"testing"
"time"
)
// TestSet_RefreshExistingDoesNotEvictAtCapacity verifies that replacing an
// existing key at capacity does not evict an unrelated live entry. The
// eviction decision must account for the replacement (count stays flat), so
// refreshing the most-recent key must leave the LRU sibling in place
// (R139 F2).
func TestSet_RefreshExistingDoesNotEvictAtCapacity(t *testing.T) {
cfg := DefaultConfig()
cfg.MaxSize = 2
cfg.EnableMemoryLimit = false
c := New(cfg)
defer c.Close()
if err := c.Set("a", "va", time.Minute); err != nil {
t.Fatalf("set a: %v", err)
}
if err := c.Set("b", "vb", time.Minute); err != nil {
t.Fatalf("set b: %v", err)
}
// Refresh the most-recent key while at capacity. On the buggy path
// the LRU sibling `a` is evicted; on the fixed path the replacement
// is free so both entries survive.
if err := c.Set("b", "vb2", time.Minute); err != nil {
t.Fatalf("refresh b: %v", err)
}
if _, ok := c.Get("a"); !ok {
t.Fatal("refreshing an existing key must not evict an unrelated live entry `a`")
}
if got := c.Size(); got != 2 {
t.Fatalf("size should remain 2 after an in-place refresh, got %d", got)
}
if v, ok := c.Get("b"); !ok || v != "vb2" {
t.Fatalf("refreshed key `b` should hold the new value, got %v ok=%v", v, ok)
}
}
+30
View File
@@ -0,0 +1,30 @@
package cache
import (
"testing"
"time"
)
// TestCacheClose_NilStopCleanupNoPanic guards the R149 fix in Close():
// stopCleanup is only allocated when EnableAutoCleanup && CleanupInterval > 0,
// but Close() previously closed it whenever EnableAutoCleanup was set. A
// cache built with EnableAutoCleanup=true and CleanupInterval left at its
// zero value therefore called close(nil) and panicked during shutdown.
func TestCacheClose_NilStopCleanupNoPanic(t *testing.T) {
c := New(Config{EnableAutoCleanup: true}) // CleanupInterval defaults to 0
if err := c.Close(); err != nil {
t.Fatalf("Close() with CleanupInterval=0 must not panic and return nil, got: %v", err)
}
}
// TestCacheClose_WithCleanupIntervalClosesOnce ensures the normal path
// (cleanup started) still closes cleanly exactly once.
func TestCacheClose_WithCleanupIntervalClosesOnce(t *testing.T) {
c := New(Config{EnableAutoCleanup: true, CleanupInterval: time.Minute})
if err := c.Close(); err != nil {
t.Fatalf("Close() with a started cleanup goroutine must succeed, got: %v", err)
}
if err := c.Close(); err == nil {
t.Fatal("second Close() must report already-closed")
}
}
+9 -2
View File
@@ -71,8 +71,8 @@ func (s *RedisStore) Save(ctx context.Context, providerURL string, creds *Client
key := s.makeKey(providerURL)
// Calculate TTL based on client_secret_expires_at if available
ttl := 30 * 24 * time.Hour // Default: 30 days
// Calculate TTL based on client_secret_expires_at if available.
var ttl time.Duration
if creds.ClientSecretExpiresAt > 0 {
expiresAt := time.Unix(creds.ClientSecretExpiresAt, 0)
ttl = time.Until(expiresAt)
@@ -83,6 +83,13 @@ func (s *RedisStore) Save(ctx context.Context, providerURL string, creds *Client
if ttl > time.Minute {
ttl -= time.Minute
}
} else {
// RFC 7591: client_secret_expires_at == 0 means the secret never
// expires (areCredentialsValid treats it as always-valid). Persist it
// effectively-permanently: a bounded TTL (previously 30 days) would
// evict a valid non-expiring credential mid-use, forcing a needless
// re-registration and orphaning the previous client at the IdP.
ttl = 100 * 365 * 24 * time.Hour
}
// Serialize credentials to JSON for storage
+64
View File
@@ -0,0 +1,64 @@
package dcrstorage
import (
"context"
"sync"
"testing"
"time"
)
// recordingCache captures the TTL each Set received, so a test can assert the
// exact TTL semantic without depending on wall-clock time.
type recordingCache struct {
mu sync.Mutex
setTTLs map[string]time.Duration
}
func newRecordingCache() *recordingCache {
return &recordingCache{setTTLs: make(map[string]time.Duration)}
}
func (c *recordingCache) Get(key string) (any, bool) { return nil, false }
func (c *recordingCache) Delete(key string) {}
func (c *recordingCache) Set(key string, value any, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.setTTLs[key] = ttl
return nil
}
// TestRedisStore_NoExpiry_EffectivePermanent guards the R116 fix: an IdP that
// reports client_secret_expires_at == 0 (RFC 7591: never expires) must be
// persisted effectively-permanently. Previously such credentials were given a
// 30-day default TTL, so a valid non-expiring credential was evicted mid-use,
// forcing a needless re-registration and orphaning the old client at the IdP.
func TestRedisStore_NoExpiry_EffectivePermanent(t *testing.T) {
t.Parallel()
cache := newRecordingCache()
store := NewRedisStore(cache, "", nil)
creds := &ClientRegistrationResponse{
ClientID: "noexp",
ClientSecret: "secret",
ClientSecretExpiresAt: 0, // never expires
}
if err := store.Save(context.Background(), "https://noexp.example.com", creds); err != nil {
t.Fatalf("failed to save: %v", err)
}
key := store.makeKey("https://noexp.example.com")
cache.mu.Lock()
ttl, ok := cache.setTTLs[key]
cache.mu.Unlock()
if !ok {
t.Fatal("expected credential to be written to cache")
}
// Effective permanence: well beyond any realistic rotation horizon. The
// old behavior was a 30-day default, so require >= 1 year.
if ttl < 365*24*time.Hour {
t.Fatalf("expected effectively-permanent TTL (>=1y) for exp==0, got %v", ttl)
}
}
+64
View File
@@ -0,0 +1,64 @@
package pool
import (
"crypto/tls"
"testing"
"time"
)
// TestTransportPool_ConfigKeyDistinguishesMinTLSVersion guards the R146
// fix in internal/pool/transport.go configKey: the key previously
// omitted MinTLSVersion (and all timeouts), so two configs differing
// only in the TLS minimum version collided on one key and silently
// shared a single transport with whoever-arrived-first's settings — a
// possible TLS-version downgrade. Distinct TLS minimum versions must
// yield non-shared transports with the requested MinVersion.
func TestTransportPool_ConfigKeyDistinguishesMinTLSVersion(t *testing.T) {
p := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 100, // high so the at-cap path isn't taken
}
cfg12 := TransportConfig{MinTLSVersion: tls.VersionTLS12}
cfg13 := TransportConfig{MinTLSVersion: tls.VersionTLS13}
a := p.GetTransport(cfg12)
b := p.GetTransport(cfg13)
if a == b {
t.Fatal("two configs differing only in MinTLSVersion must not share a transport")
}
if a.TLSClientConfig == nil || a.TLSClientConfig.MinVersion != tls.VersionTLS12 {
t.Fatalf("transport A must use TLS1.2, got %+v", a.TLSClientConfig)
}
if b.TLSClientConfig == nil || b.TLSClientConfig.MinVersion != tls.VersionTLS13 {
t.Fatalf("transport B must use TLS1.3, got %+v", b.TLSClientConfig)
}
}
// TestTransportPool_ConfigKeyDistinguishesTimeouts guards the R146 fix
// for the timeout fields: configs differing only in a timeout must not
// collide and share one transport.
func TestTransportPool_ConfigKeyDistinguishesTimeouts(t *testing.T) {
p := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 100,
}
cfgFast := TransportConfig{MinTLSVersion: tls.VersionTLS12, DialTimeout: time.Second}
cfgSlow := TransportConfig{MinTLSVersion: tls.VersionTLS12, DialTimeout: 30 * time.Second}
a := p.GetTransport(cfgFast)
b := p.GetTransport(cfgSlow)
if a == nil || b == nil {
t.Fatal("both transports must be created")
}
if a == b {
t.Fatal("two configs differing only in DialTimeout must not share a transport")
}
}
+78
View File
@@ -0,0 +1,78 @@
package pool
import (
"crypto/tls"
"net/http"
"testing"
"time"
)
// TestTransportPool_TLSMatchesNormalizesDefault covers the previously
// 0%-covered tlsMatches security guard (R146): two transports share the
// pool only when their TLS posture (certificate verification and
// minimum protocol version) matches, with 0 normalized to the TLS1.2
// default that createTransport applies.
func TestTransportPool_TLSMatchesNormalizesDefault(t *testing.T) {
p := &TransportPool{}
// 0 (createTransport's default) must be equivalent to explicit TLS1.2.
if !p.tlsMatches(TransportConfig{}, TransportConfig{MinTLSVersion: tls.VersionTLS12}) {
t.Fatal("zero and explicit TLS1.2 minimum must match after normalization")
}
// Differing verification posture must never match.
if p.tlsMatches(
TransportConfig{InsecureSkipVerify: true},
TransportConfig{InsecureSkipVerify: false},
) {
t.Fatal("configs differing in InsecureSkipVerify must not match")
}
// Differing minimum TLS version must never match.
if p.tlsMatches(
TransportConfig{MinTLSVersion: tls.VersionTLS12},
TransportConfig{MinTLSVersion: tls.VersionTLS13},
) {
t.Fatal("configs differing in MinTLSVersion must not match")
}
}
// TestTransportPool_NoTLSMismatchReuse drives getExistingTransport
// directly: a strict-verifying caller must never be handed a pooled
// InsecureSkipVerify transport (silent cert-verification bypass, R146);
// it falls back to nil so the caller builds a safe verifying client.
func TestTransportPool_NoTLSMismatchReuse(t *testing.T) {
p := &TransportPool{
transports: map[string]*sharedTransport{
"insecure": {
transport: &http.Transport{},
config: TransportConfig{InsecureSkipVerify: true, MinTLSVersion: tls.VersionTLS12},
refCount: 0,
lastUsed: time.Now(),
},
"verifyTLS13": {
transport: &http.Transport{},
config: TransportConfig{InsecureSkipVerify: false, MinTLSVersion: tls.VersionTLS13},
refCount: 0,
lastUsed: time.Now(),
},
},
maxConns: 20,
maxClients: 100,
}
// Strict TLS1.2 caller must NOT reuse the insecure or TLS1.3 peers.
if tr := p.getExistingTransport(TransportConfig{MinTLSVersion: tls.VersionTLS12}); tr != nil {
t.Fatal("strict-verifying caller received a mismatched pooled transport")
}
// Insecure caller matches the insecure peer and reuses it.
if tr := p.getExistingTransport(TransportConfig{InsecureSkipVerify: true, MinTLSVersion: tls.VersionTLS12}); tr == nil {
t.Fatal("insecure caller should reuse the insecure pooled transport")
}
// TLS1.3 verifying caller matches the TLS1.3 peer.
if tr := p.getExistingTransport(TransportConfig{MinTLSVersion: tls.VersionTLS13}); tr == nil {
t.Fatal("TLS1.3 caller should reuse the TLS1.3 verifying transport")
}
}
+46 -3
View File
@@ -118,7 +118,7 @@ func DefaultTransportConfig() TransportConfig {
func (p *TransportPool) GetTransport(config TransportConfig) *http.Transport {
// Check client limit
if atomic.LoadInt32(&p.clientCount) >= p.maxClients {
return p.getExistingTransport()
return p.getExistingTransport(config)
}
key := p.configKey(config)
@@ -180,20 +180,41 @@ func (p *TransportPool) ReleaseTransport(transport *http.Transport) {
}
// getExistingTransport returns any available transport when limit is reached
func (p *TransportPool) getExistingTransport() *http.Transport {
func (p *TransportPool) getExistingTransport(config TransportConfig) *http.Transport {
p.mu.RLock()
defer p.mu.RUnlock()
// Prefer a transport whose TLS settings (verification, min version)
// match the request. Reusing a mismatched transport at cap could hand a
// strict-verifying caller an InsecureSkipVerify transport (silent cert
// verification bypass) or an insecure caller a verifying one (R146).
for _, shared := range p.transports {
if shared != nil && shared.transport != nil {
if shared != nil && shared.transport != nil && p.tlsMatches(config, shared.config) {
atomic.AddInt32(&shared.refCount, 1)
shared.lastUsed = time.Now()
return shared.transport
}
}
// No TLS-matching pooled transport: return nil so the caller falls back
// to a safe basic client (http.DefaultTransport verifies certs) rather
// than receiving a mismatched peer. Bounded by the cap (R146).
return nil
}
// tlsMatches reports whether two transports share the same TLS posture
// (certificate verification and minimum protocol version), normalized for
// createTransport's 0-means-TLS1.2 default.
func (p *TransportPool) tlsMatches(a, b TransportConfig) bool {
amin, bmin := a.MinTLSVersion, b.MinTLSVersion
if amin == 0 {
amin = tls.VersionTLS12
}
if bmin == 0 {
bmin = tls.VersionTLS12
}
return a.InsecureSkipVerify == b.InsecureSkipVerify && amin == bmin
}
// createTransport creates a new HTTP transport with the given config
func (p *TransportPool) createTransport(config TransportConfig) *http.Transport {
// Set secure defaults
@@ -276,6 +297,28 @@ func (p *TransportPool) configKey(config TransportConfig) string {
} else {
sb.WriteByte(0)
}
// Include MinTLSVersion and every timeout so two configs differing only
// in TLS minimum version or timeouts do NOT collide on one key and
// silently share a transport with whoever-arrived-first's settings
// (a possible TLS-version downgrade). Mirror http_client_pool.go's
// all-field key (R146).
sb.WriteByte('|')
if config.MinTLSVersion == 0 {
sb.WriteString("tls12")
} else {
sb.WriteString(strconv.Itoa(int(config.MinTLSVersion)))
}
for _, d := range []time.Duration{
config.DialTimeout,
config.TLSHandshakeTimeout,
config.ResponseHeaderTimeout,
config.ExpectContinueTimeout,
config.IdleConnTimeout,
config.KeepAlive,
} {
sb.WriteByte('|')
sb.WriteString(strconv.FormatInt(int64(d), 10))
}
return sb.String()
}
+25 -7
View File
@@ -3,6 +3,8 @@ package recovery
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"math/rand"
@@ -144,8 +146,13 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
}
}
// Check for HTTP errors
if httpErr, ok := err.(*HTTPError); ok {
// Check for HTTP errors. Use errors.As (not a direct type assertion) so an
// error that wraps an *HTTPError — e.g. fmt.Errorf("token exchange failed:
// %w", httpErr) — is still recognized as retryable: previously a wrapped
// retryable error was misclassified as terminal and bailed after one
// attempt, defeating retry despite the production wrapping pattern.
var httpErr *HTTPError
if errors.As(err, &httpErr) {
for _, code := range re.config.RetryableStatusCodes {
if httpErr.StatusCode == code {
return true
@@ -157,13 +164,14 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
}
}
// Check for OIDC errors
if oidcErr, ok := err.(*OIDCError); ok {
// Check for OIDC errors (unwrapped or wrapped).
var oidcErr *OIDCError
if errors.As(err, &oidcErr) {
return oidcErr.IsRetryable()
}
// Check for context errors (don't retry these)
if err == context.Canceled || err == context.DeadlineExceeded {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
@@ -186,6 +194,12 @@ func (re *RetryExecutor) calculateDelay(attempt int) time.Duration {
if re.config.RandomizationFactor > 0 {
jitter := delay * re.config.RandomizationFactor
minDelay := delay - jitter
// A RandomizationFactor > 1 makes minDelay negative, which turns
// time.After(negative) into an immediate (zero) backoff and defeats
// the retry spacing (R150). Clamp to zero so backoff stays bounded.
if minDelay < 0 {
minDelay = 0
}
maxDelay := delay + jitter
delay = minDelay + rand.Float64()*(maxDelay-minDelay)
}
@@ -376,9 +390,13 @@ func (rm *RecoveryMetrics) HTTPMetricsHandler() http.HandlerFunc {
"health": health,
}
// Would normally use json.Marshal here, but keeping it simple for the module
body, err := json.Marshal(response)
if err != nil {
http.Error(w, "failed to marshal metrics", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "%v", response)
_, _ = w.Write(body)
}
}
+11
View File
@@ -4,6 +4,7 @@ package recovery
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
@@ -720,6 +721,16 @@ func TestRecoveryMetrics_HTTPMetricsHandler(t *testing.T) {
if body == "" {
t.Error("Expected non-empty response body")
}
// Body must be valid JSON (R147): the handler previously declared
// Content-Type: application/json but wrote Go map syntax via fmt.
var decoded map[string]any
if err := json.Unmarshal([]byte(body), &decoded); err != nil {
t.Errorf("metrics body is not valid JSON: %v (body: %s)", err, body)
}
if _, ok := decoded["metrics"]; !ok {
t.Errorf("metrics JSON body must contain a \"metrics\" key, got: %s", body)
}
}
// =============================================================================
+25
View File
@@ -0,0 +1,25 @@
package recovery
import (
"testing"
"time"
)
// TestRetryExecutor_HighRandomizationNoNegativeDelay guards the R150 fix in
// RetryExecutor.calculateDelay: with RandomizationFactor > 1 the raw
// minDelay = delay*(1-RF) is negative, letting time.After(negative)
// fire immediately and defeating backoff. Delays must be clamped to
// >= 0.
func TestRetryExecutor_HighRandomizationNoNegativeDelay(t *testing.T) {
exec := NewRetryExecutor(RetryConfig{
InitialDelay: 100 * time.Millisecond,
MaxDelay: 2 * time.Second,
Multiplier: 2.0,
RandomizationFactor: 2.5, // > 1: un-fixed code yields negative delays
}, &mockLogger{})
for attempt := 1; attempt <= 2000; attempt++ {
if d := exec.calculateDelay(attempt); d < 0 {
t.Fatalf("attempt %d produced negative delay %v", attempt, d)
}
}
}
@@ -0,0 +1,49 @@
package recovery
import (
"context"
"errors"
"fmt"
"testing"
)
// TestR175_WrappedRetryableErrorIsRetried regresses isRetryableError: it used
// direct type assertions (err.(*HTTPError)) instead of errors.As, so any
// retryable error that was WRAPPED (fmt.Errorf("...: %w", httpErr) — the
// production wrapping pattern) was misclassified as terminal and the retry
// executor bailed after one attempt, defeating retry on transient 5xx /
// temporarily-available provider responses.
func TestR175_WrappedRetryableErrorIsRetried(t *testing.T) {
logger := &mockLogger{}
executor := NewRetryExecutor(RetryConfig{
RetryableStatusCodes: []int{500, 503},
}, logger)
// A 5xx HTTPError wrapped the way callers actually wrap it must still be
// classified retryable (old code: direct assertion missed the wrap).
wrappedHTTP := fmt.Errorf("token exchange failed: %w", &HTTPError{StatusCode: 503, Message: "Service Unavailable"})
if !executor.isRetryableError(wrappedHTTP) {
t.Fatal("a wrapped 503 *HTTPError must be retryable (errors.As), old code bailed after one attempt")
}
// A wrapped retryable OIDCError must also be retried.
wrappedOIDC := fmt.Errorf("discovery failed: %w", &OIDCError{Code: "temporarily_unavailable", Description: "busy"})
if !executor.isRetryableError(wrappedOIDC) {
t.Fatal("a wrapped retryable *OIDCError must be retryable (errors.As)")
}
// Wrapped context errors remain non-retryable (errors.Is).
wrappedCtx := fmt.Errorf("retry canceled: %w", context.Canceled)
if executor.isRetryableError(wrappedCtx) {
t.Fatal("a wrapped context.Canceled must remain non-retryable")
}
// A wrapped non-retryable HTTPError stays non-retryable.
wrapped400 := fmt.Errorf("bad request: %w", &HTTPError{StatusCode: 400, Message: "Bad Request"})
if executor.isRetryableError(wrapped400) {
t.Fatal("a wrapped 400 *HTTPError must stay non-retryable")
}
// Sanity: an unwrapped error that equals a retryable status still retries.
_ = errors.New("sentinel")
}
@@ -0,0 +1,41 @@
package utils
import "testing"
// TestR176_SanitizeForwardedHostRejectsPathDelimiters regresses
// sanitizeForwardedHost: it previously only rejected control/whitespace
// characters, so a crafted X-Forwarded-Host like "example.com/path" (or
// with '?', '#', '@') survived and its delimiter was spliced verbatim into
// the host part of a redirect URL (used as redirect_uri /
// post_logout_redirect_uri and matched by the IdP). Valid host
// characters — alphanumerics, '-', '.', ':', '[', ']' (port / IPv6) —
// must still pass.
func TestR176_SanitizeForwardedHostRejectsPathDelimiters(t *testing.T) {
valid := []string{
"example.com",
"example.com:8443",
"api.v2.example.co.uk",
"10.0.0.1",
"[2001:db8::1]",
"[fe80::1]:8080",
}
for _, in := range valid {
if got := sanitizeForwardedHost(in); got != in {
t.Fatalf("valid host %q was rejected: got %q", in, got)
}
}
invalid := []string{
"example.com/path", // path injection
"example.com?x=1", // query injection
"example.com#frag", // fragment injection
"[email protected]", // userinfo injection
"example.com\\path",
"example.com /evil", // embedded space + path
}
for _, in := range invalid {
if got := sanitizeForwardedHost(in); got != "" {
t.Fatalf("host %q must be rejected (delimiter would poison the redirect base), got %q", in, got)
}
}
}
+17 -3
View File
@@ -137,8 +137,12 @@ func DetermineScheme(req *http.Request, forceHTTPS bool) string {
return "https"
}
// Check X-Forwarded-Proto header for proxy scenarios
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" {
// Check X-Forwarded-Proto header for proxy scenarios. Only trusted
// values are accepted: a malformed/junk scheme (e.g. an attacker-set
// "X-Forwarded-Proto: ftp") would otherwise be emitted verbatim into
// redirect_uri as a broken or weakened scheme. Invalid values fall
// through to the TLS check (R103).
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme == "https" || scheme == "http" {
return scheme
}
@@ -177,7 +181,17 @@ func sanitizeForwardedHost(v string) string {
if v == "" {
return ""
}
if strings.IndexFunc(v, func(r rune) bool { return r < 0x20 || r == 0x7f || r == ' ' }) >= 0 {
if strings.IndexFunc(v, func(r rune) bool {
// Reject control chars, whitespace, and the delimiters that would
// let a crafted X-Forwarded-Host smuggle a path (''/''), query
// (''?''), fragment (''#''), or userinfo (''@'') into the
// host part of a redirect URL (which is passed to the IdP as
// redirect_uri / post_logout_redirect_uri and matched there).
// Valid host characters — alphanumerics, '-', '.', ':', '[', ']'
// (port / IPv6) — remain allowed.
return r < 0x20 || r == 0x7f || r == ' ' ||
r == '/' || r == '?' || r == '#' || r == '@' || r == '\\'
}) >= 0 {
return ""
}
return v
+14 -9
View File
@@ -9,11 +9,11 @@ import (
// TestDetermineScheme verifies the scheme priority: forceHTTPS > X-Forwarded-Proto > TLS > http.
func TestDetermineScheme(t *testing.T) {
cases := []struct {
name string
forceHTTPS bool
fwdProto string
tls bool
want string
name string
forceHTTPS bool
fwdProto string
tls bool
want string
}{
{"force https wins over header", true, "http", false, "https"},
{"force https wins over tls absence", true, "", false, "https"},
@@ -21,6 +21,11 @@ func TestDetermineScheme(t *testing.T) {
{"forwarded proto http", false, "http", true, "http"},
{"tls connection", false, "", true, "https"},
{"default http", false, "", false, "http"},
// R103: a malformed X-Forwarded-Proto must not be emitted verbatim
// into redirect_uri (would yield a broken/weakened scheme). It
// falls through to the TLS check / http default instead.
{"invalid proto falls through to tls", false, "ftp", true, "https"},
{"invalid proto falls through to http", false, "ftp", false, "http"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -41,10 +46,10 @@ func TestDetermineScheme(t *testing.T) {
// TestDetermineHost verifies forwarding-header preference and fallback to req.Host.
func TestDetermineHost(t *testing.T) {
cases := []struct {
name string
fwd string
host string
want string
name string
fwd string
host string
want string
}{
{"forwarded host used", "api.example.com", "backend.local", "api.example.com"},
{"first of comma list taken", "a.example.com, b.example.com", "backend.local", "a.example.com"},
+29 -16
View File
@@ -427,10 +427,19 @@ func (mm *MemoryMonitor) StartMonitoring(ctx context.Context, interval time.Dura
// Check if monitoring is already started
if globalMonitoringStarted {
if !isTestMode() {
mm.logger.Debug("Memory monitoring already started, skipping duplicate start")
// If the singleton task was torn down out-of-band (plugin Close ->
// TaskRegistry.StopAllTasks), the process-global flag survives the
// teardown. Detect that and allow restart so memory monitoring
// resumes for a recreated plugin instance; otherwise a config
// reload would permanently disable monitoring.
if !GetResourceManager().IsTaskRunning("memory-monitor") {
globalMonitoringStarted = false
} else {
if !isTestMode() {
mm.logger.Debug("Memory monitoring already started, skipping duplicate start")
}
return
}
return
}
if interval <= 0 {
@@ -538,15 +547,19 @@ func (mm *MemoryMonitor) StopMonitoring() {
return
}
registry := GetGlobalTaskRegistry()
if task, exists := registry.GetTask("memory-monitor"); exists {
task.Stop()
globalMonitoringStarted = false
if !isTestMode() {
mm.logger.Info("Stopped global memory monitoring")
}
} else {
mm.logger.Errorf("Failed to find memory monitoring task to stop")
// The memory-monitor task is a process-global singleton registered via
// CreateSingletonTask, which stores the task in the ResourceManager's
// registry — NOT in TaskRegistry.tasks (R88 split). Look it up there;
// previously GetGlobalTaskRegistry().GetTask read TaskRegistry.tasks,
// always returned not-exists, so Stop was never called, the ticker
// goroutine leaked past shutdown, and this logged a spurious error.
if err := GetResourceManager().StopBackgroundTask("memory-monitor"); err != nil {
mm.logger.Errorf("Failed to find memory monitoring task to stop: %v", err)
return
}
globalMonitoringStarted = false
if !isTestMode() {
mm.logger.Info("Stopped global memory monitoring")
}
}
@@ -580,11 +593,11 @@ func ResetGlobalMemoryMonitor() {
defer globalMonitoringMutex.Unlock()
if globalMemoryMonitor != nil {
// Stop monitoring if it's active
// Stop monitoring if it's active (singleton task lives in the
// ResourceManager's registry, see StopMonitoring).
if globalMonitoringStarted {
registry := GetGlobalTaskRegistry()
if task, exists := registry.GetTask("memory-monitor"); exists {
task.Stop()
if err := GetResourceManager().StopBackgroundTask("memory-monitor"); err != nil {
GetSingletonNoOpLogger().Error("Failed to find memory monitoring task to stop: %v", err)
}
}
globalMemoryMonitor = nil
+20 -6
View File
@@ -88,8 +88,17 @@ func (c *ShardedCache) Get(key string) (interface{}, bool) {
// Check expiration
if !item.expiresAt.IsZero() && time.Now().After(item.expiresAt) {
// Item expired - remove it lazily
c.Delete(key)
// Item expired — remove it lazily, but ONLY if this is still the
// same entry. A concurrent Set/SetIfAbsent that refreshed the key
// (e.g. a freshly-recorded replay JTI) since this read must not be
// removed, else the just-recorded JTI is lost and a duplicate token
// could pass (R129; matches deleteIfExpired in
// internal/cache/backends/memory_shard.go).
shard.mu.Lock()
if cur, ok := shard.items[key]; ok && cur == item {
delete(shard.items, key)
}
shard.mu.Unlock()
return nil, false
}
@@ -107,9 +116,11 @@ func (c *ShardedCache) Set(key string, value interface{}, ttl time.Duration) {
}
shard.mu.Lock()
// Check if we need to evict items
if len(shard.items) >= c.maxPerShard {
// Simple eviction: remove expired items first, then oldest
// Overwriting an existing key needs no free slot; only evict when
// inserting a genuinely new key at capacity, so an overwrite never
// drops an unrelated oldest still-valid entry and shrinks the shard
// by one (repeats the R57 hazard) (R153).
if _, present := shard.items[key]; !present && len(shard.items) >= c.maxPerShard {
c.evictFromShardLocked(shard)
}
@@ -140,6 +151,7 @@ func (c *ShardedCache) SetIfAbsent(key string, value interface{}, ttl time.Durat
shard.mu.Lock()
defer shard.mu.Unlock()
_, found := shard.items[key]
if it, exists := shard.items[key]; exists {
// Present and not expired -> duplicate.
if it.expiresAt.IsZero() || !time.Now().After(it.expiresAt) {
@@ -148,7 +160,9 @@ func (c *ShardedCache) SetIfAbsent(key string, value interface{}, ttl time.Durat
// Present but expired -> treat as absent and replace below.
}
if len(shard.items) >= c.maxPerShard {
// The replace-an-expired path needs no free slot; only evict when the
// key is absent and the shard is at capacity (R153).
if !found && len(shard.items) >= c.maxPerShard {
c.evictFromShardLocked(shard)
}
+13 -8
View File
@@ -124,20 +124,25 @@ func (rm *ResourceManager) RegisterBackgroundTask(name string, interval time.Dur
defer rm.tasksMu.Unlock()
// If a task with this name already exists and is still running, keep it
// (idempotent re-registration from another middleware instance).
// (idempotent re-registration from another middleware instance). Keep it
// only when it has STARTED (started==1 && stopped==0). A task whose
// startOnce was consumed without ever starting — e.g. Start was
// rejected by the circuit breaker, or Stop raced re-registration —
// would otherwise be kept here, then StartBackgroundTask would be a
// permanent no-op (Start is sync.Once-guarded) and the singleton
// task would never run again until process restart.
if existing, exists := rm.tasks[name]; exists {
if atomic.LoadInt32(&existing.stopped) == 0 {
if atomic.LoadInt32(&existing.started) == 1 && atomic.LoadInt32(&existing.stopped) == 0 {
if rm.logger != nil {
rm.logger.Debugf("Background task %s already registered", name)
}
return nil
}
// The existing task was stopped. Reusing the same BackgroundTask
// object would leave it permanently dead: BackgroundTask.Start is
// guarded by sync.Once, so once stopped it can never restart, yet
// IsTaskRunning returns false and callers will call Start again.
// Replace it with a fresh task so re-registration after a
// close+recreate cycle (same provider URL) actually resumes it.
// The existing task was either stopped or never actually started.
// Reusing the same BackgroundTask object would leave it permanently
// dead: BackgroundTask.Start is guarded by sync.Once, so once
// consumed it can never start again. Replace it with a fresh task
// so re-registration actually resumes it.
}
task := NewBackgroundTask(name, interval, taskFunc, rm.logger, &rm.wg)
+30 -11
View File
@@ -16,11 +16,13 @@ import (
type CacheType string
const (
CacheTypeToken CacheType = "token"
CacheTypeMetadata CacheType = "metadata"
CacheTypeJWK CacheType = "jwk"
CacheTypeSession CacheType = "session"
CacheTypeGeneral CacheType = "general"
CacheTypeToken CacheType = "token"
CacheTypeBlacklist CacheType = "blacklist"
CacheTypeMetadata CacheType = "metadata"
CacheTypeIntrospection CacheType = "introspection"
CacheTypeJWK CacheType = "jwk"
CacheTypeSession CacheType = "session"
CacheTypeGeneral CacheType = "general"
// maxCacheEntrySize defines the maximum size for a single cache entry (64 MiB)
// This prevents integer overflow when allocating memory for serialization
@@ -248,7 +250,17 @@ func (c *UniversalCache) Set(key string, value interface{}, ttl time.Duration) e
if err := c.backend.Set(ctx, c.prefixKey(key), data, ttl); err != nil {
c.logger.Infof("Backend set error for key %s: %v", key, err)
// Continue with local cache even if backend fails
// The backend still holds an OLDER entry for this key. If we
// leave it in place, a subsequent Get treats the backend as
// authoritative and (via updateLocalCache) overwrites the
// just-written local value with the stale one (R162). Evict the
// stale backend entry so Get falls through to the fresh local
// value instead of resurrecting the old one.
dctx, dcancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
if _, derr := c.backend.Delete(dctx, c.prefixKey(key)); derr != nil {
c.logger.Debugf("Backend delete after failed set for key %s: %v", key, derr)
}
dcancel()
}
}
@@ -294,8 +306,12 @@ func (c *UniversalCache) setLocal(key string, value interface{}, ttl time.Durati
}
}
// Check size limits
if c.lruList.Len() >= c.config.MaxSize {
// Check size limits — only evict when inserting a NEW key. An in-place
// update of an existing key adds no net entry, so evicting would drop an
// unrelated live LRU entry and leave the cache one below capacity (cache
// thrashes under sustained traffic at MaxSize). Mirror of the shard
// backend fix (R139 F2).
if _, present := c.items[key]; !present && c.lruList.Len() >= c.config.MaxSize {
c.evictOldest()
}
@@ -397,7 +413,7 @@ func (c *UniversalCache) getLocal(key string) (interface{}, bool) {
// previous unconditional Lock serialized every JWT verify on a single
// mutex and pinned a CPU under load.
switch c.config.Type {
case CacheTypeToken, CacheTypeJWK, CacheTypeSession:
case CacheTypeToken, CacheTypeJWK, CacheTypeSession, CacheTypeIntrospection:
c.mu.RLock()
item, exists := c.items[key]
if !exists {
@@ -975,8 +991,11 @@ func (c *UniversalCache) updateLocalCache(key string, value interface{}, ttl tim
}
}
// Check size limits
if c.lruList.Len() >= c.config.MaxSize {
// Check size limits — only evict when inserting a NEW key. An in-place
// update adds no net entry (the replace below frees the old size), so
// evicting would drop an unrelated live LRU entry and leave the cache
// one below capacity. See setLocal (R141).
if _, present := c.items[key]; !present && c.lruList.Len() >= c.config.MaxSize {
c.evictOldest()
}
+40 -20
View File
@@ -94,6 +94,23 @@ func GetUniversalCacheManagerWithConfig(logger *Logger, redisConfig *RedisConfig
return universalCacheManager
}
// newBlacklistCacheConfig returns the UniversalCacheConfig used for the
// shared token-blacklist cache. It MUST keep a CacheType distinct from
// the token cache's CacheTypeToken: prefixKey composes the backend key
// from c.config.Type, so a shared type would put blacklist and token
// entries for the same raw token string in the same (e.g. Redis)
// namespace — both rejecting valid claims-cached tokens as blacklisted
// and letting token caching clobber a real blacklist marker (R128).
func newBlacklistCacheConfig(logger *Logger) UniversalCacheConfig {
return UniversalCacheConfig{
Type: CacheTypeBlacklist,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
}
}
// initializeDefaultCaches initializes caches with memory-only backends
func initializeDefaultCaches(manager *UniversalCacheManager, logger *Logger) {
// Initialize token cache - CRITICAL FIX: Reduced from 5000 to 1000
@@ -106,14 +123,12 @@ func initializeDefaultCaches(manager *UniversalCacheManager, logger *Logger) {
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize blacklist cache
manager.blacklistCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
})
// Initialize blacklist cache. Distinct CacheType so its (possibly
// shared-backend) "blacklist:" namespace can never collide with the
// token cache's "token:" namespace for the same raw token string; a
// collision would both reject valid claims-cached tokens as blacklisted
// and let token caching clobber a real blacklist marker (R128).
manager.blacklistCache = NewUniversalCache(newBlacklistCacheConfig(logger))
// Initialize metadata cache with grace periods
manager.metadataCache = NewUniversalCache(UniversalCacheConfig{
@@ -156,8 +171,13 @@ func initializeDefaultCaches(manager *UniversalCacheManager, logger *Logger) {
})
// Initialize introspection cache for OAuth 2.0 Token Introspection (RFC 7662)
// Uses its own CacheType so its Redis namespace differs from the blacklist
// (both in CacheTypeToken): with a shared Redis backend an opaque token is keyed
// by its raw value in both caches, so sharing the "token:" prefix let an
// introspection write clobber a same-token blacklist marker (revoked opaque
// token could be re-accepted) across replicas. See R114.
manager.introspectionCache = NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken, // Use token cache type for introspection results
Type: CacheTypeIntrospection,
MaxSize: 1000, // Cache up to 1000 introspection results
DefaultTTL: 5 * time.Minute, // Short TTL for security (introspect frequently)
Logger: logger,
@@ -297,15 +317,13 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r
createBackend(CacheTypeToken),
)
// Initialize blacklist cache (CRITICAL - must be consistent across replicas)
// Initialize blacklist cache (CRITICAL - must be consistent across replicas).
// Distinct CacheType so its "blacklist:" Redis namespace can't collide with
// the token cache's "token:" namespace for the same raw token (R128); a
// shared prefix would reject valid claims-cached tokens as blacklisted or
// let token caching clobber a real blacklist marker.
manager.blacklistCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
newBlacklistCacheConfig(logger),
createBackend("blacklist"),
)
@@ -355,16 +373,18 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r
SkipAutoCleanup: true, // Managed cleanup
})
// Introspection cache uses backend for sharing results
// Introspection cache uses backend for sharing results. Distinct CacheType
// so its Redis "introspection:" namespace can't collide with the blacklist's
// "token:" namespace for the same raw opaque token (R114).
manager.introspectionCache = NewUniversalCacheWithBackend(
UniversalCacheConfig{
Type: CacheTypeToken,
Type: CacheTypeIntrospection,
MaxSize: 1000,
DefaultTTL: 5 * time.Minute,
Logger: logger,
SkipAutoCleanup: true, // Managed cleanup
},
createBackend(CacheTypeToken),
createBackend(CacheTypeIntrospection),
)
// Token type cache stays memory-only (local optimization)
+97 -3
View File
@@ -10,6 +10,7 @@ import (
"html"
"net/http"
"runtime"
"strconv"
"strings"
"time"
)
@@ -136,9 +137,7 @@ func (t *TraefikOidc) isAllowedDomain(email string) bool {
}
domain := strings.ToLower(parts[1])
_, domainAllowed := t.allowedUserDomains[domain]
if domainAllowed {
if t.domainAllowed(domain) {
t.logger.Debugf("Email domain %s is allowed", domain)
return true
} else {
@@ -153,6 +152,97 @@ func (t *TraefikOidc) isAllowedDomain(email string) bool {
return false
}
// domainAllowed reports whether domain is an allowed user domain,
// including subdomains thereof. An operator configuring
// "example.com" intends to cover staff on corporate subdomains, so
// "mail.example.com" (and deeper) must be accepted; exact match is
// no longer the only path. A plain suffix lookup is avoided (would
// also match "badexample.com"), so subdomains require a leading "."
// separator. (R152)
func (t *TraefikOidc) domainAllowed(domain string) bool {
if _, ok := t.allowedUserDomains[domain]; ok {
return true
}
for allowed := range t.allowedUserDomains {
if len(domain) > len(allowed) && strings.HasSuffix(domain, "."+allowed) {
return true
}
}
return false
}
// emailVerifiedTrue extracts the boolean email_verified claim value,
// claimScalarString returns the string form of a scalar claim value,
// coercing JSON numbers (float64, json.Number, int variants) to their
// decimal string representation so a numeric identifier claim (e.g. a
// numeric sub from a non-conformant IdP) is preserved rather than
// silently dropped. second is false for non-scalar values (maps,
// slices, objects) so callers can treat them as invalid.
func claimScalarString(raw interface{}) (s string, ok bool) {
switch v := raw.(type) {
case string:
return v, true
case json.Number:
return v.String(), true
case float64:
return strconv.FormatFloat(v, 'f', -1, 64), true
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32), true
case int:
return strconv.Itoa(v), true
case int64:
return strconv.FormatInt(v, 10), true
default:
return "", false
}
}
// emailVerifiedTrue extracts the boolean email_verified claim value,
// tolerating bool and string representations. present is false when the
// claim is absent or of an uninterpretable type (the caller then decides
// how to treat absence).
func emailVerifiedTrue(v interface{}) (verified, present bool) {
switch val := v.(type) {
case bool:
return val, true
case string:
switch strings.ToLower(strings.TrimSpace(val)) {
case "true", "1":
return true, true
case "false", "0":
return false, true
}
case float64:
return val != 0, true
case json.Number:
f, err := val.Float64()
if err != nil {
return false, false
}
return f != 0, true
}
return false, false
}
// emailIdentityPermitted reports whether an email-based user identifier may be
// admitted for email/domain authorization given the ID-token claims. An email
// is admitted unless the IdP explicitly marks it unverified
// (email_verified == false): such an address must not satisfy an email
// allowlist, since the user may not control it (impersonation within an
// allowed domain). Absence is tolerated for backward compatibility (the
// middleware and its test fixtures have long run without the claim; some IdPs
// do not emit it). This matches the bearer-path stance that untrusted email
// is a spoofing vector (main.go).
func emailIdentityPermitted(userIdentifier string, claims map[string]interface{}) bool {
if !strings.Contains(userIdentifier, "@") {
return true
}
if verified, present := emailVerifiedTrue(claims["email_verified"]); present && !verified {
return false
}
return true
}
// keysFromMap extracts string keys from a map for logging purposes.
// Helper function to get keys from a map for logging.
// Parameters:
@@ -176,6 +266,10 @@ func keysFromMap(m map[string]struct{}) []string {
// - message: The error message to display.
// - code: The HTTP status code to set for the response.
func (t *TraefikOidc) sendErrorResponse(rw http.ResponseWriter, req *http.Request, message string, code int) {
// Auth-failure responses (401/403/429/503) must not be cached by
// browsers or intermediaries — a cached 401 body would persist across
// re-authentication (R101).
rw.Header().Set("Cache-Control", "no-store")
acceptHeader := req.Header.Get("Accept")
if strings.Contains(acceptHeader, "application/json") {