diff --git a/server.go b/server.go index 434ed2b..b308466 100644 --- a/server.go +++ b/server.go @@ -6,6 +6,7 @@ import ( "crypto/ed25519" "encoding/binary" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -520,9 +521,19 @@ func (s *Server) readLoopBatches(conn *net.UDPConn) (err error, done bool) { for { n, rerr := pc.ReadBatch(msgs, 0) if rerr != nil { - if opErr, ok := rerr.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" { + // A closed socket, or a server being closed, ends the loop. On + // the Linux recvmmsg path the error is wrapped twice ("read udp + // ...: raw-read udp4 ...: use of closed network connection"), + // so comparing the inner error's text never matched there and + // every reader spun on its closed socket after Close. + if errors.Is(rerr, net.ErrClosed) { return nil, true } + select { + case <-s.done: + return nil, true + default: + } slog.Debug("beacon read batch error", "err", rerr) continue } diff --git a/zz_close_readers_test.go b/zz_close_readers_test.go new file mode 100644 index 0000000..eb85e0c --- /dev/null +++ b/zz_close_readers_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package beacon + +import ( + "runtime" + "testing" + "time" +) + +// TestCloseStopsReaders: Close ends every UDP reader. On Linux the +// recvmmsg path wraps the closed-socket error twice, which the old check +// (the inner error's exact text) never matched, so all 2Ă—NumCPU readers +// kept spinning on their closed sockets for the life of the process. +func TestCloseStopsReaders(t *testing.T) { + runtime.GC() + base := runtime.NumGoroutine() + + s := New() + go func() { _ = s.ListenAndServe("127.0.0.1:0") }() + select { + case <-s.Ready(): + case <-time.After(5 * time.Second): + t.Fatal("beacon did not start") + } + time.Sleep(100 * time.Millisecond) + running := runtime.NumGoroutine() + + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + deadline := time.Now().Add(5 * time.Second) + for { + n := runtime.NumGoroutine() + if n <= base+2 { + t.Logf("goroutines: base=%d running=%d after Close=%d", base, running, n) + return + } + if time.Now().After(deadline) { + buf := make([]byte, 1<<16) + t.Fatalf("goroutines still running after Close: base=%d running=%d now=%d\n%s", + base, running, n, buf[:runtime.Stack(buf, true)]) + } + time.Sleep(20 * time.Millisecond) + } +}