From d885f9b4b69771eeb4a4b655ac86cd942331bb91 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Thu, 12 Mar 2026 15:25:01 -0500 Subject: [PATCH] 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 --- poller/internal/tunnel/portpool.go | 63 +++++++++++++++++++ poller/internal/tunnel/portpool_test.go | 82 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 poller/internal/tunnel/portpool.go create mode 100644 poller/internal/tunnel/portpool_test.go diff --git a/poller/internal/tunnel/portpool.go b/poller/internal/tunnel/portpool.go new file mode 100644 index 0000000..a4fc36b --- /dev/null +++ b/poller/internal/tunnel/portpool.go @@ -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 +} diff --git a/poller/internal/tunnel/portpool_test.go b/poller/internal/tunnel/portpool_test.go new file mode 100644 index 0000000..eeee6b8 --- /dev/null +++ b/poller/internal/tunnel/portpool_test.go @@ -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 +}