go-test-port-collision-sibling-process
Root cause. The test used hardcoded ports (19876/19877). When a sibling foreman project (hivemind sitrep server) bound <ip-address>:19876, the Available subtest failed (FindPort(19876) skipped to 19877) and the Occupied subtest couldn't even set up its fixture (bind: address already in use).
Fix. Derive the base port from the OS instead of a constant: bind net.Listen("<ip-address>:0"), read the assigned port, close the listener, then assert FindPort(base) == base / FindPort(base) == base+1. The test intent — "FindPort returns a free base, or skips an occupied base" — is unchanged, but the base can never be systematically pre-occupied by a long-lived external process.
package port
import (
"fmt"
"net"
"testing"
)
func TestFindPort(t *testing.T) {
t.Run("Available", func(t *testing.T) {
// Re-roll a few times: freeBasePort is only a hint, so if
// something races us for the port, pick a fresh one and retry.
for attempt := 0; ; attempt++ {
base := freeBasePort(t)
if got := FindPort(base); got == base {
return
} else if attempt == 4 {
t.Fatalf("FindPort(%d) = %d, want %d (base kept being grabbed)", base, got, base)
}
}
})
t.Run("Occupied", func(t *testing.T) {
// Same re-roll strategy. Assertion is still base+1 exactly;
// only re-roll if an unrelated process grabs base+1 mid-test.
for attempt := 0; ; attempt++ {
base := freeBasePort(t)
ln, err := net.Listen("tcp", fmt.Sprintf("<ip-address>:%d", base))
if err != nil {
t.Fatalf("occupy %d: %v", base, err)
}
got := FindPort(base)
ln.Close() // release before any re-roll
if got == base+1 {
return
}
if attempt == 4 {
t.Fatalf("FindPort(%d) = %d, want %d", base, got, base+1)
}
}
})
}
// freeBasePort asks the kernel for a currently-free TCP port on
// 127.0.0.1. The listener is closed immediately, so the port is only a
// hint at call time — but unlike a hardcoded constant, it is chosen
// from the OS and cannot be pre-occupied by a long-lived process.
func freeBasePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "<ip-address>:0")
if err != nil {
t.Fatalf("freeBasePort: %v", err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
What not to change: FindPort itself (function under test). The contract tested — "first free port ≥ base, skipping occupied ones" — is preserved verbatim.
I reproduced the scenario from scratch (`/tmp/porttest`, Go 1.26). The verification box turned out to be the *perfect* test environment: an unrelated process was **already live on `<ip-address>:19876`** (`ss -ltnp` confirmed), i.e. a real hivemind-style squatter. | Check | Result | |---|---| | Original test, squatter live on 19876 | **FAIL** — `Available: FindPort(19876) = 19877, want 19876`; `Occupied: bind: address already in use` — exact reported bug | | Fixed test, same live squatter | PASS, 20/20 (`-count=20`) | | Fixed test + 5 churning stress squatters on random ports | PASS, 100/100 consecutive | | Fixed test under `-race` | PASS, 20/20 | | `go vet ./...` | clean | Edge cases tested (all pass): - **Consecutive squatters** — occupy `base` and `base+1` → `FindPort(base) == base+2`. - **Range exhaustion** — occupy all 100 ports in the scan range → `FindPort(base) == 0` (no panic, no infinite loop). - **Squatter elsewhere** — fixed-port squatter (19877) has zero effect on a dynamic base picked elsewhere. - **TOCTOU hardening** — `Available`/`Occupied` re-roll the base on collision. This box runs 30+ services and 462 sockets churning the ephemeral range (32768–60999), so a raw one-shot dynamic test flakes ~2%/run here; the retry eliminates that without changing the assertions. **Honest residual risk:** a dynamic base is immune to *systematic* collisions but not to the inherent TOCTOU of any port test (something can grab `base` between `freeBasePort` and the probe, or grab `base+1` mid-test). That's unavoidable with `net.Listen`-based probing; the retry loop keeps it negligible. The hardcoded-constant class of bug is gone entirely. ---
{"model": "deepseek-v4-flash", "problem_class": "go-test-port-collision-sibling-process", "result": "passed", "tests": 5}