The stats handlers ran the database query in a goroutine that sent on an unbuffered channel. When the 4s request timeout won, nothing received and the goroutine blocked forever; the status page polls every 2s, so once the query exceeds the timeout every poll leaked one goroutine. Give both channels capacity 1 so the send always completes. The streamer started two ticker goroutines per connection that exited only with the streamer's lifetime context, leaking two on every reconnect. Scope them to a per-connection context cancelled when the stream call returns. Tests force the stats timeout repeatedly and drive many reconnects, then assert the goroutine count settles back to its starting value. The streamer gains an internal endpoint field so a test can point it at a local server. Model: opus-4-8
105 lines
2.6 KiB
Go
105 lines
2.6 KiB
Go
package streamer
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
)
|
|
|
|
func TestNewStreamer(t *testing.T) {
|
|
logger := logger.New()
|
|
metricsTracker := metrics.New()
|
|
s := New(logger, metricsTracker)
|
|
|
|
if s == nil {
|
|
t.Fatal("New() returned nil")
|
|
}
|
|
|
|
if s.logger != logger {
|
|
t.Error("logger not set correctly")
|
|
}
|
|
|
|
if s.client == nil {
|
|
t.Error("HTTP client not initialized")
|
|
}
|
|
|
|
if s.handlers == nil {
|
|
t.Error("handlers slice not initialized")
|
|
}
|
|
|
|
if s.metrics != metricsTracker {
|
|
t.Error("metrics tracker not set correctly")
|
|
}
|
|
}
|
|
|
|
// TestStreamDoesNotLeakTickersAcrossReconnects drives many short-lived
|
|
// connections (each stream call is one reconnect cycle) and asserts the
|
|
// goroutine count returns to its starting value. Each connection starts two
|
|
// ticker goroutines; before the fix they lived until the streamer's lifetime
|
|
// context was cancelled, so every reconnect leaked two.
|
|
func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
|
|
// The handler returns immediately, so the response body is empty and each
|
|
// stream call ends at once, standing in for a dropped connection.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
|
|
defer srv.Close()
|
|
|
|
s := New(logger.New(), metrics.New())
|
|
s.url = srv.URL
|
|
|
|
// One warm-up connection so any persistent HTTP transport goroutine exists
|
|
// before we take the baseline.
|
|
if err := s.stream(context.Background()); err != nil {
|
|
t.Fatalf("warm-up stream returned error: %v", err)
|
|
}
|
|
s.client.CloseIdleConnections()
|
|
|
|
baseline := settledGoroutineCount()
|
|
|
|
const reconnects = 20
|
|
for range reconnects {
|
|
if err := s.stream(context.Background()); err != nil {
|
|
t.Fatalf("stream returned error: %v", err)
|
|
}
|
|
}
|
|
s.client.CloseIdleConnections()
|
|
|
|
if !waitForGoroutines(baseline) {
|
|
t.Fatalf("goroutines did not return to baseline %d after %d reconnects, got %d",
|
|
baseline, reconnects, 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
|
|
}
|