Commit 62742f1870
62742f187088804e31f93d036984ba84a251d4ee
parent: b172654262
Verified · cmc ci/build: success ci/test: success ci/vuln: success
cmc <hello@cleberg.net> · 2026-09-01 07:03 UTC
e2e: reserve every port before releasing any of them
freePort closed its listener before returning, so the kernel was free to
give the next call the same port. An instance asks for three in a row, then
fails to bind its second listener and the test that started it times out on
"gitbayd did not start listening" — attributed to whichever test drew the
collision, so it moved around and never looked like the same bug twice.
Measured on the runner host, 200 rounds of three picks: 1 collision closing
each listener before the next, 0 holding them all open until every port is
chosen. Roughly half a percent per instance, across many instances a run.
Caught in build 157:
INFO git-daemon listening addr=[::]:33767
INFO http listening addr=127.0.0.1:33767 tls=off
gitbayd: listen tcp 127.0.0.1:33767: bind: address already in use
TestACMEServe and the backup restore allocate in pairs and had the same bug.
Distinct from #67, where a dropped log stream failed builds whose tests had
passed; that one truncates the log and names nothing.
Closes #68
Ref #62, #67
Layout: unified · split
e2e/acme_test.go
+2 −2
| @@ -22,8 +22,8 @@ func TestACMEServe(t *testing.T) { |
| 22 | 22 | inst.proc.Process.Kill() |
| 23 | 23 | inst.proc.Wait() |
| 24 | 24 | |
| 25 | | httpsPort := freePort(t) |
| 26 | | acmeHTTPPort := freePort(t) |
| 25 | ports := freePorts(t, 2) |
| 26 | httpsPort, acmeHTTPPort := ports[0], ports[1] |
| 27 | 27 | cfg := fmt.Sprintf(` |
| 28 | 28 | [server] |
| 29 | 29 | root = %q |
e2e/backup_test.go
+2 −2
| @@ -69,8 +69,8 @@ func TestAdminBackup(t *testing.T) { |
| 69 | 69 | if outB, err := exec.Command("tar", "-xzf", archive, "-C", root2).CombinedOutput(); err != nil { |
| 70 | 70 | t.Fatalf("extract: %v\n%s", err, outB) |
| 71 | 71 | } |
| 72 | | port2 := freePort(t) |
| 73 | | httpPort2 := freePort(t) |
| 72 | ports := freePorts(t, 2) |
| 73 | port2, httpPort2 := ports[0], ports[1] |
| 74 | 74 | config2 := filepath.Join(root2, "config.toml") |
| 75 | 75 | cfg := fmt.Sprintf(` |
| 76 | 76 | [server] |
e2e/ssh_test.go
+48 −9
| @@ -36,14 +36,36 @@ func buildGitbayd(t *testing.T) string { |
| 36 | 36 | return bin |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | | func freePort(t *testing.T) int { |
| 39 | // freePorts reserves n distinct ports. A port is chosen by binding :0 and |
| 40 | // reading back what the kernel assigned, so every listener has to stay open |
| 41 | // until all of them are picked — closing one before picking the next lets |
| 42 | // the kernel hand out the same port again, and the instance that asked for |
| 43 | // three then fails to bind its second listener. |
| 44 | // |
| 45 | // Still a narrowing rather than a guarantee: another process can take a port |
| 46 | // between the close here and the bind in gitbayd. Distinctness within one |
| 47 | // instance is the part that is ours. |
| 48 | func freePorts(t *testing.T, n int) []int { |
| 40 | 49 | t.Helper() |
| 41 | | ln, err := net.Listen("tcp", "127.0.0.1:0") |
| 42 | | if err != nil { |
| 43 | | t.Fatal(err) |
| 50 | lns := make([]net.Listener, 0, n) |
| 51 | ports := make([]int, 0, n) |
| 52 | for i := 0; i < n; i++ { |
| 53 | ln, err := net.Listen("tcp", "127.0.0.1:0") |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | lns = append(lns, ln) |
| 58 | ports = append(ports, ln.Addr().(*net.TCPAddr).Port) |
| 44 | 59 | } |
| 45 | | defer ln.Close() |
| 46 | | return ln.Addr().(*net.TCPAddr).Port |
| 60 | for _, ln := range lns { |
| 61 | ln.Close() |
| 62 | } |
| 63 | return ports |
| 64 | } |
| 65 | |
| 66 | func freePort(t *testing.T) int { |
| 67 | t.Helper() |
| 68 | return freePorts(t, 1)[0] |
| 47 | 69 | } |
| 48 | 70 | |
| 49 | 71 | func startInstance(t *testing.T) *instance { |
| @@ -53,12 +75,13 @@ func startInstance(t *testing.T) *instance { |
| 53 | 75 | // startInstanceWith appends extra TOML to the instance config. |
| 54 | 76 | func startInstanceWith(t *testing.T, extra string) *instance { |
| 55 | 77 | t.Helper() |
| 78 | ports := freePorts(t, 3) |
| 56 | 79 | inst := &instance{ |
| 57 | 80 | gitbayd: buildGitbayd(t), |
| 58 | 81 | root: t.TempDir(), |
| 59 | | port: freePort(t), |
| 60 | | httpPort: freePort(t), |
| 61 | | gitPort: freePort(t), |
| 82 | port: ports[0], |
| 83 | httpPort: ports[1], |
| 84 | gitPort: ports[2], |
| 62 | 85 | sshDir: t.TempDir(), |
| 63 | 86 | } |
| 64 | 87 | inst.config = filepath.Join(inst.root, "config.toml") |
| @@ -244,3 +267,19 @@ func TestControlPlaneOverBareSSH(t *testing.T) { |
| 244 | 267 | t.Fatalf("keys remove with spaced arg: exit %d (want 3), stderr %q", code, errOut) |
| 245 | 268 | } |
| 246 | 269 | } |
| 270 | |
| 271 | // freePort used to close its listener before returning, so the kernel was |
| 272 | // free to hand the same port to the next call. An instance asks for three in |
| 273 | // a row and then fails to bind its second listener, which surfaces as an |
| 274 | // unrelated test timing out on "gitbayd did not start listening". |
| 275 | func TestFreePortsAreDistinct(t *testing.T) { |
| 276 | for round := 0; round < 50; round++ { |
| 277 | seen := map[int]bool{} |
| 278 | for _, p := range freePorts(t, 8) { |
| 279 | if seen[p] { |
| 280 | t.Fatalf("round %d: port %d issued twice in one request", round, p) |
| 281 | } |
| 282 | seen[p] = true |
| 283 | } |
| 284 | } |
| 285 | } |