check / check (push) Successful in 2m40s
Stop cancels the stream and closes the handler queues under the streamer's write lock, but the read loop only checked for a stop before parsing each line. A stop landing after that check made the loop send the message to a closed queue and panic. The loop now checks again under the read lock it already takes before handing the message to the queues, and returns if the stream was stopped. Stop also clears its cancel function and returns early when there is none, so a second call no longer closes the queues again, as its comment already promised. The new test calls Stop from the raw handler, which runs in exactly that gap, and then calls Stop a second time. Model: opus-5-5
147 lines
4.2 KiB
Go
147 lines
4.2 KiB
Go
package streamer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
)
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
// updateHandler wants UPDATE messages and does nothing with them.
|
|
type updateHandler struct{}
|
|
|
|
func (updateHandler) WantsMessage(messageType string) bool { return messageType == "UPDATE" }
|
|
func (updateHandler) HandleMessage(*ristypes.RISMessage) {}
|
|
func (updateHandler) QueueCapacity() int { return 10 }
|
|
|
|
// TestStopBeforeMessageReachesQueues stops the streamer after the read loop
|
|
// has checked for cancellation but before it hands the message to the handler
|
|
// queues. That is the gap a stop from another goroutine can land in, and it
|
|
// used to end in "send on closed channel". The raw handler runs in that gap on
|
|
// the read loop itself, so calling Stop from it hits the gap every time.
|
|
func TestStopBeforeMessageReachesQueues(t *testing.T) {
|
|
const line = `{"type":"ris_message","data":{"type":"UPDATE","peer":"192.0.2.1",` +
|
|
`"peer_asn":"64496","timestamp":1700000000}}` + "\n"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, line)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := New(logger.New(), metrics.New())
|
|
s.url = srv.URL
|
|
s.RegisterHandler(updateHandler{})
|
|
s.RegisterRawHandler(func(string) { s.Stop() })
|
|
|
|
// Start would run the stream in the background, where the test cannot
|
|
// wait for it. Setting cancel as Start does lets Stop cancel the stream
|
|
// run here instead.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.cancel = cancel
|
|
|
|
if err := s.stream(ctx); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("stream returned %v, want %v", err, context.Canceled)
|
|
}
|
|
|
|
// A second Stop must not close the queues again.
|
|
s.Stop()
|
|
}
|
|
|
|
// 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
|
|
}
|