From c44e551fbfe1ebfb2dc4ebd4bc9b7e6109849054 Mon Sep 17 00:00:00 2001 From: john cohn Date: Tue, 18 Aug 2026 22:07:24 -0400 Subject: [PATCH] don't log the deliberate multicast re-bind as a socket error udpControlSupervisor closes the listener every udpRebindInterval to force a fresh group join. That close is exactly what unblocks ReadFrom, so the read loop always ends up in its error branch and logs the close it was asked to cause -- a multicast listener printed kutta: UDP control: read udp 0.0.0.0:9000: use of closed network connection every 30s for as long as it ran. On an exhibit Pi left up for an afternoon that was 375 lines of routine maintenance reported as failure, and it reads convincingly like a listener that has died, which is misleading in exactly the situation the log is there to help with. Pass the read loop an atomic.Bool the supervisor sets just before a deliberate close, so it can tell that case apart from a socket that failed on its own. Real errors still log, including on the multicast path: the flag is only ever set for the re-bind. Unicast callers pass nil, since they never close early. Co-Authored-By: Claude Opus 5 --- udpcontrol.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/udpcontrol.go b/udpcontrol.go index 05f3cc8..068dbfa 100644 --- a/udpcontrol.go +++ b/udpcontrol.go @@ -6,6 +6,7 @@ import ( "net" "strconv" "strings" + "sync/atomic" "time" ) @@ -52,13 +53,16 @@ func (g *Game) startUDPControl(addr string) error { // membership (there is none), so it only ever runs the plain read loop. func (g *Game) udpControlSupervisor(addr string, conn net.PacketConn) { if !isMulticastAddr(addr) { - g.udpControlLoop(conn) + g.udpControlLoop(conn, nil) return } for { done := make(chan struct{}) + // Set just before a deliberate close, so the read loop can tell the + // rebind below apart from a socket that failed on its own. + var rebinding atomic.Bool go func(c net.PacketConn) { - g.udpControlLoop(c) + g.udpControlLoop(c, &rebinding) close(done) }(conn) @@ -69,6 +73,7 @@ func (g *Game) udpControlSupervisor(addr string, conn net.PacketConn) { case <-time.After(udpRebindInterval): // Closing is how the read loop is unblocked, so a close error has // nowhere useful to go: the socket is being replaced regardless. + rebinding.Store(true) _ = conn.Close() <-done } @@ -113,12 +118,22 @@ func isMulticastAddr(addr string) bool { // udpControlLoop reads packets until the socket errors (typically only on // shutdown) or the process exits; each packet may hold one or more // newline-separated messages. -func (g *Game) udpControlLoop(conn net.PacketConn) { +// +// rebinding, when non-nil, reports that the caller is about to close conn on +// purpose (the multicast re-bind in udpControlSupervisor). The close is what +// unblocks ReadFrom, so that path always ends in an error, and logging it +// would report kutta's own routine maintenance as a fault: a multicast +// listener printed "use of closed network connection" every udpRebindInterval +// for as long as it ran. Unicast callers pass nil -- they never close early, +// so any error there is real. +func (g *Game) udpControlLoop(conn net.PacketConn, rebinding *atomic.Bool) { buf := make([]byte, 512) for { n, _, err := conn.ReadFrom(buf) if err != nil { - log.Printf("kutta: UDP control: %v", err) + if rebinding == nil || !rebinding.Load() { + log.Printf("kutta: UDP control: %v", err) + } return } sc := bufio.NewScanner(strings.NewReader(string(buf[:n])))