diff --git a/go.mod b/go.mod index 71d834aa..cc80e295 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/cucumber/godog v0.15.1 github.com/go-logr/logr v1.4.3 github.com/stretchr/testify v1.11.1 + go.uber.org/goleak v1.3.0 go.uber.org/mock v0.6.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.33.0 diff --git a/go.sum b/go.sum index 35f14cd1..956a6f94 100644 --- a/go.sum +++ b/go.sum @@ -51,14 +51,12 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/openfeature/client_test.go b/openfeature/client_test.go index 2cd4f9a6..4d243c7b 100644 --- a/openfeature/client_test.go +++ b/openfeature/client_test.go @@ -3,7 +3,6 @@ package openfeature import ( "context" "errors" - "math" "reflect" "testing" "time" @@ -1302,13 +1301,22 @@ func TestRequirement_1_7_5(t *testing.T) { // The client MUST default, run error hooks, and indicate an error if flag resolution is attempted while the provider // is in NOT_READY. func TestRequirement_1_7_6(t *testing.T) { - t.Cleanup(initSingleton) + t.Cleanup(func() { + eventing.(*eventExecutor).shutdown() + initSingleton() + }) ctrl := gomock.NewController(t) mockHook := NewMockHook(ctrl) mockHook.EXPECT().Error(gomock.Any(), gomock.Any(), ProviderNotReadyError, gomock.Any()) mockHook.EXPECT().Finally(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) + // Use a channel that blocks until test cleanup to keep provider in NOT_READY state + blockChan := make(chan struct{}) + t.Cleanup(func() { + close(blockChan) + }) + notReadyEventingProvider := struct { FeatureProvider StateHandler @@ -1317,7 +1325,7 @@ func TestRequirement_1_7_6(t *testing.T) { NoopProvider{}, &stateHandlerForTests{ initF: func(e EvaluationContext) error { - <-time.After(math.MaxInt) + <-blockChan return nil }, }, diff --git a/openfeature/event_executor.go b/openfeature/event_executor.go index 01fa1718..d81dbfd3 100644 --- a/openfeature/event_executor.go +++ b/openfeature/event_executor.go @@ -26,7 +26,10 @@ type eventExecutor struct { scopedRegistry map[string]scopedCallback eventChan chan eventPayload once sync.Once + shutdownOnce sync.Once mu sync.Mutex + done chan struct{} + wg sync.WaitGroup } func newEventExecutor() *eventExecutor { @@ -37,6 +40,7 @@ func newEventExecutor() *eventExecutor { apiRegistry: map[EventType][]EventCallback{}, scopedRegistry: map[string]scopedCallback{}, eventChan: make(chan eventPayload, 5), + done: make(chan struct{}), } executor.startEventListener() @@ -223,7 +227,9 @@ func (e *eventExecutor) startListeningAndShutdownOld(newProvider providerReferen e.activeSubscriptions = append(e.activeSubscriptions, newProvider) if v, ok := newProvider.featureProvider.(EventHandler); ok { + e.wg.Add(1) go func() { + defer e.wg.Done() // event handling of the new feature provider for { select { @@ -231,9 +237,14 @@ func (e *eventExecutor) startListeningAndShutdownOld(newProvider providerReferen if !ok { return } - e.eventChan <- eventPayload{ + // Try to send the event, but also watch for shutdown signal + select { + case e.eventChan <- eventPayload{ event: event, handler: newProvider.featureProvider, + }: + case <-newProvider.shutdownSemaphore: + return } case <-newProvider.shutdownSemaphore: return @@ -274,9 +285,19 @@ func (e *eventExecutor) startListeningAndShutdownOld(newProvider providerReferen // startEventListener trigger the event listening of this executor func (e *eventExecutor) startEventListener() { e.once.Do(func() { + e.wg.Add(1) go func() { - for payload := range e.eventChan { - e.triggerEvent(payload.event, payload.handler) + defer e.wg.Done() + for { + select { + case payload, ok := <-e.eventChan: + if !ok { + return + } + e.triggerEvent(payload.event, payload.handler) + case <-e.done: + return + } } }() }) @@ -353,3 +374,29 @@ func isBound(provider providerReference, defaultProvider providerReference, name return provider.equals(defaultProvider) || slices.ContainsFunc(namedProviders, provider.equals) } + +// shutdown stops the event executor's goroutine and waits for it to complete. +// This should be called when the event executor is no longer needed to prevent goroutine leaks. +func (e *eventExecutor) shutdown() { + e.shutdownOnce.Do(func() { + e.mu.Lock() + // Close shutdown semaphores to signal all active provider goroutines to stop + // Closing (rather than sending) ensures all waiting goroutines receive the signal + for _, sub := range e.activeSubscriptions { + close(sub.shutdownSemaphore) + } + e.mu.Unlock() + + // Close the done channel to stop the main event listener + close(e.done) + + // Wait for all goroutines to finish + e.wg.Wait() + + // Drain any remaining events in the channel + for len(e.eventChan) > 0 { + <-e.eventChan + } + }) +} + diff --git a/openfeature/event_executor_test.go b/openfeature/event_executor_test.go index 3e2a2030..5d8f890d 100644 --- a/openfeature/event_executor_test.go +++ b/openfeature/event_executor_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/stretchr/testify/require" + "go.uber.org/goleak" ) func init() { @@ -1142,31 +1143,49 @@ func TestEventHandler_multiSubs(t *testing.T) { // make sure events are received and count them globalEvents := make(chan string, 10) + done := make(chan struct{}) + t.Cleanup(func() { + close(done) + }) + go func() { - for rsp := range rspGlobal { - globalEvents <- rsp.ProviderName + for { + select { + case rsp := <-rspGlobal: + globalEvents <- rsp.ProviderName + case <-done: + return + } } }() clientAEvents := make(chan string, 10) go func() { - for rsp := range rspClientA { - if rsp.ProviderName != "providerOther" { - t.Errorf("incorrect event provider association, expected %s, got %s", "providerOther", rsp.ProviderName) + for { + select { + case rsp := <-rspClientA: + if rsp.ProviderName != "providerOther" { + t.Errorf("incorrect event provider association, expected %s, got %s", "providerOther", rsp.ProviderName) + } + clientAEvents <- rsp.ProviderName + case <-done: + return } - - clientAEvents <- rsp.ProviderName } }() clientBEvents := make(chan string, 10) go func() { - for rsp := range rspClientB { - if rsp.ProviderName != "providerOther" { - t.Errorf("incorrect event provider association, expected %s, got %s", "providerOther", rsp.ProviderName) + for { + select { + case rsp := <-rspClientB: + if rsp.ProviderName != "providerOther" { + t.Errorf("incorrect event provider association, expected %s, got %s", "providerOther", rsp.ProviderName) + } + clientBEvents <- rsp.ProviderName + case <-done: + return } - - clientBEvents <- rsp.ProviderName } }() @@ -1568,6 +1587,7 @@ func TestEventHandler_ChannelClosure(t *testing.T) { callBack := func(e EventDetails) { eventCount.Add(1) } + executor.AddHandler(ProviderReady, &callBack) // watch for empty events executor.AddHandler("", &callBack) @@ -1585,3 +1605,91 @@ func TestEventHandler_ChannelClosure(t *testing.T) { afterCount := eventCount.Load() require.Equal(t, initialCount, afterCount, "goroutine processed events after channel closed - indicates channel closure not detected") } + +// TestBasicShutdown verifies that the shutdown method stops goroutines +func TestBasicShutdown(t *testing.T) { + // Create a new event executor + exec := newEventExecutor() + + // Give it a moment to start + time.Sleep(10 * time.Millisecond) + + // Shutdown should complete without hanging + done := make(chan struct{}) + go func() { + exec.shutdown() + close(done) + }() + + select { + case <-done: + // Success - shutdown completed + case <-time.After(2 * time.Second): + t.Fatal("shutdown() hung and did not complete within 2 seconds") + } +} + +// TestNoGoroutineLeakWithMultipleProviders verifies that goroutine cleanup +// works correctly even when multiple providers are registered. +func TestNoGoroutineLeakWithMultipleProviders(t *testing.T) { + // Setup: shut down any existing goroutines from previous tests first + if eventing != nil { + eventing.(*eventExecutor).shutdown() + } + initSingleton() + + defer goleak.VerifyNone(t, + // Ignore the new event executor's goroutines created by Shutdown() -> initSingleton() + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"), + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startListeningAndShutdownOld.func1"), + ) + + // Ensure we clean up the event executor at the end, including any reinitialized instance + defer func() { + if eventing != nil { + eventing.(*eventExecutor).shutdown() + } + }() + + // Set default provider + defaultProvider := &ProviderEventing{c: make(chan Event, 1)} + err := SetProvider(struct { + FeatureProvider + EventHandler + }{NoopProvider{}, defaultProvider}) + if err != nil { + t.Fatal(err) + } + + // Set named provider + namedProvider := &ProviderEventing{c: make(chan Event, 1)} + err = SetNamedProvider("test-domain", struct { + FeatureProvider + EventHandler + }{NoopProvider{}, namedProvider}) + if err != nil { + t.Fatal(err) + } + + // Trigger events + defaultProvider.Invoke(Event{ + EventType: ProviderReady, + ProviderEventDetails: ProviderEventDetails{ + Message: "Default ready", + }, + }) + + namedProvider.Invoke(Event{ + EventType: ProviderReady, + ProviderEventDetails: ProviderEventDetails{ + Message: "Named ready", + }, + }) + + time.Sleep(100 * time.Millisecond) + + // Shutdown cleans up all goroutines and reinitializes + Shutdown() + + // goleak will verify no goroutines are leaked (except the new event executor) +} diff --git a/openfeature/main_test.go b/openfeature/main_test.go new file mode 100644 index 00000000..1d0d196e --- /dev/null +++ b/openfeature/main_test.go @@ -0,0 +1,19 @@ +package openfeature + +import ( + "os" + "testing" +) + +// TestMain provides setup and teardown for the entire test suite +func TestMain(m *testing.M) { + // Run tests + code := m.Run() + + // Final cleanup: shut down the global event executor + if eventing != nil { + eventing.(*eventExecutor).shutdown() + } + + os.Exit(code) +} diff --git a/openfeature/openfeature.go b/openfeature/openfeature.go index 69db3f29..150a26fc 100644 --- a/openfeature/openfeature.go +++ b/openfeature/openfeature.go @@ -18,6 +18,11 @@ func init() { } func initSingleton() { + // Shutdown existing event executor to prevent goroutine leaks + if eventing != nil { + eventing.(*eventExecutor).shutdown() + } + exec := newEventExecutor() eventing = exec @@ -149,6 +154,7 @@ func RemoveHandler(eventType EventType, callback EventCallback) { // hooks, event handlers, and providers. func Shutdown() { api.Shutdown() + eventing.(*eventExecutor).shutdown() initSingleton() } @@ -159,6 +165,7 @@ func Shutdown() { // Returns an error if any provider shutdown fails or if context is cancelled during shutdown. func ShutdownWithContext(ctx context.Context) error { err := api.ShutdownWithContext(ctx) + eventing.(*eventExecutor).shutdown() initSingleton() return err } diff --git a/openfeature/openfeature_test.go b/openfeature/openfeature_test.go index fa031cc2..efb45cf4 100644 --- a/openfeature/openfeature_test.go +++ b/openfeature/openfeature_test.go @@ -1,11 +1,13 @@ package openfeature import ( + "context" "errors" "reflect" "testing" "time" + "go.uber.org/goleak" "go.uber.org/mock/gomock" ) @@ -65,13 +67,7 @@ func TestRequirement_1_1_2_2(t *testing.T) { t.Errorf("error setting up provider %v", err) } - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - t.Errorf("initialization not invoked with provider registration") - case <-initSem: - break - } + expectChannelReceive(t, initSem, "initialization not invoked with provider registration") if !reflect.DeepEqual(provider, api.GetProvider()) { t.Errorf("provider not updated to the one set") @@ -90,13 +86,7 @@ func TestRequirement_1_1_2_2(t *testing.T) { t.Errorf("error setting up provider %v", err) } - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - t.Errorf("initialization not invoked with provider registration") - case <-initSem: - break - } + expectChannelReceive(t, initSem, "initialization not invoked with provider registration") if !reflect.DeepEqual(provider, api.GetNamedProviders()[client]) { t.Errorf("provider not updated to the one set") @@ -117,28 +107,18 @@ func TestRequirement_1_1_2_3(t *testing.T) { t.Errorf("error setting up provider %v", err) } - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - t.Errorf("initialization not invoked with provider registration") - case <-initSem: - break - } + expectChannelReceive(t, initSem, "initialization not invoked with provider registration") - providerOverride, _, _ := setupProviderWithSemaphores() + providerOverride, overrideInitSem, _ := setupProviderWithSemaphores() err = SetProvider(providerOverride) if err != nil { t.Errorf("error setting up provider %v", err) } - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - t.Errorf("shutdown not invoked for old default provider when registering new provider") - case <-shutdownSem: - break - } + // Consume the override provider's initialization semaphore to prevent goroutine leak + expectChannelReceive(t, overrideInitSem, "override provider initialization not invoked") + expectChannelReceive(t, shutdownSem, "shutdown not invoked for old default provider when registering new provider") }) t.Run("named provider", func(t *testing.T) { @@ -153,35 +133,25 @@ func TestRequirement_1_1_2_3(t *testing.T) { t.Errorf("error setting up provider %v", err) } - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - t.Errorf("initialization not invoked with provider registration") - case <-initSem: - break - } + expectChannelReceive(t, initSem, "initialization not invoked with provider registration") - providerOverride, _, _ := setupProviderWithSemaphores() + providerOverride, overrideInitSem, _ := setupProviderWithSemaphores() err = SetNamedProvider(client, providerOverride) if err != nil { t.Errorf("error setting up provider %v", err) } - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - t.Errorf("shutdown not invoked for old named provider when registering new provider") - case <-shutdownSem: - break - } + // Consume the override provider's initialization semaphore to prevent goroutine leak + expectChannelReceive(t, overrideInitSem, "override provider initialization not invoked") + expectChannelReceive(t, shutdownSem, "shutdown not invoked for old named provider when registering new provider") }) t.Run("ignore shutdown for multiple references - default bound", func(t *testing.T) { t.Cleanup(initSingleton) // setup - provider, _, shutdownSem := setupProviderWithSemaphores() + provider, initSem, shutdownSem := setupProviderWithSemaphores() // register provider multiple times err := SetProvider(provider) @@ -189,6 +159,9 @@ func TestRequirement_1_1_2_3(t *testing.T) { t.Errorf("error setting up provider %v", err) } + // Consume the initialization semaphore + expectChannelReceive(t, initSem, "provider initialization not invoked") + clientName := "clientA" err = SetNamedProvider(clientName, provider) @@ -196,28 +169,25 @@ func TestRequirement_1_1_2_3(t *testing.T) { t.Errorf("error setting up provider %v", err) } - providerOverride, _, _ := setupProviderWithSemaphores() + providerOverride, overrideInitSem, _ := setupProviderWithSemaphores() err = SetNamedProvider(clientName, providerOverride) if err != nil { t.Errorf("error setting up provider %v", err) } + // Consume the override provider's initialization semaphore to prevent goroutine leak + expectChannelReceive(t, overrideInitSem, "override provider initialization not invoked") + // validate - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - break - case <-shutdownSem: - t.Errorf("shutdown called on the provider with multiple references") - } + expectTimeout(t, shutdownSem, "shutdown called on the provider with multiple references") }) t.Run("ignore shutdown for multiple references - domain client bound", func(t *testing.T) { t.Cleanup(initSingleton) // setup - providerA, _, shutdownSemA := setupProviderWithSemaphores() + providerA, initSemA, shutdownSemA := setupProviderWithSemaphores() // register provider multiple times @@ -229,26 +199,26 @@ func TestRequirement_1_1_2_3(t *testing.T) { t.Errorf("error setting up provider %v", err) } + // Consume the initialization semaphore for providerA + expectChannelReceive(t, initSemA, "providerA initialization not invoked") + err = SetNamedProvider(clientB, providerA) if err != nil { t.Errorf("error setting up provider %v", err) } - providerOverride, _, _ := setupProviderWithSemaphores() + providerOverride, overrideInitSem, _ := setupProviderWithSemaphores() err = SetNamedProvider(clientA, providerOverride) if err != nil { t.Errorf("error setting up provider %v", err) } + // Consume the override provider's initialization semaphore to prevent goroutine leak + expectChannelReceive(t, overrideInitSem, "override provider initialization not invoked") + // validate - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - break - case <-shutdownSemA: - t.Errorf("shutdown called on the provider with multiple references") - } + expectTimeout(t, shutdownSemA, "shutdown called on the provider with multiple references") }) } @@ -564,23 +534,11 @@ func TestRequirement_1_6_1(t *testing.T) { t.Errorf("error setting up provider %v", err) } - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - t.Errorf("intialization timeout") - case <-initSem: - break - } + expectChannelReceive(t, initSem, "intialization timeout") Shutdown() - select { - // short enough wait time, but not too long - case <-time.After(100 * time.Millisecond): - t.Errorf("shutdown not invoked") - case <-shutdownSem: - break - } + expectChannelReceive(t, shutdownSem, "shutdown not invoked") } // The API's `shutdown` function MUST reset all state of the API, removing all @@ -628,12 +586,7 @@ func TestRequirement_1_6_2(t *testing.T) { t.Fatalf("shutdown not invoked") } - select { - case <-shutdownSem1: - t.Fatalf("provider1 should not have been shut down again, since it is unregistered") - case <-time.After(100 * time.Millisecond): - break - } + expectTimeout(t, shutdownSem1, "provider1 should not have been shut down again, since it is unregistered") } func TestRequirement_EventCompliance(t *testing.T) { @@ -866,3 +819,124 @@ func setupProviderWithSemaphores() (struct { return provider, intiSem, shutdownSem } + +// expectChannelReceive waits for a channel to receive a value within the timeout. +// It fails the test with the provided message if the timeout occurs first. +func expectChannelReceive(t *testing.T, ch <-chan any, timeoutMsg string) { + t.Helper() + select { + case <-time.After(100 * time.Millisecond): + t.Error(timeoutMsg) + case <-ch: + } +} + +// expectTimeout waits for a timeout to occur, expecting no value from the channel. +// It fails the test with the provided message if the channel receives a value. +func expectTimeout(t *testing.T, ch <-chan any, receiveMsg string) { + t.Helper() + select { + case <-time.After(100 * time.Millisecond): + // Expected timeout + case <-ch: + t.Error(receiveMsg) + } +} + +// TestNoGoroutineLeak verifies that the event executor's goroutine is properly +// cleaned up after shutdown, preventing goroutine leaks. +func TestNoGoroutineLeak(t *testing.T) { + // Setup: shut down any existing goroutines from previous tests first + if eventing != nil { + eventing.(*eventExecutor).shutdown() + } + initSingleton() + + // Setup: capture initial goroutines after cleanup + defer goleak.VerifyNone(t, + // Ignore the new event executor's goroutines created by Shutdown() -> initSingleton() + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"), + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startListeningAndShutdownOld.func1"), + ) + + // Ensure we clean up the event executor at the end, including any reinitialized instance + defer func() { + if eventing != nil { + eventing.(*eventExecutor).shutdown() + } + }() + + // Create a new provider and trigger some events + eventingImpl := &ProviderEventing{ + c: make(chan Event, 1), + } + + eventingProvider := struct { + FeatureProvider + EventHandler + }{ + NoopProvider{}, + eventingImpl, + } + + err := SetProvider(eventingProvider) + if err != nil { + t.Fatal(err) + } + + // Add a handler to ensure the event system is active + callbackInvoked := false + callback := func(details EventDetails) { + callbackInvoked = true + } + AddHandler(ProviderReady, &callback) + + // Trigger an event + eventingImpl.Invoke(Event{ + EventType: ProviderReady, + ProviderEventDetails: ProviderEventDetails{ + Message: "Ready", + }, + }) + + // Give the event system time to process + time.Sleep(100 * time.Millisecond) + + // Verify the callback was invoked + if !callbackInvoked { + t.Error("callback should have been invoked") + } + + // Perform shutdown - this cleans up goroutines but reinitializes the singleton + Shutdown() + + // The deferred cleanup will shut down the reinitialized instance +} + +// TestNoGoroutineLeakWithShutdownWithContext verifies that ShutdownWithContext +// also properly cleans up goroutines. +func TestNoGoroutineLeakWithShutdownWithContext(t *testing.T) { + defer goleak.VerifyNone(t, + // Ignore provider goroutines from the new event executor + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startListeningAndShutdownOld.func1"), + // Ignore the new event executor's goroutine created by ShutdownWithContext() -> initSingleton() + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"), + ) + + eventingImpl := &ProviderEventing{c: make(chan Event, 1)} + err := SetProvider(struct { + FeatureProvider + EventHandler + }{NoopProvider{}, eventingImpl}) + if err != nil { + t.Fatal(err) + } + + // Use ShutdownWithContext instead of Shutdown + err = ShutdownWithContext(context.Background()) + if err != nil { + t.Fatal(err) + } + + // goleak will verify no goroutines are leaked (except the new event executor) +} diff --git a/openfeature/reference.go b/openfeature/reference.go index 046b421d..db256cb1 100644 --- a/openfeature/reference.go +++ b/openfeature/reference.go @@ -9,7 +9,7 @@ func newProviderRef(provider FeatureProvider) providerReference { return providerReference{ featureProvider: provider, kind: reflect.TypeOf(provider).Kind(), - shutdownSemaphore: make(chan any), + shutdownSemaphore: make(chan any, 1), // Buffered to allow both send and close operations } }