From 02b7dd8e797ac431ee51b2f01b2898c4c14927fb Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 5 Nov 2026 10:43:06 +0100 Subject: [PATCH 1/2] fix: bound multicast writes so a stuck socket cannot hang Shutdown On macOS with many multicast interfaces a burst of per-interface writes can wedge the shared UDP socket: sendmsg first fails with ENETUNREACH, then blocks forever. Server.Shutdown calls unregister, which multicasts synchronously, so it never returns and every caller hangs with it (e.g. ship-go's mdns tests time out after 10 minutes). Set a 100ms write deadline per packet and treat a deadline exceeded error like a gone interface, so the interface manager backs it off instead of stalling every following send. Co-Authored-By: Claude Opus 5.5 --- conn_deadline_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++ conn_ipv4.go | 10 +++++++ conn_ipv6.go | 7 +++++ error_classify.go | 6 +++++ error_classify_test.go | 10 +++++++ 5 files changed, 94 insertions(+) create mode 100644 conn_deadline_test.go diff --git a/conn_deadline_test.go b/conn_deadline_test.go new file mode 100644 index 0000000..d492320 --- /dev/null +++ b/conn_deadline_test.go @@ -0,0 +1,61 @@ +package zeroconf + +import ( + "net" + "testing" + "time" + + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +// deadlineConn records the write deadline set on a real UDP socket +type deadlineConn struct { + *net.UDPConn + deadline time.Time +} + +func (c *deadlineConn) SetWriteDeadline(t time.Time) error { + c.deadline = t + return c.UDPConn.SetWriteDeadline(t) +} + +// every write must be bounded, a blocked interface would otherwise stall the caller forever +func TestWriteToSetsDeadline(t *testing.T) { + for _, tc := range []struct { + network, addr string + wrap func(net.PacketConn) interface { + WriteTo([]byte, int, net.Addr) (int, error) + } + }{ + {"udp4", "127.0.0.1:0", func(c net.PacketConn) interface { + WriteTo([]byte, int, net.Addr) (int, error) + } { + return newIPv4PacketConn(ipv4.NewPacketConn(c)) + }}, + {"udp6", "[::1]:0", func(c net.PacketConn) interface { + WriteTo([]byte, int, net.Addr) (int, error) + } { + return newIPv6PacketConn(ipv6.NewPacketConn(c)) + }}, + } { + t.Run(tc.network, func(t *testing.T) { + udp, err := net.ListenPacket(tc.network, tc.addr) + if err != nil { + t.Skipf("%s not available: %v", tc.network, err) + } + defer udp.Close() + + conn := &deadlineConn{UDPConn: udp.(*net.UDPConn)} + start := time.Now() + + if _, err := tc.wrap(conn).WriteTo([]byte("x"), 0, udp.LocalAddr()); err != nil { + t.Fatalf("write: %v", err) + } + + if conn.deadline.Before(start) || conn.deadline.After(time.Now().Add(writeTimeout)) { + t.Fatalf("deadline %v not within write timeout of %v", conn.deadline, start) + } + }) + } +} diff --git a/conn_ipv4.go b/conn_ipv4.go index 6acc847..7457a7d 100644 --- a/conn_ipv4.go +++ b/conn_ipv4.go @@ -5,11 +5,15 @@ import ( "net" "runtime" "syscall" + "time" "github.com/enbility/zeroconf/v3/api" "golang.org/x/net/ipv4" ) +// writeTimeout bounds a single multicast write, a healthy interface completes it in microseconds +const writeTimeout = 100 * time.Millisecond + // ipv4PacketConn wraps ipv4.PacketConn to implement api.PacketConn interface. // This adapter is needed because ipv4.PacketConn uses ControlMessage for // interface selection, but we only need the IfIndex field. @@ -64,6 +68,12 @@ func (c *ipv4PacketConn) WriteTo(b []byte, ifIndex int, dst net.Addr) (n int, er } } + // a multicast write can block forever when the socket stops draining (seen on macOS with + // many interfaces), stalling every later announcement and Shutdown: drop the packet instead + if err := c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { + return 0, err + } + return c.conn.WriteTo(b, cm, dst) } diff --git a/conn_ipv6.go b/conn_ipv6.go index c723148..dbb7c5d 100644 --- a/conn_ipv6.go +++ b/conn_ipv6.go @@ -5,6 +5,7 @@ import ( "net" "runtime" "syscall" + "time" "github.com/enbility/zeroconf/v3/api" "golang.org/x/net/ipv6" @@ -64,6 +65,12 @@ func (c *ipv6PacketConn) WriteTo(b []byte, ifIndex int, dst net.Addr) (n int, er } } + // a multicast write can block forever when the socket stops draining (seen on macOS with + // many interfaces), stalling every later announcement and Shutdown: drop the packet instead + if err := c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { + return 0, err + } + return c.conn.WriteTo(b, cm, dst) } diff --git a/error_classify.go b/error_classify.go index d70fb8d..4fbbec2 100644 --- a/error_classify.go +++ b/error_classify.go @@ -2,6 +2,7 @@ package zeroconf import ( "errors" + "os" "strings" "syscall" ) @@ -39,6 +40,11 @@ func isInterfaceGone(err error) bool { return false } + // a timed out write would stall the next send again, back off the interface like a gone one + if errors.Is(err, os.ErrDeadlineExceeded) { + return true + } + // Check known error codes for _, e := range interfaceGoneErrors { if errors.Is(err, e) { diff --git a/error_classify_test.go b/error_classify_test.go index 1b37258..841bb7a 100644 --- a/error_classify_test.go +++ b/error_classify_test.go @@ -3,6 +3,8 @@ package zeroconf import ( "errors" "fmt" + "net" + "os" "syscall" "testing" ) @@ -129,3 +131,11 @@ func TestIsInterfaceGone_FallbackMessageParsing_CaseInsensitive(t *testing.T) { t.Errorf("expected true for uppercase 'NETWORK IS DOWN', got false") } } + +func TestIsInterfaceGone_WriteDeadlineExceeded_ReturnsTrue(t *testing.T) { + // a multicast write that timed out never drains, the interface must back off + err := &net.OpError{Op: "write", Net: "udp", Err: os.ErrDeadlineExceeded} + if !isInterfaceGone(err) { + t.Errorf("expected true for write deadline exceeded, got false") + } +} From 1f38b29042a17c7db718746cc509c1e50e37007d Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 5 Nov 2026 10:45:32 +0100 Subject: [PATCH 2/2] test: check udp close in write deadline test Co-Authored-By: Claude Opus 5.5 --- conn_deadline_test.go | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/conn_deadline_test.go b/conn_deadline_test.go index d492320..72f1b99 100644 --- a/conn_deadline_test.go +++ b/conn_deadline_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/enbility/zeroconf/v3/api" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" ) @@ -20,31 +21,21 @@ func (c *deadlineConn) SetWriteDeadline(t time.Time) error { return c.UDPConn.SetWriteDeadline(t) } -// every write must be bounded, a blocked interface would otherwise stall the caller forever +// every write must be bounded, a stuck socket would otherwise stall the caller forever func TestWriteToSetsDeadline(t *testing.T) { for _, tc := range []struct { network, addr string - wrap func(net.PacketConn) interface { - WriteTo([]byte, int, net.Addr) (int, error) - } + wrap func(net.PacketConn) api.PacketConn }{ - {"udp4", "127.0.0.1:0", func(c net.PacketConn) interface { - WriteTo([]byte, int, net.Addr) (int, error) - } { - return newIPv4PacketConn(ipv4.NewPacketConn(c)) - }}, - {"udp6", "[::1]:0", func(c net.PacketConn) interface { - WriteTo([]byte, int, net.Addr) (int, error) - } { - return newIPv6PacketConn(ipv6.NewPacketConn(c)) - }}, + {"udp4", "127.0.0.1:0", func(c net.PacketConn) api.PacketConn { return newIPv4PacketConn(ipv4.NewPacketConn(c)) }}, + {"udp6", "[::1]:0", func(c net.PacketConn) api.PacketConn { return newIPv6PacketConn(ipv6.NewPacketConn(c)) }}, } { t.Run(tc.network, func(t *testing.T) { udp, err := net.ListenPacket(tc.network, tc.addr) if err != nil { t.Skipf("%s not available: %v", tc.network, err) } - defer udp.Close() + t.Cleanup(func() { _ = udp.Close() }) conn := &deadlineConn{UDPConn: udp.(*net.UDPConn)} start := time.Now()