Skip to content

Commit d224922

Browse files
authored
[release/2.1] Memory Leak in Microsoft.Extensions.Caching.Memory when handling exceptions (dotnet/extensions#3536)
* Memory Leak in Microsoft.Extensions.Caching.Memory when handling exceptions * Update patchConfig.props * Try standardizing use of SetValue * Apply feedback * Fix syntax * Undo breaking change * Feedback * Fixup * Fixup patchConfig * Add direct ref * Fix another memory leak when one cache depends on another cache * Fixup\n\nCommit migrated from dotnet/extensions@dc1dab7
1 parent 2b1ec2c commit d224922

File tree

4 files changed

+88
-40
lines changed

4 files changed

+88
-40
lines changed

src/Caching/Abstractions/src/MemoryCacheExtensions.cs

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,39 +35,43 @@ public static bool TryGetValue<TItem>(this IMemoryCache cache, object key, out T
3535

3636
public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value)
3737
{
38-
var entry = cache.CreateEntry(key);
39-
entry.Value = value;
40-
entry.Dispose();
38+
using (var entry = cache.CreateEntry(key))
39+
{
40+
entry.Value = value;
41+
}
4142

4243
return value;
4344
}
4445

4546
public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value, DateTimeOffset absoluteExpiration)
4647
{
47-
var entry = cache.CreateEntry(key);
48-
entry.AbsoluteExpiration = absoluteExpiration;
49-
entry.Value = value;
50-
entry.Dispose();
48+
using (var entry = cache.CreateEntry(key))
49+
{
50+
entry.AbsoluteExpiration = absoluteExpiration;
51+
entry.Value = value;
52+
}
5153

5254
return value;
5355
}
5456

5557
public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value, TimeSpan absoluteExpirationRelativeToNow)
5658
{
57-
var entry = cache.CreateEntry(key);
58-
entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow;
59-
entry.Value = value;
60-
entry.Dispose();
59+
using (var entry = cache.CreateEntry(key))
60+
{
61+
entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow;
62+
entry.Value = value;
63+
}
6164

6265
return value;
6366
}
6467

