Files
traefikoidc/main_goroutine_leak_test.go
T
lukaszraczylo 6efb78b7a8 Smarter approach to the cookies (#103)
* Smarter approach to the cookies

  - Single maxCookieSize = 1400 constant with clear documentation
  - Combined cookie storage for ~40-45% size reduction
  - Backward compatible migration from legacy cookies

* Tuneup the code.
2025-12-12 18:35:06 +00:00

421 lines
12 KiB
Go

package traefikoidc
import (
"context"
"runtime"
"sync"
"testing"
"time"
)
// TestGoroutineLeakPrevention_ContextCancellation tests that goroutines are properly cleaned up
// when the context is canceled during middleware initialization and operation
func TestGoroutineLeakPrevention_ContextCancellation(t *testing.T) {
tests := []struct {
name string
description string
cancelAfter time.Duration
expectedLeaks int
}{
{
name: "immediate_cancellation",
cancelAfter: 1 * time.Millisecond,
expectedLeaks: 10, // Allow for background tasks (replay-cache-cleanup, health-check, etc.)
description: "Context canceled immediately during initialization",
},
{
name: "quick_cancellation",
cancelAfter: 50 * time.Millisecond,
expectedLeaks: 5, // Allow for some background task leaks during cancellation
description: "Context canceled during metadata initialization",
},
{
name: "delayed_cancellation",
cancelAfter: 200 * time.Millisecond,
expectedLeaks: 5, // Allow for some background task leaks during cancellation
description: "Context canceled after partial initialization",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Record initial goroutine count
runtime.GC()
runtime.GC() // Double GC to ensure cleanup
time.Sleep(10 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
// Create cancellable context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create plugin config
config := CreateConfig()
config.ProviderURL = "https://accounts.google.com"
config.SessionEncryptionKey = "test-encryption-key-32-bytes-long"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
// Start goroutine leak test
var plugin *TraefikOidc
var wg sync.WaitGroup
// Initialize plugin in separate goroutine to simulate real usage
wg.Add(1)
go func() {
defer wg.Done()
handler, _ := New(ctx, nil, config, "test")
if handler != nil {
plugin = handler.(*TraefikOidc)
}
}()
// Cancel context after specified delay
time.Sleep(tt.cancelAfter)
cancel()
// Wait for initialization to complete or timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// Initialization completed (or was canceled)
case <-time.After(5 * time.Second):
t.Fatal("Plugin initialization did not complete within timeout")
}
// Clean up plugin if it was created
if plugin != nil {
// Use proper Close() method for cleanup
if err := plugin.Close(); err != nil {
t.Logf("Plugin close error: %v", err)
}
}
// Allow time for goroutine cleanup
time.Sleep(100 * time.Millisecond)
runtime.GC()
runtime.GC()
time.Sleep(50 * time.Millisecond)
// Check final goroutine count
finalGoroutines := runtime.NumGoroutine()
goroutineDiff := finalGoroutines - initialGoroutines
if goroutineDiff > tt.expectedLeaks {
t.Errorf("Goroutine leak detected: %s\n"+
"Initial goroutines: %d\n"+
"Final goroutines: %d\n"+
"Difference: %d (expected max: %d)",
tt.description, initialGoroutines, finalGoroutines,
goroutineDiff, tt.expectedLeaks)
}
t.Logf("Test %s: Initial: %d, Final: %d, Diff: %d",
tt.name, initialGoroutines, finalGoroutines, goroutineDiff)
})
}
}
// TestGoroutineLeakPrevention_PanicRecovery tests that goroutines are cleaned up
// even when panics occur during initialization
func TestGoroutineLeakPrevention_PanicRecovery(t *testing.T) {
runtime.GC()
runtime.GC()
time.Sleep(10 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
// Create context that will be valid but cause initialization issues
ctx := context.Background()
// Create invalid config to potentially cause panics
config := CreateConfig()
config.ProviderURL = "://invalid-url" // Invalid URL format
config.SessionEncryptionKey = "too-short" // Invalid key length
config.ClientID = ""
config.ClientSecret = ""
// Attempt to create plugin - should handle errors gracefully
handler, err := New(ctx, nil, config, "test")
var plugin *TraefikOidc
if handler != nil {
plugin = handler.(*TraefikOidc)
}
// Verify error is handled gracefully (no panic)
if err == nil {
t.Log("Plugin creation succeeded despite invalid config")
if plugin != nil {
// Clean up if somehow created using proper Close() method
if err := plugin.Close(); err != nil {
t.Logf("Plugin close error: %v", err)
}
}
} else {
t.Logf("Plugin creation failed as expected: %v", err)
}
// Allow cleanup time
time.Sleep(100 * time.Millisecond)
runtime.GC()
runtime.GC()
time.Sleep(50 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
goroutineDiff := finalGoroutines - initialGoroutines
if goroutineDiff > 5 { // Allow more tolerance for background tasks
t.Errorf("Goroutine leak after panic recovery: "+
"Initial: %d, Final: %d, Diff: %d",
initialGoroutines, finalGoroutines, goroutineDiff)
}
}
// TestGoroutineLeakPrevention_MultipleInstances tests that multiple middleware instances
// don't cause goroutine leaks
func TestGoroutineLeakPrevention_MultipleInstances(t *testing.T) {
runtime.GC()
runtime.GC()
time.Sleep(10 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
ctx := context.Background()
const numInstances = 5
plugins := make([]*TraefikOidc, 0, numInstances)
// Create multiple plugin instances
for i := 0; i < numInstances; i++ {
config := CreateConfig()
config.ProviderURL = "https://accounts.google.com"
config.SessionEncryptionKey = "test-encryption-key-32-bytes-long"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
handler, err := New(ctx, nil, config, "test")
if err != nil {
t.Fatalf("Failed to create plugin instance %d: %v", i, err)
}
if handler != nil {
plugin := handler.(*TraefikOidc)
plugins = append(plugins, plugin)
}
}
// Allow initialization to complete
time.Sleep(100 * time.Millisecond)
// Clean up all plugins
var wg sync.WaitGroup
for i, plugin := range plugins {
wg.Add(1)
go func(p *TraefikOidc, idx int) {
defer wg.Done()
// Use proper Close() method for cleanup
if err := p.Close(); err != nil {
t.Logf("Plugin %d close error: %v", idx, err)
}
}(plugin, i)
}
// Wait for all cleanups with timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// All cleanups completed
case <-time.After(10 * time.Second):
t.Fatal("Plugin cleanup did not complete within timeout")
}
// Allow final cleanup
time.Sleep(200 * time.Millisecond)
runtime.GC()
runtime.GC()
time.Sleep(100 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
goroutineDiff := finalGoroutines - initialGoroutines
// Allow for reasonable tolerance due to background tasks and test infrastructure
maxExpectedLeaks := 10 // Increased to account for background tasks from multiple instances
if goroutineDiff > maxExpectedLeaks {
t.Errorf("Excessive goroutine leaks with multiple instances: "+
"Initial: %d, Final: %d, Diff: %d (max expected: %d)",
initialGoroutines, finalGoroutines, goroutineDiff, maxExpectedLeaks)
}
t.Logf("Multiple instances test: Created %d instances, "+
"Initial goroutines: %d, Final: %d, Diff: %d",
numInstances, initialGoroutines, finalGoroutines, goroutineDiff)
}
// TestGoroutineLeakPrevention_TimeoutCleanup tests that stuck goroutines are cleaned up
// within reasonable timeouts
func TestGoroutineLeakPrevention_TimeoutCleanup(t *testing.T) {
runtime.GC()
runtime.GC()
time.Sleep(10 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
config := CreateConfig()
config.ProviderURL = "https://httpbin.org/delay/10" // Slow endpoint to trigger timeout
config.SessionEncryptionKey = "test-encryption-key-32-bytes-long"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
// Create plugin - initialization may timeout
handler, err := New(ctx, nil, config, "test")
var plugin *TraefikOidc
if handler != nil {
plugin = handler.(*TraefikOidc)
}
// Wait for context timeout
<-ctx.Done()
if plugin != nil {
// Clean up if plugin was created using proper Close() method
if err := plugin.Close(); err != nil {
t.Logf("Plugin close error: %v", err)
}
}
// Allow extended cleanup time for timeout scenarios
time.Sleep(300 * time.Millisecond)
runtime.GC()
runtime.GC()
time.Sleep(100 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
goroutineDiff := finalGoroutines - initialGoroutines
if goroutineDiff > 5 { // Allow more tolerance for timeout scenarios
t.Errorf("Goroutines not cleaned up after timeout: "+
"Initial: %d, Final: %d, Diff: %d, Error: %v",
initialGoroutines, finalGoroutines, goroutineDiff, err)
}
}
// TestGoroutineLeakPrevention_BackgroundTaskCleanup tests that background metadata refresh
// goroutines are properly stopped and cleaned up
func TestGoroutineLeakPrevention_BackgroundTaskCleanup(t *testing.T) {
runtime.GC()
runtime.GC()
time.Sleep(10 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
ctx := context.Background()
config := CreateConfig()
config.ProviderURL = "https://accounts.google.com"
config.SessionEncryptionKey = "test-encryption-key-32-bytes-long"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
handler, err := New(ctx, nil, config, "test")
if err != nil {
t.Fatalf("Failed to create plugin: %v", err)
}
plugin := handler.(*TraefikOidc)
// Allow initialization and background task startup
time.Sleep(200 * time.Millisecond)
// Check that we have more goroutines (background tasks started)
midGoroutines := runtime.NumGoroutine()
if midGoroutines <= initialGoroutines {
t.Log("Warning: No additional goroutines detected for background tasks")
}
// Stop all background tasks properly
err = plugin.Close()
if err != nil {
t.Logf("Warning: Error closing plugin: %v", err)
}
// Allow cleanup time
time.Sleep(200 * time.Millisecond)
runtime.GC()
runtime.GC()
time.Sleep(100 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
goroutineDiff := finalGoroutines - initialGoroutines
if goroutineDiff > 5 { // Allow tolerance for background task cleanup timing
t.Errorf("Background tasks not properly cleaned up: "+
"Initial: %d, Mid: %d, Final: %d, Diff: %d",
initialGoroutines, midGoroutines, finalGoroutines, goroutineDiff)
}
t.Logf("Background task cleanup: Initial: %d, Mid: %d, Final: %d",
initialGoroutines, midGoroutines, finalGoroutines)
}
// BenchmarkGoroutineLeakPrevention_CreationDestruction benchmarks goroutine usage
// during plugin creation and destruction cycles
func BenchmarkGoroutineLeakPrevention_CreationDestruction(b *testing.B) {
ctx := context.Background()
// Record baseline
runtime.GC()
runtime.GC()
time.Sleep(10 * time.Millisecond)
baselineGoroutines := runtime.NumGoroutine()
b.ResetTimer()
for i := 0; i < b.N; i++ {
config := CreateConfig()
config.ProviderURL = "https://accounts.google.com"
config.SessionEncryptionKey = "test-encryption-key-32-bytes-long"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
handler, err := New(ctx, nil, config, "test")
if err != nil {
b.Fatalf("Failed to create plugin: %v", err)
}
plugin := handler.(*TraefikOidc)
// Clean up immediately using proper Close() method
if err := plugin.Close(); err != nil {
b.Logf("Plugin close error at iteration %d: %v", i, err)
}
// Periodic goroutine count check
if i%100 == 99 {
runtime.GC()
current := runtime.NumGoroutine()
if current > baselineGoroutines+10 {
b.Fatalf("Goroutine leak detected at iteration %d: baseline=%d, current=%d",
i, baselineGoroutines, current)
}
}
}
b.StopTimer()
// Final cleanup and verification
runtime.GC()
runtime.GC()
time.Sleep(50 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
if finalGoroutines > baselineGoroutines+5 {
b.Errorf("Potential goroutine leak after benchmark: baseline=%d, final=%d",
baselineGoroutines, finalGoroutines)
}
}