DLLs loaded from wrong AppplicationBase when trying to load mixed C# and C++/CLI dlls in a new AppDomain
Asked Answered
M

1

18

We have a large .NET solution with both C# and C++/CLI projects which reference each other. We also have several unit testing projects. We've recently upgraded from Visual Studio 2010 & .NET 4.0 to Visual Studio 4.5 & .NET 4.5, and now when we try to run the unit tests, there seem to be a problem loading some of the DLLs during the test.

The problem appears to happen because unit testing is performed on a separate AppDomain. The unit testing process (for example nunit-agent.exe) creates a new AppDomain with AppBase set to the test project's location, but according the Fusion Log, some of the DLLs are loaded with nunit's executable's directory as the AppBase instead of the AppDomain's AppBase.

I've managed to reproduce the problem with a simpler scenario, which creates a new AppDomain and tries to run the test there. Here's how it looks (I changed the names of the unit test classes, methods and the location of the dll to protect the innocent):

class Program
{
    static void Main(string[] args)
    {

        var setup = new AppDomainSetup {
            ApplicationBase = "C:\\DirectoryOfMyUnitTestDll\\"
        };

        AppDomain domain = AppDomain.CreateDomain("MyDomain", null, setup);
        ObjectHandle handle = Activator.CreateInstanceFrom(domain, typeof(TestRunner).Assembly.CodeBase, typeof(TestRunner).FullName);
        TestRunner runner = (TestRunner)handle.Unwrap();
        runner.Run();

        AppDomain.Unload(domain);
    }

}

public class TestRunner : MarshalByRefObject
{
    public void Run()
    {
        try
        {
            HtmlTransformerUnitTest test = new HtmlTransformerUnitTest();
            test.SetUp();
            test.Transform_HttpEquiv_Refresh_Timeout();
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
        }
    }
}

This is the exception I get when trying to execute the unit test. As you can see, the problem happens the the C++ dll is initialized and tries to load the C# dll (I changed the names of the DLLs involved to CPlusPlusDll and CSharpDll):

System.TypeInitializationException: The type initializer for '' threw an exception.
 ---> .ModuleLoadExceptionHandlerException: A nested exception occurred after the primary exception that caused the C++ module to fail to load.
 ---> System.TypeInitializationException: The type initializer for '' threw an exception.
 ---> .ModuleLoadException: The C++ module failed to load during vtable initialization.
 ---> System.IO.FileNotFoundException: Could not load file or assembly 'CSharpDll, Version=8.80.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies. The system cannot find the file specified.
   at ?A0xb992d574.??__E??_7CAppletAction@CPlusPlusDll@SomeNamespace@@6B@@@YMXXZ()
   at _initterm_m((fnptr)* pfbegin, (fnptr)* pfend) in f:\dd\vctools\crt_bld\self_x86\crt\src\puremsilcode.cpp:line 219
   at .LanguageSupport.InitializeVtables(LanguageSupport* ) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 331
   at .LanguageSupport._Initialize(LanguageSupport* ) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 491
   at .LanguageSupport.Initialize(LanguageSupport* ) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 702
   --- End of inner exception stack trace ---
   at .ThrowModuleLoadException(String errorMessage, Exception innerException) in f:\dd\vctools\crt_bld\self_x86\crt\src\minternal.h:line 194
   at .LanguageSupport.Initialize(LanguageSupport* ) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 712
   at .cctor() in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 754
   --- End of inner exception stack trace ---
   at System.Runtime.InteropServices.Marshal.ThrowExceptionForHRInternal(Int32 errorCode, IntPtr errorInfo)
   at System.Runtime.InteropServices.Marshal.ThrowExceptionForHR(Int32 errorCode)
   at .DoCallBackInDefaultDomain(IntPtr function, Void* cookie) in f:\dd\vctools\crt_bld\self_x86\crt\src\minternal.h:line 406
   at .DefaultDomain.Initialize() in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 277
   at .LanguageSupport.InitializeDefaultAppDomain(LanguageSupport* ) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 342
   at .LanguageSupport._Initialize(LanguageSupport* ) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 539
   at .LanguageSupport.Initialize(LanguageSupport* ) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 702
   --- End of inner exception stack trace ---
   at .ThrowNestedModuleLoadException(Exception innerException, Exception nestedException) in f:\dd\vctools\crt_bld\self_x86\crt\src\minternal.h:line 184
   at .LanguageSupport.Cleanup(LanguageSupport* , Exception innerException) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 662
   at .LanguageSupport.Initialize(LanguageSupport* ) in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 710
   at .cctor() in f:\dd\vctools\crt_bld\self_x86\crt\src\mstartup.cpp:line 754
   --- End of inner exception stack trace ---

