Dual-stack UDP socket bound to port 0 can get a port already in use on 127.0.0.1 (FB25058707)

I filed FB25058707 and wanted to make sure it reaches the right people, because it causes silent datagram loss that's hard to trace.

On macOS, when a dual-stack UDP socket (AF_INET6 with IPV6_V6ONLY=0) binds [::]:0, the kernel sometimes gives it a port that an AF_INET socket already has bound to 127.0.0.1. Datagrams sent to 127.0.0.1 on that port then go to the AF_INET socket, so the dual-stack socket never receives them. An explicit bind of the same socket to that port fails with EADDRINUSE; only port-0 assignment hands it out.

This program binds 1000 AF_INET sockets to 127.0.0.1:0, then binds dual-stack sockets to [::]:0 and checks where a datagram to 127.0.0.1:port lands whenever the port is already held. It's loopback only and runs in a few seconds:

/*
 * A dual-stack UDP socket (AF_INET6, IPV6_V6ONLY=0) bound to [::]:0 can be
 * assigned a port already bound by an AF_INET socket on 127.0.0.1. Datagrams
 * to 127.0.0.1:port then reach the AF_INET socket, not the dual-stack one.
 * Loopback only. Build: cc -O2 -o dualstack_port0 dualstack_port0.c
 */
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>

#define HELD 1000
#define TRIALS 2000

static int held[65536];

static void die(const char *what) { perror(what); exit(1); }

static int port_of(int s) {
	struct sockaddr_storage ss;
	socklen_t len = sizeof(ss);
	if (getsockname(s, (struct sockaddr *)&ss, &len) != 0) die("getsockname");
	if (ss.ss_family == AF_INET) return ntohs(((struct sockaddr_in *)&ss)->sin_port);
	return ntohs(((struct sockaddr_in6 *)&ss)->sin6_port);
}

static int bind_v4(in_addr_t addr) {
	int s = socket(AF_INET, SOCK_DGRAM, 0);
	if (s < 0) die("socket AF_INET");
	struct sockaddr_in sin = { .sin_len = sizeof(sin), .sin_family = AF_INET, .sin_addr.s_addr = addr };
	if (bind(s, (struct sockaddr *)&sin, sizeof(sin)) != 0) die("bind AF_INET");
	return s;
}

static int bind_dual(void) {
	int s = socket(AF_INET6, SOCK_DGRAM, 0), off = 0;
	if (s < 0) die("socket AF_INET6");
	if (setsockopt(s, IPPROTO_IPV6, IPV6_V6ONLY, &off, sizeof(off)) != 0) die("IPV6_V6ONLY");
	struct sockaddr_in6 sin6 = { .sin6_len = sizeof(sin6), .sin6_family = AF_INET6, .sin6_addr = in6addr_any };
	if (bind(s, (struct sockaddr *)&sin6, sizeof(sin6)) != 0) die("bind AF_INET6");
	return s;
}

/* Returns 1 if s receives token within 100ms, skipping other datagrams. */
static int got(int s, const char *token) {
	struct timeval tv = { .tv_sec = 0, .tv_usec = 100000 };
	if (setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) die("SO_RCVTIMEO");
	char buf[64];
	for (;;) {
		ssize_t n = recv(s, buf, sizeof(buf) - 1, 0);
		if (n < 0) {
			if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
			die("recv");
		}
		buf[n] = 0;
		if (strcmp(buf, token) == 0) return 1;
	}
}

int main(void) {
	struct rlimit rl;
	if (getrlimit(RLIMIT_NOFILE, &rl) != 0) die("getrlimit");
	rl.rlim_cur = rl.rlim_max < 4096 ? rl.rlim_max : 4096;
	if (setrlimit(RLIMIT_NOFILE, &rl) != 0) die("setrlimit");
	memset(held, -1, sizeof(held));
	for (int i = 0; i < HELD;) {
		int s = bind_v4(htonl(INADDR_LOOPBACK)), p = port_of(s);
		if (held[p] >= 0) { close(s); continue; }
		held[p] = s;
		i++;
	}
	int sender = bind_v4(htonl(INADDR_LOOPBACK));
	for (int dual = 1; dual >= 0; dual--) {
		int collisions = 0, to_held = 0, to_wild = 0;
		for (int i = 0; i < TRIALS; i++) {
			int w = dual ? bind_dual() : bind_v4(htonl(INADDR_ANY)), p = port_of(w);
			if (held[p] >= 0) {
				collisions++;
				char token[32];
				snprintf(token, sizeof(token), "%d-%d", dual, i);
				struct sockaddr_in to = { .sin_len = sizeof(to), .sin_family = AF_INET, .sin_port = htons(p), .sin_addr.s_addr = htonl(INADDR_LOOPBACK) };
				if (sendto(sender, token, strlen(token), 0, (struct sockaddr *)&to, sizeof(to)) < 0) die("sendto");
				to_held += got(held[p], token);
				to_wild += got(w, token);
			}
			close(w);
		}
		printf("%s: %d/%d wildcard binds got a port held by a 127.0.0.1 socket; the probe reached that socket %d times, the wildcard socket %d times\n",
		    dual ? "AF_INET6 V6ONLY=0 [::]:0" : "AF_INET 0.0.0.0:0      ", collisions, TRIALS, to_held, to_wild);
	}
	return 0;
}

On macOS 27.0 (26A428):

AF_INET6 V6ONLY=0 [::]:0: 122/2000 wildcard binds got a port held by a 127.0.0.1 socket; the probe reached that socket 122 times, the wildcard socket 0 times
AF_INET 0.0.0.0:0      : 0/2000 wildcard binds got a port held by a 127.0.0.1 socket; the probe reached that socket 0 times, the wildcard socket 0 times

macOS 15.8.1 x86_64 gives the same pattern, and a Go version of the test reproduces on 15.7.9 and 26.6.2 as well. FreeBSD 15.1 gives 0/2000 with a dual-stack socket.

From the public XNU source (xnu-12377.121.6), in6_pcbsetport checks candidate ports with in6_pcblookup_local, which skips PCBs without INP_IPV6, while in6_pcbbind does an IPv4 PCB lookup for dual-stack wildcard binds. That would explain why an explicit bind is refused but port 0 isn't.

This is the cause of the long-standing Go issue golang/go#67226, since Go's net.ListenUDP("udp", 0.0.0.0:0) creates exactly this socket, and of intermittent failures in QUIC client tests. Using an AF_INET socket for IPv4-only traffic avoids it, but there's no workaround for a socket that needs both families on one port.

Dual-stack UDP socket bound to port 0 can get a port already in use on 127.0.0.1 (FB25058707)
 
 
Q