Skip to content

Commit 296deaa

Browse files
committed
[freeport] implement package providing free port probing and reservation
Change-Id: If321ef65d78188c9ad40e4e25bbc67668a52d6bf
1 parent f19edae commit 296deaa

3 files changed

Lines changed: 329 additions & 0 deletions

File tree

testing/freeport/doc.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Package freeport provides small helpers to allocate free TCP/UDP ports
2+
// in tests. It is useful when starting ephemeral servers (HTTP, gRPC,
3+
// WebSocket, custom TCP/UDP protocols) inside integration/unit tests, where
4+
// ports must not collide between concurrently running tests.
5+
//
6+
// # Why
7+
//
8+
// - Tests often need to bind to a local port but hard‑coding ports leads to
9+
// flakiness and conflicts on CI machines and during parallel runs.
10+
// - The package offers two styles: probing for a free port (TCP/UDP) and
11+
// reserving a port for the lifetime of a test (ReserveTCP/ReserveUDP).
12+
// - Port reservation is extremely useful for the cases when testing against
13+
// docker container and container must be stopped and started multiple times
14+
// during a test, and each time it must bind to the same port.
15+
// - Probing is inherently racy (another process could claim the port after
16+
// probing but before binding), but is often good enough for quick tests.
17+
// - Reservation is not perfect either (another process could still claim the
18+
// port), but is much safer when used in tests that all use this package.
19+
//
20+
// How to use
21+
//
22+
// 1. Quick probe (no reservation):
23+
//
24+
// p, err := freeport.TCP()
25+
// // use p to bind a listener; another process could still claim it
26+
//
27+
// 2. Safer within tests (reservation tied to t):
28+
//
29+
// p := freeport.ReserveTCP(t)
30+
// ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p))
31+
// // close the listener yourself when done; the port reservation is
32+
// // released automatically at the end of the test
33+
//
34+
// # Notes
35+
//
36+
// - Reservation is best‑effort and only prevents collisions among tests that
37+
// also use this package in the same process. It does not prevent other
38+
// external processes from binding the port.
39+
// - ReleaseTCP/ReleaseUDP return whether a release actually happened; this is
40+
// mostly useful for tests that juggle many ports and want to free them early.
41+
package freeport