This is what I'm seeing in the Fusion Log (I've changed the name of the DLL to SomeDLL.dll instead of the original):

*** Assembly Binder Log Entry  (8/1/2013 @ 01:47:48 PM) ***

The operation failed.
Bind result: hr = 0x80070002. The system cannot find the file specified.

Assembly manager loaded from:  C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.dll
Running under executable  c:\users\yshany\documents\visual studio 2012\Projects\MyTester\MyTester\bin\Debug\MyTester.exe
--- A detailed error log follows. 

=== Pre-bind state information ===
LOG: User = WF-IL\yshany
LOG: DisplayName = SomeDLL, Version=8.80.0.0, Culture=neutral, PublicKeyToken=null
 (Fully-specified)
LOG: Appbase = file:///c:/users/yshany/documents/visual studio 2012/Projects/MyTester/MyTester/bin/Debug/
LOG: Initial PrivatePath = NULL
LOG: Dynamic Base = NULL
LOG: Cache Base = NULL
LOG: AppName = MyTester.exe
Calling assembly : (Unknown).
===
LOG: This bind starts in default load context.
LOG: Using application configuration file: c:\users\yshany\documents\visual studio 2012\Projects\MyTester\MyTester\bin\Debug\MyTester.exe.Config
LOG: Using host configuration file: 
LOG: Using machine configuration file from C:\Windows\Microsoft.NET\Framework\v4.0.30319\config\machine.config.
LOG: Policy not being applied to reference at this time (private, custom, partial, or location-based assembly bind).
LOG: Attempting download of new URL file:///c:/users/yshany/documents/visual studio 2012/Projects/MyTester/MyTester/bin/Debug/SomeDLL.DLL.
LOG: Attempting download of new URL file:///c:/users/yshany/documents/visual studio 2012/Projects/MyTester/MyTester/bin/Debug/SomeDLL/SomeDLL.DLL.
LOG: Attempting download of new URL file:///c:/users/yshany/documents/visual studio 2012/Projects/MyTester/MyTester/bin/Debug/SomeDLL.EXE.
LOG: Attempting download of new URL file:///c:/users/yshany/documents/visual studio 2012/Projects/MyTester/MyTester/bin/Debug/SomeDLL/SomeDLL.EXE.
LOG: All probing URLs attempted and failed.

As you can see, the problem is that the AppBase is where MyTester.exe resides, instead of where SomeDLL.dll resides (which is the same location as the unit test dll). This happens for several DLLs, including both of the DLLs mentioned in the exception above.

