Skip to content
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

System.Linq.Expressions may prevent assembly unloading when using MethodInfo objects from non-collectible type with collectible generic arguments #112518

Closed
tbdty opened this issue Feb 13, 2025 · 7 comments · Fixed by #113085
Labels
area-System.Linq.Expressions in-pr There is an active PR which will close this issue when it is merged

Comments

@tbdty
Copy link
Contributor

tbdty commented Feb 13, 2025

Description

When you create a MethodInfo from a generic method in a non-collectible assembly, then call MakeGenericMethod with a type from a collectible assembly and use the returned MethodInfo in System.Linq.Expressions.Expression.Call the latter assembly will never be unloaded.

Reproduction Steps

Create an assembly SomeCollectibleAssembly with a single class in it:

namespace SomeCollectibleAssembly;

public class SomeType
{ }

Compile this assembly and note the path of the compiled assembly. Then create a console application with the following code:

using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;

namespace MinimalRepro;

internal static class Program
{
    private const string ASSEMBLY_PATH = """C:\Path\To\SomeCollectibleAssembly.dll""";
    private const string ASSEMBLY_NAME = "SomeCollectibleAssembly";

    private static void Main(string[] args)
    {
        WorkWithCollectibleAssembly();
        
        while (true)
        {
            if (!IsAssemblyLoaded(ASSEMBLY_NAME))
            {
                Console.WriteLine("The assembly has been unloaded.");
                break;
            }

            Console.WriteLine("The assembly has not been unloaded, collecting ...");
            GC.Collect();
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void WorkWithCollectibleAssembly()
    {
        var assembly = LoadAssembly();

        var someType = assembly.GetType("SomeCollectibleAssembly.SomeType")
            ?? throw new Exception("SomeType not found");

        var someGenericMethod = typeof(Program).GetMethod(nameof(SomeGenericMethod), BindingFlags.NonPublic | BindingFlags.Static)
            ?? throw new Exception("SomeGenericMethod not found");

        var method = someGenericMethod.MakeGenericMethod(someType);

        // Commenting out this line allows unloading to succeed.
        _ = Expression.Call(method);
    }

    private static Assembly LoadAssembly()
    {
        var alc = new AssemblyLoadContext(null, isCollectible: true);
        try
        {
            return alc.LoadFromAssemblyPath(ASSEMBLY_PATH);
        }
        finally
        {
            alc.Unload();
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static bool IsAssemblyLoaded(string assemblyName)
    {
        return AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == assemblyName);
    }

    private static void SomeGenericMethod<T>() { }
}

Expected behavior

It is expected that the assembly is eventually collected by the GC and unloads, i.e. the reproduction program prints:

The assembly has not been unloaded, collecting ...
The assembly has been unloaded.

Actual behavior

The program never terminates and prints

The assembly has not been unloaded, collecting ...
The assembly has not been unloaded, collecting ...
The assembly has not been unloaded, collecting ...
The assembly has not been unloaded, collecting ...
The assembly has not been unloaded, collecting ...
The assembly has not been unloaded, collecting ...

repeatedly.

Regression?

No response

Known Workarounds

Using a non-generic method in a generic type works around the problem, i.e. changing the program as follows allows unloading to succeed:

using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;

namespace MinimalRepro;

internal static class Program
{
    private const string ASSEMBLY_PATH = """C:\Path\To\SomeCollectibleAssembly.dll""";
    private const string ASSEMBLY_NAME = "SomeCollectibleAssembly";

    private static void Main(string[] args)
    {
        WorkWithCollectibleAssembly();
        
        while (true)
        {
            if (!IsAssemblyLoaded(ASSEMBLY_NAME))
            {
                Console.WriteLine("The assembly has been unloaded.");
                break;
            }

            Console.WriteLine("The assembly has not been unloaded, collecting ...");
            GC.Collect();
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void WorkWithCollectibleAssembly()
    {
        var assembly = LoadAssembly();

        var someType = assembly.GetType("SomeCollectibleAssembly.SomeType")
            ?? throw new Exception("SomeType not found");

        var method = typeof(SomeGenericType<>)
            .MakeGenericType(someType)
            .GetMethod(nameof(SomeGenericType<object>.SomeNonGenericMethod), BindingFlags.Public | BindingFlags.Static)
            ?? throw new Exception("Method not found");

        _ = Expression.Call(method);
    }

    private static Assembly LoadAssembly()
    {
        var alc = new AssemblyLoadContext(null, isCollectible: true);
        try
        {
            return alc.LoadFromAssemblyPath(ASSEMBLY_PATH);
        }
        finally
        {
            alc.Unload();
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static bool IsAssemblyLoaded(string assemblyName)
    {
        return AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == assemblyName);
    }

    private static class SomeGenericType<T>
    {
        public static void SomeNonGenericMethod() { }
    }
}

Configuration

No response

Other information

I have investigated this unloading problem using WinDbg and have determined that the problem is the TypeExtensions.s_paramInfoCache dictionary.

The problem lies in the parameter caching code: Instead of calling method.DeclaringType?.IsCollectible, calling method.IsCollectible here should fix the problem.

@dotnet-issue-labeler dotnet-issue-labeler bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Feb 13, 2025
@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Feb 13, 2025
@huoyaoyuan
Copy link
Member

Related to #71629 and #89536. Generics constructed from different assemblies are not easy to handle.

@jkotas jkotas added area-System.Linq.Expressions and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Feb 13, 2025
Copy link
Contributor

Tagging subscribers to this area: @cston
See info in area-owners.md if you want to be subscribed.

@jkotas
Copy link
Member

jkotas commented Feb 13, 2025

Generics constructed from different assemblies are not easy to handle.

System.Linq.Expressions has code to handle this, except that it appears to have a subtle bug as @tbdty pointed out:

The problem lies in the parameter caching code: Instead of calling method.DeclaringType?.IsCollectible, calling method.IsCollectible here should fix the problem.

@jkotas
Copy link
Member

jkotas commented Feb 13, 2025

@tbdty Would you be interested in submitting a PR with the fix that you have proposed?

@tbdty
Copy link
Contributor Author

tbdty commented Feb 13, 2025

@tbdty Would you be interested in submitting a PR with the fix that you have proposed?

I would, but sadly I currently do not have the spare time to set up a development environment for .NET and ensure that I do everything properly. I'd greatly appreciate if someone else did this.

@tbdty
Copy link
Contributor Author

tbdty commented Mar 3, 2025

I started looking into this. Are there any tests for the unloadability of assemblies? I looked and did not find any.

@jkotas
Copy link
Member

jkotas commented Mar 3, 2025

The System.Linq.Expressions tests do have some coverage for collectible assemblies, but they do not explicitly verify that everything got collected.

The fix discussed here was actually discussed earlier in https://github.com/dotnet/corefx/pull/25736/files#r155524477 .

I would be ok with the fix without associated test. The tests to verify that everything got collected with collectible assemblies are somewhat complicated.

tbdty added a commit to tbdty/dotnet-runtime that referenced this issue Mar 3, 2025
When using a method from a generic class from a non-collectible assembly
with a generic parameter from a collectible assembly in a LINQ expression,
the collectible assembly may be prevented from unloading. This is due to the
method parameters being cached in a dictionary with the method info.

Fix dotnet#112518
@dotnet-policy-service dotnet-policy-service bot added the in-pr There is an active PR which will close this issue when it is merged label Mar 3, 2025
jkotas pushed a commit that referenced this issue Mar 3, 2025
When using a method from a generic class from a non-collectible assembly
with a generic parameter from a collectible assembly in a LINQ expression,
the collectible assembly may be prevented from unloading. This is due to the
method parameters being cached in a dictionary with the method info.

Fix #112518
@dotnet-policy-service dotnet-policy-service bot removed the untriaged New issue has not been triaged by the area owner label Mar 3, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-System.Linq.Expressions in-pr There is an active PR which will close this issue when it is merged
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants