From f2dfe3868368d9815feaba2b1e5ae703e750830e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:46:27 +0000 Subject: [PATCH 1/3] fix: Honor the cancellation token when waiting for initialization Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- README.md | 2 +- .../Provider.cs | 20 ++++++- .../ProviderTests.cs | 57 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 31c880f..a8eeb29 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for .NET](https:/ | ✅ | Logging | The provider logs through the logging configuration of the `Configuration` it is given. | | ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. | | ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. | -| ✅ | Initialization | `InitializeAsync` waits for the LaunchDarkly client to become ready or to fail permanently. It has no timeout of its own; `StartWaitTime` applies to the client constructor. | +| ✅ | Initialization | `InitializeAsync` waits for the LaunchDarkly client to become ready or to fail permanently, and stops waiting if the cancellation token given to `SetProviderAsync` is canceled. It has no timeout of its own; `StartWaitTime` applies to the client constructor. | | ✅ | Shutdown | `ShutdownAsync` closes the LaunchDarkly client. A closed client cannot be restarted, so a new provider instance is required afterward. | | ✅ | Transaction Context Propagation | Provided by the OpenFeature SDK, which merges the transaction context into the evaluation context before the provider is called; no provider support is required. | | ✅ | Extending | The underlying LaunchDarkly client is available through `GetClient()` for functionality with no OpenFeature equivalent. | diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 971d3c7..3301799 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -134,7 +134,7 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke { if (_initializeCalled) { - return _initCompletion.Task; + return WaitForInitializationAsync(cancellationToken); } _initializeCalled = true; } @@ -159,7 +159,23 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); } - return _initCompletion.Task; + return WaitForInitializationAsync(cancellationToken); + } + + private async Task WaitForInitializationAsync(CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + await _initCompletion.Task.ConfigureAwait(false); + return; + } + + var cancellation = new TaskCompletionSource(); + using (cancellationToken.Register(() => cancellation.TrySetCanceled(cancellationToken))) + { + var completed = await Task.WhenAny(_initCompletion.Task, cancellation.Task).ConfigureAwait(false); + await completed.ConfigureAwait(false); + } } /// diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index ec642dd..1e5c1ed 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; @@ -83,6 +84,62 @@ public async Task ItHandlesValidInitializationWhenClientIsReadyAfterADelay() await provider.InitializeAsync(EvaluationContext.Empty); } + [Fact(Timeout = 5000)] + public async Task ItStopsWaitingForInitializationWhenTheTokenIsCanceled() + { + 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); + + using (var cancellation = new System.Threading.CancellationTokenSource(100)) + { + await Assert.ThrowsAnyAsync( + () => provider.InitializeAsync(EvaluationContext.Empty, cancellation.Token)); + } + } + + [Fact(Timeout = 5000)] + public async Task ItStopsWaitingForAnInProgressInitializationWhenTheTokenIsCanceled() + { + 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); + + var firstInitialization = provider.InitializeAsync(EvaluationContext.Empty); + + using (var cancellation = new System.Threading.CancellationTokenSource(100)) + { + await Assert.ThrowsAnyAsync( + () => provider.InitializeAsync(EvaluationContext.Empty, cancellation.Token)); + } + + Assert.False(firstInitialization.IsCompleted); + } + [Fact(Timeout = 5000)] public async Task ItCanBeShutdown() { From fb037c7db472f660312dc4a25be8d780310c6ccf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:52:54 +0000 Subject: [PATCH 2/3] fix: Emit status events after a canceled initialization wait Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- .../Provider.StatusProvider.cs | 12 +++++++ .../Provider.cs | 7 ++++ .../ProviderTests.cs | 34 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs index d2c474d..e80cb0e 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs @@ -53,6 +53,18 @@ private async Task SafeWrite(ProviderEventPayload payload) } } + /// + /// Records that the startup event has already been accounted for, so the next status change is emitted + /// instead of being suppressed as a duplicate of it. + /// + public void MarkStartupEventEmitted() + { + lock (_statusLock) + { + _firstEvent = false; + } + } + public void SetStatus(ProviderStatus status, string message = null) { lock (_statusLock) diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 3301799..0e97bcb 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -174,6 +174,13 @@ private async Task WaitForInitializationAsync(CancellationToken cancellationToke using (cancellationToken.Register(() => cancellation.TrySetCanceled(cancellationToken))) { var completed = await Task.WhenAny(_initCompletion.Task, cancellation.Task).ConfigureAwait(false); + if (completed != _initCompletion.Task) + { + // The OpenFeature SDK reports the cancellation as the initialization outcome, so any later + // status change is a new event which must be emitted. + _statusProvider.MarkStartupEventEmitted(); + } + await completed.ConfigureAwait(false); } } diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index 1e5c1ed..492243d 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -7,6 +7,7 @@ using LaunchDarkly.Sdk.Server; using LaunchDarkly.Sdk.Server.Interfaces; using Moq; +using OpenFeature.Constant; using OpenFeature.Model; using Xunit; using LaunchDarkly.Sdk.Server.Integrations; @@ -140,6 +141,39 @@ await Assert.ThrowsAnyAsync( Assert.False(firstInitialization.IsCompleted); } + [Fact(Timeout = 5000)] + public async Task ItEmitsAReadyEventAfterACanceledInitializationWait() + { + 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); + + using (var cancellation = new System.Threading.CancellationTokenSource(100)) + { + await Assert.ThrowsAnyAsync( + () => provider.InitializeAsync(EvaluationContext.Empty, cancellation.Token)); + } + + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Valid}); + + var readyEvent = await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderReady, readyEvent?.Type); + } + [Fact(Timeout = 5000)] public async Task ItCanBeShutdown() { From afef99fccf955beb8684ad1e24f81bdebcab7465 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:02:30 +0000 Subject: [PATCH 3/3] fix: Republish the provider status when a startup wait is canceled Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- .../Provider.StatusProvider.cs | 51 ++++++++++++------- .../Provider.cs | 4 +- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs index e80cb0e..f0b1023 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs @@ -11,6 +11,7 @@ public sealed partial class Provider private sealed class StatusProvider { private ProviderStatus _providerStatus = ProviderStatus.NotReady; + private string _statusMessage; private bool _firstEvent = true; private readonly object _statusLock = new object(); private readonly Channel _eventChannel; @@ -54,14 +55,39 @@ private async Task SafeWrite(ProviderEventPayload payload) } /// - /// Records that the startup event has already been accounted for, so the next status change is emitted - /// instead of being suppressed as a duplicate of it. + /// Handles a startup wait which was canceled instead of completing. The OpenFeature SDK reports the + /// cancellation as the initialization outcome, so the current status is emitted as an event and later + /// status changes are no longer suppressed as duplicates of the startup event. /// - public void MarkStartupEventEmitted() + public void StartupWaitCanceled() { lock (_statusLock) { _firstEvent = false; + if (_providerStatus != ProviderStatus.NotReady) + { + EmitStatusEvent(_providerStatus, _statusMessage); + } + } + } + + private void EmitStatusEvent(ProviderStatus status, string message) + { + switch (status) + { + case ProviderStatus.NotReady: + break; + case ProviderStatus.Ready: + EmitProviderEvent(ProviderEventTypes.ProviderReady, message); + break; + case ProviderStatus.Stale: + EmitProviderEvent(ProviderEventTypes.ProviderStale, message); + break; + case ProviderStatus.Error: + case ProviderStatus.Fatal: + default: + EmitProviderEvent(ProviderEventTypes.ProviderError, message); + break; } } @@ -75,6 +101,7 @@ public void SetStatus(ProviderStatus status, string message = null) } _providerStatus = status; + _statusMessage = message; // The OpenFeature client will emit a ready or error event when initialization completes. // We want to avoid duplicating that event. if (_firstEvent) @@ -82,22 +109,8 @@ public void SetStatus(ProviderStatus status, string message = null) _firstEvent = false; return; } - switch (status) - { - case ProviderStatus.NotReady: - break; - case ProviderStatus.Ready: - EmitProviderEvent(ProviderEventTypes.ProviderReady, message); - break; - case ProviderStatus.Stale: - EmitProviderEvent(ProviderEventTypes.ProviderStale, message); - break; - case ProviderStatus.Error: - case ProviderStatus.Fatal: - default: - EmitProviderEvent(ProviderEventTypes.ProviderError, message); - break; - } + + EmitStatusEvent(status, message); } } } diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 0e97bcb..4268a5c 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -176,9 +176,7 @@ private async Task WaitForInitializationAsync(CancellationToken cancellationToke var completed = await Task.WhenAny(_initCompletion.Task, cancellation.Task).ConfigureAwait(false); if (completed != _initCompletion.Task) { - // The OpenFeature SDK reports the cancellation as the initialization outcome, so any later - // status change is a new event which must be emitted. - _statusProvider.MarkStartupEventEmitted(); + _statusProvider.StartupWaitCanceled(); } await completed.ConfigureAwait(false);