6568
public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value, IChangeToken expirationToken)
6669
{
67-
var entry = cache.CreateEntry(key);
68-
entry.AddExpirationToken(expirationToken);
69-
entry.Value = value;
70-
entry.Dispose();
70+
using (var entry = cache.CreateEntry(key))
71+
{
72+
entry.AddExpirationToken(expirationToken);
73+
entry.Value = value;
74+
}
7175

7276
return value;
7377
}
@@ -91,13 +95,11 @@ public static TItem GetOrCreate<TItem>(this IMemoryCache cache, object key, Func
9195
{
9296
if (!cache.TryGetValue(key, out object result))
9397
{
94-
var entry = cache.CreateEntry(key);
95-
result = factory(entry);
96-
entry.SetValue(result);
97-
// need to manually call dispose instead of having a using
98-
// in case the factory passed in throws, in which case we
99-
// do not want to add the entry to the cache
100-
entry.Dispose();
98+
using (var entry = cache.CreateEntry(key))
99+
{
100+
result = factory(entry);
101+
entry.Value = result;
102+
}
101103
}
102104

103105
return (TItem)result;
@@ -107,13 +109,11 @@ public static async Task<TItem> GetOrCreateAsync<TItem>(this IMemoryCache cache,
107109
{
108110
if (!cache.TryGetValue(key, out object result))
109111
{
110-
var entry = cache.CreateEntry(key);
111-
result = await factory(entry);
112-
entry.SetValue(result);
113-
// need to manually call dispose instead of having a using
114-
// in case the factory passed in throws, in which case we
115-
// do not want to add the entry to the cache
116-
entry.Dispose();
112+
using (ICacheEntry entry = cache.CreateEntry(key))
113+
{
114+
result = await factory(entry).ConfigureAwait(false);
115+
entry.Value = result;
116+
}
117117
}
118118

119119
return (TItem)result;

src/Caching/Memory/src/CacheEntry.cs

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ namespace Microsoft.Extensions.Caching.Memory
1111
{
1212
internal class CacheEntry : ICacheEntry
1313
{
14-
private bool _added = false;
14+
private bool _disposed = false;
1515
private static readonly Action<object> ExpirationCallback = ExpirationTokensExpired;
1616
private readonly Action<CacheEntry> _notifyCacheOfExpiration;
17-
private readonly Action<CacheEntry> _notifyCacheEntryDisposed;
17+
private readonly Action<CacheEntry> _notifyCacheEntryCommit;
1818
private IList<IDisposable> _expirationTokenRegistrations;
1919
private IList<PostEvictionCallbackRegistration> _postEvictionCallbacks;
2020
private bool _isExpired;
@@ -25,22 +25,24 @@ internal class CacheEntry : ICacheEntry
2525
private TimeSpan? _slidingExpiration;
2626
private long? _size;
2727
private IDisposable _scope;
28+
private object _value;
29+
private bool _valueHasBeenSet;
2830

2931
internal readonly object _lock = new object();
3032

3133
internal CacheEntry(
3234
object key,
33-
Action<CacheEntry> notifyCacheEntryDisposed,
35+
Action<CacheEntry> notifyCacheEntryCommit,
3436
Action<CacheEntry> notifyCacheOfExpiration)
3537
{
3638
if (key == null)
3739
{
3840
throw new ArgumentNullException(nameof(key));
3941
}
4042

41-
if (notifyCacheEntryDisposed == null)
43+
if (notifyCacheEntryCommit == null)
4244
{
43-
throw new ArgumentNullException(nameof(notifyCacheEntryDisposed));
45+
throw new ArgumentNullException(nameof(notifyCacheEntryCommit));
4446
}
4547

4648
if (notifyCacheOfExpiration == null)
@@ -49,7 +51,7 @@ internal CacheEntry(
4951
}
5052

5153
Key = key;
52-
_notifyCacheEntryDisposed = notifyCacheEntryDisposed;
54+
_notifyCacheEntryCommit = notifyCacheEntryCommit;
5355
_notifyCacheOfExpiration = notifyCacheOfExpiration;
5456

5557
_scope = CacheEntryHelper.EnterScope(this);
@@ -173,20 +175,39 @@ public long? Size
173175

174176
public object Key { get; private set; }
175177

176-
public object Value { get; set; }
178+
public object Value
179+
{
180+
get => _value;
181+
set
182+
{
183+
_value = value;
184+
_valueHasBeenSet = true;
185+
}
186+
}
177187

178188
internal DateTimeOffset LastAccessed { get; set; }
179189

180190
internal EvictionReason EvictionReason { get; private set; }
181191

182192
public void Dispose()
183193
{
184-
if (!_added)
194+
if (!_disposed)
185195
{
186-
_added = true;
196+
_disposed = true;
197+
198+
// Ensure the _scope reference is cleared because it can reference other CacheEntry instances.
199+
// This CacheEntry is going to be put into a MemoryCache, and we don't want to root unnecessary objects.
187200
_scope.Dispose();
188-
_notifyCacheEntryDisposed(this);
189-
PropagateOptions(CacheEntryHelper.Current);
201+
_scope = null;
202+
203+
// Don't commit or propagate options if the CacheEntry Value was never set.
204+
// We assume an exception occurred causing the caller to not set the Value successfully,
205+
// so don't use this entry.
206+
if (_valueHasBeenSet)
207+
{
208+
_notifyCacheEntryCommit(this);
209+
PropagateOptions(CacheEntryHelper.Current);
210+
}
190211
}
191212
}
192213

src/Caching/Memory/test/MemoryCacheSetAndRemoveTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Reflection;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using Xunit;
@@ -180,6 +181,9 @@ public void GetOrCreate_WillNotCreateEmptyValue_WhenFactoryThrows()
180181
}
181182

182183
Assert.False(cache.TryGetValue(key, out int obj));
184+
185+
// verify that throwing an exception doesn't leak CacheEntry objects
186+
Assert.Null(CacheEntryHelper.Current);
183187
}
184188

185189
[Fact]
@@ -199,6 +203,21 @@ await cache.GetOrCreateAsync<int>(key, entry =>
199203
}
200204

201205
Assert.False(cache.TryGetValue(key, out int obj));
206+
207+
// verify that throwing an exception doesn't leak CacheEntry objects
208+
Assert.Null(CacheEntryHelper.Current);
209+
}
210+
211+
[Fact]
212+
public void DisposingCacheEntryReleasesScope()
213+
{
214+
var cache = CreateCache();
215+
216+
ICacheEntry entry = cache.CreateEntry("myKey");
217+
Assert.NotNull(GetScope(entry));
218+
219+
entry.Dispose();
220+
Assert.Null(GetScope(entry));
202221
}
203222

204223
[Fact]
@@ -625,6 +644,13 @@ public async Task GetOrCreateAsyncFromCacheWithNullKeyThrows()
625644
await Assert.ThrowsAsync<ArgumentNullException>(async () => await cache.GetOrCreateAsync<object>(null, null));
626645
}
627646

647+
private object GetScope(ICacheEntry entry)
648+
{
649+
return entry.GetType()
650+
.GetField("_scope", BindingFlags.NonPublic | BindingFlags.Instance)
651+
.GetValue(entry);
652+
}
653+
628654
private class TestKey
629655
{
630656
public override bool Equals(object obj) => true;

src/Caching/Memory/test/Microsoft.Extensions.Caching.Memory.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
</PropertyGroup>
77

88
<ItemGroup>
9+
<Reference Include="Microsoft.Extensions.Caching.Abstractions" />
910
<Reference Include="Microsoft.Extensions.Caching.Memory" />
1011
<Reference Include="Microsoft.Extensions.DependencyInjection" />
1112
</ItemGroup>

0 commit comments

Comments
 (0)