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]) + } + } +}