diff --git a/pkg/testutils/port.go b/pkg/testutils/port.go index faf7325d4..29bbc56e8 100644 --- a/pkg/testutils/port.go +++ b/pkg/testutils/port.go @@ -2,10 +2,22 @@ package testutils import ( "net" + "sync" "testing" ) -// GetFreePort returns a port that is free for both TCP and UDP. +// handedOutPorts remembers every port GetFreePort has returned in this process, +// so a later call never returns the same number. GetFreePort releases the port +// before returning it (see below), so without this two calls in quick +// succession can hand back the same port — the cause of the etcd +// client/peer-port collision flake (MIR-720). +var ( + handedOutMu sync.Mutex + handedOutPorts = map[int]struct{}{} +) + +// GetFreePort returns a port that is free for both TCP and UDP, and distinct +// from any port already handed out by GetFreePort in this process. // This is important for QUIC-based servers (like the RPC server) which bind both protocols. // The function binds both protocols to verify availability, then releases them. // Fails the test if a suitable port cannot be obtained after several attempts. @@ -13,7 +25,7 @@ func GetFreePort(t testing.TB) int { t.Helper() // Try a few times to find a port free for both TCP and UDP - for range 10 { + for range 20 { // First, get an available TCP port tcpListener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -29,12 +41,28 @@ func GetFreePort(t testing.TB) int { continue } - // Both succeeded - close and return the port + // Reject a port we've already handed out. Because the listeners are + // released before returning, the OS can re-offer a just-freed port to + // the next caller; rebinding on the next iteration rotates to a fresh + // ephemeral port. + handedOutMu.Lock() + _, seen := handedOutPorts[port] + if !seen { + handedOutPorts[port] = struct{}{} + } + handedOutMu.Unlock() + if seen { + tcpListener.Close() + udpConn.Close() + continue + } + + // Both succeeded and the port is new - close and return the port tcpListener.Close() udpConn.Close() return port } - t.Fatalf("failed to find a port free for both TCP and UDP after 10 attempts") + t.Fatalf("failed to find a port free for both TCP and UDP after 20 attempts") return 0 } diff --git a/pkg/testutils/port_test.go b/pkg/testutils/port_test.go new file mode 100644 index 000000000..caafbc178 --- /dev/null +++ b/pkg/testutils/port_test.go @@ -0,0 +1,22 @@ +package testutils + +import "testing" + +// TestGetFreePortReturnsDistinctPorts pins the guarantee that resolves MIR-720: +// GetFreePort releases each port before returning it, so successive calls used +// to be able to hand back the same number (etcd then failed to bind its client +// and peer URLs to one port). Every port must now be unique within the process. +func TestGetFreePortReturnsDistinctPorts(t *testing.T) { + const n = 200 + seen := make(map[int]bool, n) + for i := 0; i < n; i++ { + port := GetFreePort(t) + if port <= 0 { + t.Fatalf("call %d: GetFreePort returned non-positive port %d", i, port) + } + if seen[port] { + t.Fatalf("call %d: GetFreePort returned duplicate port %d", i, port) + } + seen[port] = true + } +}