From a576d9ecb7e10e53fcd686b12edce03cece56fd0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:24:04 +0000 Subject: [PATCH 1/3] feat: Stop waiting for initialization after the configured start wait time Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- .../Provider.cs | 31 +++++++++- .../ClientIntegrationTests.cs | 38 ++++++++++++ .../ProviderTests.cs | 58 +++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 971d3c7..dc04848 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -41,8 +41,11 @@ public sealed partial class Provider : FeatureProvider private const string ProviderShutdownMessage = "the provider has encountered a permanent error or been shutdown"; - internal Provider(ILdClient client) + private readonly TimeSpan? _initTimeout; + + internal Provider(ILdClient client, TimeSpan? initTimeout = null) { + _initTimeout = initTimeout; _client = client; _logger = _client.GetLogger().SubLogger(NameSpace); _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger); @@ -53,7 +56,7 @@ internal Provider(ILdClient client) /// Construct a new instance of the provider with the given configuration. /// /// A client configuration object - public Provider(Configuration config) : this(new LdClient(WrapConfig(config))) + public Provider(Configuration config) : this(new LdClient(WrapConfig(config)), config.StartWaitTime) { } @@ -61,7 +64,7 @@ public Provider(Configuration config) : this(new LdClient(WrapConfig(config))) /// Construct a new instance of the provider with the given SDK key. /// /// The SDK key - public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build()))) + public Provider(string sdkKey) : this(Configuration.Builder(sdkKey).Build()) { } @@ -159,6 +162,11 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); } + if (_initTimeout.HasValue && !_initCompletion.Task.IsCompleted) + { + ScheduleInitTimeout(_initTimeout.Value); + } + return _initCompletion.Task; } @@ -174,6 +182,23 @@ public override Task ShutdownAsync(CancellationToken cancellationToken = default #endregion + private void ScheduleInitTimeout(TimeSpan timeout) + { + var message = $"the provider did not become ready within {timeout.TotalMilliseconds}ms"; + Task.Delay(timeout < TimeSpan.Zero ? TimeSpan.Zero : timeout).ContinueWith(_ => + { + if (_initCompletion.Task.IsCompleted) + { + return; + } + + _logger.Warn(message); + // The client keeps trying to connect, so a later successful connection will emit a ready event. + _statusProvider.SetStatus(ProviderStatus.Error, message); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); + }).ConfigureAwait(false); + } + private void FlagChangeHandler(object sender, FlagChangeEvent changeEvent) { Task.Run(() => SafeWriteChangeEvent(changeEvent)).ConfigureAwait(false); diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs index fd1ab08..53db4a0 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using LaunchDarkly.Logging; @@ -152,6 +153,43 @@ public async Task ItCanEvaluateFlagsAfterTheDataSourceHasBeenShutdown() Assert.True(await client.GetBooleanValueAsync("the-flag", false, EvaluationContext.Builder().Set("targetingKey", "the-key").Build())); } + + [Fact(Timeout = 5000)] + public async Task ItBecomesReadyAfterInitializationTimesOut() + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var mockFlagTracker = new Mock(); + mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object); + + var provider = new Provider(mockClient.Object, TimeSpan.FromMilliseconds(50)); + + await Api.Instance.SetProviderAsync(provider); + + // The handler is added after the failed initialization, otherwise it would be immediately invoked for + // the state of any previously registered provider. + var readyCount = 0; + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, + details => { Interlocked.Increment(ref readyCount); }); + + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus { State = DataSourceState.Valid }); + + // The initialization timeout does not stop the client from connecting, so a later connection makes the + // provider ready. + Thread.Sleep(100); + Assert.Equal(1, readyCount); + } #endif } } diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index ec642dd..2779e7f 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using System.Timers; @@ -129,6 +130,63 @@ public async Task ItHandlesFailedInitialization() Assert.Equal("the provider has encountered a permanent error or been shutdown", exception.Message); } + [Fact(Timeout = 5000)] + public async Task ItStopsWaitingForInitializationAfterTheStartWaitTime() + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var mockFlagTracker = new Mock(); + mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object); + + var provider = new Provider(mockClient.Object, TimeSpan.FromMilliseconds(50)); + + var exception = + await Record.ExceptionAsync(async () => await provider.InitializeAsync(EvaluationContext.Empty)); + Assert.NotNull(exception); + Assert.Equal("the provider did not become ready within 50ms", exception.Message); + } + + [Fact(Timeout = 5000)] + public async Task ItDoesNotTimeOutInitializationWhenTheClientBecomesReady() + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var mockFlagTracker = new Mock(); + mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object); + + var provider = new Provider(mockClient.Object, TimeSpan.FromMilliseconds(2000)); + + var completionTimer = new Timer(50); + completionTimer.AutoReset = false; + completionTimer.Elapsed += (sender, args) => + { + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Valid}); + }; + completionTimer.Start(); + + await provider.InitializeAsync(EvaluationContext.Empty); + } + [Fact(Timeout = 5000)] public void ItCanBeConstructedWithLoggingConfiguration() { From 905e91a00aa27beb818e08c743e46dd0af73f7f6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:28:33 +0000 Subject: [PATCH 2/3] test: Cover evaluation after an initialization timeout Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- .../ClientIntegrationTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs index 53db4a0..337b4f5 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs @@ -160,6 +160,8 @@ public async Task ItBecomesReadyAfterInitializationTimesOut() var mockClient = new Mock(); mockClient.Setup(l => l.GetLogger()) .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + mockClient.Setup(l => l.BoolVariationDetail("the-flag", It.IsAny(), false)) + .Returns(new Sdk.EvaluationDetail(true, 10, Sdk.EvaluationReason.FallthroughReason)); var mockDataSourceStatus = new Mock(); mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus @@ -181,6 +183,11 @@ public async Task ItBecomesReadyAfterInitializationTimesOut() Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, details => { Interlocked.Increment(ref readyCount); }); + var context = EvaluationContext.Builder().Set("targetingKey", "the-key").Build(); + + // A timed out initialization does not short-circuit evaluations. + Assert.True(await Api.Instance.GetClient().GetBooleanValueAsync("the-flag", false, context)); + mockDataSourceStatus.Raise(e => e.StatusChanged += null, mockDataSourceStatus.Object, new DataSourceStatus { State = DataSourceState.Valid }); @@ -189,6 +196,7 @@ public async Task ItBecomesReadyAfterInitializationTimesOut() // provider ready. Thread.Sleep(100); Assert.Equal(1, readyCount); + Assert.True(await Api.Instance.GetClient().GetBooleanValueAsync("the-flag", false, context)); } #endif } From 5a7f3ed7902ac189b870a8eac6d80a976df17f17 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:34:13 +0000 Subject: [PATCH 3/3] fix: Do not time out initialization when the start wait time is zero Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- .../Provider.cs | 35 +++++++++++++------ .../ProviderTests.cs | 15 ++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index dc04848..6de1263 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -56,7 +56,7 @@ internal Provider(ILdClient client, TimeSpan? initTimeout = null) /// Construct a new instance of the provider with the given configuration. /// /// A client configuration object - public Provider(Configuration config) : this(new LdClient(WrapConfig(config)), config.StartWaitTime) + public Provider(Configuration config) : this(new LdClient(WrapConfig(config)), InitTimeout(config)) { } @@ -182,20 +182,30 @@ public override Task ShutdownAsync(CancellationToken cancellationToken = default #endregion + /// + /// A start wait time of zero means the caller does not want to block on initialization at all, so the provider + /// waits indefinitely and leaves it to the caller to decide how long to wait. + /// + private static TimeSpan? InitTimeout(Configuration config) => + config.StartWaitTime > TimeSpan.Zero ? config.StartWaitTime : (TimeSpan?)null; + private void ScheduleInitTimeout(TimeSpan timeout) { var message = $"the provider did not become ready within {timeout.TotalMilliseconds}ms"; - Task.Delay(timeout < TimeSpan.Zero ? TimeSpan.Zero : timeout).ContinueWith(_ => + Task.Delay(timeout).ContinueWith(_ => { - if (_initCompletion.Task.IsCompleted) + lock (_initLock) { - return; + if (_initCompletion.Task.IsCompleted) + { + return; + } + + _logger.Warn(message); + // The client keeps trying to connect, so a later successful connection will emit a ready event. + _statusProvider.SetStatus(ProviderStatus.Error, message); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); } - - _logger.Warn(message); - // The client keeps trying to connect, so a later successful connection will emit a ready event. - _statusProvider.SetStatus(ProviderStatus.Error, message); - _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); }).ConfigureAwait(false); } @@ -228,8 +238,11 @@ private void StatusChangeHandler(object sender, DataSourceStatus status) case DataSourceState.Initializing: break; case DataSourceState.Valid: - _statusProvider.SetStatus(ProviderStatus.Ready); - _initCompletion.TrySetResult(true); + lock (_initLock) + { + _statusProvider.SetStatus(ProviderStatus.Ready); + _initCompletion.TrySetResult(true); + } break; case DataSourceState.Interrupted: // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index 2779e7f..ec6ca10 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -155,6 +155,21 @@ public async Task ItStopsWaitingForInitializationAfterTheStartWaitTime() Assert.Equal("the provider did not become ready within 50ms", exception.Message); } + [Fact(Timeout = 5000)] + public async Task ItDoesNotTimeOutInitializationWhenTheStartWaitTimeIsZero() + { + var provider = new Provider(Configuration.Builder("") + .DataSource(Components.ExternalUpdatesOnly) + .Events(Components.NoEvents) + .StartWaitTime(TimeSpan.Zero) + .Build()); + + var initialization = provider.InitializeAsync(EvaluationContext.Empty); + await Task.Delay(100); + + Assert.False(initialization.IsFaulted); + } + [Fact(Timeout = 5000)] public async Task ItDoesNotTimeOutInitializationWhenTheClientBecomesReady() {