-
Notifications
You must be signed in to change notification settings - Fork 90
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
prevent port number reuse with TTL-based caching (#4689)
## Problem In devstack and tests, we start multiple services (API, NATS, publishers) that each need unique ports. Since we allocate all ports before starting the services, calling GetFreePort multiple times in quick succession could return the same port number, leading to service startup failures. ## Solution Added TTL-based port caching (5s default) to prevent GetFreePort from returning recently allocated ports. This gives devstack time to actually bind the ports before they can be reused. The change is backwards compatible - existing GetFreePort calls automatically get the new caching behavior. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new port allocation system with time-based reservations for enhanced management of network ports. - Added functionality to check if a port is open and ensure thread safety during concurrent allocations. - **Bug Fixes** - Improved the reliability of port allocation and reservation checks. - **Tests** - Expanded test suite to include new tests for eviction and reservation mechanisms, maximum attempts, concurrent allocations, and availability checks, enhancing overall test coverage. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
- Loading branch information
1 parent
de5a3ef
commit c5a922c
Showing
2 changed files
with
192 additions
and
53 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,47 +1,133 @@ | ||
//go:build unit || !integration | ||
|
||
package network_test | ||
package network | ||
|
||
import ( | ||
"net" | ||
"strconv" | ||
"sync" | ||
"testing" | ||
"time" | ||
|
||
"github.com/bacalhau-project/bacalhau/pkg/lib/network" | ||
"github.com/stretchr/testify/suite" | ||
) | ||
|
||
type FreePortTestSuite struct { | ||
type PortAllocatorTestSuite struct { | ||
suite.Suite | ||
} | ||
|
||
func TestFreePortTestSuite(t *testing.T) { | ||
suite.Run(t, new(FreePortTestSuite)) | ||
func TestPortAllocatorTestSuite(t *testing.T) { | ||
suite.Run(t, new(PortAllocatorTestSuite)) | ||
} | ||
|
||
func (s *FreePortTestSuite) TestGetFreePort() { | ||
port, err := network.GetFreePort() | ||
// TestGetFreePort verifies that GetFreePort returns a usable port | ||
func (s *PortAllocatorTestSuite) TestGetFreePort() { | ||
port, err := GetFreePort() | ||
s.Require().NoError(err) | ||
s.NotEqual(0, port, "expected a non-zero port") | ||
|
||
// Try to listen on the port | ||
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port)) | ||
// Verify we can listen on the port | ||
l, err := net.Listen("tcp", ":"+strconv.Itoa(port)) | ||
s.Require().NoError(err) | ||
defer l.Close() | ||
} | ||
|
||
func (s *FreePortTestSuite) TestGetFreePorts() { | ||
count := 3 | ||
ports, err := network.GetFreePorts(count) | ||
// TestEvictionAndReservation tests both the TTL eviction and reservation mechanism | ||
func (s *PortAllocatorTestSuite) TestEvictionAndReservation() { | ||
now := time.Now() | ||
allocator := &PortAllocator{ | ||
reservedPorts: map[int]time.Time{ | ||
8080: now.Add(-time.Second), // expired | ||
8081: now.Add(time.Second), // not expired | ||
8082: now.Add(-time.Second), // expired | ||
}, | ||
ttl: time.Second, | ||
maxAttempts: 10, | ||
} | ||
|
||
// Getting a free port should clean up expired entries | ||
port, err := allocator.GetFreePort() | ||
s.Require().NoError(err) | ||
|
||
// Verify expired ports were cleaned up | ||
s.Len(allocator.reservedPorts, 2) // port we just got plus 8081 | ||
_, hasPort := allocator.reservedPorts[8081] | ||
s.True(hasPort, "non-expired port should still be present") | ||
|
||
// New port should be reserved | ||
_, hasNewPort := allocator.reservedPorts[port] | ||
s.True(hasNewPort, "new port should be reserved") | ||
} | ||
|
||
// TestMaxAttempts verifies the retry limit when ports are reserved | ||
func (s *PortAllocatorTestSuite) TestMaxAttempts() { | ||
allocator := &PortAllocator{ | ||
reservedPorts: make(map[int]time.Time), | ||
ttl: time.Second, | ||
maxAttempts: 3, | ||
} | ||
|
||
// Reserve all possible user ports (1024-65535) to force GetFreePort to fail | ||
// System ports (1-1023) are not used as they typically require elevated privileges | ||
for i := 1024; i <= 65535; i++ { | ||
allocator.reservedPorts[i] = time.Now().Add(time.Minute) | ||
} | ||
|
||
// Should fail after maxAttempts since all ports are reserved | ||
_, err := allocator.GetFreePort() | ||
s.Require().Error(err) | ||
s.Contains(err.Error(), "failed to find an available port after 3 attempts") | ||
} | ||
|
||
// TestConcurrentPortAllocation verifies thread-safety of port allocation | ||
func (s *PortAllocatorTestSuite) TestConcurrentPortAllocation() { | ||
var wg sync.WaitGroup | ||
allocator := NewPortAllocator(time.Second, 10) | ||
ports := make(map[int]bool) | ||
var mu sync.Mutex | ||
|
||
// Spawn 20 goroutines to allocate ports concurrently | ||
for i := 0; i < 20; i++ { | ||
wg.Add(1) | ||
go func() { | ||
defer wg.Done() | ||
port, err := allocator.GetFreePort() | ||
s.Require().NoError(err) | ||
|
||
mu.Lock() | ||
s.False(ports[port], "port %d was allocated multiple times", port) | ||
ports[port] = true | ||
mu.Unlock() | ||
|
||
// Verify we can listen on the port | ||
l, err := net.Listen("tcp", ":"+strconv.Itoa(port)) | ||
s.Require().NoError(err) | ||
l.Close() | ||
}() | ||
} | ||
wg.Wait() | ||
} | ||
|
||
// TestIsPortOpen verifies the port availability check | ||
func (s *PortAllocatorTestSuite) TestIsPortOpen() { | ||
port, err := GetFreePort() | ||
s.Require().NoError(err) | ||
s.True(IsPortOpen(port), "newly allocated port should be open") | ||
|
||
// Listen on the port | ||
l, err := net.Listen("tcp", ":"+strconv.Itoa(port)) | ||
s.Require().NoError(err) | ||
s.Equal(count, len(ports), "expected %d ports", count) | ||
defer l.Close() | ||
|
||
for _, port := range ports { | ||
s.NotEqual(0, port, "expected a non-zero port") | ||
// Port should now be in use | ||
s.False(IsPortOpen(port), "port should be in use") | ||
} | ||
|
||
// Try to listen on the port | ||
l, err := net.Listen("tcp", ":"+strconv.Itoa(port)) | ||
s.Require().NoError(err, "failed to listen on newly given port") | ||
defer l.Close() | ||
// TestGlobalAllocator verifies the global allocator behavior | ||
func (s *PortAllocatorTestSuite) TestGlobalAllocator() { | ||
usedPorts := make(map[int]bool) | ||
for i := 0; i < 3; i++ { | ||
port, err := GetFreePort() | ||
s.Require().NoError(err) | ||
s.False(usedPorts[port], "global allocator reused port %d", port) | ||
usedPorts[port] = true | ||
} | ||
} |