autofac/Autofac.Extensions.DependencyInjection

Memory leak in both Generic host & web host builder

ysmoradi opened this issue ยท 14 comments

Describe the bug

I have a few simple/small asp.net core integration tests which have been integrated with autofac.
Without autofac integration, I see no leak, but after I integrate with autofac, I see a memory leak in Visual Studio diagnostics

image

To reproduce
Clone and run the following repo
https://github.com/ysmoradi/AspIntegrationTestMemoryLeakIssue
It runs each test 3 times
In diagnostics, you'd expect to see only one Startup class instance, but you'll see 3 instances!

public static class GCContainer
{
    public static List<WeakReference> References { get; set; } = new List<WeakReference>();
}

[TestClass]
public class ServerTests
{
    [DataTestMethod]
    [DataRow, DataRow, DataRow]
    public async Task RealServerTest() // no issue! (See Additional context at bottom of issue)
    {
        using var webHost = WebHost.CreateDefaultBuilder()
            .UseUrls("http://localhost/")
            .UseStartup<Startup1>()
            .Build();

        await webHost.StartAsync();

        using HttpClient client = new();

        Assert.AreEqual("Hello World!", (await (await client.GetAsync("http://localhost/")).EnsureSuccessStatusCode().Content.ReadAsStringAsync()));

        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);

        Assert.AreEqual(1, GCContainer.References.Count(r => r.IsAlive));
    }

    [DataTestMethod]
    [DataRow, DataRow, DataRow]
    public async Task TestServerTest_WebHost()
    {
        using TestServer server = new(WebHost.CreateDefaultBuilder().UseUrls("http://localhost/").UseStartup<Startup1>());

        Assert.AreEqual("Hello World!", (await (await server.CreateClient().GetAsync("/")).EnsureSuccessStatusCode().Content.ReadAsStringAsync()));

        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);

        Assert.AreEqual(1, GCContainer.References.Count(r => r.IsAlive));
    }

    [DataTestMethod]
    [DataRow, DataRow, DataRow]
    public async Task TestServerTest_GenericHost()
    {
        using IHost host = new HostBuilder()
            .UseServiceProviderFactory(new AutofacServiceProviderFactory())
            .ConfigureWebHostDefaults(webHostBuilder =>
            {
                webHostBuilder
                    .UseTestServer()
                    .UseUrls("http://localhost/")
                    .UseStartup<Startup2>();
            })
            .Build();

        await host.StartAsync();

        using TestServer server = host.GetTestServer();

        Assert.AreEqual("Hello World!", (await (await server.CreateClient().GetAsync("/")).EnsureSuccessStatusCode().Content.ReadAsStringAsync()));

        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);

        Assert.AreEqual(1, GCContainer.References.Count(r => r.IsAlive));
    }
}

public class Startup1 // web host builder
{
    public Startup1()
    {
        GCContainer.References.Add(new WeakReference(this));
    }

    public virtual IServiceProvider ConfigureServices(IServiceCollection services)
    {
        ContainerBuilder builder = new ContainerBuilder();

        builder.Populate(services);

        IContainer container = builder.Build();

        return new AutofacServiceProvider(container);
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/", async context =>
            {
                await context.Response.WriteAsync("Hello World!");
            });
        });
    }
}

public class Startup2 // generic host
{
    public Startup2()
    {
        GCContainer.References.Add(new WeakReference(this));
    }

    public void ConfigureContainer(ContainerBuilder builder)
    {

    }

    public virtual void ConfigureServices(IServiceCollection services)
    {

    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/", async context =>
            {
                await context.Response.WriteAsync("Hello World!");
            });
        });
    }
}

Full exception with stack trace:

N/A

Assembly/dependency versions:

<PropertyGroup>
	<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>

<PackageReference Include="Autofac" Version="6.1.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="7.1.0" />

Additional context
N/A

It only happens with the test server. I wasn't able to reproduce that with a real kestrel server!

It's highly likely that this has nothing to do with Autofac and everything to do with the way the test host handles external dependency injection integration. For example, since the snippets above are super simple, try using Lamar for dependency injection instead of Autofac. Do you still have the issue?

