Search code examples
c#system.reflectionactivator

AssemblyLoadContext and dependency resolving when assemblies and the calling app have different versions of the same dependency


I believe I've exhausted the answers and comments on SO related to this topic. Full source code for the project I used to demonstrate the problem can be found here: https://github.com/jchristn/DependencyExample

Assume there is a solution with three projects. The first is a console app (Runner) with dependency on RestWrapper v3.0.19. The second is a class library (Module1) with dependency on RestWrapper v3.0.20. The third is a class library (Module2) with dependency on RestWrapper v3.0.18.

All three of these attempt to perform an HTTP GET to a user-specified URL that is retrieved in Runner. Runner then uses AssemblyLoadContext, LoadFromAssemblyPath, Resolving, and Activator.CreateInstance to create and invoke the Process method within Runner1 and then Runner2, which perform the same HTTP GET, e.g.

Instantiating the modules

AssemblyLoadContext ctx1 = new AssemblyLoadContext("module1", false);
Assembly asm1 = ctx1.LoadFromAssemblyPath(Path.GetFullPath("module1/Module1.dll"));

ctx1.Resolving += (ctx, assemblyName) =>
{
    Console.WriteLine("| Module 1 loading assembly " + assemblyName.FullName);
    var parts = assemblyName.FullName.Split(',');
    string name = parts[0];
    var version = Version.Parse(parts[1].Split('=')[1]);
    string filename = new FileInfo(Path.GetFullPath("module1/" + name + ".dll")).FullName;
    Console.WriteLine("| Module 1 loading from file " + filename);

    Assembly asm = Assembly.LoadFrom(filename);
    Console.WriteLine("| Module 1 loaded assembly " + filename);
    return asm;
};

Type type1 = asm1.GetType("Module1.Processor", true);
dynamic instance1 = Activator.CreateInstance(type1);
instance1.Process(url);

Simple RESTful Request

using (RestRequest req = new RestRequest(url))
{
    using (RestResponse resp = req.Send())
    {
        Console.WriteLine("| Status (runner) : " + resp.StatusCode + " " + resp.ContentLength + " bytes");
    }
}

I can confirm that the files actually exist:

C:\Code\Misc\DependencyExample\Runner\bin\Debug\net8.0>dir module1
 Volume in drive C is OS
 Volume Serial Number is 541C-D54E

 Directory of C:\Code\Misc\DependencyExample\Runner\bin\Debug\net8.0\module1

05/24/2024  08:22 AM    <DIR>          .
05/24/2024  08:22 AM    <DIR>          ..
05/24/2024  08:17 AM            37,648 Module1.deps.json
05/24/2024  08:17 AM             4,608 Module1.dll
05/24/2024  08:17 AM            10,548 Module1.pdb
05/20/2024  10:26 PM            29,184 RestWrapper.dll
05/20/2024  10:12 PM            11,776 Timestamps.dll
               5 File(s)         93,764 bytes
               2 Dir(s)  51,069,906,944 bytes free

C:\Code\Misc\DependencyExample\Runner\bin\Debug\net8.0>dir module2
 Volume in drive C is OS
 Volume Serial Number is 541C-D54E

 Directory of C:\Code\Misc\DependencyExample\Runner\bin\Debug\net8.0\module2

05/24/2024  08:22 AM    <DIR>          .
05/24/2024  08:22 AM    <DIR>          ..
05/24/2024  08:17 AM            37,648 Module2.deps.json
05/24/2024  08:17 AM             4,608 Module2.dll
05/24/2024  08:17 AM            10,548 Module2.pdb
05/20/2024  10:26 PM            29,184 RestWrapper.dll
05/20/2024  10:12 PM            11,776 Timestamps.dll
               5 File(s)         93,764 bytes
               2 Dir(s)  51,069,906,944 bytes free

Both the Module1 and Module2 have entries in their .csproj to copy dependency files into the output directory:

<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>

I've operated under the assumption that by having these loaded in separate AssemblyLoadContexts with their own Resolving function that they would be able to load these differing versions, but it fails with an System.IO.FileLoadException: Could not load file or assembly 'RestWrapper, Version=3.0.20.0, Culture=neutral, PublicKeyToken=null'. Could not find or load a specific file. (0x80131621) exception when attempting to run Runner1.

Console Output

C:\Code\Misc\DependencyExample\Runner\bin\Debug\net8.0>runner

