From ad34c63ad3be3268ebcabcd992882879205094df Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Tue, 25 Nov 2025 13:21:10 +0100 Subject: [PATCH 1/8] fix: allow for providers to safely shutdown Signed-off-by: Nicklas Lundin --- .../openfeature/sdk/ProviderRepository.java | 10 +++ .../sdk/ProviderRepositoryTest.java | 75 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/main/java/dev/openfeature/sdk/ProviderRepository.java b/src/main/java/dev/openfeature/sdk/ProviderRepository.java index 147074a58..356704f98 100644 --- a/src/main/java/dev/openfeature/sdk/ProviderRepository.java +++ b/src/main/java/dev/openfeature/sdk/ProviderRepository.java @@ -10,6 +10,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -277,5 +278,14 @@ public void shutdown() { .forEach(this::shutdownProvider); this.stateManagers.clear(); taskExecutor.shutdown(); + try { + if (!taskExecutor.awaitTermination(3, TimeUnit.SECONDS)) { + log.warn("Task executor did not terminate before the timeout period had elapsed"); + taskExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + taskExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } } } diff --git a/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java index 7041df5c1..99e34436c 100644 --- a/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java +++ b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java @@ -15,6 +15,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; @@ -289,6 +290,80 @@ void shouldRunLambdasOnError() throws Exception { verify(afterError, timeout(TIMEOUT)).accept(eq(errorFeatureProvider), any()); } } + + @Nested + class GracefulShutdownBehavior { + + @Test + @DisplayName("should complete shutdown successfully when executor terminates within timeout") + void shouldCompleteShutdownSuccessfullyWhenExecutorTerminatesWithinTimeout() { + FeatureProvider provider = createMockedProvider(); + setFeatureProvider(provider); + + assertThatCode(() -> providerRepository.shutdown()).doesNotThrowAnyException(); + + verify(provider, timeout(TIMEOUT)).shutdown(); + } + + @Test + @DisplayName("should force shutdown when executor does not terminate within timeout") + void shouldForceShutdownWhenExecutorDoesNotTerminateWithinTimeout() throws Exception { + FeatureProvider provider = createMockedProvider(); + AtomicBoolean wasInterrupted = new AtomicBoolean(false); + doAnswer(invocation -> { + try { + Thread.sleep(TIMEOUT); + } catch (InterruptedException e) { + wasInterrupted.set(true); + throw e; + } + return null; + }) + .when(provider) + .shutdown(); + + setFeatureProvider(provider); + + assertThatCode(() -> providerRepository.shutdown()).doesNotThrowAnyException(); + + verify(provider, timeout(TIMEOUT)).shutdown(); + // Verify that shutdownNow() interrupted the running shutdown task + await().atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(wasInterrupted.get()).isTrue()); + } + + @Test + @DisplayName("should handle interruption during shutdown gracefully") + void shouldHandleInterruptionDuringShutdownGracefully() throws Exception { + FeatureProvider provider = createMockedProvider(); + setFeatureProvider(provider); + + Thread shutdownThread = new Thread(() -> { + providerRepository.shutdown(); + }); + + shutdownThread.start(); + shutdownThread.interrupt(); + shutdownThread.join(TIMEOUT); + + assertThat(shutdownThread.isAlive()).isFalse(); + verify(provider, timeout(TIMEOUT)).shutdown(); + } + + @Test + @DisplayName("should not hang indefinitely on shutdown") + void shouldNotHangIndefinitelyOnShutdown() { + FeatureProvider provider = createMockedProvider(); + setFeatureProvider(provider); + + await().alias("shutdown should complete within reasonable time") + .atMost(Duration.ofSeconds(5)) + .until(() -> { + providerRepository.shutdown(); + return true; + }); + } + } } @Test From 7efe5fd02e93801b03dcbf079a9e7e7a0cc79386 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 27 Nov 2025 13:34:45 +0100 Subject: [PATCH 2/8] fix: prevent race conditions during repository shutdown Signed-off-by: Nicklas Lundin --- .../openfeature/sdk/ProviderRepository.java | 43 +++- .../sdk/ProviderRepositoryTest.java | 97 +++++++-- .../sdk/vmlens/ProviderRepositoryCT.java | 189 ++++++++++++++++++ 3 files changed, 304 insertions(+), 25 deletions(-) create mode 100644 src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java diff --git a/src/main/java/dev/openfeature/sdk/ProviderRepository.java b/src/main/java/dev/openfeature/sdk/ProviderRepository.java index 356704f98..552f78888 100644 --- a/src/main/java/dev/openfeature/sdk/ProviderRepository.java +++ b/src/main/java/dev/openfeature/sdk/ProviderRepository.java @@ -11,6 +11,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -24,6 +25,7 @@ class ProviderRepository { private final Map stateManagers = new ConcurrentHashMap<>(); private final AtomicReference defaultStateManger = new AtomicReference<>(new FeatureProviderStateManager(new NoOpProvider())); + private final AtomicBoolean isShuttingDown = new AtomicBoolean(false); private final ExecutorService taskExecutor = Executors.newCachedThreadPool(new ConfigurableThreadFactory("openfeature-provider-thread", true)); private final Object registerStateManagerLock = new Object(); @@ -163,6 +165,9 @@ private void prepareAndInitializeProvider( final FeatureProviderStateManager oldStateManager; synchronized (registerStateManagerLock) { + if (isShuttingDown.get()) { + throw new IllegalStateException("Provider cannot be set while repository is shutting down"); + } FeatureProviderStateManager existing = getExistingStateManagerForProvider(newProvider); if (existing == null) { newStateManager = new FeatureProviderStateManager(newProvider); @@ -255,16 +260,27 @@ private void shutdownProvider(FeatureProviderStateManager manager) { } private void shutdownProvider(FeatureProvider provider) { - taskExecutor.submit(() -> { + try { + taskExecutor.submit(() -> { + try { + provider.shutdown(); + } catch (Exception e) { + log.error( + "Exception when shutting down feature provider {}", + provider.getClass().getName(), + e); + } + }); + } catch (java.util.concurrent.RejectedExecutionException e) { try { provider.shutdown(); - } catch (Exception e) { + } catch (Exception ex) { log.error( "Exception when shutting down feature provider {}", provider.getClass().getName(), - e); + ex); } - }); + } } /** @@ -273,10 +289,21 @@ private void shutdownProvider(FeatureProvider provider) { * including the default feature provider. */ public void shutdown() { - Stream.concat(Stream.of(this.defaultStateManger.get()), this.stateManagers.values().stream()) - .distinct() - .forEach(this::shutdownProvider); - this.stateManagers.clear(); + List managersToShutdown; + + synchronized (registerStateManagerLock) { + if (isShuttingDown.getAndSet(true)) { + return; + } + + managersToShutdown = Stream.concat( + Stream.of(this.defaultStateManger.get()), this.stateManagers.values().stream()) + .distinct() + .collect(Collectors.toList()); + this.stateManagers.clear(); + } + + managersToShutdown.forEach(this::shutdownProvider); taskExecutor.shutdown(); try { if (!taskExecutor.awaitTermination(3, TimeUnit.SECONDS)) { diff --git a/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java index 99e34436c..dfe8e8074 100644 --- a/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java +++ b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java @@ -4,6 +4,7 @@ import static dev.openfeature.sdk.testutils.stubbing.ConditionStubber.doDelayResponse; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -332,23 +333,9 @@ void shouldForceShutdownWhenExecutorDoesNotTerminateWithinTimeout() throws Excep .untilAsserted(() -> assertThat(wasInterrupted.get()).isTrue()); } - @Test - @DisplayName("should handle interruption during shutdown gracefully") - void shouldHandleInterruptionDuringShutdownGracefully() throws Exception { - FeatureProvider provider = createMockedProvider(); - setFeatureProvider(provider); - - Thread shutdownThread = new Thread(() -> { - providerRepository.shutdown(); - }); - - shutdownThread.start(); - shutdownThread.interrupt(); - shutdownThread.join(TIMEOUT); - - assertThat(shutdownThread.isAlive()).isFalse(); - verify(provider, timeout(TIMEOUT)).shutdown(); - } + // Note: shouldHandleInterruptionDuringShutdownGracefully was removed because the + // interrupt timing is not guaranteed. Proper concurrency testing is done in + // ProviderRepositoryCT using VMLens. @Test @DisplayName("should not hang indefinitely on shutdown") @@ -363,6 +350,82 @@ void shouldNotHangIndefinitelyOnShutdown() { return true; }); } + + @Test + @DisplayName("should handle shutdown during provider initialization") + void shouldHandleShutdownDuringProviderInitialization() throws Exception { + FeatureProvider slowInitProvider = createMockedProvider(); + AtomicBoolean shutdownCalled = new AtomicBoolean(false); + + doDelayResponse(Duration.ofMillis(500)).when(slowInitProvider).initialize(any()); + + doAnswer(invocation -> { + shutdownCalled.set(true); + return null; + }) + .when(slowInitProvider) + .shutdown(); + + providerRepository.setProvider( + slowInitProvider, + mockAfterSet(), + mockAfterInit(), + mockAfterShutdown(), + mockAfterError(), + false); + + // Call shutdown while initialization is in progress + assertThatCode(() -> providerRepository.shutdown()).doesNotThrowAnyException(); + + await().atMost(Duration.ofSeconds(1)).untilTrue(shutdownCalled); + verify(slowInitProvider, times(1)).shutdown(); + } + + @Test + @DisplayName("should handle provider replacement during shutdown") + void shouldHandleProviderReplacementDuringShutdown() throws Exception { + FeatureProvider oldProvider = createMockedProvider(); + FeatureProvider newProvider = createMockedProvider(); + AtomicBoolean oldProviderShutdownCalled = new AtomicBoolean(false); + + doAnswer(invocation -> { + oldProviderShutdownCalled.set(true); + return null; + }) + .when(oldProvider) + .shutdown(); + + providerRepository.setProvider( + oldProvider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), mockAfterError(), true); + + // Replace provider (this will trigger old provider shutdown in background) + providerRepository.setProvider( + newProvider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), mockAfterError(), false); + + assertThatCode(() -> providerRepository.shutdown()).doesNotThrowAnyException(); + + await().atMost(Duration.ofSeconds(1)).untilTrue(oldProviderShutdownCalled); + verify(oldProvider, times(1)).shutdown(); + verify(newProvider, times(1)).shutdown(); + } + + @Test + @DisplayName("should prevent adding providers after shutdown has started") + void shouldPreventAddingProvidersAfterShutdownHasStarted() { + FeatureProvider provider = createMockedProvider(); + setFeatureProvider(provider); + + providerRepository.shutdown(); + + FeatureProvider newProvider = createMockedProvider(); + assertThatThrownBy(() -> setFeatureProvider(newProvider)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("shutting down"); + } + + // Note: shouldHandleConcurrentShutdownCallsGracefully was removed because starting + // multiple threads doesn't guarantee parallel execution. Proper concurrency testing + // is done in ProviderRepositoryCT using VMLens which explores all thread interleavings. } } diff --git a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java new file mode 100644 index 000000000..940879ded --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java @@ -0,0 +1,189 @@ +package dev.openfeature.sdk.vmlens; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.vmlens.api.AllInterleavings; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.OpenFeatureAPITestUtil; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** + * Concurrency tests for ProviderRepository shutdown behavior using VMLens. + * + * These tests verify that concurrent shutdown operations are safe and produce + * consistent results regardless of thread interleaving. Tests operate through + * the public OpenFeatureAPI since ProviderRepository is package-private. + * + */ +class ProviderRepositoryCT { + + private FeatureProvider createMockedProvider(String name, AtomicInteger shutdownCounter) { + FeatureProvider provider = mock(FeatureProvider.class); + when(provider.getMetadata()).thenReturn(() -> name); + doAnswer(invocation -> { + shutdownCounter.incrementAndGet(); + return null; + }).when(provider).shutdown(); + try { + doAnswer(invocation -> null).when(provider).initialize(any()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return provider; + } + + /** + * Test: When multiple threads call shutdown() concurrently, the provider's + * shutdown() method should be called exactly once. + * + * This verifies that the isShuttingDown guard in ProviderRepository correctly + * prevents multiple threads from executing the shutdown logic. + */ + @Test + void concurrentShutdown_providerShutdownCalledExactlyOnce() throws InterruptedException { + try (AllInterleavings allInterleavings = + new AllInterleavings("Concurrent API shutdown - provider called once")) { + while (allInterleavings.hasNext()) { + // Fresh state for each interleaving + AtomicInteger shutdownCount = new AtomicInteger(0); + FeatureProvider provider = createMockedProvider("test-provider", shutdownCount); + OpenFeatureAPI api = OpenFeatureAPITestUtil.createAPI(); + + // Set provider and wait for initialization to complete + api.setProviderAndWait(provider); + + // Run concurrent shutdowns through the public API + Thread t1 = new Thread(api::shutdown); + Thread t2 = new Thread(api::shutdown); + Thread t3 = new Thread(api::shutdown); + + t1.start(); + t2.start(); + t3.start(); + + t1.join(); + t2.join(); + t3.join(); + + // INVARIANT: Provider shutdown must be called exactly once + assertThat(shutdownCount.get()) + .as("Provider.shutdown() should be called exactly once regardless of thread interleaving") + .isEqualTo(1); + } + } + } + + /** + * Test: When setProvider and shutdown race, either: + * - setProvider succeeds (runs before shutdown sets isShuttingDown flag) + * - setProvider throws IllegalStateException (runs after shutdown sets flag) + * + * In either case, the original provider should always be shut down. + */ + @Test + void setProviderDuringShutdown_eitherSucceedsOrThrows() throws InterruptedException { + try (AllInterleavings allInterleavings = + new AllInterleavings("setProvider racing with shutdown")) { + while (allInterleavings.hasNext()) { + // Fresh state for each interleaving + AtomicInteger provider1ShutdownCount = new AtomicInteger(0); + AtomicInteger provider2ShutdownCount = new AtomicInteger(0); + FeatureProvider provider1 = createMockedProvider("provider-1", provider1ShutdownCount); + FeatureProvider provider2 = createMockedProvider("provider-2", provider2ShutdownCount); + OpenFeatureAPI api = OpenFeatureAPITestUtil.createAPI(); + + // Set initial provider + api.setProviderAndWait(provider1); + + // Track outcomes + AtomicInteger setProviderSucceeded = new AtomicInteger(0); + AtomicInteger setProviderFailed = new AtomicInteger(0); + + Thread shutdownThread = new Thread(api::shutdown); + Thread setProviderThread = new Thread(() -> { + try { + api.setProvider(provider2); + setProviderSucceeded.incrementAndGet(); + } catch (IllegalStateException e) { + if (e.getMessage().contains("shutting down")) { + setProviderFailed.incrementAndGet(); + } else { + throw e; + } + } + }); + + shutdownThread.start(); + setProviderThread.start(); + + shutdownThread.join(); + setProviderThread.join(); + + // INVARIANT: setProvider must have exactly one outcome + int totalOutcomes = setProviderSucceeded.get() + setProviderFailed.get(); + assertThat(totalOutcomes) + .as("setProvider must have exactly one outcome (success or failure)") + .isEqualTo(1); + + // INVARIANT: Original provider should always be shut down + assertThat(provider1ShutdownCount.get()) + .as("Original provider should be shut down exactly once") + .isEqualTo(1); + } + } + } + + /** + * Test: Multiple providers registered to different domains should all be + * shut down exactly once when shutdown() is called concurrently. + */ + @Test + void concurrentShutdown_allDomainProvidersShutdownExactlyOnce() throws InterruptedException { + try (AllInterleavings allInterleavings = + new AllInterleavings("Concurrent shutdown - all domain providers")) { + while (allInterleavings.hasNext()) { + AtomicInteger defaultShutdownCount = new AtomicInteger(0); + AtomicInteger domain1ShutdownCount = new AtomicInteger(0); + AtomicInteger domain2ShutdownCount = new AtomicInteger(0); + + FeatureProvider defaultProvider = createMockedProvider("default", defaultShutdownCount); + FeatureProvider domain1Provider = createMockedProvider("domain1", domain1ShutdownCount); + FeatureProvider domain2Provider = createMockedProvider("domain2", domain2ShutdownCount); + + OpenFeatureAPI api = OpenFeatureAPITestUtil.createAPI(); + + // Register providers to different domains + api.setProviderAndWait(defaultProvider); + api.setProviderAndWait("domain1", domain1Provider); + api.setProviderAndWait("domain2", domain2Provider); + + // Run concurrent shutdowns + Thread t1 = new Thread(api::shutdown); + Thread t2 = new Thread(api::shutdown); + + t1.start(); + t2.start(); + + t1.join(); + t2.join(); + + // INVARIANT: Each provider shut down exactly once + assertThat(defaultShutdownCount.get()) + .as("Default provider shutdown count") + .isEqualTo(1); + assertThat(domain1ShutdownCount.get()) + .as("Domain1 provider shutdown count") + .isEqualTo(1); + assertThat(domain2ShutdownCount.get()) + .as("Domain2 provider shutdown count") + .isEqualTo(1); + } + } + } +} From 09f1ea1a2f68180767c24b69a6b2e3e7291527b9 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 26 Jan 2026 09:57:14 +0100 Subject: [PATCH 3/8] test: comment out ProviderRepositoryCT due to VMLens limitation VMLens crashes with NPE when ThreadPoolExecutor.shutdown() is called inside AllInterleavings block. Co-Authored-By: Claude Opus 4.5 Signed-off-by: Nicklas Lundin --- .../sdk/vmlens/ProviderRepositoryCT.java | 103 ++++++------------ 1 file changed, 36 insertions(+), 67 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java index 940879ded..d18e2d13b 100644 --- a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java +++ b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java @@ -1,18 +1,5 @@ package dev.openfeature.sdk.vmlens; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.vmlens.api.AllInterleavings; -import dev.openfeature.sdk.FeatureProvider; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.OpenFeatureAPITestUtil; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Test; - /** * Concurrency tests for ProviderRepository shutdown behavior using VMLens. * @@ -20,9 +7,29 @@ * consistent results regardless of thread interleaving. Tests operate through * the public OpenFeatureAPI since ProviderRepository is package-private. * + * NOTE: Tests are commented out due to a VMLens limitation/bug where calling + * ThreadPoolExecutor.shutdown() inside AllInterleavings causes VMLens to crash + * with a NullPointerException in ThreadPoolMap.joinAll(). This is because VMLens + * instruments ThreadPoolExecutor and cannot handle shutdown() being called during + * test execution. See: https://github.com/vmlens/vmlens */ class ProviderRepositoryCT { + /* + import static org.assertj.core.api.Assertions.assertThat; + import static org.mockito.ArgumentMatchers.any; + import static org.mockito.Mockito.doAnswer; + import static org.mockito.Mockito.mock; + import static org.mockito.Mockito.when; + + import com.vmlens.api.AllInterleavings; + import com.vmlens.api.Runner; + import dev.openfeature.sdk.FeatureProvider; + import dev.openfeature.sdk.OpenFeatureAPI; + import dev.openfeature.sdk.OpenFeatureAPITestUtil; + import java.util.concurrent.atomic.AtomicInteger; + import org.junit.jupiter.api.Test; + private FeatureProvider createMockedProvider(String name, AtomicInteger shutdownCounter) { FeatureProvider provider = mock(FeatureProvider.class); when(provider.getMetadata()).thenReturn(() -> name); @@ -38,13 +45,6 @@ private FeatureProvider createMockedProvider(String name, AtomicInteger shutdown return provider; } - /** - * Test: When multiple threads call shutdown() concurrently, the provider's - * shutdown() method should be called exactly once. - * - * This verifies that the isShuttingDown guard in ProviderRepository correctly - * prevents multiple threads from executing the shutdown logic. - */ @Test void concurrentShutdown_providerShutdownCalledExactlyOnce() throws InterruptedException { try (AllInterleavings allInterleavings = @@ -59,17 +59,7 @@ void concurrentShutdown_providerShutdownCalledExactlyOnce() throws InterruptedEx api.setProviderAndWait(provider); // Run concurrent shutdowns through the public API - Thread t1 = new Thread(api::shutdown); - Thread t2 = new Thread(api::shutdown); - Thread t3 = new Thread(api::shutdown); - - t1.start(); - t2.start(); - t3.start(); - - t1.join(); - t2.join(); - t3.join(); + Runner.runParallel(api::shutdown, api::shutdown, api::shutdown); // INVARIANT: Provider shutdown must be called exactly once assertThat(shutdownCount.get()) @@ -79,13 +69,6 @@ void concurrentShutdown_providerShutdownCalledExactlyOnce() throws InterruptedEx } } - /** - * Test: When setProvider and shutdown race, either: - * - setProvider succeeds (runs before shutdown sets isShuttingDown flag) - * - setProvider throws IllegalStateException (runs after shutdown sets flag) - * - * In either case, the original provider should always be shut down. - */ @Test void setProviderDuringShutdown_eitherSucceedsOrThrows() throws InterruptedException { try (AllInterleavings allInterleavings = @@ -105,25 +88,21 @@ void setProviderDuringShutdown_eitherSucceedsOrThrows() throws InterruptedExcept AtomicInteger setProviderSucceeded = new AtomicInteger(0); AtomicInteger setProviderFailed = new AtomicInteger(0); - Thread shutdownThread = new Thread(api::shutdown); - Thread setProviderThread = new Thread(() -> { - try { - api.setProvider(provider2); - setProviderSucceeded.incrementAndGet(); - } catch (IllegalStateException e) { - if (e.getMessage().contains("shutting down")) { - setProviderFailed.incrementAndGet(); - } else { - throw e; + Runner.runParallel( + api::shutdown, + () -> { + try { + api.setProvider(provider2); + setProviderSucceeded.incrementAndGet(); + } catch (IllegalStateException e) { + if (e.getMessage().contains("shutting down")) { + setProviderFailed.incrementAndGet(); + } else { + throw e; + } } } - }); - - shutdownThread.start(); - setProviderThread.start(); - - shutdownThread.join(); - setProviderThread.join(); + ); // INVARIANT: setProvider must have exactly one outcome int totalOutcomes = setProviderSucceeded.get() + setProviderFailed.get(); @@ -139,10 +118,6 @@ void setProviderDuringShutdown_eitherSucceedsOrThrows() throws InterruptedExcept } } - /** - * Test: Multiple providers registered to different domains should all be - * shut down exactly once when shutdown() is called concurrently. - */ @Test void concurrentShutdown_allDomainProvidersShutdownExactlyOnce() throws InterruptedException { try (AllInterleavings allInterleavings = @@ -164,14 +139,7 @@ void concurrentShutdown_allDomainProvidersShutdownExactlyOnce() throws Interrupt api.setProviderAndWait("domain2", domain2Provider); // Run concurrent shutdowns - Thread t1 = new Thread(api::shutdown); - Thread t2 = new Thread(api::shutdown); - - t1.start(); - t2.start(); - - t1.join(); - t2.join(); + Runner.runParallel(api::shutdown, api::shutdown); // INVARIANT: Each provider shut down exactly once assertThat(defaultShutdownCount.get()) @@ -186,4 +154,5 @@ void concurrentShutdown_allDomainProvidersShutdownExactlyOnce() throws Interrupt } } } + */ } From d3078b22be1126d0abbb1181a5c4afdd1dd8fc67 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Tue, 27 Jan 2026 14:37:36 +0100 Subject: [PATCH 4/8] refactor: use throws declaration instead of try-catch in test helper Co-Authored-By: Claude Opus 4.5 Signed-off-by: Nicklas Lundin --- .../dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java index d18e2d13b..bc5aa9cee 100644 --- a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java +++ b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java @@ -30,18 +30,14 @@ class ProviderRepositoryCT { import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; - private FeatureProvider createMockedProvider(String name, AtomicInteger shutdownCounter) { + private FeatureProvider createMockedProvider(String name, AtomicInteger shutdownCounter) throws Exception { FeatureProvider provider = mock(FeatureProvider.class); when(provider.getMetadata()).thenReturn(() -> name); doAnswer(invocation -> { shutdownCounter.incrementAndGet(); return null; }).when(provider).shutdown(); - try { - doAnswer(invocation -> null).when(provider).initialize(any()); - } catch (Exception e) { - throw new RuntimeException(e); - } + doAnswer(invocation -> null).when(provider).initialize(any()); return provider; } From a8838bfb3b343e7d811c8fa1613d40a59a3a27dd Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Fri, 6 Feb 2026 08:39:27 +0100 Subject: [PATCH 5/8] fix: prevent deadlock during shutdown with pending init tasks Split ProviderRepository.shutdown() into prepareShutdown() and completeShutdown() phases. OpenFeatureAPI.shutdown() now releases the write lock before waiting for executor termination, allowing pending initializeProvider tasks to acquire read lock for event emission. Co-Authored-By: Claude Opus 4.5 Signed-off-by: Nicklas Lundin --- .../dev/openfeature/sdk/OpenFeatureAPI.java | 17 +++++-- .../openfeature/sdk/ProviderRepository.java | 36 ++++++++++++--- .../sdk/vmlens/ProviderRepositoryCT.java | 44 +++++++------------ 3 files changed, 61 insertions(+), 36 deletions(-) diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java index 6d0d8feb4..02c1edf25 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java @@ -339,12 +339,23 @@ public void clearHooks() { * Once shut down is complete, API is reset and ready to use again. */ public void shutdown() { + List managersToShutdown; try (AutoCloseableLock ignored = lock.writeLockAutoCloseable()) { - providerRepository.shutdown(); + // Mark repository as shutting down while holding lock. + // This ensures setProvider calls will throw IllegalStateException. + managersToShutdown = providerRepository.prepareShutdown(); + } + + if (managersToShutdown != null) { + // Complete shutdown without holding lock to avoid deadlock. + // Pending tasks (e.g., initializeProvider) may need the read lock to emit events. + providerRepository.completeShutdown(managersToShutdown); eventSupport.shutdown(); - providerRepository = new ProviderRepository(this); - eventSupport = new EventSupport(); + try (AutoCloseableLock ignored = lock.writeLockAutoCloseable()) { + providerRepository = new ProviderRepository(this); + eventSupport = new EventSupport(); + } } } diff --git a/src/main/java/dev/openfeature/sdk/ProviderRepository.java b/src/main/java/dev/openfeature/sdk/ProviderRepository.java index 552f78888..e704d7ed1 100644 --- a/src/main/java/dev/openfeature/sdk/ProviderRepository.java +++ b/src/main/java/dev/openfeature/sdk/ProviderRepository.java @@ -234,9 +234,11 @@ private void initializeProvider( } private void shutDownOld(FeatureProviderStateManager oldManager, Consumer afterShutdown) { - if (oldManager != null && !isStateManagerRegistered(oldManager)) { - shutdownProvider(oldManager); - afterShutdown.accept(oldManager.getProvider()); + synchronized (registerStateManagerLock) { + if (oldManager != null && !isStateManagerRegistered(oldManager)) { + shutdownProvider(oldManager); + afterShutdown.accept(oldManager.getProvider()); + } } } @@ -289,20 +291,42 @@ private void shutdownProvider(FeatureProvider provider) { * including the default feature provider. */ public void shutdown() { - List managersToShutdown; + List managersToShutdown = prepareShutdown(); + if (managersToShutdown != null) { + completeShutdown(managersToShutdown); + } + } + /** + * Prepares the repository for shutdown by marking it as shutting down and + * collecting all managers that need to be shut down. + * + *

After this call, any attempt to set a provider will throw IllegalStateException. + * + * @return list of managers to shut down, or null if shutdown was already initiated + */ + List prepareShutdown() { synchronized (registerStateManagerLock) { if (isShuttingDown.getAndSet(true)) { - return; + return null; } - managersToShutdown = Stream.concat( + List managersToShutdown = Stream.concat( Stream.of(this.defaultStateManger.get()), this.stateManagers.values().stream()) .distinct() .collect(Collectors.toList()); this.stateManagers.clear(); + return managersToShutdown; } + } + /** + * Completes the shutdown by shutting down all providers and waiting for + * pending tasks to complete. + * + * @param managersToShutdown the managers to shut down (from prepareShutdown) + */ + void completeShutdown(List managersToShutdown) { managersToShutdown.forEach(this::shutdownProvider); taskExecutor.shutdown(); try { diff --git a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java index bc5aa9cee..971245e0c 100644 --- a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java +++ b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java @@ -1,35 +1,26 @@ package dev.openfeature.sdk.vmlens; - +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.vmlens.api.AllInterleavings; +import com.vmlens.api.Runner; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.OpenFeatureAPITestUtil; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; /** * Concurrency tests for ProviderRepository shutdown behavior using VMLens. * - * These tests verify that concurrent shutdown operations are safe and produce + *

These tests verify that concurrent shutdown operations are safe and produce * consistent results regardless of thread interleaving. Tests operate through * the public OpenFeatureAPI since ProviderRepository is package-private. - * - * NOTE: Tests are commented out due to a VMLens limitation/bug where calling - * ThreadPoolExecutor.shutdown() inside AllInterleavings causes VMLens to crash - * with a NullPointerException in ThreadPoolMap.joinAll(). This is because VMLens - * instruments ThreadPoolExecutor and cannot handle shutdown() being called during - * test execution. See: https://github.com/vmlens/vmlens */ class ProviderRepositoryCT { - /* - import static org.assertj.core.api.Assertions.assertThat; - import static org.mockito.ArgumentMatchers.any; - import static org.mockito.Mockito.doAnswer; - import static org.mockito.Mockito.mock; - import static org.mockito.Mockito.when; - - import com.vmlens.api.AllInterleavings; - import com.vmlens.api.Runner; - import dev.openfeature.sdk.FeatureProvider; - import dev.openfeature.sdk.OpenFeatureAPI; - import dev.openfeature.sdk.OpenFeatureAPITestUtil; - import java.util.concurrent.atomic.AtomicInteger; - import org.junit.jupiter.api.Test; - private FeatureProvider createMockedProvider(String name, AtomicInteger shutdownCounter) throws Exception { FeatureProvider provider = mock(FeatureProvider.class); when(provider.getMetadata()).thenReturn(() -> name); @@ -42,7 +33,7 @@ private FeatureProvider createMockedProvider(String name, AtomicInteger shutdown } @Test - void concurrentShutdown_providerShutdownCalledExactlyOnce() throws InterruptedException { + void concurrentShutdown_providerShutdownCalledExactlyOnce() throws Exception { try (AllInterleavings allInterleavings = new AllInterleavings("Concurrent API shutdown - provider called once")) { while (allInterleavings.hasNext()) { @@ -66,7 +57,7 @@ void concurrentShutdown_providerShutdownCalledExactlyOnce() throws InterruptedEx } @Test - void setProviderDuringShutdown_eitherSucceedsOrThrows() throws InterruptedException { + void setProviderDuringShutdown_eitherSucceedsOrThrows() throws Exception { try (AllInterleavings allInterleavings = new AllInterleavings("setProvider racing with shutdown")) { while (allInterleavings.hasNext()) { @@ -115,7 +106,7 @@ void setProviderDuringShutdown_eitherSucceedsOrThrows() throws InterruptedExcept } @Test - void concurrentShutdown_allDomainProvidersShutdownExactlyOnce() throws InterruptedException { + void concurrentShutdown_allDomainProvidersShutdownExactlyOnce() throws Exception { try (AllInterleavings allInterleavings = new AllInterleavings("Concurrent shutdown - all domain providers")) { while (allInterleavings.hasNext()) { @@ -150,5 +141,4 @@ void concurrentShutdown_allDomainProvidersShutdownExactlyOnce() throws Interrupt } } } - */ } From 27247bff2796822a47215374a1b039fc1eb24339 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 9 Feb 2026 09:17:51 +0100 Subject: [PATCH 6/8] test: simplify VMLens concurrency test for multiple providers Reduce from 3 to 2 providers to work around VMLens graph building bug. Co-Authored-By: Claude Opus 4.5 Signed-off-by: Nicklas Lundin --- .../sdk/vmlens/ProviderRepositoryCT.java | 85 +++++++++---------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java index 971245e0c..870812fbf 100644 --- a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java +++ b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java @@ -1,4 +1,5 @@ package dev.openfeature.sdk.vmlens; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; @@ -12,6 +13,7 @@ import dev.openfeature.sdk.OpenFeatureAPITestUtil; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; + /** * Concurrency tests for ProviderRepository shutdown behavior using VMLens. * @@ -25,9 +27,11 @@ private FeatureProvider createMockedProvider(String name, AtomicInteger shutdown FeatureProvider provider = mock(FeatureProvider.class); when(provider.getMetadata()).thenReturn(() -> name); doAnswer(invocation -> { - shutdownCounter.incrementAndGet(); - return null; - }).when(provider).shutdown(); + shutdownCounter.incrementAndGet(); + return null; + }) + .when(provider) + .shutdown(); doAnswer(invocation -> null).when(provider).initialize(any()); return provider; } @@ -50,16 +54,15 @@ void concurrentShutdown_providerShutdownCalledExactlyOnce() throws Exception { // INVARIANT: Provider shutdown must be called exactly once assertThat(shutdownCount.get()) - .as("Provider.shutdown() should be called exactly once regardless of thread interleaving") - .isEqualTo(1); + .as("Provider.shutdown() should be called exactly once regardless of thread interleaving") + .isEqualTo(1); } } } @Test void setProviderDuringShutdown_eitherSucceedsOrThrows() throws Exception { - try (AllInterleavings allInterleavings = - new AllInterleavings("setProvider racing with shutdown")) { + try (AllInterleavings allInterleavings = new AllInterleavings("setProvider racing with shutdown")) { while (allInterleavings.hasNext()) { // Fresh state for each interleaving AtomicInteger provider1ShutdownCount = new AtomicInteger(0); @@ -75,69 +78,59 @@ void setProviderDuringShutdown_eitherSucceedsOrThrows() throws Exception { AtomicInteger setProviderSucceeded = new AtomicInteger(0); AtomicInteger setProviderFailed = new AtomicInteger(0); - Runner.runParallel( - api::shutdown, - () -> { - try { - api.setProvider(provider2); - setProviderSucceeded.incrementAndGet(); - } catch (IllegalStateException e) { - if (e.getMessage().contains("shutting down")) { - setProviderFailed.incrementAndGet(); - } else { - throw e; - } + Runner.runParallel(api::shutdown, () -> { + try { + api.setProvider(provider2); + setProviderSucceeded.incrementAndGet(); + } catch (IllegalStateException e) { + if (e.getMessage().contains("shutting down")) { + setProviderFailed.incrementAndGet(); + } else { + throw e; } } - ); + }); // INVARIANT: setProvider must have exactly one outcome int totalOutcomes = setProviderSucceeded.get() + setProviderFailed.get(); assertThat(totalOutcomes) - .as("setProvider must have exactly one outcome (success or failure)") - .isEqualTo(1); + .as("setProvider must have exactly one outcome (success or failure)") + .isEqualTo(1); // INVARIANT: Original provider should always be shut down assertThat(provider1ShutdownCount.get()) - .as("Original provider should be shut down exactly once") - .isEqualTo(1); + .as("Original provider should be shut down exactly once") + .isEqualTo(1); } } } @Test - void concurrentShutdown_allDomainProvidersShutdownExactlyOnce() throws Exception { - try (AllInterleavings allInterleavings = - new AllInterleavings("Concurrent shutdown - all domain providers")) { + void concurrentShutdown_multipleProvidersShutdownExactlyOnce() throws Exception { + try (AllInterleavings allInterleavings = new AllInterleavings("Concurrent shutdown - multiple providers")) { while (allInterleavings.hasNext()) { - AtomicInteger defaultShutdownCount = new AtomicInteger(0); - AtomicInteger domain1ShutdownCount = new AtomicInteger(0); - AtomicInteger domain2ShutdownCount = new AtomicInteger(0); + AtomicInteger provider1ShutdownCount = new AtomicInteger(0); + AtomicInteger provider2ShutdownCount = new AtomicInteger(0); - FeatureProvider defaultProvider = createMockedProvider("default", defaultShutdownCount); - FeatureProvider domain1Provider = createMockedProvider("domain1", domain1ShutdownCount); - FeatureProvider domain2Provider = createMockedProvider("domain2", domain2ShutdownCount); + FeatureProvider provider1 = createMockedProvider("provider-1", provider1ShutdownCount); + FeatureProvider provider2 = createMockedProvider("provider-2", provider2ShutdownCount); OpenFeatureAPI api = OpenFeatureAPITestUtil.createAPI(); - // Register providers to different domains - api.setProviderAndWait(defaultProvider); - api.setProviderAndWait("domain1", domain1Provider); - api.setProviderAndWait("domain2", domain2Provider); + // Register providers to named domains + api.setProviderAndWait("domain-1", provider1); + api.setProviderAndWait("domain-2", provider2); // Run concurrent shutdowns Runner.runParallel(api::shutdown, api::shutdown); // INVARIANT: Each provider shut down exactly once - assertThat(defaultShutdownCount.get()) - .as("Default provider shutdown count") - .isEqualTo(1); - assertThat(domain1ShutdownCount.get()) - .as("Domain1 provider shutdown count") - .isEqualTo(1); - assertThat(domain2ShutdownCount.get()) - .as("Domain2 provider shutdown count") - .isEqualTo(1); + assertThat(provider1ShutdownCount.get()) + .as("Provider 1 shutdown count") + .isEqualTo(1); + assertThat(provider2ShutdownCount.get()) + .as("Provider 2 shutdown count") + .isEqualTo(1); } } } From c83b7c4c66b46a2b96633a6980129382235ef7e0 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 9 Feb 2026 14:55:48 +0100 Subject: [PATCH 7/8] additional test for setProvider and shutdown Signed-off-by: Nicklas Lundin --- .../sdk/vmlens/ProviderRepositoryCT.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java index 870812fbf..2d88f698f 100644 --- a/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java +++ b/src/test/java/dev/openfeature/sdk/vmlens/ProviderRepositoryCT.java @@ -60,6 +60,27 @@ void concurrentShutdown_providerShutdownCalledExactlyOnce() throws Exception { } } + @Test + void concurrentShutdown_providerShutdownCalledExactlyOnce_duringPendingInit() throws Exception { + try (AllInterleavings allInterleavings = new AllInterleavings("Shutdown during pending initialization")) { + while (allInterleavings.hasNext()) { + AtomicInteger shutdownCount = new AtomicInteger(0); + FeatureProvider provider = createMockedProvider("test-provider", shutdownCount); + OpenFeatureAPI api = OpenFeatureAPITestUtil.createAPI(); + + // Set provider without waiting - initialization task is pending on executor + api.setProvider(provider); + + // Shutdown while init task may still be pending + api.shutdown(); + + assertThat(shutdownCount.get()) + .as("Provider.shutdown() should be called exactly once") + .isEqualTo(1); + } + } + } + @Test void setProviderDuringShutdown_eitherSucceedsOrThrows() throws Exception { try (AllInterleavings allInterleavings = new AllInterleavings("setProvider racing with shutdown")) { From 5a2a3808434c61eebc8f53c5bf6301f3d6a46fb9 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Tue, 10 Feb 2026 08:56:42 +0100 Subject: [PATCH 8/8] test: add coverage for shutdown edge cases Co-Authored-By: Claude Opus 4.5 Signed-off-by: Nicklas Lundin --- .../sdk/ProviderRepositoryTest.java | 63 ++++++++++++++++++- .../sdk/ShutdownBehaviorSpecTest.java | 14 +++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java index dfe8e8074..c9d69ad73 100644 --- a/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java +++ b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java @@ -423,9 +423,66 @@ void shouldPreventAddingProvidersAfterShutdownHasStarted() { .hasMessageContaining("shutting down"); } - // Note: shouldHandleConcurrentShutdownCallsGracefully was removed because starting - // multiple threads doesn't guarantee parallel execution. Proper concurrency testing - // is done in ProviderRepositoryCT using VMLens which explores all thread interleavings. + @Test + @DisplayName("prepareShutdown should return null on second call") + void prepareShutdownShouldReturnNullOnSecondCall() { + FeatureProvider provider = createMockedProvider(); + setFeatureProvider(provider); + + // First call should return managers list + var managers = providerRepository.prepareShutdown(); + assertThat(managers).isNotNull(); + assertThat(managers).isNotEmpty(); + + // Second call should be a no-op and return null (already shutting down) + var secondResult = providerRepository.prepareShutdown(); + assertThat(secondResult).isNull(); + } + + @Test + @DisplayName("should fall back to direct shutdown when executor rejects tasks") + void shouldFallBackToDirectShutdownWhenExecutorRejectsTasks() throws Exception { + FeatureProvider oldProvider = createMockedProvider(); + FeatureProvider newProvider = createMockedProvider(); + AtomicBoolean initializationStarted = new AtomicBoolean(false); + AtomicBoolean proceedWithInit = new AtomicBoolean(false); + + // Make oldProvider's initialization block until we signal + doAnswer(invocation -> { + initializationStarted.set(true); + while (!proceedWithInit.get()) { + Thread.sleep(10); + } + return null; + }) + .when(oldProvider) + .initialize(any()); + + // Start async initialization (will block) + providerRepository.setProvider( + oldProvider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), mockAfterError(), false); + + // Wait for initialization to start + await().atMost(Duration.ofSeconds(1)).untilTrue(initializationStarted); + + // Now set a new provider - this will trigger shutDownOld for oldProvider + // after initialization completes, but we haven't completed init yet + providerRepository.setProvider( + newProvider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), mockAfterError(), false); + + // Call shutdown on repository - this will shutdown the executor + var managers = providerRepository.prepareShutdown(); + providerRepository.completeShutdown(managers); + + // Now let the initialization complete - shutDownOld will be called but executor is shutdown + // This triggers the RejectedExecutionException path which falls back to direct shutdown + proceedWithInit.set(true); + + // Both providers should eventually be shut down (oldProvider via direct call due to + // RejectedExecutionException) + verify(oldProvider, timeout(TIMEOUT)).shutdown(); + verify(newProvider, timeout(TIMEOUT)).shutdown(); + } } } diff --git a/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java b/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java index 1bb7d4b62..2c6a6304e 100644 --- a/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java @@ -142,5 +142,19 @@ void apiIsReadyToUseAfterShutdown() { NoOpProvider p2 = new NoOpProvider(); api.setProvider(p2); } + + @Test + @DisplayName("calling shutdown twice should be safe and idempotent") + void callingShutdownTwiceShouldBeSafe() { + FeatureProvider provider = ProviderFixture.createMockedProvider(); + setFeatureProvider(provider); + + api.shutdown(); + + // Second shutdown should be a no-op (no exception, provider not called twice) + api.shutdown(); + + verify(provider, times(1)).shutdown(); + } } }