Skip to content

Fix a race in TestClient - #31

Merged
jdtw merged 1 commit into
mainfrom
fix/flaky-client-test
Aug 2, 2026
Merged

jdtw merged 1 commit into
mainfrom
fix/flaky-client-test

Conversation

@jdtw

@jdtw jdtw commented Aug 2, 2026

Copy link
Copy Markdown
Owner

What happened

CI failed on the merge of #30. That PR added only four files under frontend/ — no Go, no go.mod, no workflow changes — so it didn't cause this. TestClient has been racy all along and happened to lose on that run.

client_test.go:50: Get(foo) returned Get "http://localhost:46697/api/links/foo":
    dial tcp [::1]:46697: connect: connection refused; want err not found

The race

addr := fmt.Sprintf("localhost:%d", getFreePort(t))   // binds, then CLOSES
s := &http.Server{Addr: addr, ...}
go func() { s.ListenAndServe() }()                     // binds asynchronously
c := New("http://"+addr, signer)
c.Get("foo")                                           // dials immediately

Nothing orders the bind before the first request. On an idle machine the goroutine wins; on a loaded CI runner it doesn't. There's a second, narrower race too: closing the listener before rebinding leaves a window for another process to take the port.

The [::1] in the error is a symptom of the same design — localhost was reassembled from a port number rather than using the address actually bound, so the client could resolve to a different stack than the server used.

The fix

httptest.NewServer binds before it returns and hands back the address it actually bound. That closes both races and the IPv4/IPv6 mismatch at once, and drops getFreePort, the sync.WaitGroup, and the manual Shutdown. pkg/frontend/server_test.go already tests this way, so this makes the two consistent.

Verification

I could not reproduce the failure locally — expected for a scheduling race on an idle machine. I tried -count=500, GOMAXPROCS=1, and saturating every core; it passed throughout, which is why the fix is reasoned from the code rather than from a red test.

After the change: 500 runs, 50 runs under -race, 300 under GOMAXPROCS=1, the full unit suite, and the end-to-end ./test.sh all pass.

🤖 Generated with Claude Code

The test picked a free port by binding a listener and closing it, started
ListenAndServe in a goroutine, and then immediately dialed without waiting
for the server to come up. Nothing ordered the bind before the first
request, so on a loaded runner the client could win and the test failed
with "connection refused". Closing the listener before rebinding also left
a window for another process to take the port.

httptest.NewServer binds before returning and hands back the address it
actually bound, which removes both races and the localhost IPv4/IPv6
mismatch in the failure. pkg/frontend already tests this way.

This was pre-existing flakiness rather than a regression; it happened to
surface on the merge of an unrelated change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdtw
jdtw merged commit 1599d3d into main Aug 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant