From b72f4053409fd15866819ab719f76cd9348ad495 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 24 Sep 2026 10:31:29 +0300 Subject: [PATCH] fix(server): UDP readers exit when the server is closed readLoopBatches recognised a closed socket by comparing the inner error's text to "use of closed network connection". On the Linux recvmmsg path the error is wrapped twice ("read udp ...: raw-read udp4 ...: use of closed network connection"), so the comparison never matched: after Close every reader (2 x NumCPU of them) logged at Debug and retried the closed socket forever, a hot loop per core for the rest of the process. Match net.ErrClosed with errors.Is, and also end the loop on any read error once Close has begun (s.done). TestCloseStopsReaders starts a server, closes it and waits for the goroutine count to return to its baseline. On Linux (10 CPUs, 20 readers) it fails without this change ("base=2 running=36 now=22") and passes with it ("after Close=2"). Co-Authored-By: Claude Opus 5.5 (1M context) --- server.go | 13 +++++++++++- zz_close_readers_test.go | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 zz_close_readers_test.go 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) + } +}