feat(poller): add port pool for WinBox tunnel allocation

Implements PortPool with mutex-protected allocation, bind verification
to skip ports already in use by the OS, and release-for-reuse semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-12 15:25:01 -05:00
parent 5f9410fa54
commit d885f9b4b6
2 changed files with 145 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
package tunnel
import (
"fmt"
"net"
"sync"
)
// PortPool tracks available ports in a fixed range for WinBox tunnel allocation.
type PortPool struct {
mu sync.Mutex
used []bool
base int
count int
}
func NewPortPool(min, max int) *PortPool {
count := max - min + 1
return &PortPool{
used: make([]bool, count),
base: min,
count: count,
}
}
// Allocate returns the next free port, verifying it can actually be bound.
// Returns error if all ports are exhausted.
func (pp *PortPool) Allocate() (int, error) {
pp.mu.Lock()
defer pp.mu.Unlock()
for i := 0; i < pp.count; i++ {
if pp.used[i] {
continue
}
port := pp.base + i
if !canBind(port) {
continue
}
pp.used[i] = true
return port, nil
}
return 0, fmt.Errorf("no ports available in range %d-%d", pp.base, pp.base+pp.count-1)
}
// Release returns a port to the pool.
func (pp *PortPool) Release(port int) {
pp.mu.Lock()
defer pp.mu.Unlock()
idx := port - pp.base
if idx >= 0 && idx < pp.count {
pp.used[idx] = false
}
}
func canBind(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return false
}
ln.Close()
return true
}

View File

@@ -0,0 +1,82 @@
package tunnel
import (
"net"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPortPool_Allocate(t *testing.T) {
pp := NewPortPool(49000, 49002) // 3 ports: 49000, 49001, 49002
p1, err := pp.Allocate()
require.NoError(t, err)
assert.GreaterOrEqual(t, p1, 49000)
assert.LessOrEqual(t, p1, 49002)
}
func TestPortPool_AllocateAll(t *testing.T) {
pp := NewPortPool(49000, 49002)
ports := make(map[int]bool)
for i := 0; i < 3; i++ {
p, err := pp.Allocate()
require.NoError(t, err)
ports[p] = true
}
assert.Len(t, ports, 3)
}
func TestPortPool_Exhausted(t *testing.T) {
pp := NewPortPool(49000, 49001)
_, _ = pp.Allocate()
_, _ = pp.Allocate()
_, err := pp.Allocate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "no ports available")
}
func TestPortPool_Release(t *testing.T) {
pp := NewPortPool(49000, 49000) // single port
p, _ := pp.Allocate()
pp.Release(p)
p2, err := pp.Allocate()
require.NoError(t, err)
assert.Equal(t, p, p2)
}
func TestPortPool_ConcurrentAccess(t *testing.T) {
pp := NewPortPool(49000, 49099) // 100 ports
var wg sync.WaitGroup
allocated := make(chan int, 100)
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p, err := pp.Allocate()
if err == nil {
allocated <- p
}
}()
}
wg.Wait()
close(allocated)
ports := make(map[int]bool)
for p := range allocated {
assert.False(t, ports[p], "duplicate port allocated: %d", p)
ports[p] = true
}
}
func TestPortPool_BindVerification(t *testing.T) {
// Occupy a port, then verify Allocate skips it
ln, err := net.Listen("tcp", "127.0.0.1:49050")
require.NoError(t, err)
defer ln.Close()
pp := NewPortPool(49050, 49051)
p, err := pp.Allocate()
require.NoError(t, err)
assert.Equal(t, 49051, p) // should skip 49050 since it's occupied
}