If you do, it's a test host problem and nothing we can do about it. If you don't it might warrant looking deeper. However, Autofac doesn't hang onto the startup class or anything; we simply implement the interfaces provided just like every other container.

We'll wait to hear back on how Lamar goes for you to see if we need to take action. If we don't hear back in a week or so, I'll close the issue.

asp.net core has built-in dependency injection and I see no memory leak when using that.
Let me try other dependency injection libraries I know.

https://github.com/ysmoradi/AspIntegrationTestMemoryLeakIssue/tree/aspnetcore

I tested against followings without any issue:

DryIoc
TestServer
GenericHost

repo:
https://github.com/ysmoradi/AspIntegrationTestMemoryLeakIssue/tree/dry-ioc

I tested against the followings without any issue:

Castle Windsor
TestServer
WebHost

repo:
https://github.com/ysmoradi/AspIntegrationTestMemoryLeakIssue/tree/windsor

It's so interesting that Lamar has the same issue as autofac! ;D

https://github.com/ysmoradi/AspIntegrationTestMemoryLeakIssue/tree/lamar

Since Lamar also has it, it makes me lean toward something in the way the test host works. There are lots of things that happen inside the test host that are entirely outside our control. For example, stuff that might matter (but not guaranteed to be important) could include:

  • Does the DI container use the Microsoft.Extensions.DependencyInjection.IServiceProviderFactory<T> method of integration? Autofac, Lamar, and DryIoc do; Windsor doesn't.
  • Is the DI container disposable? I didn't research which of these containers are, I know Autofac is. Perhaps the test host isn't disposing of things right?

Of course, the leak you're seeing is in the Startup class - there are multiple instances. What's actually holding that reference? The Autofac stuff doesn't reference the Startup class anywhere. It's the other way around - the hosting startup process calls the Autofac/factory methods.

Next step is to figure out what is actually holding the references to the startup class.

I've no idea does following report help or not really )":

Following is for TestServer_WebHost scenario

There's an "Autofac.Core.Registration.ComponentRegistration"
It's Activator property, has a field called "_instance" which is an instance of MS.AspNetCore.TestHost.TestServer

TestServer itself has a Host property which itself has a _startup field.

The rest is clear in the following picture

image

Note that root scope has been disposed

In generic host, there's an "Autofac.Core.Registration.ComponentRegistration"
It's Activator property, has a field called "_instance" which is an instance of MS.Ext.Options.ConfigureNamedOptions<MS.AspNetCore.Hosting.GenericWebHostServiceOptions>
Its Action property has a Target property which has a field called instance which is the type of Startup2

image

image

The component registration is not disposed in GenericHost, but it's disposed in WebHost scenario. Is this fine?

  • Does the DI container use the Microsoft.Extensions.DependencyInjection.IServiceProviderFactory<T> method of integration? Autofac, Lamar, and DryIoc do; Windsor doesn't.

Note that Autofac is not working properly in both TestHost with WebHost & TestHost with GenericHost scenarios.
That's why I decided to use DryIoc with GenericHost and Windsor with WebHost approaches

This sounds suspiciously similar to #68.

Turned out there was an issue inside the DI system in aspnet core (linked in that issue).

I was thinking this was starting to feel familiar. As a recap - the host builder holds onto the root IServiceProvider it creates. The host builder actually creates two containers - one for use during app startup (e.g., the stuff that it can inject into the methods in your startup class) and one that's actually built for use by the app itself. It's not letting go of that first container.

It appears the issue has been resolved but isn't out in .NET 5.0. Unclear when it'll be out, but there's nothing we can do from an Autofac standpoint. It's the hosting system.

I'll try with nightly builds of dotnet runtime and report back to you soon

I managed to dispose autofac service provider using workaround which was described dotnet/runtime#36060
But even disposed autofac service provider, has strong references to a lot of objects

workaround (phase1)

AutofacServiceProvider serviceProvider = ((AutofacServiceProvider)typeof(HostBuilder).GetField("_appServices", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(hostBuilder));
await serviceProvider.DisposeAsync();

Should Host remove its reference to autofac service provider?
I can't see something like that in dotnet runtime's PR