From 5db5c8d4ed252996f9e79dc2b988a38ead9fc7d2 Mon Sep 17 00:00:00 2001 From: Ted Pricer <165290866+epricer-polly@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:46:42 -0400 Subject: [PATCH 1/2] Share HttpServiceEndpointResolver as a singleton AddServiceDiscovery creates a new HttpServiceEndpointResolver for every HTTP message handler that is built. Handlers are rebuilt on the normal HttpClientFactory handler-lifetime rotation, so a fresh resolver is allocated periodically for the lifetime of the process. Both call sites are affected: the AddHttpMessageHandler delegate in AddServiceDiscovery(IHttpClientBuilder) and CreateHandler in ServiceDiscoveryHttpMessageHandlerFactory. HttpServiceEndpointResolver is IAsyncDisposable and owns endpoint-refresh timers plus configuration change-token subscriptions. ResolvingHttp- DelegatingHandler wraps it but never disposes it, so each orphaned resolver stays rooted (by the runtime timer queue and the configuration root) and never becomes eligible for collection. Its live timers keep doing work, which shows up as a slow, uptime-correlated CPU climb. A two-day production heap diff showed the count grow from 0 to 199. Register HttpServiceEndpointResolver as a singleton, mirroring the existing ServiceEndpointResolver registration, and resolve it at both sites. It has no per-client state and caches watchers per service name internally, so a single shared instance is correct and is disposed once with the container. --- ...rviceDiscoveryHttpMessageHandlerFactory.cs | 9 +-- ...iceDiscoveryHttpClientBuilderExtensions.cs | 6 +- ...iceDiscoveryServiceCollectionExtensions.cs | 7 +++ ...HttpServiceEndpointResolverSharingTests.cs | 63 +++++++++++++++++++ 4 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/HttpServiceEndpointResolverSharingTests.cs diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs index e5e7f7587bb..874506550ab 100644 --- a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs @@ -6,14 +6,9 @@ namespace Microsoft.Extensions.ServiceDiscovery.Http; internal sealed class ServiceDiscoveryHttpMessageHandlerFactory( - TimeProvider timeProvider, - IServiceProvider serviceProvider, - ServiceEndpointWatcherFactory factory, + HttpServiceEndpointResolver resolver, IOptions options) : IServiceDiscoveryHttpMessageHandlerFactory { public HttpMessageHandler CreateHandler(HttpMessageHandler handler) - { - var registry = new HttpServiceEndpointResolver(factory, serviceProvider, timeProvider); - return new ResolvingHttpDelegatingHandler(registry, options, handler); - } + => new ResolvingHttpDelegatingHandler(resolver, options, handler); } diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs index d2890ae8c8d..0f31c490c13 100644 --- a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs @@ -30,11 +30,9 @@ public static IHttpClientBuilder AddServiceDiscovery(this IHttpClientBuilder htt services.AddServiceDiscoveryCore(); httpClientBuilder.AddHttpMessageHandler(services => { - var timeProvider = services.GetService() ?? TimeProvider.System; - var watcherFactory = services.GetRequiredService(); - var registry = new HttpServiceEndpointResolver(watcherFactory, services, timeProvider); + var resolver = services.GetRequiredService(); var options = services.GetRequiredService>(); - return new ResolvingHttpDelegatingHandler(registry, options); + return new ResolvingHttpDelegatingHandler(resolver, options); }); #if NET diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs index 8de759af1f6..0e707789216 100644 --- a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs @@ -65,6 +65,13 @@ public static IServiceCollection AddServiceDiscoveryCore(this IServiceCollection services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(sp => new ServiceEndpointResolver(sp.GetRequiredService(), sp.GetRequiredService())); + + // Registered as a singleton (rather than created per HTTP message handler) so its refresh + // timers and configuration change-token subscriptions are created once and disposed with the + // container, instead of leaking on every handler rotation. It has no per-client state and + // caches watchers per service name internally, so a single shared instance is correct. + services.TryAddSingleton(sp => new HttpServiceEndpointResolver(sp.GetRequiredService(), sp, sp.GetRequiredService())); + if (configureOptions is not null) { services.Configure(configureOptions); diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/HttpServiceEndpointResolverSharingTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/HttpServiceEndpointResolverSharingTests.cs new file mode 100644 index 00000000000..2b90ad8584e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/HttpServiceEndpointResolverSharingTests.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ServiceDiscovery.Http; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +/// +/// Tests that a single is shared across HTTP message handlers +/// rather than created per handler build. Creating one per build leaks the resolver (an +/// that roots refresh timers and configuration change-token subscriptions), +/// because never disposes it. +/// +public class HttpServiceEndpointResolverSharingTests +{ + [Fact] + public async Task AddServiceDiscoveryCore_RegistersResolverAsSingleton() + { + await using var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + + var first = services.GetRequiredService(); + var second = services.GetRequiredService(); + + Assert.Same(first, second); + } + + [Fact] + public async Task AddServiceDiscovery_HttpClient_HandlerUsesSharedResolverSingleton() + { + var services = new ServiceCollection(); + services.AddHttpClient("test").AddServiceDiscovery(); + await using var provider = services.BuildServiceProvider(); + + var handler = provider.GetRequiredService().CreateHandler("test"); + var resolvingHandler = FindHandler(handler); + Assert.NotNull(resolvingHandler); + + var resolverField = typeof(ResolvingHttpDelegatingHandler) + .GetField("_resolver", BindingFlags.Instance | BindingFlags.NonPublic); + var usedResolver = resolverField!.GetValue(resolvingHandler); + + Assert.Same(provider.GetRequiredService(), usedResolver); + } + + private static T? FindHandler(HttpMessageHandler handler) + where T : HttpMessageHandler + { + for (var current = handler; current is not null; current = (current as DelegatingHandler)?.InnerHandler) + { + if (current is T match) + { + return match; + } + } + + return null; + } +} From 38f22e35b2cb3c6f48c53e1cbe12b35609684c87 Mon Sep 17 00:00:00 2001 From: Ted Pricer <165290866+epricer-polly@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:48:54 -0400 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../HttpServiceEndpointResolverSharingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/HttpServiceEndpointResolverSharingTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/HttpServiceEndpointResolverSharingTests.cs index 2b90ad8584e..fb7e41ff2d2 100644 --- a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/HttpServiceEndpointResolverSharingTests.cs +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/HttpServiceEndpointResolverSharingTests.cs @@ -36,7 +36,7 @@ public async Task AddServiceDiscovery_HttpClient_HandlerUsesSharedResolverSingle services.AddHttpClient("test").AddServiceDiscovery(); await using var provider = services.BuildServiceProvider(); - var handler = provider.GetRequiredService().CreateHandler("test"); + using var handler = provider.GetRequiredService().CreateHandler("test"); var resolvingHandler = FindHandler(handler); Assert.NotNull(resolvingHandler);