From 8c7704e8a1c2b9eb20e0da6ef928dbc744e05a2e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Thu, 6 Aug 2026 13:09:30 -0700 Subject: [PATCH 1/2] fix(sharedtest): make FakeLDClient.Close idempotent Close() closed CloseCh unconditionally whenever it was non-nil, so any path that closed the same fake client twice (e.g. env teardown racing a re-anchor that already closed the old client) panicked with "close of closed channel" and took down the whole test binary. Guard the close with sync.Once, matching the closeOnce pattern already used elsewhere in the codebase (metrics, streams, autoconfig, bigsegments, filedata, events) for the same purpose. --- internal/sharedtest/testclient/fake_client.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/sharedtest/testclient/fake_client.go b/internal/sharedtest/testclient/fake_client.go index 46168a78..1262cec2 100644 --- a/internal/sharedtest/testclient/fake_client.go +++ b/internal/sharedtest/testclient/fake_client.go @@ -36,6 +36,7 @@ type FakeLDClient struct { dataSourceStatus *interfaces.DataSourceStatus initialized bool lock sync.Mutex + closeOnce sync.Once } type CapturedLDClient struct { @@ -70,7 +71,9 @@ func (c *FakeLDClient) GetDataStoreStatus() sdks.DataStoreStatusInfo { func (c *FakeLDClient) Close() error { if c.CloseCh != nil { - close(c.CloseCh) + c.closeOnce.Do(func() { + close(c.CloseCh) + }) } return nil } From eb715ec8b5556f7490c7feb6f4a6c4c7f351f9f1 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Thu, 6 Aug 2026 13:22:26 -0700 Subject: [PATCH 2/2] test(sharedtest): cover FakeLDClient.Close idempotency Adds a doc comment on Close() explaining why it must tolerate a second call, plus a regression test that fails with the original "close of closed channel" panic if the sync.Once guard is ever removed. Also covers concurrent callers under -race, which a plain bool guard would not. --- internal/sharedtest/testclient/fake_client.go | 2 + .../sharedtest/testclient/fake_client_test.go | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 internal/sharedtest/testclient/fake_client_test.go diff --git a/internal/sharedtest/testclient/fake_client.go b/internal/sharedtest/testclient/fake_client.go index 1262cec2..b541afb2 100644 --- a/internal/sharedtest/testclient/fake_client.go +++ b/internal/sharedtest/testclient/fake_client.go @@ -69,6 +69,8 @@ func (c *FakeLDClient) GetDataStoreStatus() sdks.DataStoreStatusInfo { return sdks.DataStoreStatusInfo{Available: true} } +// Close is idempotent, matching the real SDK client: Relay may tear a client down from more than +// one code path, and the second call must not panic. func (c *FakeLDClient) Close() error { if c.CloseCh != nil { c.closeOnce.Do(func() { diff --git a/internal/sharedtest/testclient/fake_client_test.go b/internal/sharedtest/testclient/fake_client_test.go new file mode 100644 index 00000000..51cbab04 --- /dev/null +++ b/internal/sharedtest/testclient/fake_client_test.go @@ -0,0 +1,42 @@ +package testclient + +import ( + "sync" + "testing" + + "github.com/launchdarkly/ld-relay/v8/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFakeLDClientCloseIsIdempotent(t *testing.T) { + c := &FakeLDClient{Key: config.SDKKey("key"), CloseCh: make(chan struct{})} + + require.NoError(t, c.Close()) + require.NoError(t, c.Close()) + + select { + case <-c.CloseCh: + default: + assert.Fail(t, "CloseCh was not closed") + } +} + +func TestFakeLDClientCloseIsSafeForConcurrentCallers(t *testing.T) { + c := &FakeLDClient{Key: config.SDKKey("key"), CloseCh: make(chan struct{})} + + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { + assert.NoError(t, c.Close()) + }) + } + wg.Wait() + + select { + case <-c.CloseCh: + default: + assert.Fail(t, "CloseCh was not closed") + } +}