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

C++/CLI assembly is loaded into isolated AssemblyLoadContext (IsolatedComponentLoadContext) in .NET 6 #61105

Closed
szilvaa-adsk opened this issue Nov 2, 2021 · 22 comments
Labels
area-AssemblyLoader-coreclr untriaged New issue has not been triaged by the area owner

Comments

@szilvaa-adsk
Copy link
Contributor

This is a continuation of the discussion in #56391 which is closed and therefore cannot be commented on.

@jkoritzinsky said:

This is a known limitation with C++/CLI in .NET Core and .NET 5+. If the first time managed code in a C++/CLI assembly is executed is from a native caller, the assembly will be loaded into a separate ALC.

Can we expect this limitation to be removed? This is a major migration blocker for our (native/unmanaged) C++ application that used C++/CLI to implement some of its functionality. C++/CLI dlls are called via the PE import table and LoadLibrary/GetProcAddress and it will be very, very difficult to track down & change all these code paths.

Is there a way to make this customizable via some event, or runtime config? I already "host" the runtime as described here.

@dotnet-issue-labeler dotnet-issue-labeler bot added area-AssemblyLoader-coreclr untriaged New issue has not been triaged by the area owner labels Nov 2, 2021
@ghost
Copy link

ghost commented Nov 2, 2021

Tagging subscribers to this area: @vitek-karas, @agocke, @VSadov
See info in area-owners.md if you want to be subscribed.

Issue Details

This is a continuation of the discussion in #56391 which is closed and therefore cannot be commented on.

@jkoritzinsky said:

This is a known limitation with C++/CLI in .NET Core and .NET 5+. If the first time managed code in a C++/CLI assembly is executed is from a native caller, the assembly will be loaded into a separate ALC.

Can we expect this limitation to be removed? This is a major migration blocker for our (native/unmanaged) C++ application that used C++/CLI to implement some of its functionality. C++/CLI dlls are called via the PE import table and LoadLibrary/GetProcAddress and it will be very, very difficult to track down & change all these code paths.

Is there a way to make this customizable via some event, or runtime config? I already "host" the runtime as described here.

Author: szilvaa-adsk
Assignees: -
Labels:

area-AssemblyLoader-coreclr, untriaged

Milestone: -

@agocke
Copy link
Member

agocke commented Nov 2, 2021

One of the workarounds described is pre-loading via an "initialize" stub. Can you elaborate on why that wouldn't work for you? Your description here:

C++/CLI dlls are called via the PE import table and LoadLibrary/GetProcAddress and it will be very, very difficult to track down & change all these code paths.

doesn't sound like it should be necessary, since you shouldn't need to change the calls, as long as the assembly is first loaded via a managed shim.

@szilvaa-adsk
Copy link
Contributor Author

szilvaa-adsk commented Nov 2, 2021

The workaround works. I implemented in the first scenario that we hit. The problem is that the application is very large. C++/CLI dlls get loaded in many code paths. The same C++/CLI dll may be loaded via one code path or another depending on what the user does. Loading all C++/CLI dlls on startup is non-starter because of the performance cost.

Essentially, I'm trying to highlight the significant migration cost in hopes that I can change the design decision that caused IsolatedComponentLoadContext to be applied (or at least give the host control over this).

@jkoritzinsky
Copy link
Member

The decision to use an IsolatedComponentLoadContext was for a few reasons:

  1. We need a way to point to the dependencies of the C++/CLI assembly, and the mechanism for that is an AssemblyLoadContext with an AssemblyDependencyResolver that uses the C++/CLI assembly's deps.json file. We can't update the set of "known" assemblies in the default assembly load context as we bake that in during CoreCLR startup.
  2. If we're activating the runtime for the first time, we don't know if the C++/CLI "component" will be the only thing to ever run managed code, so we don't want to pollute the TPA (the list of known assemblies in the runtime) with the C++/CLI assembly and its dependencies. If we did pollute the TPA, then any other C++/CLI assembly that is loaded from native or any .NET assembly called from COM might accidentally load the wrong assembly from the TPA.

One possible way to work around this would be to fork the ijwhost and have it call into your own method in the runtime to forcibly load it and all of its dependencies into the default ALC.

@szilvaa-adsk
Copy link
Contributor Author

@jkoritzinsky Thanks for the details.

  1. So why don't we have the same problem when the DLL is loaded via managed code first? What so different about the unmanaged entry point vs. managed entry point?

  2. Why do you need to change the TPA? I don't think there's any way to add any of my assemblies to the TPA when I call hostfxr_initialize_for_runtime_config. So why couldn't the implicit initialization via C++/CLI work the same?

I'm probably demonstrating huge amount of ignorance here. :-) Thank you in advance for accommodating me.

@jkoritzinsky
Copy link
Member

  1. So why don't we have the same problem when the DLL is loaded via managed code first? What so different about the unmanaged entry point vs. managed entry point?