testing/freeport/freeport.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package freeport
2+
3+
import (
4+
"net"
5+
"sync"
6+
"testing"
7+
8+
"dev.gaijin.team/go/golib/e"
9+
)
10+
11+
// resolver manages port allocation and reservation for a specific protocol.
12+
// It maintains a registry of ports that are currently reserved by tests
13+
// and provides methods to get free ports or reserve ports exclusively.
14+
type resolver struct {
15+
// reserved maps port numbers to the test that reserved them
16+
reserved map[int]*testing.T
17+
// reservedMu protects access to the reserved map
18+
reservedMu sync.Mutex `exhaustruct:"optional"`
19+
20+
// resolverFn is the function used to find free ports for this protocol
21+
resolverFn func() (int, error)
22+
}
23+
24+
func (r *resolver) getPort() (int, error) {
25+
r.reservedMu.Lock()
26+
defer r.reservedMu.Unlock()
27+
28+
for {
29+
port, err := r.resolverFn()
30+
if err != nil {
31+
return 0, e.NewFrom("failed to get free port", err)
32+
}
33+
34+
if _, ok := r.reserved[port]; !ok {
35+
return port, nil
36+
}
37+
}
38+
}
39+
40+
func (r *resolver) reservePort(t *testing.T) (int, error) { //nolint:thelper
41+
if t == nil {
42+
panic("t is required to reserve a port")
43+
}
44+
45+
t.Helper()
46+
47+
r.reservedMu.Lock()
48+
defer r.reservedMu.Unlock()
49+
50+
for {
51+
port, err := r.resolverFn()
52+
if err != nil {
53+
return 0, e.NewFrom("failed to reserve free port", err)
54+
}
55+
56+
if _, ok := r.reserved[port]; !ok {
57+
r.reserved[port] = t
58+
t.Cleanup(func() {
59+
r.releasePort(t, port)
60+
})
61+
62+
return port, nil
63+
}
64+
}
65+
}
66+
67+
func (r *resolver) releasePort(t *testing.T, port int) bool { //nolint:thelper
68+
if t == nil {
69+
panic("t is required to release a port")
70+
}
71+
72+
t.Helper()
73+
74+
r.reservedMu.Lock()
75+
defer r.reservedMu.Unlock()
76+
77+
if owner, ok := r.reserved[port]; ok && owner == t {
78+
delete(r.reserved, port)
79+
return true
80+
}
81+
82+
return false
83+
}
84+
85+
var (
86+
tcpResolv = &resolver{ //nolint:gochecknoglobals
87+
reserved: make(map[int]*testing.T),
88+
resolverFn: func() (int, error) {
89+
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
90+
if err != nil {
91+
return 0, e.NewFrom("failed to resolve TCP address", err)
92+
}
93+
94+
listener, err := net.ListenTCP("tcp", addr)
95+
if err != nil {
96+
return 0, e.NewFrom("failed to listen on TCP port", err)
97+
}
98+
defer func() { _ = listener.Close() }()
99+
100+
return listener.Addr().(*net.TCPAddr).Port, nil //nolint:forcetypeassert
101+
},
102+
}
103+
udpResolv = &resolver{ //nolint:gochecknoglobals
104+
reserved: make(map[int]*testing.T),
105+
resolverFn: func() (int, error) {
106+
addr, err := net.ResolveUDPAddr("udp", "localhost:0")
107+
if err != nil {
108+
return 0, e.NewFrom("failed to resolve UDP address", err)
109+
}
110+
111+
conn, err := net.ListenUDP("udp", addr)
112+
if err != nil {
113+
return 0, e.NewFrom("failed to listen on UDP port", err)
114+
}
115+
defer func() { _ = conn.Close() }()
116+
117+
return conn.LocalAddr().(*net.UDPAddr).Port, nil //nolint:forcetypeassert
118+
},
119+
}
120+
)
121+
122+
// TCP returns a free TCP port that is available for use.
123+
// This function does not reserve the port, so there's a small chance
124+
// another process could claim it before you use it.
125+
func TCP() (int, error) {
126+
return tcpResolv.getPort()
127+
}
128+
129+
// UDP returns a free UDP port that is available for use.
130+
// This function does not reserve the port, so there's a small chance
131+
// another process could claim it before you use it.
132+
func UDP() (int, error) {
133+
return udpResolv.getPort()
134+
}
135+
136+
// ReserveTCP reserves a TCP port for exclusive use within the test.
137+
// The port is automatically released when the test completes.
138+
//
139+
// There is no guarantee the port will not be used by another process
140+
// or code that does not use this package.
141+
func ReserveTCP(t *testing.T) int {
142+
t.Helper()
143+
144+
port, err := tcpResolv.reservePort(t)
145+
if err != nil {
146+
t.Fatalf("ReserveTCP: %v", err)
147+
}
148+
149+
return port
150+
}
151+
152+
// ReserveUDP reserves a UDP port for exclusive use within the test.
153+
// The port is automatically released when the test completes.
154+
//
155+
// There is no guarantee the port will not be used by another process
156+
// or code that does not use this package.
157+
func ReserveUDP(t *testing.T) int {
158+
t.Helper()
159+
160+
port, err := udpResolv.reservePort(t)
161+
if err != nil {
162+
t.Fatalf("ReserveUDP: %v", err)
163+
}
164+
165+
return port
166+
}
167+
168+
// ReleaseTCP manually releases a TCP port that was reserved for the test. This
169+
// is typically not needed as ports are automatically released when the test
170+
// completes, but can be useful for tests that reserve many ports and want to
171+
// free them early. In case *testing.T does not own the port, or it already was
172+
// released - this function is noop.
173+
//
174+
// Returns true if the port was released, false otherwise.
175+
func ReleaseTCP(t *testing.T, port int) bool {
176+
t.Helper()
177+
return tcpResolv.releasePort(t, port)
178+
}
179+
180+
// ReleaseUDP manually releases a UDP port that was reserved for the test. This
181+
// is typically not needed as ports are automatically released when the test
182+
// completes, but can be useful for tests that reserve many ports and want to
183+
// free them early. In case *testing.T does not own the port, or it already was
184+
// released - this function is noop.
185+
//
186+
// Returns true if the port was released, false otherwise.
187+
func ReleaseUDP(t *testing.T, port int) bool {
188+
t.Helper()
189+
return udpResolv.releasePort(t, port)
190+
}

