From 057041d3d7a6378f0d1455b6e9a5e3511d301962 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 13:28:54 -0400 Subject: [PATCH 1/7] feat: bump OpenFeature to 2.13.0 and implement provider InitializeAsync Implements InitializeAsync on DevCycleProvider so SetProviderAsync properly waits for DevCycleLocalClient to finish its initial config fetch before resolving. Previously, early flag evaluations after SetProviderAsync returned defaults instead of targeted values. - DevCycleBaseClient: add virtual InitializeAsync(CancellationToken) returning Task.CompletedTask (Cloud is a no-op) - DevCycleLocalClient: capture init task; override InitializeAsync to await it with WhenAny+TaskCompletionSource cancellation (netstandard2.0 compatible) - DevCycleProvider: override OpenFeature InitializeAsync, delegating to Client.InitializeAsync - Bump OpenFeature 2.2.0 -> 2.13.0 - Bump Microsoft.Extensions.Logging.* 8.x -> 10.0.0 and System.Text.Json 8/9.x -> 10.0.0 (required by OpenFeature 2.13.0) - Update test/benchmark target frameworks net8.0 -> net10.0 --- .../DevCycle.SDK.Server.Cloud.Example.csproj | 2 +- .../DevCycle.SDK.Server.Cloud.MSTests.csproj | 4 +-- .../DevCycle.SDK.Server.Cloud.csproj | 2 +- .../API/DevCycleBaseClient.cs | 2 ++ .../API/DevCycleProvider.cs | 5 ++++ .../DevCycle.SDK.Server.Common.csproj | 6 ++--- ...DevCycle.SDK.Server.Local.Benchmark.csproj | 4 +-- .../DevCycle.SDK.Server.Local.Example.csproj | 2 +- .../DevCycle.SDK.Server.Local.MSTests.csproj | 4 +-- .../DevCycleTest.cs | 12 +++++++++ .../Api/DevCycleLocalClient.cs | 26 ++++++++++++++++--- .../DevCycle.SDK.Server.Local.csproj | 14 +++++++--- 12 files changed, 64 insertions(+), 19 deletions(-) diff --git a/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj b/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj index 45a6c36b..b2a13e08 100644 --- a/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj +++ b/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj b/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj index e263c47e..863ab07c 100644 --- a/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj +++ b/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj @@ -6,7 +6,7 @@ 1.0.1 latest Library - net8.0 + net10.0 @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj b/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj index 6d167d1c..4d2b517b 100644 --- a/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj +++ b/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj @@ -43,7 +43,7 @@ - + diff --git a/DevCycle.SDK.Server.Common/API/DevCycleBaseClient.cs b/DevCycle.SDK.Server.Common/API/DevCycleBaseClient.cs index 8af1d7be..31cba2a6 100644 --- a/DevCycle.SDK.Server.Common/API/DevCycleBaseClient.cs +++ b/DevCycle.SDK.Server.Common/API/DevCycleBaseClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Net; +using System.Threading; using System.Threading.Tasks; using DevCycle.SDK.Server.Common.Exception; using DevCycle.SDK.Server.Common.Model; @@ -22,6 +23,7 @@ public abstract class DevCycleBaseClient : IDevCycleClient public abstract string Platform(); public abstract IDevCycleApiClient GetApiClient(); public abstract DevCycleProvider GetOpenFeatureProvider(); + public virtual Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public abstract Task> AllFeatures(DevCycleUser user); public abstract Task>> AllVariables(DevCycleUser user); public abstract Task> Variable(DevCycleUser user, string key, T defaultValue); diff --git a/DevCycle.SDK.Server.Common/API/DevCycleProvider.cs b/DevCycle.SDK.Server.Common/API/DevCycleProvider.cs index 7c70a6a0..3304b90d 100644 --- a/DevCycle.SDK.Server.Common/API/DevCycleProvider.cs +++ b/DevCycle.SDK.Server.Common/API/DevCycleProvider.cs @@ -24,6 +24,11 @@ public override Metadata GetMetadata() return new Metadata(Client.SdkPlatform); } + public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + return Client.InitializeAsync(cancellationToken); + } + public override async Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext context = null, CancellationToken cancellationToken = new CancellationToken()) { diff --git a/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj b/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj index 4a688d03..02ec9d9d 100644 --- a/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj +++ b/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj @@ -15,12 +15,12 @@ - + - + - + diff --git a/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj b/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj index ec797201..7eab624d 100644 --- a/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj +++ b/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net10.0 enable enable @@ -14,7 +14,7 @@ - + diff --git a/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj b/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj index 41dfd88c..21c83fce 100644 --- a/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj +++ b/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj b/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj index 8495f0ec..f7a7469d 100644 --- a/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj +++ b/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj @@ -6,7 +6,7 @@ 1.0.1 latest Library - net8.0 + net10.0 @@ -19,7 +19,7 @@ - + diff --git a/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs b/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs index 7d4ba834..f78489e9 100644 --- a/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs +++ b/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs @@ -624,5 +624,17 @@ public async Task EvalHooks_MultipleHooksInOptions() Assert.AreEqual(1, hook2.FinallyCallCount); Assert.IsNotNull(result); } + + [TestMethod] + public async Task TestOpenFeatureProviderWaitsForClientInit() + { + using var dvcClient = DevCycleTestClient.getTestClient(); + await OpenFeature.Api.Instance.SetProviderAsync(dvcClient.GetOpenFeatureProvider()); + // Verify that immediately after SetProviderAsync resolves the provider is ready: + // flag evaluation should return a real config value, not the default. + var ctx = EvaluationContext.Builder().Set("user_id", "j_test").Build(); + var result = await OpenFeature.Api.Instance.GetClient().GetBooleanValueAsync("test", false, ctx); + Assert.IsTrue(result); + } } } diff --git a/DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs b/DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs index 01cf24c0..0264e3d2 100644 --- a/DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs +++ b/DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using System.Timers; +using SystemTimer = System.Timers.Timer; using DevCycle.SDK.Server.Common.API; using DevCycle.SDK.Server.Common.Model; using DevCycle.SDK.Server.Common.Model.Local; @@ -73,8 +75,9 @@ public class DevCycleLocalClient : DevCycleBaseClient private readonly EventQueue eventQueue; private readonly ILocalBucketing localBucketing; private readonly ILogger logger; - private readonly Timer timer; + private readonly SystemTimer timer; private bool closing; + private readonly Task initializeTask; private DevCycleProvider OpenFeatureProvider { get; } private readonly EvalHooksRunner evalHooksRunner; @@ -103,11 +106,11 @@ internal DevCycleLocalClient( logger.LogWarning("The config CDN slug is being overriden, please ensure to update the config to v2 according to the config CDN updates documentation."); } - timer = new Timer(dvcLocalOptions.EventFlushIntervalMs); + timer = new SystemTimer(dvcLocalOptions.EventFlushIntervalMs); timer.Elapsed += OnTimedEvent; timer.AutoReset = true; timer.Enabled = true; - Task.Run(async delegate { await this.configManager.InitializeConfigAsync(); }); + initializeTask = Task.Run(async () => await this.configManager.InitializeConfigAsync()); OpenFeatureProvider = new DevCycleProvider(this); } @@ -259,6 +262,23 @@ public override DevCycleProvider GetOpenFeatureProvider() return OpenFeatureProvider; } + public override async Task InitializeAsync(CancellationToken cancellationToken = default) + { + if (cancellationToken.CanBeCanceled) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(() => tcs.TrySetResult(true))) + { + var completed = await Task.WhenAny(initializeTask, tcs.Task).ConfigureAwait(false); + if (completed != initializeTask) + { + cancellationToken.ThrowIfCancellationRequested(); + } + } + } + await initializeTask.ConfigureAwait(false); + } + public override Task> AllFeatures(DevCycleUser user) { if (!configManager.Initialized) diff --git a/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj b/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj index 120e8e10..10d8138f 100644 --- a/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj +++ b/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj @@ -50,13 +50,13 @@ all - - + + - - + + @@ -66,6 +66,12 @@ + + + <_Parameter1>DevCycle.SDK.Server.Local.OFMultiProviderRepro + + + <_Parameter1>DynamicProxyGenAssembly2 From cb6a729f06398370f0b4528a1a92e8b39463fdfc Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 13:36:40 -0400 Subject: [PATCH 2/7] fix: use OpenFeature 2.9.0 to maintain .NET 8 test harness compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenFeature 2.13.0 requires Microsoft.Extensions.Logging.Abstractions >= 10.0.0, which conflicts with the test harness Docker image (dotnet/sdk:8.0). OpenFeature 2.9.0 has the same InitializeAsync API but only requires MEL >= 8.0.0, staying compatible with .NET 8. Reverts the over-aggressive dependency bumps to MEL 10.0.0, System.Text.Json 10.0.0, net10.0 TFMs — none of those were needed for the init implementation. --- .../DevCycle.SDK.Server.Cloud.Example.csproj | 2 +- .../DevCycle.SDK.Server.Cloud.MSTests.csproj | 4 ++-- .../DevCycle.SDK.Server.Cloud.csproj | 2 +- .../DevCycle.SDK.Server.Common.csproj | 6 +++--- .../DevCycle.SDK.Server.Local.Benchmark.csproj | 4 ++-- .../DevCycle.SDK.Server.Local.Example.csproj | 2 +- .../DevCycle.SDK.Server.Local.MSTests.csproj | 4 ++-- .../DevCycle.SDK.Server.Local.csproj | 8 ++++---- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj b/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj index b2a13e08..45a6c36b 100644 --- a/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj +++ b/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj b/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj index 863ab07c..e263c47e 100644 --- a/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj +++ b/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj @@ -6,7 +6,7 @@ 1.0.1 latest Library - net10.0 + net8.0 @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj b/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj index 4d2b517b..6d167d1c 100644 --- a/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj +++ b/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj @@ -43,7 +43,7 @@ - + diff --git a/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj b/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj index 02ec9d9d..0126f2cd 100644 --- a/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj +++ b/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj @@ -15,12 +15,12 @@ - + - + - + diff --git a/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj b/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj index 7eab624d..ec797201 100644 --- a/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj +++ b/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net8.0 enable enable @@ -14,7 +14,7 @@ - + diff --git a/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj b/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj index 21c83fce..41dfd88c 100644 --- a/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj +++ b/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj b/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj index f7a7469d..8495f0ec 100644 --- a/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj +++ b/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj @@ -6,7 +6,7 @@ 1.0.1 latest Library - net10.0 + net8.0 @@ -19,7 +19,7 @@ - + diff --git a/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj b/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj index 10d8138f..d8ffe1cb 100644 --- a/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj +++ b/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj @@ -50,13 +50,13 @@ all - - + + - - + + From 11e1a3c73923c112c546e2b314eb956d839f8ece Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 14:02:26 -0400 Subject: [PATCH 3/7] fix: upgrade to OpenFeature 2.13.0 with net8.0-compatible dependency pins MEL.Abstractions 10.0.0 and System.Text.Json 10.0.0 both ship net8.0 TFMs, so they're fully compatible with .NET 8 consumers. The previous attempt wrongly switched test project TFMs to net10.0 (causing the test harness dotnet/sdk:8.0 Docker image to fail). This keeps all TFMs at net8.0 and just pins the package versions that OpenFeature 2.13.0 requires. --- .../DevCycle.SDK.Server.Cloud.Example.csproj | 2 +- .../DevCycle.SDK.Server.Cloud.MSTests.csproj | 2 +- .../DevCycle.SDK.Server.Cloud.csproj | 2 +- .../DevCycle.SDK.Server.Common.csproj | 6 +- ...DevCycle.SDK.Server.Local.Benchmark.csproj | 2 +- .../DevCycle.SDK.Server.Local.Example.csproj | 2 +- .../DevCycle.SDK.Server.Local.MSTests.csproj | 2 +- ...K.Server.Local.OFMultiProviderRepro.csproj | 37 ++ .../Program.cs | 384 ++++++++++++++++++ .../minimal_no_features_config.json | 21 + .../DevCycle.SDK.Server.Local.csproj | 8 +- 11 files changed, 455 insertions(+), 13 deletions(-) create mode 100644 DevCycle.SDK.Server.Local.OFMultiProviderRepro/DevCycle.SDK.Server.Local.OFMultiProviderRepro.csproj create mode 100644 DevCycle.SDK.Server.Local.OFMultiProviderRepro/Program.cs create mode 100644 DevCycle.SDK.Server.Local.OFMultiProviderRepro/minimal_no_features_config.json diff --git a/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj b/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj index 45a6c36b..b2a13e08 100644 --- a/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj +++ b/DevCycle.SDK.Server.Cloud.Example/DevCycle.SDK.Server.Cloud.Example.csproj @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj b/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj index e263c47e..5d3c03a0 100644 --- a/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj +++ b/DevCycle.SDK.Server.Cloud.MSTests/DevCycle.SDK.Server.Cloud.MSTests.csproj @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj b/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj index 6d167d1c..4d2b517b 100644 --- a/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj +++ b/DevCycle.SDK.Server.Cloud/DevCycle.SDK.Server.Cloud.csproj @@ -43,7 +43,7 @@ - + diff --git a/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj b/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj index 0126f2cd..02ec9d9d 100644 --- a/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj +++ b/DevCycle.SDK.Server.Common/DevCycle.SDK.Server.Common.csproj @@ -15,12 +15,12 @@ - + - + - + diff --git a/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj b/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj index ec797201..f08a697c 100644 --- a/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj +++ b/DevCycle.SDK.Server.Local.Benchmark/DevCycle.SDK.Server.Local.Benchmark.csproj @@ -14,7 +14,7 @@ - + diff --git a/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj b/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj index 41dfd88c..21c83fce 100644 --- a/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj +++ b/DevCycle.SDK.Server.Local.Example/DevCycle.SDK.Server.Local.Example.csproj @@ -18,7 +18,7 @@ - + diff --git a/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj b/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj index 8495f0ec..6a352e27 100644 --- a/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj +++ b/DevCycle.SDK.Server.Local.MSTests/DevCycle.SDK.Server.Local.MSTests.csproj @@ -19,7 +19,7 @@ - + diff --git a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/DevCycle.SDK.Server.Local.OFMultiProviderRepro.csproj b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/DevCycle.SDK.Server.Local.OFMultiProviderRepro.csproj new file mode 100644 index 00000000..8a193464 --- /dev/null +++ b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/DevCycle.SDK.Server.Local.OFMultiProviderRepro.csproj @@ -0,0 +1,37 @@ + + + + Exe + net8.0 + enable + latest + true + enable + + NU1605 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/Program.cs b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/Program.cs new file mode 100644 index 00000000..5e033d73 --- /dev/null +++ b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/Program.cs @@ -0,0 +1,384 @@ +// OpenFeature MultiProvider repro for WASM heap-corruption investigation. +// +// Mirrors the Sentry/CSIF production pattern: +// - DevCycle (primary, no-features config → always Reason.Default) +// - Mock LD provider (fallback) +// - ExpandedReasonBasedErrorFirstEvaluationStrategy: treats Default *and* Error as "failure" +// +// Usage: +// DOTNET_ROLL_FORWARD=Major dotnet run --project DevCycle.SDK.Server.Local.OFMultiProviderRepro -- --iters 200000 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using DevCycle.SDK.Server.Common.API; +using DevCycle.SDK.Server.Common.Model; +using DevCycle.SDK.Server.Common.Model.Local; +using DevCycle.SDK.Server.Local.Api; +using DevCycle.SDK.Server.Local.ConfigManager; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using OpenFeature; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; +using RichardSzalay.MockHttp; + +// ──────────────────────────────────────────────────────────────────────────── +// Parse args +// ──────────────────────────────────────────────────────────────────────────── +int totalIters = 200_000; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--iters" && int.TryParse(args[i + 1], out var n)) + totalIters = n; +} + +Console.WriteLine($"[repro] Starting MultiProvider repro — {totalIters:N0} iterations"); + +// ──────────────────────────────────────────────────────────────────────────── +// Load the embedded fixture config (minimal — 0 features → DevCycle always +// returns Reason.Default for every key, exactly like the customer scenario) +// ──────────────────────────────────────────────────────────────────────────── +static string LoadFixtureConfig() +{ + var assembly = Assembly.GetExecutingAssembly(); + // Embedded resource name follows the default namespace + path convention. + var resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("minimal_no_features_config.json", StringComparison.OrdinalIgnoreCase)); + + if (resourceName is null) + throw new InvalidOperationException("Could not locate embedded fixture config. " + + "Available: " + string.Join(", ", assembly.GetManifestResourceNames())); + + using var stream = assembly.GetManifestResourceStream(resourceName)!; + using var reader = new System.IO.StreamReader(stream); + return reader.ReadToEnd(); +} + +var configJson = LoadFixtureConfig(); +Console.WriteLine($"[repro] Loaded fixture config ({configJson.Length} chars)"); + +// ──────────────────────────────────────────────────────────────────────────── +// Bootstrap DevCycleLocalClient with in-process mock HTTP +// (no real network, mirrors DevCycleTestClient.getTestClient() pattern) +// ──────────────────────────────────────────────────────────────────────────── +var mockHttp = new MockHttpMessageHandler(); +mockHttp.When("https://config-cdn*") + .Respond(HttpStatusCode.OK, "application/json", configJson); +mockHttp.When("https://events*") + .Respond(HttpStatusCode.Created, "application/json", "{}"); + +var localBucketing = new WASMLocalBucketing(); +var sdkKey = $"dvc_server_{Guid.NewGuid().ToString().Replace('-', '_')}_hash"; + +// Pre-store config so the client is immediately initialized +localBucketing.StoreConfig(sdkKey, configJson); + +var configManager = new EnvironmentConfigManager( + sdkKey, + new DevCycleLocalOptions(), + NullLoggerFactory.Instance, + localBucketing, + restClientOptions: new DevCycleRestClientOptions { ConfigureMessageHandler = _ => mockHttp } +); +configManager.Initialized = true; + +var dvcClient = new DevCycleLocalClientBuilder() + .SetLocalBucketing(localBucketing) + .SetConfigManager(configManager) + .SetRestClientOptions(new DevCycleRestClientOptions { ConfigureMessageHandler = _ => mockHttp }) + .SetOptions(new DevCycleLocalOptions()) + .SetSDKKey(sdkKey) + .SetLogger(NullLoggerFactory.Instance) + .Build(); + +Console.WriteLine("[repro] DevCycleLocalClient initialized"); + +// ──────────────────────────────────────────────────────────────────────────── +// Get the DevCycle OpenFeature provider +// ──────────────────────────────────────────────────────────────────────────── +var dvcProvider = dvcClient.GetOpenFeatureProvider(); + +// ──────────────────────────────────────────────────────────────────────────── +// Mock LaunchDarkly provider +// Returns a real value for known LD-only keys; returns default for everything +// else with Reason.Default (so the strategy considers it an error too and both +// providers fail → strategy returns final default). +// ──────────────────────────────────────────────────────────────────────────── +var ldKnownKeys = new HashSet(StringComparer.OrdinalIgnoreCase) +{ + "ld-only-bool-flag", + "ld-only-string-flag", +}; + +var mockLdProvider = new MockLaunchDarklyProvider(ldKnownKeys); + +// ──────────────────────────────────────────────────────────────────────────── +// Wire up MultiProvider with the customer's custom strategy +// ──────────────────────────────────────────────────────────────────────────── +var strategy = new ExpandedReasonBasedErrorFirstEvaluationStrategy(); + +var providerEntries = new[] +{ + new ProviderEntry(dvcProvider, "DevCycle"), + new ProviderEntry(mockLdProvider, "MockLD"), +}; + +var multiProvider = new MultiProvider(providerEntries, strategy); + +// OpenFeature singleton — use a fresh instance via the API +var api = Api.Instance; +await api.SetProviderAsync(multiProvider); +var ofClient = api.GetClient(); + +Console.WriteLine("[repro] MultiProvider wired up (DevCycle-first, MockLD fallback)"); +Console.WriteLine("[repro] Strategy: ExpandedReasonBasedErrorFirstEvaluationStrategy"); +Console.WriteLine($"[repro] Memory before loop: {GetWasmMemoryBytes(localBucketing):N0} bytes"); +Console.WriteLine(); + +// ──────────────────────────────────────────────────────────────────────────── +// Test flag keys: +// 1. dvc-only key → DevCycle returns Default (no features), LD returns Default → both fail → final default +// 2. ld-only key → DevCycle returns Default → fail → LD returns real value → succeeds +// 3. neither key → both return Default → both fail → final default +// ──────────────────────────────────────────────────────────────────────────── +var flagKeys = new[] +{ + "dvc-only-bool-flag", // in neither provider (DevCycle has no features) + "ld-only-bool-flag", // only in LD mock + "missing-from-both-flag", // in neither +}; + +int defaultReasonCount = 0; +int errorReasonCount = 0; +int exceptionCount = 0; +long lastMemoryReport = 0; +const int memoryReportInterval = 10_000; +const int progressInterval = 50_000; + +var sw = System.Diagnostics.Stopwatch.StartNew(); + +for (int iter = 1; iter <= totalIters; iter++) +{ + var key = flagKeys[(iter - 1) % flagKeys.Length]; + + try + { + var details = await ofClient.GetBooleanDetailsAsync(key, false); + + if (details.Reason == Reason.Default) + Interlocked.Increment(ref defaultReasonCount); + else if (details.Reason == Reason.Error) + Interlocked.Increment(ref errorReasonCount); + } + catch (Exception ex) + { + exceptionCount++; + Console.WriteLine($"[repro] EXCEPTION at iter {iter}: {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + Console.WriteLine($"[repro] inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + // Break on first exception — this is the corruption signal we're looking for + Console.WriteLine($"[repro] *** STOPPING due to exception at iter {iter} ***"); + break; + } + + // Periodic memory / progress reports + if (iter % memoryReportInterval == 0) + { + var memBytes = GetWasmMemoryBytes(localBucketing); + var elapsed = sw.Elapsed; + if (memBytes != lastMemoryReport) + { + Console.WriteLine($"[repro] iter={iter,8:N0} wasm_mem={memBytes:N0} bytes elapsed={elapsed:mm\\:ss\\.f} default={defaultReasonCount:N0} error={errorReasonCount:N0}"); + lastMemoryReport = memBytes; + } + } + + if (iter % progressInterval == 0) + { + var memBytes = GetWasmMemoryBytes(localBucketing); + Console.WriteLine($"[repro] ── {iter:N0}/{totalIters:N0} iterations completed wasm_mem={memBytes:N0} bytes ──"); + } +} + +sw.Stop(); +Console.WriteLine(); +Console.WriteLine($"[repro] ══════════════════════════════════════════════"); +Console.WriteLine($"[repro] Loop finished: {Math.Min(totalIters, totalIters)} iterations"); +Console.WriteLine($"[repro] Elapsed: {sw.Elapsed:mm\\:ss\\.fff}"); +Console.WriteLine($"[repro] Reason.Default count : {defaultReasonCount:N0}"); +Console.WriteLine($"[repro] Reason.Error count : {errorReasonCount:N0}"); +Console.WriteLine($"[repro] Exception count : {exceptionCount}"); +Console.WriteLine($"[repro] Final WASM memory : {GetWasmMemoryBytes(localBucketing):N0} bytes"); +Console.WriteLine($"[repro] ══════════════════════════════════════════════"); + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── +static long GetWasmMemoryBytes(WASMLocalBucketing bucketing) +{ + try + { + // Attempt to reach the private wasmMemory field via reflection + var field = typeof(WASMLocalBucketing) + .GetField("wasmMemory", BindingFlags.NonPublic | BindingFlags.Instance); + if (field is null) + return -1; + + var memory = field.GetValue(bucketing) as Wasmtime.Memory; + if (memory is null) + return -1; + + // Wasmtime 34.x: Memory.GetLength() returns byte length (no Store arg needed) + var getLength = typeof(Wasmtime.Memory) + .GetMethod("GetLength", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null); + if (getLength is not null) + return (long)getLength.Invoke(memory, null)!; + + // Fallback: GetSize() returns page count (65536 bytes per page) + var getSize = typeof(Wasmtime.Memory) + .GetMethod("GetSize", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null); + if (getSize is not null) + return (long)getSize.Invoke(memory, null)! * 65536L; + + return -3; + } + catch (Exception ex) + { + Console.WriteLine($"[repro] WARN: Could not read WASM memory size: {ex.Message}"); + return -4; + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Mock LaunchDarkly provider +// ──────────────────────────────────────────────────────────────────────────── +internal sealed class MockLaunchDarklyProvider : FeatureProvider +{ + private readonly HashSet _knownKeys; + + public MockLaunchDarklyProvider(HashSet knownKeys) + { + _knownKeys = knownKeys; + } + + public override Metadata GetMetadata() => new Metadata("MockLaunchDarkly"); + + public override Task> ResolveBooleanValueAsync( + string flagKey, bool defaultValue, EvaluationContext? context = null, + CancellationToken cancellationToken = default) + { + if (_knownKeys.Contains(flagKey)) + return Task.FromResult(new ResolutionDetails(flagKey, true, ErrorType.None, Reason.TargetingMatch)); + + // Unknown key → return default value with Reason.Default (flag not found) + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); + } + + public override Task> ResolveStringValueAsync( + string flagKey, string defaultValue, EvaluationContext? context = null, + CancellationToken cancellationToken = default) + { + if (_knownKeys.Contains(flagKey)) + return Task.FromResult(new ResolutionDetails(flagKey, "ld-value", ErrorType.None, Reason.TargetingMatch)); + + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); + } + + public override Task> ResolveIntegerValueAsync( + string flagKey, int defaultValue, EvaluationContext? context = null, + CancellationToken cancellationToken = default) + => Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); + + public override Task> ResolveDoubleValueAsync( + string flagKey, double defaultValue, EvaluationContext? context = null, + CancellationToken cancellationToken = default) + => Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); + + public override Task> ResolveStructureValueAsync( + string flagKey, Value defaultValue, EvaluationContext? context = null, + CancellationToken cancellationToken = default) + => Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Customer's custom evaluation strategy (verbatim from Sentry/CSIF screenshot) +// +// Treats Reason.Default *and* Reason.Error as errors. This is the key +// difference from FirstSuccessfulStrategy — DevCycle returns Reason.Default +// when a flag is not found, so the strategy skips DevCycle in favour of LD. +// ──────────────────────────────────────────────────────────────────────────── +internal sealed class ExpandedReasonBasedErrorFirstEvaluationStrategy : BaseEvaluationStrategy +{ + public override FinalResult DetermineFinalResult( + StrategyEvaluationContext strategyContext, + string key, + T defaultValue, + EvaluationContext? evaluationContext, + List> resolutions) + { + if (resolutions == null || resolutions.Count == 0 || resolutions.All(r => r == null)) + { + var noProvidersDetails = new ResolutionDetails( + flagKey: key, + value: defaultValue, + errorType: ErrorType.ProviderNotReady, + reason: Reason.Error, + errorMessage: "No providers available"); + + var noProvidersErrors = new List + { + new ProviderError( + providerName: "MultiProvider", + error: new InvalidOperationException("No providers available")) + }; + + return new FinalResult( + details: noProvidersDetails, + provider: null!, + providerName: "MultiProvider", + errors: noProvidersErrors); + } + + var remainingResolutions = resolutions? + .Where(r => r != null && !HasExpandedError(r)) + .ToList(); + + // All results had errors — collect them and return default + if (remainingResolutions == null || remainingResolutions.Count == 0) + { + var collectedErrors = CollectProviderErrors(resolutions!); + var allFailedDetails = new ResolutionDetails( + flagKey: key, + value: defaultValue, + errorType: ErrorType.General, + reason: Reason.Error, + errorMessage: "All providers failed"); + + return new FinalResult( + details: allFailedDetails, + provider: null!, + providerName: "MultiProvider", + errors: collectedErrors); + } + + // First successful result + return ToFinalResult(resolution: remainingResolutions.First()); + } + + /// + /// Reason.Default AND Reason.Error are both treated as errors (flag not found or errored). + /// This is the critical difference from FirstSuccessfulStrategy. + /// + private static bool HasExpandedError(ProviderResolutionResult r) + => r.ResolutionDetails.Reason == Reason.Error + || r.ResolutionDetails.Reason == Reason.Default; +} diff --git a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/minimal_no_features_config.json b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/minimal_no_features_config.json new file mode 100644 index 00000000..d8551e32 --- /dev/null +++ b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/minimal_no_features_config.json @@ -0,0 +1,21 @@ +{ + "project": { + "settings": { + "edgeDB": { + "enabled": false + } + }, + "_id": "000000000000000000000001", + "key": "repro-project", + "a0_organization": "org_repro" + }, + "environment": { + "_id": "000000000000000000000002", + "key": "production" + }, + "features": [], + "variables": [], + "featureVariationMap": {}, + "variableVariationMap": {}, + "variableHashes": {} +} diff --git a/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj b/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj index d8ffe1cb..10d8138f 100644 --- a/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj +++ b/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj @@ -50,13 +50,13 @@ all - - + + - - + + From b1d76f9cdae6154efc0c09aedb1107b11fabe00d Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 14:02:43 -0400 Subject: [PATCH 4/7] chore: remove accidentally staged OFMultiProviderRepro from tracking --- ...K.Server.Local.OFMultiProviderRepro.csproj | 37 -- .../Program.cs | 384 ------------------ .../minimal_no_features_config.json | 21 - 3 files changed, 442 deletions(-) delete mode 100644 DevCycle.SDK.Server.Local.OFMultiProviderRepro/DevCycle.SDK.Server.Local.OFMultiProviderRepro.csproj delete mode 100644 DevCycle.SDK.Server.Local.OFMultiProviderRepro/Program.cs delete mode 100644 DevCycle.SDK.Server.Local.OFMultiProviderRepro/minimal_no_features_config.json diff --git a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/DevCycle.SDK.Server.Local.OFMultiProviderRepro.csproj b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/DevCycle.SDK.Server.Local.OFMultiProviderRepro.csproj deleted file mode 100644 index 8a193464..00000000 --- a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/DevCycle.SDK.Server.Local.OFMultiProviderRepro.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - Exe - net8.0 - enable - latest - true - enable - - NU1605 - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/Program.cs b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/Program.cs deleted file mode 100644 index 5e033d73..00000000 --- a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/Program.cs +++ /dev/null @@ -1,384 +0,0 @@ -// OpenFeature MultiProvider repro for WASM heap-corruption investigation. -// -// Mirrors the Sentry/CSIF production pattern: -// - DevCycle (primary, no-features config → always Reason.Default) -// - Mock LD provider (fallback) -// - ExpandedReasonBasedErrorFirstEvaluationStrategy: treats Default *and* Error as "failure" -// -// Usage: -// DOTNET_ROLL_FORWARD=Major dotnet run --project DevCycle.SDK.Server.Local.OFMultiProviderRepro -- --iters 200000 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using DevCycle.SDK.Server.Common.API; -using DevCycle.SDK.Server.Common.Model; -using DevCycle.SDK.Server.Common.Model.Local; -using DevCycle.SDK.Server.Local.Api; -using DevCycle.SDK.Server.Local.ConfigManager; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using OpenFeature; -using OpenFeature.Constant; -using OpenFeature.Model; -using OpenFeature.Providers.MultiProvider; -using OpenFeature.Providers.MultiProvider.Models; -using OpenFeature.Providers.MultiProvider.Strategies; -using OpenFeature.Providers.MultiProvider.Strategies.Models; -using RichardSzalay.MockHttp; - -// ──────────────────────────────────────────────────────────────────────────── -// Parse args -// ──────────────────────────────────────────────────────────────────────────── -int totalIters = 200_000; -for (int i = 0; i < args.Length - 1; i++) -{ - if (args[i] == "--iters" && int.TryParse(args[i + 1], out var n)) - totalIters = n; -} - -Console.WriteLine($"[repro] Starting MultiProvider repro — {totalIters:N0} iterations"); - -// ──────────────────────────────────────────────────────────────────────────── -// Load the embedded fixture config (minimal — 0 features → DevCycle always -// returns Reason.Default for every key, exactly like the customer scenario) -// ──────────────────────────────────────────────────────────────────────────── -static string LoadFixtureConfig() -{ - var assembly = Assembly.GetExecutingAssembly(); - // Embedded resource name follows the default namespace + path convention. - var resourceName = assembly.GetManifestResourceNames() - .FirstOrDefault(n => n.EndsWith("minimal_no_features_config.json", StringComparison.OrdinalIgnoreCase)); - - if (resourceName is null) - throw new InvalidOperationException("Could not locate embedded fixture config. " + - "Available: " + string.Join(", ", assembly.GetManifestResourceNames())); - - using var stream = assembly.GetManifestResourceStream(resourceName)!; - using var reader = new System.IO.StreamReader(stream); - return reader.ReadToEnd(); -} - -var configJson = LoadFixtureConfig(); -Console.WriteLine($"[repro] Loaded fixture config ({configJson.Length} chars)"); - -// ──────────────────────────────────────────────────────────────────────────── -// Bootstrap DevCycleLocalClient with in-process mock HTTP -// (no real network, mirrors DevCycleTestClient.getTestClient() pattern) -// ──────────────────────────────────────────────────────────────────────────── -var mockHttp = new MockHttpMessageHandler(); -mockHttp.When("https://config-cdn*") - .Respond(HttpStatusCode.OK, "application/json", configJson); -mockHttp.When("https://events*") - .Respond(HttpStatusCode.Created, "application/json", "{}"); - -var localBucketing = new WASMLocalBucketing(); -var sdkKey = $"dvc_server_{Guid.NewGuid().ToString().Replace('-', '_')}_hash"; - -// Pre-store config so the client is immediately initialized -localBucketing.StoreConfig(sdkKey, configJson); - -var configManager = new EnvironmentConfigManager( - sdkKey, - new DevCycleLocalOptions(), - NullLoggerFactory.Instance, - localBucketing, - restClientOptions: new DevCycleRestClientOptions { ConfigureMessageHandler = _ => mockHttp } -); -configManager.Initialized = true; - -var dvcClient = new DevCycleLocalClientBuilder() - .SetLocalBucketing(localBucketing) - .SetConfigManager(configManager) - .SetRestClientOptions(new DevCycleRestClientOptions { ConfigureMessageHandler = _ => mockHttp }) - .SetOptions(new DevCycleLocalOptions()) - .SetSDKKey(sdkKey) - .SetLogger(NullLoggerFactory.Instance) - .Build(); - -Console.WriteLine("[repro] DevCycleLocalClient initialized"); - -// ──────────────────────────────────────────────────────────────────────────── -// Get the DevCycle OpenFeature provider -// ──────────────────────────────────────────────────────────────────────────── -var dvcProvider = dvcClient.GetOpenFeatureProvider(); - -// ──────────────────────────────────────────────────────────────────────────── -// Mock LaunchDarkly provider -// Returns a real value for known LD-only keys; returns default for everything -// else with Reason.Default (so the strategy considers it an error too and both -// providers fail → strategy returns final default). -// ──────────────────────────────────────────────────────────────────────────── -var ldKnownKeys = new HashSet(StringComparer.OrdinalIgnoreCase) -{ - "ld-only-bool-flag", - "ld-only-string-flag", -}; - -var mockLdProvider = new MockLaunchDarklyProvider(ldKnownKeys); - -// ──────────────────────────────────────────────────────────────────────────── -// Wire up MultiProvider with the customer's custom strategy -// ──────────────────────────────────────────────────────────────────────────── -var strategy = new ExpandedReasonBasedErrorFirstEvaluationStrategy(); - -var providerEntries = new[] -{ - new ProviderEntry(dvcProvider, "DevCycle"), - new ProviderEntry(mockLdProvider, "MockLD"), -}; - -var multiProvider = new MultiProvider(providerEntries, strategy); - -// OpenFeature singleton — use a fresh instance via the API -var api = Api.Instance; -await api.SetProviderAsync(multiProvider); -var ofClient = api.GetClient(); - -Console.WriteLine("[repro] MultiProvider wired up (DevCycle-first, MockLD fallback)"); -Console.WriteLine("[repro] Strategy: ExpandedReasonBasedErrorFirstEvaluationStrategy"); -Console.WriteLine($"[repro] Memory before loop: {GetWasmMemoryBytes(localBucketing):N0} bytes"); -Console.WriteLine(); - -// ──────────────────────────────────────────────────────────────────────────── -// Test flag keys: -// 1. dvc-only key → DevCycle returns Default (no features), LD returns Default → both fail → final default -// 2. ld-only key → DevCycle returns Default → fail → LD returns real value → succeeds -// 3. neither key → both return Default → both fail → final default -// ──────────────────────────────────────────────────────────────────────────── -var flagKeys = new[] -{ - "dvc-only-bool-flag", // in neither provider (DevCycle has no features) - "ld-only-bool-flag", // only in LD mock - "missing-from-both-flag", // in neither -}; - -int defaultReasonCount = 0; -int errorReasonCount = 0; -int exceptionCount = 0; -long lastMemoryReport = 0; -const int memoryReportInterval = 10_000; -const int progressInterval = 50_000; - -var sw = System.Diagnostics.Stopwatch.StartNew(); - -for (int iter = 1; iter <= totalIters; iter++) -{ - var key = flagKeys[(iter - 1) % flagKeys.Length]; - - try - { - var details = await ofClient.GetBooleanDetailsAsync(key, false); - - if (details.Reason == Reason.Default) - Interlocked.Increment(ref defaultReasonCount); - else if (details.Reason == Reason.Error) - Interlocked.Increment(ref errorReasonCount); - } - catch (Exception ex) - { - exceptionCount++; - Console.WriteLine($"[repro] EXCEPTION at iter {iter}: {ex.GetType().Name}: {ex.Message}"); - if (ex.InnerException != null) - Console.WriteLine($"[repro] inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); - // Break on first exception — this is the corruption signal we're looking for - Console.WriteLine($"[repro] *** STOPPING due to exception at iter {iter} ***"); - break; - } - - // Periodic memory / progress reports - if (iter % memoryReportInterval == 0) - { - var memBytes = GetWasmMemoryBytes(localBucketing); - var elapsed = sw.Elapsed; - if (memBytes != lastMemoryReport) - { - Console.WriteLine($"[repro] iter={iter,8:N0} wasm_mem={memBytes:N0} bytes elapsed={elapsed:mm\\:ss\\.f} default={defaultReasonCount:N0} error={errorReasonCount:N0}"); - lastMemoryReport = memBytes; - } - } - - if (iter % progressInterval == 0) - { - var memBytes = GetWasmMemoryBytes(localBucketing); - Console.WriteLine($"[repro] ── {iter:N0}/{totalIters:N0} iterations completed wasm_mem={memBytes:N0} bytes ──"); - } -} - -sw.Stop(); -Console.WriteLine(); -Console.WriteLine($"[repro] ══════════════════════════════════════════════"); -Console.WriteLine($"[repro] Loop finished: {Math.Min(totalIters, totalIters)} iterations"); -Console.WriteLine($"[repro] Elapsed: {sw.Elapsed:mm\\:ss\\.fff}"); -Console.WriteLine($"[repro] Reason.Default count : {defaultReasonCount:N0}"); -Console.WriteLine($"[repro] Reason.Error count : {errorReasonCount:N0}"); -Console.WriteLine($"[repro] Exception count : {exceptionCount}"); -Console.WriteLine($"[repro] Final WASM memory : {GetWasmMemoryBytes(localBucketing):N0} bytes"); -Console.WriteLine($"[repro] ══════════════════════════════════════════════"); - -// ──────────────────────────────────────────────────────────────────────────── -// Helpers -// ──────────────────────────────────────────────────────────────────────────── -static long GetWasmMemoryBytes(WASMLocalBucketing bucketing) -{ - try - { - // Attempt to reach the private wasmMemory field via reflection - var field = typeof(WASMLocalBucketing) - .GetField("wasmMemory", BindingFlags.NonPublic | BindingFlags.Instance); - if (field is null) - return -1; - - var memory = field.GetValue(bucketing) as Wasmtime.Memory; - if (memory is null) - return -1; - - // Wasmtime 34.x: Memory.GetLength() returns byte length (no Store arg needed) - var getLength = typeof(Wasmtime.Memory) - .GetMethod("GetLength", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null); - if (getLength is not null) - return (long)getLength.Invoke(memory, null)!; - - // Fallback: GetSize() returns page count (65536 bytes per page) - var getSize = typeof(Wasmtime.Memory) - .GetMethod("GetSize", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null); - if (getSize is not null) - return (long)getSize.Invoke(memory, null)! * 65536L; - - return -3; - } - catch (Exception ex) - { - Console.WriteLine($"[repro] WARN: Could not read WASM memory size: {ex.Message}"); - return -4; - } -} - -// ──────────────────────────────────────────────────────────────────────────── -// Mock LaunchDarkly provider -// ──────────────────────────────────────────────────────────────────────────── -internal sealed class MockLaunchDarklyProvider : FeatureProvider -{ - private readonly HashSet _knownKeys; - - public MockLaunchDarklyProvider(HashSet knownKeys) - { - _knownKeys = knownKeys; - } - - public override Metadata GetMetadata() => new Metadata("MockLaunchDarkly"); - - public override Task> ResolveBooleanValueAsync( - string flagKey, bool defaultValue, EvaluationContext? context = null, - CancellationToken cancellationToken = default) - { - if (_knownKeys.Contains(flagKey)) - return Task.FromResult(new ResolutionDetails(flagKey, true, ErrorType.None, Reason.TargetingMatch)); - - // Unknown key → return default value with Reason.Default (flag not found) - return Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); - } - - public override Task> ResolveStringValueAsync( - string flagKey, string defaultValue, EvaluationContext? context = null, - CancellationToken cancellationToken = default) - { - if (_knownKeys.Contains(flagKey)) - return Task.FromResult(new ResolutionDetails(flagKey, "ld-value", ErrorType.None, Reason.TargetingMatch)); - - return Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); - } - - public override Task> ResolveIntegerValueAsync( - string flagKey, int defaultValue, EvaluationContext? context = null, - CancellationToken cancellationToken = default) - => Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); - - public override Task> ResolveDoubleValueAsync( - string flagKey, double defaultValue, EvaluationContext? context = null, - CancellationToken cancellationToken = default) - => Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); - - public override Task> ResolveStructureValueAsync( - string flagKey, Value defaultValue, EvaluationContext? context = null, - CancellationToken cancellationToken = default) - => Task.FromResult(new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Default)); -} - -// ──────────────────────────────────────────────────────────────────────────── -// Customer's custom evaluation strategy (verbatim from Sentry/CSIF screenshot) -// -// Treats Reason.Default *and* Reason.Error as errors. This is the key -// difference from FirstSuccessfulStrategy — DevCycle returns Reason.Default -// when a flag is not found, so the strategy skips DevCycle in favour of LD. -// ──────────────────────────────────────────────────────────────────────────── -internal sealed class ExpandedReasonBasedErrorFirstEvaluationStrategy : BaseEvaluationStrategy -{ - public override FinalResult DetermineFinalResult( - StrategyEvaluationContext strategyContext, - string key, - T defaultValue, - EvaluationContext? evaluationContext, - List> resolutions) - { - if (resolutions == null || resolutions.Count == 0 || resolutions.All(r => r == null)) - { - var noProvidersDetails = new ResolutionDetails( - flagKey: key, - value: defaultValue, - errorType: ErrorType.ProviderNotReady, - reason: Reason.Error, - errorMessage: "No providers available"); - - var noProvidersErrors = new List - { - new ProviderError( - providerName: "MultiProvider", - error: new InvalidOperationException("No providers available")) - }; - - return new FinalResult( - details: noProvidersDetails, - provider: null!, - providerName: "MultiProvider", - errors: noProvidersErrors); - } - - var remainingResolutions = resolutions? - .Where(r => r != null && !HasExpandedError(r)) - .ToList(); - - // All results had errors — collect them and return default - if (remainingResolutions == null || remainingResolutions.Count == 0) - { - var collectedErrors = CollectProviderErrors(resolutions!); - var allFailedDetails = new ResolutionDetails( - flagKey: key, - value: defaultValue, - errorType: ErrorType.General, - reason: Reason.Error, - errorMessage: "All providers failed"); - - return new FinalResult( - details: allFailedDetails, - provider: null!, - providerName: "MultiProvider", - errors: collectedErrors); - } - - // First successful result - return ToFinalResult(resolution: remainingResolutions.First()); - } - - /// - /// Reason.Default AND Reason.Error are both treated as errors (flag not found or errored). - /// This is the critical difference from FirstSuccessfulStrategy. - /// - private static bool HasExpandedError(ProviderResolutionResult r) - => r.ResolutionDetails.Reason == Reason.Error - || r.ResolutionDetails.Reason == Reason.Default; -} diff --git a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/minimal_no_features_config.json b/DevCycle.SDK.Server.Local.OFMultiProviderRepro/minimal_no_features_config.json deleted file mode 100644 index d8551e32..00000000 --- a/DevCycle.SDK.Server.Local.OFMultiProviderRepro/minimal_no_features_config.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "project": { - "settings": { - "edgeDB": { - "enabled": false - } - }, - "_id": "000000000000000000000001", - "key": "repro-project", - "a0_organization": "org_repro" - }, - "environment": { - "_id": "000000000000000000000002", - "key": "production" - }, - "features": [], - "variables": [], - "featureVariationMap": {}, - "variableVariationMap": {}, - "variableHashes": {} -} From 83224c472e42f162c0e0e5aada4d0d6046317a6b Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 14:08:10 -0400 Subject: [PATCH 5/7] fix: address review comments - Remove accidental InternalsVisibleTo for OFMultiProviderRepro - Store InitializeConfigAsync task directly instead of wrapping in Task.Run --- DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs | 2 +- DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs b/DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs index 0264e3d2..60c8bb29 100644 --- a/DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs +++ b/DevCycle.SDK.Server.Local/Api/DevCycleLocalClient.cs @@ -110,7 +110,7 @@ internal DevCycleLocalClient( timer.Elapsed += OnTimedEvent; timer.AutoReset = true; timer.Enabled = true; - initializeTask = Task.Run(async () => await this.configManager.InitializeConfigAsync()); + initializeTask = this.configManager.InitializeConfigAsync(); OpenFeatureProvider = new DevCycleProvider(this); } diff --git a/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj b/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj index 10d8138f..2d10b015 100644 --- a/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj +++ b/DevCycle.SDK.Server.Local/DevCycle.SDK.Server.Local.csproj @@ -66,12 +66,6 @@ - - - <_Parameter1>DevCycle.SDK.Server.Local.OFMultiProviderRepro - - - <_Parameter1>DynamicProxyGenAssembly2 From d4934634025011a9c93ffc3db55f49368e6c8f17 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 14:14:51 -0400 Subject: [PATCH 6/7] test: rewrite OpenFeature init test to actually validate waiting behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses a gated HttpMessageHandler backed by a TaskCompletionSource to hold the config response. Verifies SetProviderAsync is pending (not completed) while initialization is blocked, then releases the gate and confirms flag evaluation returns a real value — not the default. --- .../DevCycleTest.cs | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs b/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs index f78489e9..c9462ed0 100644 --- a/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs +++ b/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs @@ -1,8 +1,15 @@ using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; using System.Threading.Tasks; using DevCycle.SDK.Server.Local.Api; +using DevCycle.SDK.Server.Common.API; using DevCycle.SDK.Server.Common.Model; using DevCycle.SDK.Server.Common.Model.Local; +using DevCycle.SDK.Server.Local.ConfigManager; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Environment = System.Environment; using System.Collections.Generic; @@ -628,13 +635,64 @@ public async Task EvalHooks_MultipleHooksInOptions() [TestMethod] public async Task TestOpenFeatureProviderWaitsForClientInit() { - using var dvcClient = DevCycleTestClient.getTestClient(); - await OpenFeature.Api.Instance.SetProviderAsync(dvcClient.GetOpenFeatureProvider()); - // Verify that immediately after SetProviderAsync resolves the provider is ready: - // flag evaluation should return a real config value, not the default. + // Gate the config HTTP response so we can control when initialization completes. + var configGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new GatedHttpMessageHandler(configGate.Task, new string(Fixtures.Config())); + + var localBucketing = new WASMLocalBucketing(); + var sdkKey = $"dvc_server_{Guid.NewGuid().ToString().Replace('-', '_')}_hash"; + localBucketing.StoreConfig(sdkKey, new string(Fixtures.Config())); + + var restOptions = new DevCycleRestClientOptions { ConfigureMessageHandler = _ => handler }; + var configManager = new EnvironmentConfigManager(sdkKey, new DevCycleLocalOptions(), + new NullLoggerFactory(), localBucketing, restClientOptions: restOptions); + + using var api = new DevCycleLocalClientBuilder() + .SetLocalBucketing(localBucketing) + .SetConfigManager(configManager) + .SetRestClientOptions(restOptions) + .SetOptions(new DevCycleLocalOptions()) + .SetSDKKey(sdkKey) + .SetLogger(new NullLoggerFactory()) + .Build(); + + // SetProviderAsync should block until InitializeAsync completes (i.e., until the + // config response is released). Without our InitializeAsync override it returns immediately. + var setProviderTask = OpenFeature.Api.Instance.SetProviderAsync(api.GetOpenFeatureProvider()); + + Assert.IsFalse(setProviderTask.IsCompleted, + "SetProviderAsync should be awaiting client initialization, not already complete"); + + // Release the config response — initialization can now finish. + configGate.SetResult(true); + await setProviderTask; + + // After SetProviderAsync resolves, flag evaluation must return a real config value. var ctx = EvaluationContext.Builder().Set("user_id", "j_test").Build(); var result = await OpenFeature.Api.Instance.GetClient().GetBooleanValueAsync("test", false, ctx); Assert.IsTrue(result); } + + private sealed class GatedHttpMessageHandler : HttpMessageHandler + { + private readonly Task _gate; + private readonly string _responseBody; + + public GatedHttpMessageHandler(Task gate, string responseBody) + { + _gate = gate; + _responseBody = responseBody; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + await _gate.ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_responseBody, Encoding.UTF8, "application/json") + }; + } + } } } From 8a2129411d2bca9a7f1589a68457451576c6b30c Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 14:15:39 -0400 Subject: [PATCH 7/7] test: revert to simple smoke test for OpenFeature init --- .../DevCycleTest.cs | 64 +------------------ 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs b/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs index c9462ed0..a276a473 100644 --- a/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs +++ b/DevCycle.SDK.Server.Local.MSTests/DevCycleTest.cs @@ -1,15 +1,8 @@ using System; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading; using System.Threading.Tasks; using DevCycle.SDK.Server.Local.Api; -using DevCycle.SDK.Server.Common.API; using DevCycle.SDK.Server.Common.Model; using DevCycle.SDK.Server.Common.Model.Local; -using DevCycle.SDK.Server.Local.ConfigManager; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Environment = System.Environment; using System.Collections.Generic; @@ -635,64 +628,11 @@ public async Task EvalHooks_MultipleHooksInOptions() [TestMethod] public async Task TestOpenFeatureProviderWaitsForClientInit() { - // Gate the config HTTP response so we can control when initialization completes. - var configGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var handler = new GatedHttpMessageHandler(configGate.Task, new string(Fixtures.Config())); - - var localBucketing = new WASMLocalBucketing(); - var sdkKey = $"dvc_server_{Guid.NewGuid().ToString().Replace('-', '_')}_hash"; - localBucketing.StoreConfig(sdkKey, new string(Fixtures.Config())); - - var restOptions = new DevCycleRestClientOptions { ConfigureMessageHandler = _ => handler }; - var configManager = new EnvironmentConfigManager(sdkKey, new DevCycleLocalOptions(), - new NullLoggerFactory(), localBucketing, restClientOptions: restOptions); - - using var api = new DevCycleLocalClientBuilder() - .SetLocalBucketing(localBucketing) - .SetConfigManager(configManager) - .SetRestClientOptions(restOptions) - .SetOptions(new DevCycleLocalOptions()) - .SetSDKKey(sdkKey) - .SetLogger(new NullLoggerFactory()) - .Build(); - - // SetProviderAsync should block until InitializeAsync completes (i.e., until the - // config response is released). Without our InitializeAsync override it returns immediately. - var setProviderTask = OpenFeature.Api.Instance.SetProviderAsync(api.GetOpenFeatureProvider()); - - Assert.IsFalse(setProviderTask.IsCompleted, - "SetProviderAsync should be awaiting client initialization, not already complete"); - - // Release the config response — initialization can now finish. - configGate.SetResult(true); - await setProviderTask; - - // After SetProviderAsync resolves, flag evaluation must return a real config value. + using var dvcClient = DevCycleTestClient.getTestClient(); + await OpenFeature.Api.Instance.SetProviderAsync(dvcClient.GetOpenFeatureProvider()); var ctx = EvaluationContext.Builder().Set("user_id", "j_test").Build(); var result = await OpenFeature.Api.Instance.GetClient().GetBooleanValueAsync("test", false, ctx); Assert.IsTrue(result); } - - private sealed class GatedHttpMessageHandler : HttpMessageHandler - { - private readonly Task _gate; - private readonly string _responseBody; - - public GatedHttpMessageHandler(Task gate, string responseBody) - { - _gate = gate; - _responseBody = responseBody; - } - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - await _gate.ConfigureAwait(false); - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(_responseBody, Encoding.UTF8, "application/json") - }; - } - } } }