URL: https://www.google.com
| Status (runner) : 200 57841 bytes
| Module 1 loading assembly RestWrapper, Version=3.0.20.0, Culture=neutral, PublicKeyToken=null
| Module 1 loading from file C:\Code\Misc\DependencyExample\Runner\bin\Debug\net8.0\module1\RestWrapper.dll
System.IO.FileLoadException: Could not load file or assembly 'RestWrapper, Version=3.0.20.0, Culture=neutral, PublicKeyToken=null'. Could not find or load a specific file. (0x80131621)
File name: 'RestWrapper, Version=3.0.20.0, Culture=neutral, PublicKeyToken=null'
 ---> System.IO.FileLoadException: Could not load file or assembly 'RestWrapper, Version=3.0.20.0, Culture=neutral, PublicKeyToken=null'.
   at System.Runtime.Loader.AssemblyLoadContext.LoadFromAssemblyPath(String assemblyPath)
   at System.Reflection.Assembly.LoadFrom(String assemblyFile)
   at DependencyExample.Program.<>c.<Main>b__0_0(AssemblyLoadContext ctx, AssemblyName assemblyName) in C:\Code\Misc\DependencyExample\Runner\Program.cs:line 49
   at System.Runtime.Loader.AssemblyLoadContext.GetFirstResolvedAssemblyFromResolvingEvent(AssemblyName assemblyName)
   at System.Runtime.Loader.AssemblyLoadContext.ResolveUsingEvent(AssemblyName assemblyName)
   at System.Runtime.Loader.AssemblyLoadContext.ResolveUsingResolvingEvent(IntPtr gchManagedAssemblyLoadContext, AssemblyName assemblyName)
   at Module1.Processor.Process(String url)
   at System.Dynamic.UpdateDelegates.UpdateAndExecuteVoid2[T0,T1](CallSite site, T0 arg0, T1 arg1)
   at DependencyExample.Program.Main(String[] args) in C:\Code\Misc\DependencyExample\Runner\Program.cs:line 56

I've uploaded the full source (intentionally leaving out a .gitignore) at the link above. The bin/Debug/net8.0 output of Module1 and Module2 are copied into subdirectory module1/ and module2/ in the Runner's bin/Debug/net8.0 directory.

Also, another wrinkle to the problem, is that I need to run this on both Ubuntu and Windows.

Any help would be really appreciated!

Edit 1

I've tried also using AssemblyDependencyResolver (examples from https://tsuyoshiushio.medium.com/understand-advanced-assemblyloadcontext-with-c-16a9d0cfeae3 and https://gist.github.com/TsuyoshiUshio/551cb0f71c704aca75209552af50fc7a#file-pluginloadcontext-cs), which works, however all three of Runner, Module1, and Module2 somehow load the same version of the RestWrapper dependency, and not the version packaged in each's output directory.

Edit 2

It was requested that the code be pasted here.

Program.cs in Runner

namespace DependencyExample
{
    using RestWrapper;
    using System;
    using System.IO;
    using System.Reflection;
    using System.Runtime.Loader;

    internal class Program
    {
        static void Main(string[] args)
        {
            while (true)
            {
                Console.Write(Environment.NewLine + "URL: ");
                string url = Console.ReadLine();
                if (String.IsNullOrEmpty(url)) continue;

                try
                {
                    #region Local

                    using (RestRequest req = new RestRequest(url))
                    {
                        using (RestResponse resp = req.Send())
                        {
                            Console.WriteLine("| Status (runner) : " + resp.StatusCode + " " + resp.ContentLength + " bytes");
                        }
                    }

                    #endregion

                    #region Module1

                    PluginLoadContext ctx1 = new PluginLoadContext("module1/");
                    Assembly asm1 = ctx1.LoadFromAssemblyPath(Path.GetFullPath("module1/Module1.dll"));

                    ctx1.Resolving += (ctx, assemblyName) =>
                    {
                        Console.WriteLine("| Module 1 loading assembly " + assemblyName.FullName);
                        var parts = assemblyName.FullName.Split(',');
                        string name = parts[0];
                        var version = Version.Parse(parts[1].Split('=')[1]);
                        string filename = new FileInfo(Path.GetFullPath("module1/" + name + ".dll")).FullName;
                        Console.WriteLine("| Module 1 loading from file " + filename);

                        Assembly asm = Assembly.LoadFrom(filename);
                        Console.WriteLine("| Module 1 loaded assembly " + filename);
                        return asm;
                    };

                    Type type1 = asm1.GetType("Module1.Processor", true);
                    dynamic instance1 = Activator.CreateInstance(type1);
                    instance1.Process(url);

                    #endregion

                    #region Module2

                    PluginLoadContext ctx2 = new PluginLoadContext("module2/");
                    Assembly asm2 = ctx2.LoadFromAssemblyPath(Path.GetFullPath("module2/Module2.dll"));

                    ctx1.Resolving += (ctx, assemblyName) =>
                    {
                        Console.WriteLine("| Module 2 loading assembly " + assemblyName.FullName);
                        var parts = assemblyName.FullName.Split(',');
                        string name = parts[0];
                        var version = Version.Parse(parts[1].Split('=')[1]);
                        string filename = new FileInfo(Path.GetFullPath("module2/" + name + ".dll")).FullName;
                        Console.WriteLine("| Module 2 loading from file " + filename);

                        Assembly asm = Assembly.LoadFrom(filename);
                        Console.WriteLine("| Module 2 loaded assembly " + filename);
                        return asm;
                    };

                    Type type2 = asm2.GetType("Module2.Processor", true);
                    dynamic instance2 = Activator.CreateInstance(type2);
                    instance2.Process(url);

                    #endregion

                    Console.WriteLine("Finished URL " + url);
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.ToString());
                }
            }
        }
        private class PluginLoadContext : AssemblyLoadContext
        {
            private AssemblyDependencyResolver _Resolver;