testing/freeport/freeport_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package freeport_test
2+
3+
import (
4+
"net"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"dev.gaijin.team/go/golib/testing/freeport"
10+
)
11+
12+
func TestGet(t *testing.T) {
13+
t.Parallel()
14+
15+
t.Run("TCP", func(t *testing.T) {
16+
t.Parallel()
17+
18+
p, err := freeport.TCP()
19+
20+
require.NoError(t, err)
21+
require.Positive(t, p)
22+
23+
// Verify we can listen on the port
24+
l, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: p, Zone: ""})
25+
require.NoError(t, err)
26+
require.NotNil(t, l)
27+
_ = l.Close()
28+
})
29+
30+
t.Run("UDP", func(t *testing.T) {
31+
t.Parallel()
32+
33+
p, err := freeport.UDP()
34+
35+
require.NoError(t, err)
36+
require.Positive(t, p)
37+
38+
// Verify we can bind UDP to the port
39+
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: p, Zone: ""})
40+
require.NoError(t, err)
41+
require.NotNil(t, c)
42+
_ = c.Close()
43+
})
44+
}
45+
46+
func TestReserve(t *testing.T) {
47+
t.Parallel()
48+
49+
t.Run("TCP", func(t *testing.T) {
50+
t.Parallel()
51+
p := freeport.ReserveTCP(t)
52+
require.Positive(t, p)
53+
54+
t.Run("foreign-release", func(t2 *testing.T) { //nolint:paralleltest
55+
require.False(t2, freeport.ReleaseTCP(t2, p))
56+
})
57+
58+
require.True(t, freeport.ReleaseTCP(t, p))
59+
require.False(t, freeport.ReleaseTCP(t, p))
60+
})
61+
62+
t.Run("UDP", func(t *testing.T) {
63+
t.Parallel()
64+
p := freeport.ReserveUDP(t)
65+
require.Positive(t, p)
66+
67+
t.Run("foreign-release", func(t2 *testing.T) { //nolint:paralleltest
68+
require.False(t2, freeport.ReleaseUDP(t2, p))
69+
})
70+
71+
require.True(t, freeport.ReleaseUDP(t, p))
72+
require.False(t, freeport.ReleaseUDP(t, p))
73+
})
74+
75+
t.Run("Panics", func(t *testing.T) {
76+
t.Parallel()
77+
78+
t.Run("ReserveTCP_NilT", func(t *testing.T) {
79+
t.Parallel()
80+
require.Panics(t, func() { freeport.ReserveTCP(nil) })
81+
})
82+
83+
t.Run("ReserveUDP_NilT", func(t *testing.T) {
84+
t.Parallel()
85+
require.Panics(t, func() { freeport.ReserveUDP(nil) })
86+
})
87+
88+
t.Run("ReleaseTCP_NilT", func(t *testing.T) {
89+
t.Parallel()
90+
require.Panics(t, func() { freeport.ReleaseTCP(nil, 1) })
91+
})
92+
93+
t.Run("ReleaseUDP_NilT", func(t *testing.T) {
94+
t.Parallel()
95+
require.Panics(t, func() { freeport.ReleaseUDP(nil, 1) })
96+
})
97+
})
98+
}

0 commit comments

Comments
 (0)