package server import ( "context" "net/http" "net/http/httptest" "runtime" "testing" "time" "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/metrics" "git.eeqj.de/sneak/routewatch/internal/streamer" ) // blockingStatsDB embeds database.Store (left nil) and overrides only // GetStatsContext, which blocks until release is closed. The stats handlers // call it in a goroutine; every other Store method is unused on the timeout // path and would panic if called. type blockingStatsDB struct { database.Store release chan struct{} } func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, error) { <-d.release return database.Stats{}, nil } // TestStatsHandlersDoNotLeakOnTimeout drives each stats handler repeatedly with // a request whose context times out before the database responds, then releases // the blocked queries and asserts the goroutine count returns to its starting // value. Before the fix the per-request goroutine sent on an unbuffered channel // that nothing received once the timeout won, so it blocked forever and every // poll leaked one goroutine. func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) { release := make(chan struct{}) db := blockingStatsDB{release: release} s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{}) handlers := map[string]http.HandlerFunc{ "status.json": s.handleStatusJSON(), "stats": s.handleStats(), } baseline := settledGoroutineCount() const ( iterations = 20 requestTimeout = 50 * time.Millisecond ) for _, handler := range handlers { for range iterations { ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) handler(httptest.NewRecorder(), req) cancel() } } // Let the blocked queries finish; with buffered channels each goroutine's // send now succeeds and the goroutine exits. close(release) if !waitForGoroutines(baseline) { t.Fatalf("goroutines did not return to baseline %d, got %d", baseline, runtime.NumGoroutine()) } } // settledGoroutineCount lets transient goroutines finish, then reports the // current count. func settledGoroutineCount() int { prev := runtime.NumGoroutine() for range 20 { time.Sleep(10 * time.Millisecond) cur := runtime.NumGoroutine() if cur == prev { return cur } prev = cur } return prev } // waitForGoroutines waits until the goroutine count drops to target or below. func waitForGoroutines(target int) bool { for range 100 { if runtime.NumGoroutine() <= target { return true } time.Sleep(10 * time.Millisecond) } return false }