Is Microsoft Documentation incorrect for Assembly.LoadFile method?

I have this code which tries to load all assemblies in a folder and then for each assembly, also check if all of its dependencies too can be found in the same folder.

private static string searchDirectory = @"C:\Users\Anon\Downloads\AssemblyLoadFilePOC_TESTFOLDER";

    private static readonly List<string> errors = new List<string>();

    static void Main(string[] args)
    {
        if (args.Length > 1)
            searchDirectory = args[1];

        var files = Directory
            .GetFiles(searchDirectory, "*.*", SearchOption.AllDirectories)
            .Where(s => s.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || s.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));

        foreach (var file in files)
        {
            try
            {
                AssemblyLoadHandler.Enable(Path.GetDirectoryName(file));

                Assembly assembly = null;
                if (File.Exists(file) && Path.GetExtension(file) == ".dll")
                {
                    assembly = Assembly.LoadFrom(file);
                }
                else
                {
                    var fileInfo = new FileInfo(file);
                    assembly = Assembly.LoadFile(fileInfo.FullName);
                }

                ValidateDependencies(Path.GetDirectoryName(file), assembly, errors);
            }
            catch (BadImageFormatException e)
            {
                errors.Add(e.Message);
            }
            catch (Exception ex)
            {
                errors.Add(ex.Message); ;
            }
        }
    }

Here's the AssemblyLoadHandler class which contains an event handler for Assembly Resolve event.

public static class AssemblyLoadHandler
{
    /// <summary>
    /// Indicates whether the load handler is already enabled.
    /// </summary>
    private static bool enabled;

    /// <summary>
    /// Path to search the assemblies from
    /// </summary>
    private static string path;

    /// <summary>
    /// Enables the load handler.
    /// </summary>
    public static void Enable(string directoryPath)
    {
        path = directoryPath;
        if (enabled)
        {
            return;
        }

        AppDomain.CurrentDomain.AssemblyResolve += LoadAssembly;
        enabled = true;
    }

    /// <summary>
    /// A handler for the <see cref="AppDomain.AssemblyResolve"/> event.
    /// </summary>
    /// <param name="sender">The sender of the event.</param>
    /// <param name="args">The event arguments.</param>
    /// <returns>
    /// A <see cref="Assembly"/> instance fot the resolved assembly, or <see langword="null" /> if the assembly wasn't found.
    /// </returns>
    private static Assembly LoadAssembly(object sender, ResolveEventArgs args)
    {
        // Load managed assemblies from the same path as this one - just take the DLL (or EXE) name from the first part of the fully qualified name.
        var filePath = Path.Combine(path, args.Name.Split(',')[0]);

        try
        {
            if (File.Exists(filePath + ".dll"))
            {
                return Assembly.LoadFile(filePath + ".dll");
            }
        }
        catch (Exception)
        {
        }

        try
        {
            if (File.Exists(filePath + ".exe"))
            {
                return Assembly.LoadFile(filePath + ".exe");
            }
        }
        catch (Exception)
        {
        }

        return null;
    }
}

Here's the ValidateDependencies method which tries to load all dependencies of an assembly:

private static void ValidateDependencies(string searchDirectory, Assembly assembly, List<string> errors)
    {
        var references = assembly.GetReferencedAssemblies();

        foreach (var r in references)
        {
            var searchDirectoryPath = Path.Combine(searchDirectory, r.Name + ".dll");
            var runtimeDirectoryPath = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), r.Name + ".dll", SearchOption.AllDirectories);

            try
            {
                if (!File.Exists(searchDirectoryPath) && (runtimeDirectoryPath == null || runtimeDirectoryPath.Length == 0))
                {
                    throw new FileNotFoundException("Dependency " + r.Name + " could not be found.", r.FullName);
                }
                else
                {
                    Assembly foundAssembly = null;

                    if (File.Exists(searchDirectoryPath))
                    {
                        foundAssembly = Assembly.LoadFrom(searchDirectoryPath);

                        if (foundAssembly.GetName().Version != r.Version && !r.Flags.HasFlag(AssemblyNameFlags.Retargetable))
                            foundAssembly = null;
                    }
                    else
                    {
                        foundAssembly = Assembly.LoadFrom(runtimeDirectoryPath[0]);
                    }

                    if (foundAssembly == null)
                    {
                        throw new FileNotFoundException("Required version of dependency " + r.Name + " could not be found.", r.FullName);
                    }
                }
            }
            catch (Exception e)
            {
                errors.Add(e.ToString());
            }
        }
    }

For testing, I have only put 2 assemblies in my test path: C:\Users\Anon\Downloads\AssemblyLoadFilePOC_TESTFOLDER:

Microsoft.AspNetCore.DataProtection.dll having assembly version 5.0.17.0 and ONE of its dependency: Microsoft.Extensions.Logging.Abstractions.dll having asembly version 5.0.0.0

Note that Microsoft.AspNetCore.DataProtection.dll version 5.0.17.0 has a dependency on Microsoft.Extensions.Logging.Abstractions.dll version 5.0.0.0

