From 2f9595d2a5fc89ae17632f7d059a7fdcd73ce2e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:04:32 +0000 Subject: [PATCH 1/7] Initial plan From e28a825a335297707d2ca08cd77da149537dd816 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:59:19 +0000 Subject: [PATCH 2/7] Optimize IOptionsMonitor.CurrentValue with thread-safe field caching Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/2dd0f00d-1db1-4ba0-8ca0-2e7e7e226b45 Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../src/OptionsMonitor.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs index 2c8180d0205674..3753eddab5a6b8 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; using Microsoft.Extensions.Primitives; namespace Microsoft.Extensions.Options @@ -21,6 +23,7 @@ public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAcces private readonly IOptionsFactory _factory; private readonly List _registrations = new List(); internal event Action? _onChange; + private TOptions? _currentValue; /// /// Initializes a new instance of with the specified factory, sources, and cache. @@ -66,6 +69,10 @@ private void InvokeChanged(string? name) name ??= Options.DefaultName; _cache.TryRemove(name); TOptions options = Get(name); + if (name == Options.DefaultName) + { + Interlocked.Exchange(ref _currentValue, options); + } _onChange?.Invoke(options, name); } @@ -76,7 +83,15 @@ private void InvokeChanged(string? name) /// The does not have a public parameterless constructor or is . public TOptions CurrentValue { - get => Get(Options.DefaultName); + get => Volatile.Read(ref _currentValue) ?? InitializeCurrentValue(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private TOptions InitializeCurrentValue() + { + TOptions value = Get(Options.DefaultName); + Interlocked.CompareExchange(ref _currentValue, value, null); + return Volatile.Read(ref _currentValue)!; } /// From 61593cdedb6683631fe4d4d2043166b8b9095929 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:45:01 +0000 Subject: [PATCH 3/7] Fix IOptionsMonitorCache invalidation gap with generation counter Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/68273300-1337-4b71-94ae-c2ca6cbd2f7c Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../src/OptionsCache.cs | 26 +++++- .../src/OptionsMonitor.cs | 38 +++++++-- .../OptionsMonitorTest.cs | 84 +++++++++++++++++++ 3 files changed, 136 insertions(+), 12 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs index ac6bab4c221656..c2372177bbbc9b 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Threading; namespace Microsoft.Extensions.Options { @@ -16,11 +17,22 @@ public class OptionsCache<[DynamicallyAccessedMembers(Options.DynamicallyAccesse where TOptions : class { private readonly ConcurrentDictionary> _cache = new ConcurrentDictionary>(concurrencyLevel: 1, capacity: 31, StringComparer.Ordinal); // 31 == default capacity + private int _generation; + + /// + /// Gets a monotonically-increasing counter that is incremented on every mutation (Clear, TryAdd, TryRemove). + /// Consumers can use this to detect whether the cache contents may have changed since they last read an entry. + /// + internal int Generation => Volatile.Read(ref _generation); /// /// Clears all options instances from the cache. /// - public void Clear() => _cache.Clear(); + public void Clear() + { + _cache.Clear(); + Interlocked.Increment(ref _generation); + } /// /// Gets a named options instance, or adds a new instance created with . @@ -97,11 +109,13 @@ public virtual bool TryAdd(string? name, TOptions options) { ArgumentNullException.ThrowIfNull(options); - return _cache.TryAdd(name ?? Options.DefaultName, new Lazy( + bool added = _cache.TryAdd(name ?? Options.DefaultName, new Lazy( #if !(NET || NETSTANDARD2_1) () => #endif options)); + Interlocked.Increment(ref _generation); + return added; } /// @@ -109,7 +123,11 @@ public virtual bool TryAdd(string? name, TOptions options) /// /// The name of the options instance. /// if anything was removed; otherwise, . - public virtual bool TryRemove(string? name) => - _cache.TryRemove(name ?? Options.DefaultName, out _); + public virtual bool TryRemove(string? name) + { + bool removed = _cache.TryRemove(name ?? Options.DefaultName, out _); + Interlocked.Increment(ref _generation); + return removed; + } } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs index 3753eddab5a6b8..95694556eb8254 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs @@ -20,10 +20,12 @@ public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAcces where TOptions : class { private readonly IOptionsMonitorCache _cache; + private readonly OptionsCache? _fastCache; private readonly IOptionsFactory _factory; private readonly List _registrations = new List(); internal event Action? _onChange; private TOptions? _currentValue; + private int _currentValueGeneration; /// /// Initializes a new instance of with the specified factory, sources, and cache. @@ -35,6 +37,7 @@ public OptionsMonitor(IOptionsFactory factory, IEnumerable; void RegisterSource(IOptionsChangeTokenSource source) { @@ -69,10 +72,6 @@ private void InvokeChanged(string? name) name ??= Options.DefaultName; _cache.TryRemove(name); TOptions options = Get(name); - if (name == Options.DefaultName) - { - Interlocked.Exchange(ref _currentValue, options); - } _onChange?.Invoke(options, name); } @@ -83,15 +82,38 @@ private void InvokeChanged(string? name) /// The does not have a public parameterless constructor or is . public TOptions CurrentValue { - get => Volatile.Read(ref _currentValue) ?? InitializeCurrentValue(); + get + { + OptionsCache? fastCache = _fastCache; + if (fastCache is null) + { + // User-supplied IOptionsMonitorCache: no generation tracking, always go through Get. + return Get(Options.DefaultName); + } + + // Read generation before value. If a mutation bumps the generation after we read it + // but before we read the cached value, we'll see a stale cached value at most once — + // the very next access will detect the generation mismatch and refresh. + int gen = fastCache.Generation; + TOptions? value = Volatile.Read(ref _currentValue); + if (value is not null && _currentValueGeneration == gen) + { + return value; + } + + return RefreshCurrentValue(gen); + } } [MethodImpl(MethodImplOptions.NoInlining)] - private TOptions InitializeCurrentValue() + private TOptions RefreshCurrentValue(int gen) { TOptions value = Get(Options.DefaultName); - Interlocked.CompareExchange(ref _currentValue, value, null); - return Volatile.Read(ref _currentValue)!; + // Write generation before value so that a reader whose Volatile.Read of _currentValue + // sees the new value is guaranteed (via the acquire fence) to also see the updated generation. + Volatile.Write(ref _currentValueGeneration, gen); + Volatile.Write(ref _currentValue, value); + return value; } /// diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs index a66a2f554b46dc..b7801964b42543 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs @@ -537,5 +537,89 @@ public WaitHandleConfigureOptions(WaitHandle waitHandle) void IConfigureNamedOptions.Configure(string? name, FakeOptions options) => _waitHandle.WaitOne(); void IConfigureOptions.Configure(FakeOptions options) => _waitHandle.WaitOne(); } + + [Fact] + public void CurrentValue_ReflectsLatestAfterCacheClear() + { + var services = new ServiceCollection().AddOptions().AddSingleton>(new CountIncrement(this)); + var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + var cache = sp.GetRequiredService>(); + + Assert.Equal("1", monitor.CurrentValue.Message); + + cache.Clear(); + + Assert.Equal("2", monitor.CurrentValue.Message); + } + + [Fact] + public void CurrentValue_ReflectsLatestAfterTryRemoveDefault() + { + var services = new ServiceCollection().AddOptions().AddSingleton>(new CountIncrement(this)); + var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + var cache = sp.GetRequiredService>(); + + Assert.Equal("1", monitor.CurrentValue.Message); + + cache.TryRemove(Options.DefaultName); + + Assert.Equal("2", monitor.CurrentValue.Message); + } + + [Fact] + public void CurrentValue_FallsBackToGetForCustomIOptionsMonitorCache() + { + var customCache = new CustomOptionsMonitorCache(); + var services = new ServiceCollection() + .AddOptions() + .AddSingleton>(new CountIncrement(this)) + .AddSingleton>(customCache); + var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + + Assert.Equal("1", monitor.CurrentValue.Message); + + customCache.Clear(); + + Assert.Equal("2", monitor.CurrentValue.Message); + } + + private sealed class CustomOptionsMonitorCache : IOptionsMonitorCache + where TOptions : class + { + private readonly Dictionary _cache = new Dictionary(StringComparer.Ordinal); + + public TOptions GetOrAdd(string? name, Func createOptions) + { + name ??= Options.DefaultName; + if (!_cache.TryGetValue(name, out TOptions? value)) + { + value = createOptions(); + _cache[name] = value; + } + return value; + } + + public bool TryAdd(string? name, TOptions options) + { + name ??= Options.DefaultName; + if (_cache.ContainsKey(name)) + return false; + _cache[name] = options; + return true; + } + + public bool TryRemove(string? name) + { + return _cache.Remove(name ?? Options.DefaultName); + } + + public void Clear() => _cache.Clear(); + } } } From 055c4329e0b065ed12c2bafca558e58812ace0bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:49:48 +0000 Subject: [PATCH 4/7] Only bump generation on successful TryAdd/TryRemove mutations Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/68273300-1337-4b71-94ae-c2ca6cbd2f7c Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../Microsoft.Extensions.Options/src/OptionsCache.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs index c2372177bbbc9b..39e7864b7482ae 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs @@ -114,7 +114,10 @@ public virtual bool TryAdd(string? name, TOptions options) () => #endif options)); - Interlocked.Increment(ref _generation); + if (added) + { + Interlocked.Increment(ref _generation); + } return added; } @@ -126,7 +129,10 @@ public virtual bool TryAdd(string? name, TOptions options) public virtual bool TryRemove(string? name) { bool removed = _cache.TryRemove(name ?? Options.DefaultName, out _); - Interlocked.Increment(ref _generation); + if (removed) + { + Interlocked.Increment(ref _generation); + } return removed; } } From f8e2be6c08f6fcbce8751da803d4123700a247dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:26:40 +0000 Subject: [PATCH 5/7] Leverage _fastCache in Get() to avoid repeated type-check cast on every call Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/b689e1fa-3d02-4686-9b0a-db54c6093f72 Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../Microsoft.Extensions.Options/src/OptionsMonitor.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs index 95694556eb8254..7a008d8c5e2c63 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs @@ -125,7 +125,7 @@ private TOptions RefreshCurrentValue(int gen) /// The does not have a public parameterless constructor or is . public virtual TOptions Get(string? name) { - if (_cache is not OptionsCache optionsCache) + if (_fastCache is not OptionsCache optionsCache) { // copying captured variables to locals avoids allocating a closure if we don't enter the if string localName = name ?? Options.DefaultName; @@ -135,7 +135,6 @@ public virtual TOptions Get(string? name) // non-allocating fast path return optionsCache.GetOrAdd(name, static (name, factory) => factory.Create(name), _factory); - } /// From 10feca23ae3c94b0dc85e1963ac1f2f4526c5a30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:03:41 +0000 Subject: [PATCH 6/7] Simplify Get() null check: _fastCache is OptionsCache?, no need for type pattern Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/c04e56e6-c4ab-4474-9f24-139701ec3530 Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../Microsoft.Extensions.Options/src/OptionsMonitor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs index 7a008d8c5e2c63..07d5fd935d704a 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs @@ -125,7 +125,7 @@ private TOptions RefreshCurrentValue(int gen) /// The does not have a public parameterless constructor or is . public virtual TOptions Get(string? name) { - if (_fastCache is not OptionsCache optionsCache) + if (_fastCache is null) { // copying captured variables to locals avoids allocating a closure if we don't enter the if string localName = name ?? Options.DefaultName; @@ -134,7 +134,7 @@ public virtual TOptions Get(string? name) } // non-allocating fast path - return optionsCache.GetOrAdd(name, static (name, factory) => factory.Create(name), _factory); + return _fastCache.GetOrAdd(name, static (name, factory) => factory.Create(name), _factory); } /// From 5fceb85f1497c56de76a9a3ac3c629fada657356 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:10:09 +0000 Subject: [PATCH 7/7] Fix write ordering in RefreshCurrentValue: publish value before generation Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/d47320a7-7485-4775-a8ac-463641f0b492 Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../src/OptionsMonitor.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs index 07d5fd935d704a..1727c5dc19c1e8 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs @@ -91,12 +91,14 @@ public TOptions CurrentValue return Get(Options.DefaultName); } - // Read generation before value. If a mutation bumps the generation after we read it - // but before we read the cached value, we'll see a stale cached value at most once — - // the very next access will detect the generation mismatch and refresh. int gen = fastCache.Generation; + // Read generation before value. RefreshCurrentValue writes _currentValue before + // _currentValueGeneration (release), so an acquire-load of _currentValueGeneration + // here guarantees the subsequent read of _currentValue sees the matching published + // value and not a stale one. + int cachedGen = Volatile.Read(ref _currentValueGeneration); TOptions? value = Volatile.Read(ref _currentValue); - if (value is not null && _currentValueGeneration == gen) + if (value is not null && cachedGen == gen) { return value; } @@ -109,10 +111,12 @@ public TOptions CurrentValue private TOptions RefreshCurrentValue(int gen) { TOptions value = Get(Options.DefaultName); - // Write generation before value so that a reader whose Volatile.Read of _currentValue - // sees the new value is guaranteed (via the acquire fence) to also see the updated generation. - Volatile.Write(ref _currentValueGeneration, gen); + // Write value before generation: the generation acts as a release signal that + // _currentValue is ready. A reader that acquire-loads _currentValueGeneration and + // sees gen is then guaranteed (via the release/acquire pairing) to observe the + // _currentValue written here. Volatile.Write(ref _currentValue, value); + Volatile.Write(ref _currentValueGeneration, gen); return value; }