From d68817a4082902c468adad80e3315452ec4226cb Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 21:27:02 +0300 Subject: [PATCH] fix(telemetry): make the launch-source once-guard goroutine-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #991 launchSourceOnceT was a bare done bool with no synchronization; concurrent DetectLaunchSourceOnce calls (two listeners' /api/v1/status handlers in practice) raced on the flag and the cached value. The guard now holds a mutex — Do is exclusive and the cached write is ordered before any post-Do read — and resetLaunchSourceOnce resets under the same lock instead of copying the struct. ## Testing - New TestDetectLaunchSourceOnceConcurrent (32 goroutines): DATA RACE before the fix, green after - Original repro green: go test -race ./internal/server -run TestE2E_DualListener_Concurrent - lint v2 0 issues --- internal/telemetry/launch_source.go | 12 +++++++++- internal/telemetry/launch_source_test.go | 28 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/telemetry/launch_source.go b/internal/telemetry/launch_source.go index f71bfbb0..3459daa9 100644 --- a/internal/telemetry/launch_source.go +++ b/internal/telemetry/launch_source.go @@ -1,5 +1,7 @@ package telemetry +import "sync" + // LaunchSource identifies how the current mcpproxy process was launched, for // retention telemetry (spec 044). Detection happens once at process startup // (via DetectLaunchSourceOnce, added in a later task), with a one-shot @@ -110,11 +112,17 @@ var ( ) // launchSourceOnceT is a test-friendly sync.Once clone with reset support. +// Do is mutex-guarded: DetectLaunchSourceOnce is reached concurrently by every +// telemetry-reporting surface (two listeners' status handlers race here), and +// the mutex also orders the launchSourceCached write before any post-Do read. type launchSourceOnceT struct { + mu sync.Mutex done bool } func (o *launchSourceOnceT) Do(f func()) { + o.mu.Lock() + defer o.mu.Unlock() if o.done { return } @@ -124,7 +132,9 @@ func (o *launchSourceOnceT) Do(f func()) { // resetLaunchSourceOnce is exposed for tests (lower-case). func resetLaunchSourceOnce() { - launchSourceOnce = launchSourceOnceT{} + launchSourceOnce.mu.Lock() + defer launchSourceOnce.mu.Unlock() + launchSourceOnce.done = false launchSourceCached = "" } diff --git a/internal/telemetry/launch_source_test.go b/internal/telemetry/launch_source_test.go index 34016820..ecfc2455 100644 --- a/internal/telemetry/launch_source_test.go +++ b/internal/telemetry/launch_source_test.go @@ -1,6 +1,7 @@ package telemetry import ( + "sync" "testing" ) @@ -166,3 +167,30 @@ func TestDetectLaunchSourceOnce_Cached(t *testing.T) { t.Fatalf("DetectLaunchSourceOnce returned invalid %q", first) } } + +// DetectLaunchSourceOnce is reached concurrently by every surface that reports +// telemetry (two listeners' /api/v1/status handlers race here in practice); +// the once-guard must be goroutine-safe. Red under -race before the guard +// gained its mutex. +func TestDetectLaunchSourceOnceConcurrent(t *testing.T) { + resetLaunchSourceOnce() + t.Cleanup(resetLaunchSourceOnce) + + const goroutines = 32 + results := make([]LaunchSource, goroutines) + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func(i int) { + defer wg.Done() + results[i] = DetectLaunchSourceOnce() + }(i) + } + wg.Wait() + + for i := 1; i < goroutines; i++ { + if results[i] != results[0] { + t.Fatalf("goroutine %d saw %q, goroutine 0 saw %q", i, results[i], results[0]) + } + } +}