-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Make HttpClientFactory implement IDisposable to clean up resources #115826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
aa38f19
7252930
31b0a30
dea710c
6cf5b2a
cd24f01
eb97f38
70c11d0
9e16202
e2c3344
a67ec9a
da799e8
82e8cd6
ac5f1b8
1621aef
67f2418
0ad79a4
71344ed
2af2913
4835d72
2d3b136
2c0b22a
1249ae2
0f94471
7ec67fe
b4a95e1
23a6ec3
99a6183
5c9e418
5b3d51f
9ae421a
aaaf1a1
c5f45b1
b37a806
ef1d362
1807675
29771cf
2f13499
938815b
3d3f64d
9c60237
d8278bc
2f1de62
efa49e1
b0216e5
65066bd
dcd8cd0
c05b5bf
7a0d56e
5bffb75
37c0742
89070cc
a6ab59c
62c0678
1bcbe3e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,7 +16,7 @@ | |
|
||
namespace Microsoft.Extensions.Http | ||
{ | ||
internal class DefaultHttpClientFactory : IHttpClientFactory, IHttpMessageHandlerFactory | ||
internal class DefaultHttpClientFactory : IHttpClientFactory, IHttpMessageHandlerFactory, IDisposable | ||
CarnaViire marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
private static readonly TimerCallback _cleanupCallback = (s) => ((DefaultHttpClientFactory)s!).CleanupTimer_Tick(); | ||
private readonly IServiceProvider _services; | ||
|
@@ -33,14 +33,15 @@ internal class DefaultHttpClientFactory : IHttpClientFactory, IHttpMessageHandle | |
// This seems frequent enough. We also rely on GC occurring to actually trigger disposal. | ||
private readonly TimeSpan DefaultCleanupInterval = TimeSpan.FromSeconds(10); | ||
|
||
// We use a new timer for each regular cleanup cycle, protected with a lock. Note that this scheme | ||
// doesn't give us anything to dispose, as the timer is started/stopped as needed. | ||
// We use a new timer for each regular cleanup cycle, protected with a lock. | ||
// | ||
// There's no need for the factory itself to be disposable. If you stop using it, eventually everything will | ||
// get reclaimed. | ||
// The factory implements IDisposable to ensure that resources are properly cleaned up when the hosting | ||
// service provider is disposed. This prevents memory leaks in applications that create and dispose | ||
// service providers frequently. | ||
private Timer? _cleanupTimer; | ||
private readonly object _cleanupTimerLock; | ||
private readonly object _cleanupActiveLock; | ||
private bool _disposed; | ||
|
||
// Collection of 'active' handlers. | ||
// | ||
|
@@ -104,6 +105,8 @@ public HttpClient CreateClient(string name) | |
{ | ||
ArgumentNullException.ThrowIfNull(name); | ||
|
||
ObjectDisposedException.ThrowIf(_disposed, nameof(DefaultHttpClientFactory)); | ||
|
||
HttpMessageHandler handler = CreateHandler(name); | ||
var client = new HttpClient(handler, disposeHandler: false); | ||
|
||
|
@@ -120,6 +123,8 @@ public HttpMessageHandler CreateHandler(string name) | |
{ | ||
ArgumentNullException.ThrowIfNull(name); | ||
|
||
ObjectDisposedException.ThrowIf(_disposed, nameof(DefaultHttpClientFactory)); | ||
|
||
ActiveHandlerTrackingEntry entry = _activeHandlers.GetOrAdd(name, _entryFactory).Value; | ||
|
||
StartHandlerEntryTimer(entry); | ||
|
@@ -194,6 +199,12 @@ internal void ExpiryTimer_Tick(object? state) | |
{ | ||
var active = (ActiveHandlerTrackingEntry)state!; | ||
|
||
// If factory is disposed, don't process expiry | ||
if (_disposed) | ||
{ | ||
return; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at
Thus, even if factory's The more I look at it, the more I think it is somehow more complicated than it should be if we could guarantee that Dispose is only ever called when the entry is not within the collection anymore, we could even just null out the handler reference (via compare exchange obviously), so technically it shouldn't be touched by the CreateClient anymore? But what if it got retrieved there just before it was removed and about to be disposed? thinking more, while it was guaranteed previously that the handler will remain alive while it's still being used - it is now broken, but only at the moment of factory disposal. and factory's (and transitively active entry's) dispose is only ever called during the full container dispose. meaning, this is a time when it becomes erroneous to use anything created by this container anymore. so this race - as any other errors during - is smth that can be expected. but I think just in case we should still play nice and throw ODE in the property getter instead of leaving it for a subsequent NRE. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you know what? I definitely was overthinking this. then, from active entry's perspective, it immutable with exception of the timer. from expired entry perspective, it's completely immutable. the most straightforward approach to Factory's dispose would be:
from thread-safety perspective:
|
||
} | ||
|
||
// The timer callback should be the only one removing from the active collection. If we can't find | ||
// our entry in the collection, then this is a bug. | ||
bool removed = _activeHandlers.TryRemove(active.Name, out Lazy<ActiveHandlerTrackingEntry>? found); | ||
|
@@ -206,12 +217,17 @@ internal void ExpiryTimer_Tick(object? state) | |
// | ||
// We use a different state object to track expired handlers. This allows any other thread that acquired | ||
// the 'active' entry to use it without safety problems. | ||
var expired = new ExpiredHandlerTrackingEntry(active); | ||
_expiredHandlers.Enqueue(expired); | ||
|
||
Log.HandlerExpired(_logger, active.Name, active.Lifetime); | ||
// Check again after removal to prevent race with Dispose | ||
if (!_disposed) | ||
{ | ||
var expired = new ExpiredHandlerTrackingEntry(active); | ||
_expiredHandlers.Enqueue(expired); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. here as well. what's more, if the one of the possible solutions would be to null out the _expiredHandlers reference completely on dispose, same as with active entry |
||
|
||
StartCleanupTimer(); | ||
Log.HandlerExpired(_logger, active.Name, active.Lifetime); | ||
|
||
StartCleanupTimer(); | ||
} | ||
} | ||
|
||
// Internal so it can be overridden in tests | ||
|
@@ -225,7 +241,11 @@ internal virtual void StartCleanupTimer() | |
{ | ||
lock (_cleanupTimerLock) | ||
{ | ||
_cleanupTimer ??= NonCapturingTimer.Create(_cleanupCallback, this, DefaultCleanupInterval, Timeout.InfiniteTimeSpan); | ||
// Don't start cleanup timer if factory is disposed | ||
if (!_disposed) | ||
{ | ||
_cleanupTimer ??= NonCapturingTimer.Create(_cleanupCallback, this, DefaultCleanupInterval, Timeout.InfiniteTimeSpan); | ||
} | ||
} | ||
} | ||
|
||
|
@@ -234,7 +254,7 @@ internal virtual void StopCleanupTimer() | |
{ | ||
lock (_cleanupTimerLock) | ||
{ | ||
_cleanupTimer!.Dispose(); | ||
_cleanupTimer?.Dispose(); | ||
_cleanupTimer = null; | ||
} | ||
} | ||
|
@@ -252,6 +272,12 @@ internal void CleanupTimer_Tick() | |
// whether we need to start the timer. | ||
StopCleanupTimer(); | ||
|
||
// If factory is disposed, don't perform any cleanup | ||
if (_disposed) | ||
{ | ||
return; | ||
} | ||
|
||
if (!Monitor.TryEnter(_cleanupActiveLock)) | ||
{ | ||
// We don't want to run a concurrent cleanup cycle. This can happen if the cleanup cycle takes | ||
|
@@ -266,6 +292,12 @@ internal void CleanupTimer_Tick() | |
|
||
try | ||
{ | ||
// Check again after acquiring the lock in case Dispose was called | ||
if (_disposed) | ||
{ | ||
return; | ||
} | ||
|
||
int initialCount = _expiredHandlers.Count; | ||
Log.CleanupCycleStart(_logger, initialCount); | ||
|
||
|
@@ -282,8 +314,8 @@ internal void CleanupTimer_Tick() | |
{ | ||
try | ||
{ | ||
entry.InnerHandler.Dispose(); | ||
entry.Scope?.Dispose(); | ||
// During normal cleanup, let the entry check CanDispose itself | ||
CarnaViire marked this conversation as resolved.
Show resolved
Hide resolved
|
||
entry.Dispose(); | ||
disposedCount++; | ||
} | ||
catch (Exception ex) | ||
|
@@ -307,12 +339,76 @@ internal void CleanupTimer_Tick() | |
} | ||
|
||
// We didn't totally empty the cleanup queue, try again later. | ||
if (!_expiredHandlers.IsEmpty) | ||
// But only if not disposed | ||
if (!_expiredHandlers.IsEmpty && !_disposed) | ||
{ | ||
StartCleanupTimer(); | ||
} | ||
} | ||
|
||
public void Dispose() | ||
{ | ||
if (_disposed) | ||
{ | ||
return; | ||
} | ||
|
||
_disposed = true; | ||
|
||
// Stop the cleanup timer | ||
StopCleanupTimer(); | ||
|
||
// Stop all active handler timers to prevent more entries being added to _expiredHandlers | ||
List<IDisposable> disposables = new List<IDisposable>(); | ||
|
||
// Lock to safely collect handlers from active and expired collections | ||
lock (_cleanupActiveLock) | ||
{ | ||
// Stop active handler timers and collect expired handlers | ||
foreach (KeyValuePair<string, Lazy<ActiveHandlerTrackingEntry>> entry in _activeHandlers) | ||
{ | ||
ActiveHandlerTrackingEntry activeEntry = entry.Value.Value; | ||
activeEntry.StopTimer(); | ||
disposables.Add(activeEntry); | ||
} | ||
|
||
// Collect all expired handlers for disposal | ||
while (_expiredHandlers.TryDequeue(out ExpiredHandlerTrackingEntry? entry)) | ||
{ | ||
if (entry != null) | ||
{ | ||
// During explicit disposal, we force disposal of all handlers regardless of CanDispose status | ||
disposables.Add(entry); | ||
|
||
// Force dispose the scope even if the handler is still alive | ||
if (entry.Scope != null) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when we fix entry's Dispose to remove the redundant CanDispose check, it should be enough to just dispose the entry here. |
||
{ | ||
disposables.Add(entry.Scope); | ||
} | ||
} | ||
} | ||
|
||
// Clear the collections to help with garbage collection | ||
_activeHandlers.Clear(); | ||
} | ||
|
||
// Dispose all handlers outside the lock | ||
foreach (IDisposable disposable in disposables) | ||
{ | ||
try | ||
{ | ||
disposable.Dispose(); | ||
} | ||
catch (Exception ex) | ||
{ | ||
Log.CleanupItemFailed(_logger, | ||
disposable is ActiveHandlerTrackingEntry active ? active.Name : | ||
disposable is ExpiredHandlerTrackingEntry expired ? expired.Name : | ||
"Unknown", ex); | ||
} | ||
} | ||
} | ||
|
||
private static class Log | ||
{ | ||
public static class EventIds | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,7 +8,7 @@ | |
namespace Microsoft.Extensions.Http | ||
{ | ||
// Thread-safety: This class is immutable | ||
internal sealed class ExpiredHandlerTrackingEntry | ||
internal sealed class ExpiredHandlerTrackingEntry : IDisposable | ||
{ | ||
private readonly WeakReference _livenessTracker; | ||
|
||
|
@@ -23,12 +23,30 @@ public ExpiredHandlerTrackingEntry(ActiveHandlerTrackingEntry other) | |
InnerHandler = other.Handler.InnerHandler!; | ||
} | ||
|
||
// Used during normal cleanup cycles | ||
public bool CanDispose => !_livenessTracker.IsAlive; | ||
|
||
public HttpMessageHandler InnerHandler { get; } | ||
|
||
public string Name { get; } | ||
|
||
public IServiceScope? Scope { get; } | ||
|
||
public void Dispose() | ||
{ | ||
try | ||
{ | ||
InnerHandler.Dispose(); | ||
} | ||
finally | ||
{ | ||
if (!_livenessTracker.IsAlive) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is wrong. this either should be already checked by the caller, or the caller wants everything to be disposed regardless |
||
{ | ||
Scope?.Dispose(); | ||
} | ||
// If IsAlive is true, it means the handler is still in use | ||
// Don't dispose the scope as it's still being used with the handler | ||
} | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.