Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions pkg/testutils/port.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,30 @@ 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.
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 {
Expand All @@ -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
}
22 changes: 22 additions & 0 deletions pkg/testutils/port_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading