From 7fead53ed7275d49521da4dd514460b7908877ce Mon Sep 17 00:00:00 2001 From: Santiago Blanco <56326361+sablancoleis@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:43:55 -0700 Subject: [PATCH 1/7] Fix MemoryCache negative _cacheSize drift on concurrent replace+remove Decrement the prior entry's size exactly once, atomically with the TryUpdate that swaps it out, instead of speculatively inside UpdateCacheSizeExceedsCapacity before the swap. The speculative subtraction races with a concurrent RemoveEntry of the prior entry (expiration/explicit Remove/eviction), double-counts the decrement, drives _cacheSize negative, and permanently latches the cache into silently rejecting all inserts. Restores the .NET 8 accounting semantics. Fixes #129186 --- .../src/MemoryCache.cs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs index 440eeefca2831c..21a93677b4d2e6 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs @@ -164,7 +164,7 @@ internal void SetEntry(CacheEntry entry) coherentState.RemoveEntry(priorEntry, _options); } } - else if (!UpdateCacheSizeExceedsCapacity(entry, priorEntry, coherentState)) + else if (!UpdateCacheSizeExceedsCapacity(entry, coherentState)) { bool entryAdded; if (priorEntry == null) @@ -177,11 +177,25 @@ internal void SetEntry(CacheEntry entry) // Try to update with the new entry if a previous entries exist. entryAdded = coherentState.TryUpdate(entry.Key, entry, priorEntry); - if (!entryAdded) + if (entryAdded) + { + if (_options.HasSizeLimit) + { + // The prior entry was atomically replaced by this entry via TryUpdate, so + // no other path can also remove (and decrement) it. Decrement its size here + // exactly once, tied to the swap we performed. Doing this speculatively + // inside UpdateCacheSizeExceedsCapacity (before the swap) races with a + // concurrent RemoveEntry of the prior entry and double-counts the decrement, + // drifting _cacheSize negative and permanently blocking all future inserts. + Interlocked.Add(ref coherentState._cacheSize, -priorEntry.Size); + } + } + else { // The update will fail if the previous entry was removed after retrieval. // Adding the new entry will succeed only if no entry has been added since. // This guarantees removing an old entry does not prevent adding a new entry. + // The prior entry's size is decremented by whichever path removed it, not here. entryAdded = coherentState.TryAdd(entry.Key, entry); } } @@ -194,8 +208,8 @@ internal void SetEntry(CacheEntry entry) { if (_options.HasSizeLimit) { - // Entry could not be added, reset cache size - Interlocked.Add(ref coherentState._cacheSize, -entry.Size + (priorEntry?.Size).GetValueOrDefault()); + // Entry could not be added, roll back the size increment for this entry only. + Interlocked.Add(ref coherentState._cacheSize, -entry.Size); } entry.SetExpired(EvictionReason.Replaced); entry.InvokeEvictionCallbacks(); @@ -526,7 +540,7 @@ private void ScanForExpiredItems() /// if increasing the cache size would /// cause it to exceed the size limit; otherwise, . /// - private bool UpdateCacheSizeExceedsCapacity(CacheEntry entry, CacheEntry? priorEntry, CoherentState coherentState) + private bool UpdateCacheSizeExceedsCapacity(CacheEntry entry, CoherentState coherentState) { long sizeLimit = _options.SizeLimitValue; if (sizeLimit < 0) @@ -537,12 +551,10 @@ private bool UpdateCacheSizeExceedsCapacity(CacheEntry entry, CacheEntry? priorE long sizeRead = coherentState.Size; for (int i = 0; i < 100; i++) { + // Only the new entry's size is committed here. The prior entry's size (for a replace) + // is decremented by the caller, atomically with the actual dictionary swap, so that it + // cannot race with a concurrent removal of the prior entry and be subtracted twice. long newSize = sizeRead + entry.Size; - if (priorEntry != null) - { - Debug.Assert(entry.Key == priorEntry.Key); - newSize -= priorEntry.Size; - } if ((ulong)newSize > (ulong)sizeLimit) { From 5461528a02b9ad771a827ffe1faad01b79ad38c7 Mon Sep 17 00:00:00 2001 From: Santiago Blanco <56326361+sablancoleis@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:44:21 -0700 Subject: [PATCH 2/7] Add concurrency regression test for MemoryCache size-tracking drift (#129186) --- .../MemoryCacheConcurrentSizeTrackingTests.cs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs new file mode 100644 index 00000000000000..d00dcc37c2e1de --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs @@ -0,0 +1,139 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.Caching.Memory +{ + public class MemoryCacheConcurrentSizeTrackingTests + { + // Regression test for the size-tracking double-decrement that drives _cacheSize negative and + // permanently latches a size-limited cache into rejecting all inserts. Many threads + // concurrently Set (replace), Get, and Remove a small set of string keys with short + // expirations under a generous SizeLimit. The working set is a tiny fraction of the limit, so + // no legitimate capacity rejection can occur. After the storm the tracked size must not be + // negative and the cache must still retain fresh, non-expiring entries. + [Fact] + public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() + { + using var cache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = 200L * 1024 * 1024, // far larger than the working set below + TrackStatistics = true + }); + + const int KeyCount = 16; + const int ValueSize = 4096; + byte[] payload = new byte[ValueSize]; + int threadCount = Math.Max(8, Environment.ProcessorCount * 4); + + long observedNegative = 0; + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3))) + { + Task monitor = Task.Run(() => + { + while (!cts.IsCancellationRequested) + { + long? size = cache.GetCurrentStatistics()?.CurrentEstimatedSize; + if (size.HasValue && size.Value < Interlocked.Read(ref observedNegative)) + { + Interlocked.Exchange(ref observedNegative, size.Value); + } + } + }); + + var workers = new Task[threadCount]; + for (int t = 0; t < threadCount; t++) + { + workers[t] = Task.Run(() => + { + var rnd = new Random(Environment.CurrentManagedThreadId); + while (!cts.IsCancellationRequested) + { + string key = "k" + rnd.Next(KeyCount); + int roll = rnd.Next(100); + if (roll < 65) + { + using ICacheEntry entry = cache.CreateEntry(key); + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(15); + entry.Size = ValueSize; + entry.Value = payload; + } + else if (roll < 85) + { + cache.TryGetValue(key, out _); + } + else + { + cache.Remove(key); + } + } + }); + } + + Task.WaitAll(workers); + cts.Cancel(); + monitor.Wait(); + } + + // Drain the working set so the cache is logically empty. + for (int k = 0; k < KeyCount; k++) + { + cache.Remove("k" + k); + } + Thread.Sleep(100); + + Assert.True(observedNegative >= 0, $"CurrentEstimatedSize drifted negative to {observedNegative}."); + + long drainedSize = cache.GetCurrentStatistics().CurrentEstimatedSize ?? 0; + Assert.True(drainedSize >= 0, $"CurrentEstimatedSize is negative after drain: {drainedSize}."); + + // The cache must still retain fresh, non-expiring entries (i.e., it is not latched). + const int Probe = 512; + int retained = 0; + for (int i = 0; i < Probe; i++) + { + string key = "fresh-" + i; + using (ICacheEntry entry = cache.CreateEntry(key)) + { + entry.Size = ValueSize; + entry.Value = payload; + } + + if (cache.TryGetValue(key, out _)) + { + retained++; + } + } + + Assert.Equal(Probe, retained); + } + + // Guards that the fix does not disable SizeLimit eviction: a tiny limit must keep the cache + // bounded even when far more entries (in aggregate size) are inserted. + [Fact] + public void SizeLimit_StillEnforced_AfterReplaceAccountingFix() + { + using var cache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = 40, // room for ~10 entries of size 4 + TrackStatistics = true + }); + + for (int i = 0; i < 1000; i++) + { + using ICacheEntry entry = cache.CreateEntry("cap-" + i); + entry.Size = 4; + entry.Value = i; + } + + MemoryCacheStatistics stats = cache.GetCurrentStatistics(); + Assert.True(stats.CurrentEstimatedSize >= 0); + Assert.True(stats.CurrentEstimatedSize <= 40, $"size {stats.CurrentEstimatedSize} exceeds limit 40"); + Assert.True(stats.CurrentEntryCount <= 10, $"count {stats.CurrentEntryCount} exceeds capacity"); + } + } +} From 01f90d32a7c34e15a7e686095b8fe8433b40592b Mon Sep 17 00:00:00 2001 From: Santiago Blanco <56326361+sablancoleis@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:38:49 -0700 Subject: [PATCH 3/7] Keep prior-aware capacity check; commit only entry.Size The first revision removed priorEntry.Size from the capacity check as well as the commit, which regressed CapacityTests.ReplaceOldEntryWithSameSizeOrLessNew EntryAtSizeLimitCapacity (a same-or-smaller replace at the size limit was falsely rejected). Restore the prior-aware capacity decision while still committing only entry.Size to _cacheSize; the prior entry's size is decremented exactly once, atomically with the TryUpdate swap, which is what fixes the race. --- .../src/MemoryCache.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs index 21a93677b4d2e6..51b5c3fe523e64 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs @@ -164,7 +164,7 @@ internal void SetEntry(CacheEntry entry) coherentState.RemoveEntry(priorEntry, _options); } } - else if (!UpdateCacheSizeExceedsCapacity(entry, coherentState)) + else if (!UpdateCacheSizeExceedsCapacity(entry, priorEntry, coherentState)) { bool entryAdded; if (priorEntry == null) @@ -540,7 +540,7 @@ private void ScanForExpiredItems() /// if increasing the cache size would /// cause it to exceed the size limit; otherwise, . /// - private bool UpdateCacheSizeExceedsCapacity(CacheEntry entry, CoherentState coherentState) + private bool UpdateCacheSizeExceedsCapacity(CacheEntry entry, CacheEntry? priorEntry, CoherentState coherentState) { long sizeLimit = _options.SizeLimitValue; if (sizeLimit < 0) @@ -548,21 +548,27 @@ private bool UpdateCacheSizeExceedsCapacity(CacheEntry entry, CoherentState cohe return false; } + long priorSize = priorEntry?.Size ?? 0; long sizeRead = coherentState.Size; for (int i = 0; i < 100; i++) { - // Only the new entry's size is committed here. The prior entry's size (for a replace) - // is decremented by the caller, atomically with the actual dictionary swap, so that it - // cannot race with a concurrent removal of the prior entry and be subtracted twice. - long newSize = sizeRead + entry.Size; + // The capacity decision still accounts for the prior entry being replaced (its size is + // freed by the replace), so a same-or-smaller replacement at the size limit is admitted. + // However, only the new entry's size is committed to _cacheSize here. The prior entry's + // size is decremented by the caller, atomically with the dictionary swap that actually + // removes it. Decrementing the prior size here (before the swap) races with a concurrent + // RemoveEntry of the same prior entry and double-counts the decrement, drifting + // _cacheSize negative and permanently blocking all future inserts. + long sizeAfterReplace = sizeRead + entry.Size - priorSize; - if ((ulong)newSize > (ulong)sizeLimit) + if ((ulong)sizeAfterReplace > (ulong)sizeLimit) { - // Overflow occurred, return true without updating the cache size + // Exceeds the limit (or overflow); return true without updating the cache size. return true; } - long original = Interlocked.CompareExchange(ref coherentState._cacheSize, newSize, sizeRead); + long committedSize = sizeRead + entry.Size; + long original = Interlocked.CompareExchange(ref coherentState._cacheSize, committedSize, sizeRead); if (sizeRead == original) { return false; From ee67b97684b4fd63b84c2f21a5b81557cf511fcf Mon Sep 17 00:00:00 2001 From: Santiago Blanco <56326361+sablancoleis@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:44:44 -0700 Subject: [PATCH 4/7] Use explicit types and target-typed new in the regression test --- .../tests/MemoryCacheConcurrentSizeTrackingTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs index d00dcc37c2e1de..18e039814c475c 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs @@ -19,7 +19,7 @@ public class MemoryCacheConcurrentSizeTrackingTests [Fact] public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() { - using var cache = new MemoryCache(new MemoryCacheOptions + using MemoryCache cache = new(new MemoryCacheOptions { SizeLimit = 200L * 1024 * 1024, // far larger than the working set below TrackStatistics = true @@ -31,7 +31,7 @@ public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() int threadCount = Math.Max(8, Environment.ProcessorCount * 4); long observedNegative = 0; - using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3))) + using (CancellationTokenSource cts = new(TimeSpan.FromSeconds(3))) { Task monitor = Task.Run(() => { @@ -45,12 +45,12 @@ public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() } }); - var workers = new Task[threadCount]; + Task[] workers = new Task[threadCount]; for (int t = 0; t < threadCount; t++) { workers[t] = Task.Run(() => { - var rnd = new Random(Environment.CurrentManagedThreadId); + Random rnd = new(Environment.CurrentManagedThreadId); while (!cts.IsCancellationRequested) { string key = "k" + rnd.Next(KeyCount); @@ -117,7 +117,7 @@ public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() [Fact] public void SizeLimit_StillEnforced_AfterReplaceAccountingFix() { - using var cache = new MemoryCache(new MemoryCacheOptions + using MemoryCache cache = new(new MemoryCacheOptions { SizeLimit = 40, // room for ~10 entries of size 4 TrackStatistics = true From 9fddf79ae95d881cfc6c656fee718e9af97c10d1 Mon Sep 17 00:00:00 2001 From: Santiago Blanco <56326361+sablancoleis@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:56:10 +0000 Subject: [PATCH 5/7] Use LongRunning tasks for the concurrent size-tracking regression test Back the concurrency workers with dedicated threads via TaskCreationOptions.LongRunning instead of Task.Run so the storm cannot saturate the shared ThreadPool and starve timing-sensitive post-eviction callbacks in sibling tests. Sample CurrentEstimatedSize inline (dropping the busy-spin monitor task), bound the work to a fixed iteration count, and gate the test on PlatformDetection.IsThreadingSupported. --- .../MemoryCacheConcurrentSizeTrackingTests.cs | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs index 18e039814c475c..a2d97c991ef65b 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs @@ -16,7 +16,12 @@ public class MemoryCacheConcurrentSizeTrackingTests // expirations under a generous SizeLimit. The working set is a tiny fraction of the limit, so // no legitimate capacity rejection can occur. After the storm the tracked size must not be // negative and the cache must still retain fresh, non-expiring entries. - [Fact] + // + // The workers are LongRunning tasks, which the default scheduler backs with dedicated threads + // rather than the shared ThreadPool. This prevents the storm from starving timing-sensitive + // post-eviction callbacks in sibling tests. ConditionalFact skips platforms without real + // thread support (e.g. browser/wasm). + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))] public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() { using MemoryCache cache = new(new MemoryCacheOptions @@ -27,31 +32,22 @@ public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() const int KeyCount = 16; const int ValueSize = 4096; + const int IterationsPerThread = 200_000; + const int SampleMask = 1023; // sample CurrentEstimatedSize roughly every 1024 iterations byte[] payload = new byte[ValueSize]; - int threadCount = Math.Max(8, Environment.ProcessorCount * 4); + int threadCount = Math.Min(Math.Max(4, Environment.ProcessorCount), 16); - long observedNegative = 0; - using (CancellationTokenSource cts = new(TimeSpan.FromSeconds(3))) - { - Task monitor = Task.Run(() => - { - while (!cts.IsCancellationRequested) - { - long? size = cache.GetCurrentStatistics()?.CurrentEstimatedSize; - if (size.HasValue && size.Value < Interlocked.Read(ref observedNegative)) - { - Interlocked.Exchange(ref observedNegative, size.Value); - } - } - }); + long observedMinSize = 0; - Task[] workers = new Task[threadCount]; - for (int t = 0; t < threadCount; t++) - { - workers[t] = Task.Run(() => + Task[] workers = new Task[threadCount]; + for (int t = 0; t < threadCount; t++) + { + int seed = t + 1; + workers[t] = Task.Factory.StartNew( + () => { - Random rnd = new(Environment.CurrentManagedThreadId); - while (!cts.IsCancellationRequested) + Random rnd = new(seed); + for (int i = 0; i < IterationsPerThread; i++) { string key = "k" + rnd.Next(KeyCount); int roll = rnd.Next(100); @@ -70,15 +66,24 @@ public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() { cache.Remove(key); } - } - }); - } - Task.WaitAll(workers); - cts.Cancel(); - monitor.Wait(); + if ((i & SampleMask) == 0) + { + long? size = cache.GetCurrentStatistics()?.CurrentEstimatedSize; + if (size.HasValue && size.Value < Interlocked.Read(ref observedMinSize)) + { + Interlocked.Exchange(ref observedMinSize, size.Value); + } + } + } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); } + Task.WaitAll(workers); + // Drain the working set so the cache is logically empty. for (int k = 0; k < KeyCount; k++) { @@ -86,7 +91,7 @@ public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() } Thread.Sleep(100); - Assert.True(observedNegative >= 0, $"CurrentEstimatedSize drifted negative to {observedNegative}."); + Assert.True(observedMinSize >= 0, $"CurrentEstimatedSize drifted negative to {observedMinSize}."); long drainedSize = cache.GetCurrentStatistics().CurrentEstimatedSize ?? 0; Assert.True(drainedSize >= 0, $"CurrentEstimatedSize is negative after drain: {drainedSize}."); From 8664fcb7187bb51faf5a6caa194565ca0ae8b959 Mon Sep 17 00:00:00 2001 From: Santiago Blanco <56326361+sablancoleis@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:02:10 -0700 Subject: [PATCH 6/7] Fix build: use PlatformDetection.IsMultithreadingSupported --- .../tests/MemoryCacheConcurrentSizeTrackingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs index a2d97c991ef65b..14d49e0a594188 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs @@ -21,7 +21,7 @@ public class MemoryCacheConcurrentSizeTrackingTests // rather than the shared ThreadPool. This prevents the storm from starving timing-sensitive // post-eviction callbacks in sibling tests. ConditionalFact skips platforms without real // thread support (e.g. browser/wasm). - [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))] + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() { using MemoryCache cache = new(new MemoryCacheOptions From a243d4685ea76c8f095a184edf6095a5794adf0a Mon Sep 17 00:00:00 2001 From: Santiago Blanco <56326361+sablancoleis@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:05:08 -0700 Subject: [PATCH 7/7] Address review: remove redundant SizeLimit test, mark concurrency test [OuterLoop] --- .../MemoryCacheConcurrentSizeTrackingTests.cs | 29 ++----------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs index 14d49e0a594188..8e108f93f97f51 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheConcurrentSizeTrackingTests.cs @@ -19,9 +19,10 @@ public class MemoryCacheConcurrentSizeTrackingTests // // The workers are LongRunning tasks, which the default scheduler backs with dedicated threads // rather than the shared ThreadPool. This prevents the storm from starving timing-sensitive - // post-eviction callbacks in sibling tests. ConditionalFact skips platforms without real - // thread support (e.g. browser/wasm). + // post-eviction callbacks in sibling tests. It runs as OuterLoop because it is a long-running + // stress test, and ConditionalFact skips platforms without real thread support (e.g. browser/wasm). [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [OuterLoop] public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() { using MemoryCache cache = new(new MemoryCacheOptions @@ -116,29 +117,5 @@ public void ConcurrentSetReplaceAndRemove_DoesNotDriftSizeNegative_NorLatch() Assert.Equal(Probe, retained); } - - // Guards that the fix does not disable SizeLimit eviction: a tiny limit must keep the cache - // bounded even when far more entries (in aggregate size) are inserted. - [Fact] - public void SizeLimit_StillEnforced_AfterReplaceAccountingFix() - { - using MemoryCache cache = new(new MemoryCacheOptions - { - SizeLimit = 40, // room for ~10 entries of size 4 - TrackStatistics = true - }); - - for (int i = 0; i < 1000; i++) - { - using ICacheEntry entry = cache.CreateEntry("cap-" + i); - entry.Size = 4; - entry.Value = i; - } - - MemoryCacheStatistics stats = cache.GetCurrentStatistics(); - Assert.True(stats.CurrentEstimatedSize >= 0); - Assert.True(stats.CurrentEstimatedSize <= 40, $"size {stats.CurrentEstimatedSize} exceeds limit 40"); - Assert.True(stats.CurrentEntryCount <= 10, $"count {stats.CurrentEntryCount} exceeds capacity"); - } } }