test: enhance lease port management in server tests

Kim committed May 20, 2026 at 09:58 UTC 6f7f75cd90fb4a8131b696fb664033c283701192
2 files changed +86 -7
portal/server_test.go
+85 -6
@@ -12,11 +12,14 @@ import (
12 "encoding/pem"
13 "io"
14 "math/big"
15 + "net"
16 "net/http"
17 "net/http/httptest"
18 "os"
19 "path/filepath"
20 + "strconv"
21 "strings"
22 + "sync"
23 "testing"
24 "time"
25
@@ -25,11 +28,85 @@ import (
28 "github.com/gosuda/portal-tunnel/v2/utils"
29 )
30
31 +var (
32 + testLeasePortsMu sync.Mutex
33 + testLeasePorts = make(map[int]struct{})
34 +)
35 +
36 func tempIdentityPath(t *testing.T) string {
37 t.Helper()
38 return t.TempDir()
39 }
40
41 +func tempLeasePort(t *testing.T) int {
42 + t.Helper()
43 +
44 + for attempt := 0; attempt < 100; attempt++ {
45 + probe, err := net.Listen("tcp", "127.0.0.1:0")
46 + if err != nil {
47 + t.Fatalf("allocate probe port: %v", err)
48 + }
49 + _, portText, err := net.SplitHostPort(probe.Addr().String())
50 + if closeErr := probe.Close(); closeErr != nil {
51 + t.Fatalf("close probe port: %v", closeErr)
52 + }
53 + if err != nil {
54 + t.Fatalf("parse probe port: %v", err)
55 + }
56 + start, err := strconv.Atoi(portText)
57 + if err != nil {
58 + t.Fatalf("parse probe port %q: %v", portText, err)
59 + }
60 + if start <= 0 || start > 65535 {
61 + continue
62 + }
63 + if !reserveTestLeasePort(start) {
64 + continue
65 + }
66 + if tempLeasePortAvailable(start) {
67 + return start
68 + }
69 + releaseTestLeasePort(start)
70 + }
71 + t.Fatalf("could not find a free lease port")
72 + return 0
73 +}
74 +
75 +func reserveTestLeasePort(port int) bool {
76 + testLeasePortsMu.Lock()
77 + defer testLeasePortsMu.Unlock()
78 +
79 + if _, exists := testLeasePorts[port]; exists {
80 + return false
81 + }
82 + testLeasePorts[port] = struct{}{}
83 + return true
84 +}
85 +
86 +func releaseTestLeasePort(port int) {
87 + testLeasePortsMu.Lock()
88 + defer testLeasePortsMu.Unlock()
89 +
90 + delete(testLeasePorts, port)
91 +}
92 +
93 +func tempLeasePortAvailable(port int) bool {
94 + addr := ":" + strconv.Itoa(port)
95 + tcpListener, err := net.Listen("tcp", addr)
96 + if err != nil {
97 + return false
98 + }
99 + defer tcpListener.Close()
100 +
101 + udpListener, err := net.ListenPacket("udp", addr)
102 + if err != nil {
103 + return false
104 + }
105 + defer udpListener.Close()
106 +
107 + return true
108 +}
109 +
110 func newTestClient(t *testing.T, cancel context.CancelFunc, server *Server) *http.Client {
111 t.Helper()
112 client := utils.NewHTTPClient(
@@ -266,12 +343,13 @@ func TestServerStartDomainReportsCompatibilityInfo(t *testing.T) {
343 func TestRegisterLeaseIncludesSNIPortForPublicIngress(t *testing.T) {
344 t.Parallel()
345
346 + port := tempLeasePort(t)
347 server, err := NewServer(ServerConfig{
348 PortalURL: "https://portal.example.com:4017",
349 IdentityPath: tempIdentityPath(t),
350 SNIPort: 4443,
273 - MinPort: 40000,
274 - MaxPort: 40009,
351 + MinPort: port,
352 + MaxPort: port,
353 TCPEnabled: true,
354 })
355 if err != nil {
@@ -401,11 +479,12 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
479 func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
480 t.Parallel()
481
482 + port := tempLeasePort(t)
483 server, err := NewServer(ServerConfig{
484 PortalURL: "https://portal.example.com",
485 IdentityPath: tempIdentityPath(t),
407 - MinPort: 40000,
408 - MaxPort: 40009,
486 + MinPort: port,
487 + MaxPort: port,
488 UDPEnabled: true,
489 })
490 if err != nil {
@@ -433,8 +512,8 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
512 if record.datagram == nil {
513 t.Fatal("datagram = nil, want datagram runtime")
514 }
436 - if got := record.datagram.UDPPort(); got < 40000 || got > 40009 {
437 - t.Fatalf("UDPPort() = %d, want port within %d-%d", got, 40000, 40009)
515 + if got := record.datagram.UDPPort(); got != port {
516 + t.Fatalf("UDPPort() = %d, want %d", got, port)
517 }
518 if resp.SNIPort != server.config().SNIPort {
519 t.Fatalf("RegisterResponse.SNIPort = %d, want %d", resp.SNIPort, server.config().SNIPort)
sdk/expose_test.go
+1 -1
@@ -131,7 +131,7 @@ func TestExposureReconcileRemovesStaleListener(t *testing.T) {
131
132 relayAClosed := make(chan struct{})
133 exposure := &Exposure{
134 - cfg: utils.NewSnapshot(ExposeConfig{RelayURLs: []string{relayA, relayB}}, ExposeConfig.snapshot),
134 + cfg: utils.NewSnapshot(ExposeConfig{RelayURLs: []string{relayB}}, ExposeConfig.snapshot),
135 relaySet: mustRelaySet(t, relayA, relayB),
136 relayListeners: make(map[string]*listener, 2),
137 }