I also tried to reproduce with a simpler unit test project (a small VS2012 solution with 3 projects - a C# project which references a C++/CLI project which references another C# project), but the problem did not reproduce and it worked perfecty. As I mentioned before, the unit tests were ok before we upgraded to VS2012 & .NET 4.5.

What can I do? Thanks!

Mantling answered 1/8, 2013 at 11:38 Comment(5)
Does it only happen with the NUnit-TestRunner? Can you also repro it with MSTest?Compliant
It happens in NUnit, MSTest and also in the Tester program that I wrote here.Mantling
This obfuscation doesn't help us help you. What's the relationship between "CSharpDll" and "SomeDLL"?Mansion
As I wrote here, "SomeDLL" stands for all kinds of DLLs that appear in the fusion log, including the CSharpDll and the CPlusPlusDll. They all have the same errors. I apologize for the obfuscation, but I can't write the names of the projects I'm working on.Mantling
This seems fixed in .NET 4.5.1 - I re-ran my test program (see below) and it doesn't throw an exception anymore.Trickster
T
16

This appears to be a bug in .NET 4.5.

NUnit creates a new app domain to run the unit tests. If the unit test assembly or any of its references are mixed-mode assemblies, it ends up trying to load the mixed-mode assembly's references in the default app domain too, under certain conditions.

The runtime has to initialize the unmanaged c++ code of the mixed mode assembly before it does anything else in that assembly. It does this via the automatically compiled-in LanguageSupport class (the source code for this is distributed with Visual Studio). LanguageSupport::Initialize is first run in the static constructor of the mixed-mode unit test assembly's compiler-generated .module class, in the context of the NUnit-created appdomain. LanguageSupport in turn re-triggers the same static constructor in the default appdomain, which finally calls LanguageSupport::Initialize again. Here's the same call stack from above minus the error handling stuff:

   at _initterm_m((fnptr)* pfbegin, (fnptr)* pfend)
   at .LanguageSupport.InitializeVtables(LanguageSupport* )
   at .LanguageSupport._Initialize(LanguageSupport* )
   at .LanguageSupport.Initialize(LanguageSupport* )
   at .LanguageSupport.Initialize(LanguageSupport* )
   at .DoCallBackInDefaultDomain(IntPtr function, Void* cookie)
   at .LanguageSupport.InitializeDefaultAppDomain(LanguageSupport* )
   at .LanguageSupport._Initialize(LanguageSupport* )
   at .LanguageSupport.Initialize(LanguageSupport* )
   at .LanguageSupport.Initialize(LanguageSupport* )

The appdomain that NUnit creates is actually succeeding in loading the unit test assembly and its references (assuming you don't have other problems), but the 2nd LanguageSupport initialization in the default appdomain is failing.

By dumping the IL for the mixed mode assembly, I found that some of the unmanaged classes had a static initializer method automatically generated - these are among the methods that get called in the InitializeVtables method seen 2nd from the top of the call stack. After some trial and error compiling, I discovered that if the unmanaged class has a constructor and at least one virtual method with a .NET type in the signature, the compiler will emit a static initializer for the class.

LanguageSupport::InitializeVtables calls these static initializer functions. When the initializer runs, it's apparently causing the CLR to try to load the references containing the imported types found in the signatures of the virtual methods of the unmanaged class. Because the default appdomain doesn't have the unit test assemblies and its references in the application base, the call fails and generates the error you see above.

What's more, the error (in the toy app I made, anyway) will only occur if there's another non-vtable initializer that also runs.

Here's the relevant part of my app:

class DomainDumper {
public:
   DomainDumper() {
      Console::WriteLine("Dumper called from appdomain {0}", 
         AppDomain::CurrentDomain->Id);
   }
};

// comment out this line and InitializeVtables succeeds in default appdomain
DomainDumper dumper;

class CppClassUsingManagedRef {
public:
   // comment out this line and the dynamic vtable initializer doesn't get created
   CppClassUsingManagedRef(){}

   virtual void VirtualMethodWithNoArgs() {}

   // comment out this line and the dynamic vtable initializer doesn't get created
   virtual void VirtualMethodWithImportedTypeRef(ReferredToClassB^ bref) {}

   void MethodWithImportedTypeRef(ReferredToClassB^ bref) {}
};

Workarounds:

  • If your unit tests are in a subdirectory of the NUnit executable (unlikely, I guess), you can modify the <probing> portion of the app.config file.
  • You can copy nunit and its dependencies to the unit test directory, or vice versa
  • You can modify the virtual methods in your unmanaged c++ classes to exclude references to types that NUnit won't be able to load. You can do this by limiting yourself to Object^ and casting to the actual type in the method implementation, which is quite lame but works.
  • You can make the virtual method in question a non-virtual one
  • You can remove the constructor from the unamanaged c++ class
Trickster answered 28/8, 2013 at 23:28 Comment(3)
@Oliver Mellet, for "copy nunit and its dependencies to the unit test directory", is it possible to force VS to run the tests from the unit test directory? I've only had success by running nunit-x86.exe from the test directory.Invertase
As it happens, forcing NUnit to run tests in the default AppDomain (/domain:None for NUnit 2.x) is another workaround where applicable.Radie
Also FWIW there's an issue in NUnit's GitHub tracker that proposes an NUnit-side fix/workaround for this problem which should be somewhat inelegant but functional.Radie

© 2022 - 2024 — McMap. All rights reserved.