Skip to content

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

Draft
wants to merge 51 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
aa38f19
Initial plan for issue
Copilot May 21, 2025
7252930
Make HttpClientFactory disposable to clean up resources
Copilot May 21, 2025
31b0a30
Add unit test for HttpClientFactory dispose
Copilot May 21, 2025
dea710c
Fix build error by removing trailing whitespace in DefaultHttpClientF…
Copilot May 21, 2025
6cf5b2a
Implement requested changes from PR review
Copilot May 21, 2025
cd24f01
Remove trailing whitespace in ActiveHandlerTrackingEntry.cs
Copilot May 21, 2025
eb97f38
Use null-conditional operator in ActiveHandlerTrackingEntry.StopTimer()
Copilot May 21, 2025
70c11d0
Fix ExpiredHandlerTrackingEntry.Dispose() method
Copilot May 21, 2025
9e16202
Fix assert in ActiveHandlerTrackingEntry.Timer_Tick()
Copilot May 21, 2025
e2c3344
Fix ActiveHandlerTrackingEntry to not dispose handlers on conversion …
Copilot May 21, 2025
a67ec9a
Fix trailing whitespace in DefaultHttpClientFactory.cs line 261
Copilot May 21, 2025
da799e8
Update ActiveHandlerTrackingEntry to use interlocked operations
Copilot May 21, 2025
82e8cd6
Replace ObjectDisposedException checks with ThrowIf method
Copilot May 21, 2025
ac5f1b8
Merge branch 'main' into copilot/fix-113494
CarnaViire May 22, 2025
1621aef
Initial plan to fix HttpClientFactory resource cleanup
Copilot May 22, 2025
67f2418
Implement improved resource cleanup in HttpClientFactory
Copilot May 22, 2025
0ad79a4
Fix restore for lib tests, add build instructions
CarnaViire May 22, 2025
71344ed
Implement improved resource cleanup in HttpClientFactory
Copilot May 22, 2025
2af2913
Install dependencies
CarnaViire May 22, 2025
4835d72
sudo
CarnaViire May 22, 2025
2d3b136
Remove NotDisposed constant and simplify state checking in ActiveHand…
Copilot May 22, 2025
2c0b22a
Update LifetimeTrackingHttpMessageHandler.Dispose to properly dispose…
Copilot May 22, 2025
1249ae2
add more missing pkgs
CarnaViire May 22, 2025
0f94471
Fix null reference in StopCleanupTimer and update test to use proper …
Copilot May 22, 2025
7ec67fe
Update test to use direct handler tracking to verify disposal
Copilot May 22, 2025
b4a95e1
Create copilot-instructions.md
CarnaViire May 22, 2025
23a6ec3
Fix handler disposal in ActiveHandlerTrackingEntry.Dispose()
Copilot May 23, 2025
99a6183
Revert changes to LifetimeTrackingHttpMessageHandler and update Activ…
Copilot May 23, 2025
5c9e418
Update HttpClientFactory disposal test and debug logging
Copilot May 23, 2025
5b3d51f
Remove debug code and cleanup implementation
Copilot May 23, 2025
9ae421a
Instructions wip
CarnaViire May 23, 2025
aaaf1a1
more instructions
CarnaViire May 23, 2025
c5f45b1
Fix code review issues from PR
Copilot May 23, 2025
b37a806
Pre-build clr
CarnaViire May 23, 2025
ef1d362
Update .github/copilot-instructions.md
CarnaViire May 23, 2025
1807675
Use common install-dependencies.sh script in copilot-setup-steps.yml
Copilot May 23, 2025
29771cf
Merge branch 'main' into CarnaViire-patch-1
CarnaViire May 23, 2025
2f13499
Code review suggestions, unify formatting
CarnaViire May 23, 2025
938815b
Merge branch 'CarnaViire-patch-1' into copilot/fix-113494
CarnaViire May 23, 2025
3d3f64d
fix
CarnaViire May 23, 2025
9c60237
Apply code review suggestions: simplify condition checks and cleanup …
Copilot May 23, 2025
d8278bc
Add missing testhost instruction
CarnaViire May 23, 2025
2f1de62
Fix ExpiredHandlerTrackingEntry.Dispose to not dispose scope when han…
Copilot May 23, 2025
efa49e1
update yml
CarnaViire May 23, 2025
b0216e5
Fix HttpClientFactoryDisposeTests to create distinct client handlers
Copilot May 23, 2025
65066bd
Merge remote-tracking branch 'upstream/main' into copilot/fix-113494
CarnaViire May 26, 2025
dcd8cd0
Merge branch 'main' into copilot/fix-113494
CarnaViire May 27, 2025
c05b5bf
Remove redundant forceDispose property and simplify handler disposal …
Copilot May 27, 2025
7a0d56e
Update instructions
CarnaViire May 27, 2025
5bffb75
Merge branch 'main' into copilot/fix-113494
CarnaViire Jun 19, 2025
37c0742
Enhance HttpClientFactory dispose tests to verify scope disposal and …
Copilot Jun 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ namespace Microsoft.Extensions.Http
{
// Thread-safety: We treat this class as immutable except for the timer. Creating a new object
// for the 'expiry' pool simplifies the threading requirements significantly.
internal sealed class ActiveHandlerTrackingEntry
internal sealed class ActiveHandlerTrackingEntry : IDisposable
{
private static readonly TimerCallback _timerCallback = (s) => ((ActiveHandlerTrackingEntry)s!).Timer_Tick();
private readonly object _lock;
private bool _timerInitialized;
private Timer? _timer;
private TimerCallback? _callback;
// States for the handler tracking entry
private const int Disposed = 1;
private const int Expired = 2;
private int _disposed;

public ActiveHandlerTrackingEntry(
string name,
Expand All @@ -43,6 +47,11 @@ public ActiveHandlerTrackingEntry(

public void StartExpiryTimer(TimerCallback callback)
{
if (_disposed > 0)
{
return;
}

if (Lifetime == Timeout.InfiniteTimeSpan)
{
return; // never expires.
Expand All @@ -62,7 +71,8 @@ private void StartExpiryTimerSlow(TimerCallback callback)

lock (_lock)
{
if (Volatile.Read(ref _timerInitialized))
if (Volatile.Read(ref _timerInitialized) ||
_disposed > 0)
{
return;
}
Expand All @@ -76,7 +86,7 @@ private void StartExpiryTimerSlow(TimerCallback callback)
private void Timer_Tick()
{
Debug.Assert(_callback != null);
Debug.Assert(_timer != null);
Debug.Assert(_timer != null || _disposed > 0);

lock (_lock)
{
Expand All @@ -85,9 +95,44 @@ private void Timer_Tick()
_timer.Dispose();
_timer = null;

_callback(this);
// Only invoke the callback if we successfully transition from 0 to Expired
// This ensures we don't convert to expired if already disposed
if (Interlocked.CompareExchange(ref _disposed, Expired, 0) == 0)
{
_callback(this);
}
}
}
}

public void StopTimer()
{
lock (_lock)
{
_timer?.Dispose();
_timer = null;
}
}

public void Dispose()
{
// Try to transition from 0 to Disposed state
// If already in another state (Expired), do nothing further with handlers
if (Interlocked.CompareExchange(ref _disposed, Disposed, 0) != 0)
{
// If the entry was already disposed or expired, exit
// If it was expired, the timer has already stopped and
// ExpiredHandlerTrackingEntry now owns both handler and scope
return;
}

StopTimer();

// When we're directly disposed (not converted to an expired entry),
// we need to dispose the inner handler (not the LifetimeTrackingHttpMessageHandler itself)
// and the scope
Handler.InnerHandler?.Dispose();
Scope?.Dispose();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

namespace Microsoft.Extensions.Http
{
internal class DefaultHttpClientFactory : IHttpClientFactory, IHttpMessageHandlerFactory
internal class DefaultHttpClientFactory : IHttpClientFactory, IHttpMessageHandlerFactory, IDisposable
{
private static readonly TimerCallback _cleanupCallback = (s) => ((DefaultHttpClientFactory)s!).CleanupTimer_Tick();
private readonly IServiceProvider _services;
Expand All @@ -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.
//
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -234,14 +239,19 @@ internal virtual void StopCleanupTimer()
{
lock (_cleanupTimerLock)
{
_cleanupTimer!.Dispose();
_cleanupTimer?.Dispose();
_cleanupTimer = null;
}
}

// Internal for tests
internal void CleanupTimer_Tick()
{
// If factory is disposed, don't perform any cleanup
if (_disposed)
{
return;
}
// Stop any pending timers, we'll restart the timer if there's anything left to process after cleanup.
//
// With the scheme we're using it's possible we could end up with some redundant cleanup operations.
Expand Down Expand Up @@ -282,8 +292,8 @@ internal void CleanupTimer_Tick()
{
try
{
entry.InnerHandler.Dispose();
entry.Scope?.Dispose();
// During normal cleanup, let the entry check CanDispose itself
entry.Dispose();
disposedCount++;
}
catch (Exception ex)
Expand Down Expand Up @@ -313,6 +323,69 @@ internal void CleanupTimer_Tick()
}
}

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)
{
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
{
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
}
}
}
}
Loading
Loading