            public PluginLoadContext(string baseDirectory)
            {
                _Resolver = new AssemblyDependencyResolver(Path.GetFullPath(baseDirectory));
            }

            protected override Assembly? Load(AssemblyName name)
            {
                Console.WriteLine("  | Loading assembly: " + name);
                string path = _Resolver.ResolveAssemblyToPath(name);
                if (path != null)
                {
                    return LoadFromAssemblyPath(path);
                }
                return null;
            }

            protected override IntPtr LoadUnmanagedDll(string name)
            {
                Console.WriteLine("  | Loading unmanaged assembly: " + name);
                string path = _Resolver.ResolveUnmanagedDllToPath(name);
                if (path != null)
                {
                    return LoadUnmanagedDllFromPath(path);
                }
                return IntPtr.Zero;
            }
        }
    }
}

Processor.cs in Module1

namespace Module1
{
    using RestWrapper;
    using System;

    public class Processor
    {
        public Processor()
        {

        }

        public void Process(string url)
        {
            using (RestRequest req = new RestRequest(url))
            {
                using (RestResponse resp = req.Send())
                {
                    Console.WriteLine("| Status (module1): " + resp.StatusCode + " " + resp.ContentLength + " bytes");
                }
            }
        }
    }
}

Processor.cs in Module2

namespace Module2
{
    using RestWrapper;
    using System;

    public class Processor
    {
        public Processor()
        {

        }

        public void Process(string url)
        {
            using (RestRequest req = new RestRequest(url))
            {
                using (RestResponse resp = req.Send())
                {
                    Console.WriteLine("| Status (module2): " + resp.StatusCode + " " + resp.ContentLength + " bytes");
                }
            }
        }
    }
}

Solution

  • I think that the problem is that you are loading your dependencies (inside AssemblyLoadContext.Resolving event handler) using Assembly.LoadFrom. It fails due to its behavior.

    I think that the right way to do this is to invoke the AssemblyLoadContext.LoadFromAssemblyPath method.

    ctx1.Resolving += (ctx, assemblyName) =>
    {
        Console.WriteLine("| Module 1 loading assembly " + assemblyName.FullName);
        var parts = assemblyName.FullName.Split(',');
        string name = parts[0];
        var version = Version.Parse(parts[1].Split('=')[1]);
        string filename = new FileInfo(Path.GetFullPath("module1/" + name + ".dll")).FullName;
        Console.WriteLine("| Module 1 loading from file " + filename);
    
        //Assembly asm = Assembly.LoadFrom(filename); This line is replaced with the one below
        Assembly asm = ctx.LoadFromAssemblyPath(filename);
    
        Console.WriteLine("| Module 1 loaded assembly " + filename);
        return asm;
    };
    

    Same apply for ctx2. Please note that when loading Module2 you are still using ctx1 instead of ctx2 so this also needs to be fixed.

    AssemblyLoadContext ctx2 = new AssemblyLoadContext("module2", false);
    Assembly asm2 = ctx2.LoadFromAssemblyPath(Path.GetFullPath("module2/Module2.dll")); // Replaced ctx1 with ctx2
    
    ctx2.Resolving += (ctx, assemblyName) => // Replaced ctx1 with ctx2
    {
        Console.WriteLine("| Module 2 loading assembly " + assemblyName.FullName);
        var parts = assemblyName.FullName.Split(',');
        string name = parts[0];
        var version = Version.Parse(parts[1].Split('=')[1]);
        string filename = new FileInfo(Path.GetFullPath("module2/" + name + ".dll")).FullName;
        Console.WriteLine("| Module 2 loading from file " + filename);
    
        //Assembly asm = Assembly.LoadFrom(filename); This line is replaced with the one below
        Assembly asm = ctx.LoadFromAssemblyPath(filename);
    
        Console.WriteLine("| Module 2 loaded assembly " + filename);
        return asm;
    };