Strange behavior when loading assemblies and its dependencies programatically

2

The following experimental codes/projects are using netcore 2.0 and netstandard 2.0 in VS2017. Let's say I have two versions of a third party dll v1.0.0.0 and v2.0.0.0, which contains only one class Constants.cs.

//ThirdPartyDependency.dll v1.0.0.0
public class Constants
{
    public static readonly string TestValue = "test value v1.0.0.0";
}

//ThirdPartyDependency.dll v2.0.0.0
public class Constants
{
    public static readonly string TestValue = "test value v2.0.0.0";
}

Then I created my own solution named AssemblyLoadTest, which contains:

Wrapper.Abstraction: class library with no project references

namespace Wrapper.Abstraction
{
    public interface IValueLoader
    {
        string GetValue();
    }

    public class ValueLoaderFactory
    {
        public static IValueLoader Create(string wrapperAssemblyPath)
        {
            var assembly = Assembly.LoadFrom(wrapperAssemblyPath);
            return (IValueLoader)assembly.CreateInstance("Wrapper.Implementation.ValueLoader");
        }
    }
}

Wrapper.V1: class library with project reference Wrapper.Abstractions and dll reference ThirdPartyDependency v1.0.0.0

namespace Wrapper.Implementation
{
    public class ValueLoader : IValueLoader
    {
        public string GetValue()
        {
            return Constants.TestValue;
        }
    }
}

Wrapper.V2: class library with project reference Wrapper.Abstractions and dll reference ThirdPartyDependency v2.0.0.0

namespace Wrapper.Implementation
{
    public class ValueLoader : IValueLoader
    {
        public string GetValue()
        {
            return Constants.TestValue;
        }
    }
}

AssemblyLoadTest: console application with project reference Wrapper.Abstraction

class Program
{
    static void Main(string[] args)
    {
        AppDomain.CurrentDomain.AssemblyResolve += (s, e) =>
        {
            Console.WriteLine($"AssemblyResolve: {e.Name}");

            if (e.Name.StartsWith("ThirdPartyDependency, Version=1.0.0.0"))
            {
                return Assembly.LoadFrom(@"v1\ThirdPartyDependency.dll");
            }
            else if (e.Name.StartsWith("ThirdPartyDependency, Version=2.0.0.0"))
            {
                //return Assembly.LoadFrom(@"v2\ThirdPartyDependency.dll");//FlagA
                return Assembly.LoadFile(@"C:\FULL-PATH-TO\v2\ThirdPartyDependency.dll");//FlagB
            }

            throw new Exception();
        };

        var v1 = ValueLoaderFactory.Create(@"v1\Wrapper.V1.dll");
        var v2 = ValueLoaderFactory.Create(@"v2\Wrapper.V2.dll");

        Console.WriteLine(v1.GetValue());
        Console.WriteLine(v2.GetValue());

        Console.Read();
    }
}

STEPS

  1. Build AssemblyLoadTest in DEBUG

  2. Build Wrapper.V1 project in DEBUG, copy files in Wrapper.V1\bin\Debug\netstandard2.0\ to AssemblyLoadTest\bin\Debug\netcoreapp2.0\v1\

  3. Build Wrapper.V2 project in DEBUG, copy files in Wrapper.V2\bin\Debug\netstandard2.0\ to AssemblyLoadTest\bin\Debug\netcoreapp2.0\v2\

  4. Replace FULL-PATH-TO in AssemblyLoadTest.Program.Main with the correct absolute v2 path that you copied in step 3

  5. Run AssemblyLoadTest - Test1

  6. Comment FlagB line and uncomment FlagA line, run AssemblyLoadTest - Test2

  7. Comment AppDomain.CurrentDomain.AssemblyResolve, run AssemblyLoadTest - Test3

My results and questions:

  1. Test1 succeeds and prints v1.0.0.0 and v2.0.0.0 as expected

  2. Test2 throws exception at v2.GetValue()

System.IO.FileLoadException: 'Could not load file or assembly 'ThirdPartyDependency, Version=2.0.0.0, Culture=neutral, PublicKeyToken=null'. Could not find or load a specific file. (Exception from HRESULT: 0x80131621)'

Question1: Why LoadFile with absolute path works as expected, while LoadFrom with relative path not working, meanwhile LoadFrom with relative path works for v1.0.0.0 in the first if statement?

  1. Test3 fails with the same exception above at the same place, here my understanding is CLR locates the assemblies with the following priority rule:

Rule1: Check if AppDomain.AssemblyResolve is registered (highest priority)

Rule2: Otherwise check if the assembly is loaded.

