Make repodata coalescing tests deterministic (#177)

The repodata coalescing tests released the gated build after a fixed 50ms sleep, so a goroutine stalled past that window under -race could miss the in-flight singleflight call, trigger a second build and flake the test.

- add sfWaiters, counting goroutines parked in singleflight Do via runtime.Stack
- release the gate only once every follower is parked on the in-flight call
- drop the trailing sleep from waitFor

Reviewed-on: #177
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #177.
This commit is contained in:
2026-10-25 22:51:03 +11:00
committed by BenVincent
parent 7f65fc377c
commit e4833b6f96
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"runtime"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -206,18 +207,35 @@ func TestCachedRepodataEvictsIdleRemotes(t *testing.T) {
} }
} }
// waitFor polls until cond holds, then gives goroutines that passed the
// fingerprint a moment to reach the singleflight call.
func waitFor(t *testing.T, cond func() bool) { func waitFor(t *testing.T, cond func() bool) {
t.Helper() t.Helper()
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(10 * time.Second)
for !cond() { for !cond() {
if time.Now().After(deadline) { if time.Now().After(deadline) {
t.Fatal("condition not reached") t.Fatal("condition not reached")
} }
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
} }
time.Sleep(50 * time.Millisecond) }
// sfWaiters counts goroutines parked on an in-flight singleflight call.
func sfWaiters() int {
buf := make([]byte, 1<<20)
for {
n := runtime.Stack(buf, true)
if n < len(buf) {
buf = buf[:n]
break
}
buf = make([]byte, 2*len(buf))
}
waiters := 0
for _, g := range strings.Split(string(buf), "\n\n") {
if strings.Contains(g, "sync.(*WaitGroup).Wait") && strings.Contains(g, "singleflight.(*Group).Do(") {
waiters++
}
}
return waiters
} }
func TestCachedRepodataCoalescesConcurrentBuilds(t *testing.T) { func TestCachedRepodataCoalescesConcurrentBuilds(t *testing.T) {
@@ -234,7 +252,7 @@ func TestCachedRepodataCoalescesConcurrentBuilds(t *testing.T) {
codes[i] = serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), repodataPaths[i%len(repodataPaths)]).Code codes[i] = serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), repodataPaths[i%len(repodataPaths)]).Code
}() }()
} }
waitFor(t, func() bool { return store.fpCount() == n }) waitFor(t, func() bool { return store.listCount() == 1 && sfWaiters() == n-1 })
close(gate) close(gate)
wg.Wait() wg.Wait()
for i, c := range codes { for i, c := range codes {
@@ -261,7 +279,7 @@ func TestCachedRepodataLeaderCancelDoesNotFailWaiters(t *testing.T) {
go func() { go func() {
results <- serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/repomd.xml").Code results <- serveReq(t, p, remote, store, httptest.NewRequest(http.MethodGet, "/x", nil), "repodata/repomd.xml").Code
}() }()
waitFor(t, func() bool { return store.fpCount() == 2 }) waitFor(t, func() bool { return sfWaiters() == 1 })
cancelLeader() cancelLeader()
close(gate) close(gate)
for range 2 { for range 2 {