When loaded from managed code, there are two cases. One, the C++/CLI assembly and its dependencies are already in the dependency list of the assembly that loaded it, so it can be loaded in the same ALC as the calling assembly, which in most cases is the default ALC. Otherwise, the developer has used the ALC or Assembly APIs to explicitly load a C++/CLI assembly in a particular ALC, so the runtime tries to load it as best it can.

  1. Why do you need to change the TPA? I don't think there's any way to add any of my assemblies to the TPA when I call hostfxr_initialize_for_runtime_config. So why couldn't the implicit initialization via C++/CLI work the same?

That's because hostfxr_initialize_for_runtime_config (which is what the IJW Host uses under the hood) sets up a default TPA that has only the framework assemblies for whatever frameworks you reference. That entry-point is designed for loading "components", which COM and IJW (C++/CLI) are both examples of. If you want to use the hosting APIs to host an app, you should use hostfxr_initialize_for_dotnet_command_line instead, which will load the application entry point's .deps.json to set up the TPA.

@szilvaa-adsk
Copy link
Contributor Author

When loaded from managed code, there are two cases. One, the C++/CLI assembly and its dependencies are already in the dependency list of the assembly that loaded it, so it can be loaded in the same ALC as the calling assembly, which in most cases is the default ALC.

So why couldn't the default ALC be used when the caller has no ALC (because it is unmanaged code)? It seems rather arbitrary to create a new IsolatedComponentLoadContext in this case. In other words, I still don't see the advantage of creating an isolated context. I see the disadvantage: it breaks .net4 apps that are trying to move to net6 platform.

@jkoritzinsky
Copy link
Member

So why couldn't the default ALC be used when the caller has no ALC (because it is unmanaged code)? It seems rather arbitrary to create a new IsolatedComponentLoadContext in this case. In other words, I still don't see the advantage of creating an isolated context. I see the disadvantage: it breaks .net4 apps that are trying to move to net6 platform.

What if someone tries to run another C++/CLI assembly as a plugin that should be fully isolated? If we don't create a separate ALC for each component, than every component after the first could end up with non-deterministic failures since some assemblies (and as a result values of static fields) might be shared while others aren't and the set of shared assemblies would depend on which component was loaded first.

For example, let's take the following setup:

  • Native entry point exe A
  • C++/CLI assembly B depends on assembly C version 1
  • COM component D depends on assembly C version 2

Since calls from A would fall into your "no ALC" case, that would mean that calls to B or D from A would both load into the default ALC. In this scenario, if B is called first, then assembly C version 1 would be loaded into the default ALC. Then, if A tries to call the COM component D, it would try to load assembly C version 2 into the default ALC and would fail since C version 1 is already loaded. As a result, there would be no safe mechanism to load two components at the same time as any dependency might conflict. By isolating B and D and their dependencies each into their own ALCs, they can each load their required version of C and A can successfully call into both.

We can't block loading multiple components because that effectively makes the component story useless.

@rseanhall
Copy link
Contributor

rseanhall commented Nov 2, 2021

I don't think there's any way to add any of my assemblies to the TPA when I call hostfxr_initialize_for_runtime_config.

You're expected to provide a .deps.json as part of hostfxr_initialize_* (edit: actually it's only hostfxr_initialize_for_dotnet_command_line that adds it to the TPA). That's the way to get your assemblies automatically added to the TPA.

