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");
- }
}
}