From 3fe91ea6e27858d81836b69f2cc8a472bf2d961e Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:46:13 -0500 Subject: [PATCH 1/3] net: report the kernel-assigned port after listening on port 0 Listening on ":0" asks the kernel for an ephemeral port, but the listener kept the requested address, so ln.Addr() reported port 0 and dialing it could not work. Add GetSockname to the host netdev and look it up from listenTCP through an optional interface, so netdevs that cannot report the bound address keep their previous behavior. ListenUDP already fills in a port before binding. --- netdev_native.go | 30 ++++++++++++++++++++++-------- tcpsock.go | 13 +++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/netdev_native.go b/netdev_native.go index a8a9301..b6b4c67 100644 --- a/netdev_native.go +++ b/netdev_native.go @@ -176,15 +176,29 @@ func (*hostNetdev) Accept(sockfd int) (int, netip.AddrPort, error) { default: return -1, netip.AddrPort{}, err } - var raddr netip.AddrPort - switch s := sa.(type) { - case *syscall.SockaddrInet4: - raddr = netip.AddrPortFrom(netip.AddrFrom4(s.Addr), uint16(s.Port)) - case *syscall.SockaddrInet6: - raddr = netip.AddrPortFrom(netip.AddrFrom16(s.Addr), uint16(s.Port)) - } - return nfd, raddr, nil + return nfd, addrPortFromSockaddr(sa), nil + } +} + +// addrPortFromSockaddr converts a Sockaddr from Accept or Getsockname. +func addrPortFromSockaddr(sa syscall.Sockaddr) netip.AddrPort { + switch s := sa.(type) { + case *syscall.SockaddrInet4: + return netip.AddrPortFrom(netip.AddrFrom4(s.Addr), uint16(s.Port)) + case *syscall.SockaddrInet6: + return netip.AddrPortFrom(netip.AddrFrom16(s.Addr), uint16(s.Port)) + } + return netip.AddrPort{} +} + +// GetSockname reports the bound local address, including a port the kernel +// picked for port 0. +func (*hostNetdev) GetSockname(sockfd int) (netip.AddrPort, error) { + sa, err := syscall.Getsockname(sockfd) + if err != nil { + return netip.AddrPort{}, err } + return addrPortFromSockaddr(sa), nil } func (*hostNetdev) Send(sockfd int, buf []byte, flags int, deadline time.Time) (int, error) { diff --git a/tcpsock.go b/tcpsock.go index cc0b180..0a863d2 100644 --- a/tcpsock.go +++ b/tcpsock.go @@ -413,6 +413,19 @@ func listenTCP(laddr *TCPAddr) (Listener, error) { return nil, err } + if laddr.Port == 0 { + // Binding port 0 asks for an ephemeral port; report the port that was + // actually assigned, like the standard library, so callers can dial + // ln.Addr(). Netdevs that cannot report it keep port 0. + if g, ok := netdev.(interface { + GetSockname(sockfd int) (netip.AddrPort, error) + }); ok { + if ap, err := g.GetSockname(fd); err == nil && ap.Port() != 0 { + laddr = &TCPAddr{IP: laddr.IP, Port: int(ap.Port())} + } + } + } + return &listener{fd: fd, laddr: laddr}, nil } From cb9875abe79c54f63f7113468b2886d9ef96d7e5 Mon Sep 17 00:00:00 2001 From: yohimik Date: Sat, 5 Sep 2026 06:43:12 +0400 Subject: [PATCH 2/3] net: preserve listener zone when applying the bound port (cherry picked from commit 0f460803c832e5edad095ce02731f1000496dd88) --- ephemeral_darwin_test.go | 18 ++++++++++ ephemeral_native_test.go | 78 ++++++++++++++++++++++++++++++++++++++++ netdev_native.go | 4 +-- tcpsock.go | 7 ++-- 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 ephemeral_darwin_test.go create mode 100644 ephemeral_native_test.go diff --git a/ephemeral_darwin_test.go b/ephemeral_darwin_test.go new file mode 100644 index 0000000..d987546 --- /dev/null +++ b/ephemeral_darwin_test.go @@ -0,0 +1,18 @@ +//go:build darwin && !baremetal && !tinygo.wasm + +package net + +import ( + "syscall" + "testing" +) + +func TestEphemeralAcceptedNoSigpipe(t *testing.T) { + client, server := ephemeralTestPair(t) + defer client.Close() + defer server.Close() + value, err := syscall.GetsockoptInt(server.(*TCPConn).fd, syscall.SOL_SOCKET, syscall.SO_NOSIGPIPE) + if err != nil || value != 1 { + t.Fatalf("accepted SO_NOSIGPIPE=%d error=%v", value, err) + } +} diff --git a/ephemeral_native_test.go b/ephemeral_native_test.go new file mode 100644 index 0000000..eb3a7bf --- /dev/null +++ b/ephemeral_native_test.go @@ -0,0 +1,78 @@ +//go:build (linux || darwin) && !baremetal && !tinygo.wasm + +package net + +import ( + "net/netip" + "testing" + "time" +) + +func ephemeralTestPair(t *testing.T) (Conn, Conn) { + t.Helper() + l, err := Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + if l.Addr().(*TCPAddr).Port == 0 { + t.Fatalf("listener reports port 0: %v", l.Addr()) + } + client, err := Dial("tcp4", l.Addr().String()) + if err != nil { + t.Fatal(err) + } + server, err := l.Accept() + if err != nil { + client.Close() + t.Fatal(err) + } + return client, server +} + +func TestEphemeralListenDial(t *testing.T) { + client, server := ephemeralTestPair(t) + defer client.Close() + defer server.Close() + server.SetReadDeadline(time.Now().Add(time.Second)) + if _, err := client.Write([]byte("x")); err != nil { + t.Fatal(err) + } + b := make([]byte, 1) + if n, err := server.Read(b); n != 1 || err != nil || b[0] != 'x' { + t.Fatalf("read=%d %q error=%v", n, b, err) + } +} + +// ephemeralTestNetdev lets listenTCP succeed without a kernel socket, and +// reports a fixed bound address so the applied port and zone are predictable. +type ephemeralTestNetdev struct { + nopNetdev +} + +func (*ephemeralTestNetdev) Socket(int, int, int) (int, error) { return 42, nil } +func (*ephemeralTestNetdev) Bind(int, netip.AddrPort) error { return nil } +func (*ephemeralTestNetdev) Listen(int, int) error { return nil } +func (*ephemeralTestNetdev) Close(int) error { return nil } +func (*ephemeralTestNetdev) GetSockname(int) (netip.AddrPort, error) { + return netip.MustParseAddrPort("[fe80::1]:12345"), nil +} + +func TestEphemeralPreservesZone(t *testing.T) { + previous := netdev + defer func() { netdev = previous }() + netdev = &ephemeralTestNetdev{} + addr := &TCPAddr{IP: ParseIP("fe80::1"), Zone: "test-zone"} + l, err := listenTCP(addr) + if err != nil { + t.Fatal(err) + } + defer l.Close() + got := l.Addr().(*TCPAddr) + if got.Port != 12345 || got.Zone != addr.Zone || !got.IP.Equal(addr.IP) { + t.Fatalf("bound address=%v, want [%s%%%s]:12345", got, addr.IP, addr.Zone) + } + if addr.Port != 0 { + t.Fatalf("input address changed: %v", addr) + } +} diff --git a/netdev_native.go b/netdev_native.go index b6b4c67..9be48c0 100644 --- a/netdev_native.go +++ b/netdev_native.go @@ -191,8 +191,8 @@ func addrPortFromSockaddr(sa syscall.Sockaddr) netip.AddrPort { return netip.AddrPort{} } -// GetSockname reports the bound local address, including a port the kernel -// picked for port 0. +// GetSockname reports the bound address. +// See https://pkg.go.dev/syscall#Getsockname. func (*hostNetdev) GetSockname(sockfd int) (netip.AddrPort, error) { sa, err := syscall.Getsockname(sockfd) if err != nil { diff --git a/tcpsock.go b/tcpsock.go index 0a863d2..4afa2ca 100644 --- a/tcpsock.go +++ b/tcpsock.go @@ -414,14 +414,13 @@ func listenTCP(laddr *TCPAddr) (Listener, error) { } if laddr.Port == 0 { - // Binding port 0 asks for an ephemeral port; report the port that was - // actually assigned, like the standard library, so callers can dial - // ln.Addr(). Netdevs that cannot report it keep port 0. + // Report the selected port. See https://pkg.go.dev/net#Listen. + // Drivers without GetSockname keep the requested address. if g, ok := netdev.(interface { GetSockname(sockfd int) (netip.AddrPort, error) }); ok { if ap, err := g.GetSockname(fd); err == nil && ap.Port() != 0 { - laddr = &TCPAddr{IP: laddr.IP, Port: int(ap.Port())} + laddr = &TCPAddr{IP: laddr.IP, Port: int(ap.Port()), Zone: laddr.Zone} } } } From 402f50afc7f018cec9028af0112d2742f717fe19 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:42:51 -0500 Subject: [PATCH 3/3] net: report the bound port from ListenTCP too ListenTCP has its own socket, bind and listen path, so net.ListenTCP("tcp", &TCPAddr{}) still reported port 0. Both paths now call boundTCPAddr. The tests are linux only, since this repo has a host netdev only for linux. The darwin SO_NOSIGPIPE test needed #74 and is removed. --- ephemeral_darwin_test.go | 18 ------------------ ephemeral_native_test.go | 29 ++++++++++++++++++++++++++++- tcpsock.go | 33 ++++++++++++++++++++------------- 3 files changed, 48 insertions(+), 32 deletions(-) delete mode 100644 ephemeral_darwin_test.go diff --git a/ephemeral_darwin_test.go b/ephemeral_darwin_test.go deleted file mode 100644 index d987546..0000000 --- a/ephemeral_darwin_test.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build darwin && !baremetal && !tinygo.wasm - -package net - -import ( - "syscall" - "testing" -) - -func TestEphemeralAcceptedNoSigpipe(t *testing.T) { - client, server := ephemeralTestPair(t) - defer client.Close() - defer server.Close() - value, err := syscall.GetsockoptInt(server.(*TCPConn).fd, syscall.SOL_SOCKET, syscall.SO_NOSIGPIPE) - if err != nil || value != 1 { - t.Fatalf("accepted SO_NOSIGPIPE=%d error=%v", value, err) - } -} diff --git a/ephemeral_native_test.go b/ephemeral_native_test.go index eb3a7bf..31b4f9b 100644 --- a/ephemeral_native_test.go +++ b/ephemeral_native_test.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin) && !baremetal && !tinygo.wasm +//go:build linux && !baremetal && !tinygo.wasm package net @@ -76,3 +76,30 @@ func TestEphemeralPreservesZone(t *testing.T) { t.Fatalf("input address changed: %v", addr) } } + +func TestEphemeralListenTCP(t *testing.T) { + l, err := ListenTCP("tcp", &TCPAddr{IP: IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer l.Close() + if l.Addr().(*TCPAddr).Port == 0 { + t.Fatalf("ListenTCP reports port 0: %v", l.Addr()) + } +} + +func TestEphemeralListenTCPPreservesZone(t *testing.T) { + previous := netdev + defer func() { netdev = previous }() + netdev = &ephemeralTestNetdev{} + addr := &TCPAddr{IP: ParseIP("fe80::1"), Zone: "test-zone"} + l, err := ListenTCP("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer l.Close() + got := l.Addr().(*TCPAddr) + if got.Port != 12345 || got.Zone != addr.Zone || addr.Port != 0 { + t.Fatalf("bound address=%v input=%v", got, addr) + } +} diff --git a/tcpsock.go b/tcpsock.go index 4afa2ca..10c6f36 100644 --- a/tcpsock.go +++ b/tcpsock.go @@ -413,19 +413,26 @@ func listenTCP(laddr *TCPAddr) (Listener, error) { return nil, err } - if laddr.Port == 0 { - // Report the selected port. See https://pkg.go.dev/net#Listen. - // Drivers without GetSockname keep the requested address. - if g, ok := netdev.(interface { - GetSockname(sockfd int) (netip.AddrPort, error) - }); ok { - if ap, err := g.GetSockname(fd); err == nil && ap.Port() != 0 { - laddr = &TCPAddr{IP: laddr.IP, Port: int(ap.Port()), Zone: laddr.Zone} - } - } - } + return &listener{fd: fd, laddr: boundTCPAddr(fd, laddr)}, nil +} - return &listener{fd: fd, laddr: laddr}, nil +// boundTCPAddr reports the port the kernel picked for port 0. See https://pkg.go.dev/net#Listen. +// Drivers without GetSockname keep the requested address. +func boundTCPAddr(fd int, laddr *TCPAddr) *TCPAddr { + if laddr.Port != 0 { + return laddr + } + g, ok := netdev.(interface { + GetSockname(sockfd int) (netip.AddrPort, error) + }) + if !ok { + return laddr + } + ap, err := g.GetSockname(fd) + if err != nil || ap.Port() == 0 { + return laddr + } + return &TCPAddr{IP: laddr.IP, Port: int(ap.Port()), Zone: laddr.Zone} } // TCPListener is a TCP network listener. Clients should typically @@ -467,7 +474,7 @@ func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error) { return nil, err } - return &TCPListener{listener{fd: fd, laddr: laddr}}, nil + return &TCPListener{listener{fd: fd, laddr: boundTCPAddr(fd, laddr)}}, nil } // AcceptTCP accepts the next incoming call and returns the new