The alternate way is to manually add your assemblies to the TPA through a runtime property (I don't remember what it is, maybe TRUSTED_PLATFORM_ASSEMBLIES). This is similar to your previous issue, but I hope you're realizing that manually setting these properties is a sign that you're doing something wrong.

@szilvaa-adsk
Copy link
Contributor Author

What if someone tries to run another C++/CLI assembly as a plugin that should be fully isolated?

They can always do this by creating their own ALC. My point is that .net4 didn't have ALCs. Assemblies got loaded into the "default" context all the time (yes, sometime causing failure). I think the path of least surprise for migration is to maintain this behavior. Clients who wish to have more advanced plugin/component story can start using ALC.

@vitek-karas
Copy link
Member

Assembly loading is one of the areas where .NET Core/.NET was intentionally made different. Just like .NET Core/.NET doesn't have app domains. The magic of fusion in .NET Framework worked reasonably well for simple cases, but more complex cases caused all kinds of trouble (probably the most known is binding redirect hell). .NET Core was intentionally designed to have a much simpler assembly loader, where most things are specified explicitly. This can be either by the SDK (.deps.json) or by the code (ALCs, and the various loader extension points).

Yes, it causes some friction when you try to migrate a large system from .NET Framework, but it also creates lot of clarity and robustness (way less surprises).

The case you mention where components were just loaded automatically onto one big pile frequently caused problems and the need for complex binding redirects to keep the system working.

@szilvaa-adsk
Copy link
Contributor Author

@vitek-karas Fair enough. Thanks for the details.

A data point: the unmanaged host that I work on (AutoCAD) has hosted .NET Framework since 2004 with many C++/CLI and C# assemblies. The "big pile" has not caused any trouble (no need for binding redirects). Perhaps, we have been lucky. :-)

I'll pursue @jkoritzinsky's suggestion to see if a forked/custom built ijwhost.dll could help ease our migration pain.

Thanks for the discussion. I hope this will be useful for others.

@vitek-karas
Copy link
Member

I'm not saying that it should not be possible to load everything into default, just that having that as the default is problematic. Unfortunately currently we don't have a setting to allow this easily.

szilvaa-adsk added a commit to szilvaa-adsk/runtime that referenced this issue Dec 13, 2021
@szilvaa-adsk
Copy link
Contributor Author

Working around this by modifying ijwhost.dll is not super pretty. The changes in ijwhost.dll are simple but unfortunately AssemblyLoadContext.LoadFromInMemoryModule is internal so I had to use reflection.

extern "C" _declspec(dllexport) void __stdcall ijwLoadInMemoryAssembly(HINSTANCE moduleHandle, const wchar_t* assemblyPath)
{
    // This is a slighty modified version of [InMemoryAssemblyLoader.LoadInMemoryAssembly](https://github.com/dotnet/runtime/blob/57bfe474518ab5b7cfe6bf7424a79ce3af9d6657/src/coreclr/System.Private.CoreLib/src/Internal/Runtime/InteropServices/InMemoryAssemblyLoader.cs#L26)
    // It is called from a modified version of ijwhost.dl. See https://github.com/dotnet/runtime/compare/release/6.0...szilvaa-adsk:release/6.0
    auto assemblyPathString = Marshal::PtrToStringUni(IntPtr((void*)assemblyPath));
    if (assemblyPathString == nullptr)
    {
        throw gcnew ArgumentOutOfRangeException("assemblyPath");
    }
    // This is the key difference. We use the default context instead of an isolated context.
    AssemblyLoadContext^ context = AssemblyLoadContext::Default;
    // Must use reflection because LoadFromInMemoryModule is internal.
    auto loadFromInMemoryModule = context->GetType()->GetMethod("LoadFromInMemoryModule", BindingFlags::Instance | BindingFlags::NonPublic);
    loadFromInMemoryModule->Invoke(context, gcnew array<System::Object^> {IntPtr(moduleHandle)});
}

@fiercekittenz
Copy link

I would like to piggyback on this issue and note that it has been a significant roadblock to the migration of my company's 250+ project solution. We have several C++/CLI projects and it isn't reasonable to expect us to invoke a workaround "Initialize()" method in the C++ projects for every single C# assembly. At least, that is how I'm interpreting this workaround for the moment.

@szilvaa
Copy link

szilvaa commented Feb 7, 2022

The problems you experience due to this issue will manifest when native C++ code calls C++/CLI code (or C# code calls C++/CLI code via DllImport but this looks weird so I expect it to be very infrequent).

If you experience an issue when calling from C# to C++/CLI via a managed entry point then it must be different problem.

@fiercekittenz
Copy link

The path is:

C# -> Create object defined in C++/CLI -> the C++/CLI object then invokes a method in another C++/CLI project -> The second CLI library calls back into C# and that's when I pick up that the C# library has a context IsolatedComponentLoadContext.

@szilvaa
Copy link

szilvaa commented Feb 7, 2022

the C++/CLI object then invokes a method in another C++/CLI project

I suspect that this call is via a native export so this is where things go south.

@fiercekittenz
Copy link

That appears to be the case. The class that makes the call has mixed code - some methods are native, some are managed. It's extremely funky for lack of a better word. The call to initialize the singleton instance in the C# code passes from a native method to a managed method in the same class. Adding an Initialize() method and invoking it from C# before heading into the CLR code seemed to fix it, though I'm really not a fan of having to do this to work around the problem.

@fiercekittenz
Copy link

I have put together a repro case of this which demonstrates issue concisely with the "dummy/hack" method workaround: https://github.com/fiercekittenz/CLIAssemblyLoadContextDemo

@elinor-fung
Copy link
Member

Thanks all for sharing your feedback/pain around this.

Based on all the feedback we have received, we will be loading C++/CLI DLLs built targeting .NET 7.0 into the default AssemblyLoadContext - the change will be in .NET 7.0 Preview 3.

Getting this behaviour will require rebuilding the C++/CLI component (to pick up the updated ijwhost.dll). Older components (using an older ijwhost.dll) will continue to be loaded into an isolated context. There is currently no configuration to go back to using the isolated context rather than the default ALC, but we do intend to add one.

@AaronRobinsonMSFT
Copy link
Member

Closing based on #66486

@ghost ghost locked as resolved and limited conversation to collaborators Apr 27, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-AssemblyLoader-coreclr untriaged New issue has not been triaged by the area owner
Projects
None yet
Development

No branches or pull requests

9 participants