Just to test if my event handler for Assembly Resolve event is working correctly, I added this assembly binding redirect in my app config for this console application.

<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
        <dependentAssembly>
            <assemblyIdentity name="Microsoft.Extensions.Logging.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
            <bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
        </dependentAssembly>
    </assemblyBinding>

This application is a .NET 4.8 console application, when I run it without the binding redirect in the app config, as expected, the Assembly Resolve event is not fired. However, when I do add the redirect, Assembly Resolve event is fired multiple times and the application eventually crashes with Stackoverflow exception.

Quoting from MSDN docs here: https://learn.microsoft.com/en-us/dotnet/standard/assembly/resolve-loads#the-correct-way-to-handle-assemblyresolve

When resolving assemblies from the AssemblyResolve event handler, a StackOverflowException will eventually be thrown if the handler uses the Assembly.Load or AppDomain.Load method calls. Instead, use LoadFile or LoadFrom methods, as they do not raise the AssemblyResolve event.

As you can see, I am using Assembly.LoadFile method in my event handler and yet, the event keeps getting fired multiple times. Is the documentation incorrect? Or am I doing something wrong?

Upvotes: 1

Views: 246

Answers (1)

cemahseri
cemahseri

Reputation: 565

Nope, the documentation you mentioned is just fine! Pay attention that in the documentation, they create a new AppDomain called "Test";

AppDomain ad = AppDomain.CreateDomain("Test");
ad.AssemblyResolve += MyHandler;

If you create a new AppDomain and use it instead of AppDomain.CurrentDomain, your problem will be solved.

But let's say that for some reason you want to use the current AppDomain for some reason. We know that there is an uncontrolled recursive call hell which causes the stack overflow exception. How can we get rid of it? Simple! Just unsubscribe your handler method from AssemblyResolve event before loading assembly in the handler;

private static Assembly AssemblyResolveHandler(object sender, ResolveEventArgs args)
{
    AppDomain.CurrentDomain.AssemblyResolve -= AssemblyResolveHandler;

    return Assembly.LoadFile("SomeAssembly.dll");
}

This is the minimal code that I've derived from your code;

private const string SearchDirectory = @"...\AssemblyLoadFilePOC_TESTFOLDER";

private static void Main()
{
    foreach (var file in Directory.GetFiles(SearchDirectory))
    {
        AppDomain.CurrentDomain.AssemblyResolve += AssemblyResolveHandler;

        try
        {
            var assembly = Assembly.LoadFrom(file);

            foreach (var referenceAssembly in assembly.GetReferencedAssemblies())
            {
                var referenceAssemblyPath = Path.Combine(SearchDirectory, referenceAssembly.Name + ".dll");
                if (!File.Exists(referenceAssemblyPath))
                {
                    continue;
                }

                try
                {
                    var loadedAssembly = Assembly.LoadFrom(referenceAssemblyPath);

                    Console.WriteLine("Reference Assembly Version: " + referenceAssembly.Version);
                    Console.WriteLine("Loaded Assembly Version: " + loadedAssembly.GetName().Version);
                    Console.WriteLine("Is Reference Assembly Retargetable: " + referenceAssembly.Flags.HasFlag(AssemblyNameFlags.Retargetable));
                }
                catch (FileNotFoundException)
                {
                    // We already check if the file exists or not in the beginning of foreach loop.
                    // So if we face any FileNotFoundException, it's because of version mismatch.
                    Console.WriteLine($"Required version of dependency {referenceAssembly.Name} could not be found.");
                }
            }
        }
        catch (FileNotFoundException)
        {
            // Same as above. We get files from the folder and then try to load them.
            // So if we face any FileNotFoundException, it's because of version mismatch.
            Console.WriteLine($"Redirected version of assembly {file} could not be found.");
        }
    }

    Console.ReadKey(true);
}

private static Assembly AssemblyResolveHandler(object sender, ResolveEventArgs args)
{
    AppDomain.CurrentDomain.AssemblyResolve -= AssemblyResolveHandler;

    return Assembly.LoadFile(Path.Combine(SearchDirectory, args.Name.Split(',')[0]) + ".dll");
}

Output when no assembly redirecting is used - which commenting out unsubscribing line in AssemblyResolveHandler method has no effect on;

Reference Assembly Version: 5.0.0.0
Loaded Assembly Version: 5.0.0.0
Is Reference Assembly Retargetable: False

Output when assembly redirecting is used and commented out that line;

Process is terminated due to StackOverflowException.

Output when assembly redirecting is used and uncommented out that line;

Required version of dependency Microsoft.Extensions.Logging.Abstractions could not be found.
Redirected version of assembly ...\AssemblyLoadFilePOC_TESTFOLDER\Microsoft.Extensions.Logging.Abstractions.dll could not be found.

Edit: I just saw your comment about ResolveEventArgs.RequestingAssembly is being null. I'd like to explain it but it's a different thing and I don't want to make my answer longer. For more information about that, click me.

Upvotes: 4

Related Questions