LabKit v2.8.0: httpserver.Server.{Addr,ProbeAddr}() race with Server.Start

📫 Context

LabKit v2.8.0's httpserver.Server.Start writes the bound listener fields (s.ln, s.probeLn) at labkit/v2/httpserver/server.go:241 (and surrounding lines) without explicit synchronization against concurrent reads of those fields via the public Server.Addr() and Server.ProbeAddr() accessors at server.go:206-221.

Go's -race detector flags two distinct races during the lifecycle integration tests that the S01 Step 6 composition root drives:

  1. probeLn field assignment vs nil-check. Start writes s.probeLn while a concurrent reader calls ProbeAddr() and hits if s.probeLn == nil at server.go:217.
  2. probeLn.Addr() byte read. Once probeLn is non-nil, ProbeAddr() calls s.probeLn.Addr(), which races with Start's ongoing field initialization on the *TCPListener.

The race surfaces when tests spawn go app.Run(ctx) and then poll srv.ProbeAddr() / srv.Addr() from the test goroutine to discover the bound ephemeral ports. Tests pass without -race; they fail under -race on every run.

Sample race trace (full output available in the AR MR thread):

WARNING: DATA RACE
Write at 0x00c000481b40 by goroutine 22:
  gitlab.com/gitlab-org/labkit/v2/httpserver.(*Server).Start()
      labkit/v2@v2.8.0/httpserver/server.go:241

Previous read at 0x00c000481b40 by goroutine 11:
  gitlab.com/gitlab-org/labkit/v2/httpserver.(*Server).ProbeAddr()
      labkit/v2@v2.8.0/httpserver/server.go:217

The accessors are documented for cross-goroutine use ("Use this after starting with Addr :0 to discover the bound port" — server.go:206-211), so callers reasonably expect them to be race-safe.

🚒 Workaround in AR

S01 Step 6's lifecycle integration tests register a test-only startBarrier app.Component as the last component on the App. The barrier's Start closes a channel; LIFO start ordering guarantees its Start runs only after Server.Start has returned, and the channel close gives the test goroutine a happens-before edge against LabKit's listener-field writes. Reads of srv.Addr() / srv.ProbeAddr() from the test goroutine after <-barrier.started are therefore race-safe without polling or build-tag skipping. See cmd/artifact-registry/main_test.go (startBarrier, runApp).

🚒 Upstream resolution

Status: upstream fix merged. gitlab-org/labkit!504 (merged) merged on 2026-05-26 and shipped in LabKit v2.10.2. The fix adds an sync.RWMutex to httpserver.Server: Start writes s.ln / s.probeLn under mu.Lock() and Addr() / ProbeAddr() read under mu.RLock().

Confirm against the released source (after go mod download gitlab.com/gitlab-org/labkit/v2@v2.10.2):

// httpserver/server.go @ v2.10.2
type Server struct {
    mu sync.RWMutex
    // ...
}

func (s *Server) Addr() string {
    s.mu.RLock()
    defer s.mu.RUnlock()
    // ...
}

Post-merge cleanup (this issue's actionable scope)

  1. Bump the gitlab.com/gitlab-org/labkit/v2 pin in go.mod from v2.8.0 to v2.10.2 (or the latest patch release containing the fix). Run go mod tidy.
  2. Run the lifecycle suite under -race -count=10 against the bumped LabKit to confirm the race no longer fires. The startBarrier is now a no-op on a fixed LabKit, so this should be a green run on the first try.
  3. Optional cleanup (recommended once #2 is confirmed): remove the startBarrier type, the awaitStartBarrier helper, and the barrier registration from runApp / TestLifecycle_DrainTimeoutExits1 in cmd/artifact-registry/main_test.go. Reads of srv.ProbeAddr() / srv.Addr() can go back to direct calls after app.Start returns.
  4. Update the cluster-header comment in main_test.go (currently pointing at this issue) to drop the race rationale, or remove the comment entirely.
  5. Cross-check with issue #98 — that issue's section 1b also touches lifecycle-test wiring; coordinate if both MRs land at once.
  6. Close this issue.

The bump-and-cleanup is intentionally separate from gitlab-org/ops/artifact-registry!291 (S01 Step 6) to keep that MR focused. It can ride as a standalone chore MR.

  • gitlab-org/labkit httpserver/server.go:206-221 (the unsynchronized accessors in v2.8.0)
  • gitlab-org/labkit httpserver/server.go:228-262 (the writer in Start in v2.8.0)
  • gitlab-org/labkit!504 (merged) — upstream fix (merged 2026-05-26, in v2.10.2)
  • gitlab-org/ops/artifact-registry!291 — Step 6 MR introducing the AR-side barrier workaround
  • gitlab-org/ops/artifact-registry — issue #98 (sibling LabKit-v2 follow-up: dual-listener spec drift and AC-7 / AC-18 dispatch-model drift)
Edited by Suleimi Ahmed