Rule3: Otherwise search the assembly in folders(can be configured in probing and codeBase.

Here in Test3 where AssemblyResolve is not registered, v1.GetValue works because Rule1 and Rule2 is N/A, AssemblyLoadTest\bin\Debug\netcoreapp2.1\v1 is in Rule3 scan candidates. When executing v2.GetValue, Rule1 is still N/A, however Rule2 is applied here (if Rule3 is applied, why exceptions?)

Question2: Why the version is ignored even Wrapper.V2 reference ThirdPartyDependency.dll using

<Reference Include="ThirdPartyDependency, Version=2.0.0.0">
  <HintPath>..\lib\ThirdPartyDependency\2.0.0.0\ThirdPartyDependency.dll</HintPath>
</Reference> 
c#
.net
.net-core
clr
.net-standard
asked on Stack Overflow Aug 8, 2018 by Cheng Chen

1 Answer

1

Great answer from Vitek Karas, original link here.

Kind of unfortunately all of the behavior you describe is currently as designed. That doesn't mean it's intuitive (which it's totally not). Let me try to explain.

Assembly binding happens based on AssemblyLoadContext (ALC). Each ALC can have only one version of any given assembly loaded (so only one assembly of a given simple name, ignoring versions, culture, keys and so on). You can create a new ALC which then can have any assemblies loaded again, with same or different versions. So ALCs provide binding isolation.

Your .exe and related assemblies are loaded into a Default ALC - one which is created at the start of the runtime.

Assembly.LoadFrom will try to load the specified file into the Default ALC - always. Let me stress the "try" word here. If the Default ALC already loaded assembly with the same name, and the already loaded assembly is equal or higher version, then the LoadFrom will succeed, but it will use the already loaded assembly (effectively ignoring the path you specified). If on the other hand the already loaded assembly is of a lower version then the one you're trying to load - this will fail (we can't load the same assembly for the second time into the same ALC).

Assembly.LoadFile will load the specified file into a new ALC - always creates a new ALC. So the load will effectively always succeed (there's no way this can collide with anything since it's in its own ALC).

So now to your scenarios:

Test1 This works because your ResolveAssembly event handler loads the two assemblies into separate ALCs (LoadFile will create a new one, so the first assembly goes to the default ALC, and the second one goes into its own).

Test2 This fails because LoadFrom tries to load the assembly into the Default ALC. The failure actually occurs in the AssemblyResolve handler when it calls the second LoadFrom. First time it loaded v1 into Default, the second time it tries to load v2 into Default - which fails because Default already has v1 loaded.

Test3 This fails the same way because it internally does basically exactly what Test2 does. Assembly.LoadFrom also registers event handler for AssemblyResolve and that makes sure that dependent assemblies can be loaded from the same folder. So in your case v1\Wrapper.V1.dll will resolve its dependency to v1\ThirdPartyDependency.dll because it's next to it on the disk. Then for v2 it will try to do the same, but v1 is already loaded, so it fails just like in Test2. Remember that LoadFrom loads everything into the Default ALC, so collisions are possible.

Your questions:

Question1 LoadFile works because it loads the assembly into its own ALC, which provides full isolation and thus there are never any conflicts. LoadFrom loads the assembly into the Default ALC, so if that already has assembly with the same name loaded, there might be conflicts.

Question2 The version is actually not ignored. The version is honored which is why Test2 and Test3 fail. But I might not understand this question correctly - it's not clear to me in which context you're asking it.

CLR binding order The order of Rules you describe is different. It's basically:

  • Rule2 - if it's already loaded - use it (including if higher version is already loaded, then use that)
  • Rule1 - if everything fails - as a last resort - call AppDomain.AssemblyResolve

Rule 3 actually doesn't exist. .NET Core doesn't have a notion of probing paths or code base. It sort of does for the assemblies which are statically referenced by the app, but for dynamically loaded assemblies no probing is performed (with the exception of LoadFrom loading dependent assemblies from the same folder as the parent as described above).

Solutions To make this fully work, you would need to do either:

  • Use the LoadFile along with your AssemblyResolve handler. But the problem here is that if you LoadFile an assembly which itself has other dependencies, you will need to handle those in your handler as well (you lose the "nice" behavior of LoadFrom which loads dependencies from the same folder)

  • Implement your own ALC which handles all dependencies. This is technically the cleaner solution, but potentially more work. And it's similar in that regard that you still have to implement the loading from the same folder if needed.

We are actively working on making scenarios like this easy. Today they are doable, but pretty hard. The plan is to have something which solves this for .NET Core 3. We're also very aware of the lack of documentation/guidance in this area. And last but not least, we are working on improving the error messages, which are currently very confusing.

answered on Stack Overflow Aug 10, 2018 by Cheng Chen

User contributions licensed under CC BY-SA 3.0