dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
14.91k stars 4.63k forks source link

focusing on AssemblyLoadContext.CurrentContextualReflectionContext to support child/parent alc fallback #37576

Open John0King opened 4 years ago

John0King commented 4 years ago

background

In most time we need alc fallback when doing plugin dynamic load. When a assembly can not be find it should look to it's parent alc instead of just default alc. current issues like #13472 , #37231 also has the issue in alc fallback.

after I look to those issue I also notice that people in .net team seems not “support” to use the CurrentContextualReflectionContext , and I do see a valuable usage when I create the sub app for asp.net core

the problem

when create my library sub app for asp.net core, I need to share two type from the Host and the SubApp ===> HttpContext and RequestDelegate , but after assembly fallback to use the HostAlc's Assembly (the current HostAlc is also the default alc), for example: ConfigureLogging() on IHostBuilder the IHostBuilder and the parameter will also use from default alc, then it end up type IHostBuilder is not the type IHostBuilder , the reason this issue happen is because a few assembly from <FrameworkReference Include="Microsoft.AspNetCore.App"/> is being copy or not copy to the app directory. (this is uncommon from a clear plugin loader which the abstract will only exist in the HostAlc) anyway, after ConfigureLogging on host alc being called everything will call into the HostAlc even the CurrentContextualReflectionContext is Plugin Alc

the current solution

the current solution is make a full framework assembly manifest that force it being loaded to HostAlc

the future solution

  1. force to use AssemblyLoadContext.CurrentContextualReflectionContext to fallback again to the plugin alc , every time looking for a type look "CurrentContextualReflectionContext" fist
  2. AssemblyLoadContext.Load(AssemlbyName) and AssemblyLoadContext.LoadUmanagedDll(string) to handle the alc fallback by default from ContextualReflectionScope , so we need a new static property of Stack<AssemblyLoadContext> in ContextualReflectionScope itself or AssemblyLoadContext , and people can just call base.Load(assemlbyName) to do this complex thing eg.

    struct ContextualReflectionScope : IDisposeable
    {
    //  the AssemblyLoadContext.Load  and LoadUnmanagedDll "virtual" method need to access this property ,
    // when the stack is empty then raise assembly not fond
    //  this property in struct won't be copied right?
    internal static Stack<AssemblyLoadContext> Predecessor= new ();
    internal ContextualReflectionScope(AssemblyLoadContext? activating)
    {
             ///////  add to stack
            if(AssemblyLoadContext.CurrentContextualReflectionContext != null)
            {
                // this property is not be null for application, but when clr call default alc's
                // EnterContextualReflection()  this property is null  see the change in 3
                Predecessor = AssemblyLoadContext.CurrentContextualReflectionContext;
            }
    
            AssemblyLoadContext.SetCurrentContextualReflectionContext(activating);
            _activated = activating;
            _initialized = true;
    }
    
    Dispose()
    {
        if (_initialized)
                {
                    // Do not clear initialized. Always restore the _predecessor in Dispose()
                    // _initialized = false;
                   ////////  note of use .Pop() method
                    AssemblyLoadContext.SetCurrentContextualReflectionContext(Predecessor.Pop());
                }
    }
    }
  3. CurrentContextualReflectionContext will default to the AseemlbyLoadContext.Default , we are alway in a ContextualReflectionScope , so the fallback to default alc is not being affect (see 1) eg.
    using(AssemlbyLoadContext.Default.EnterContextualReflection())
    {
    Program.Main(args)
    }
  4. Assemlby? AssemblyLoadContext.EntryAssembly and optional constructor AssemblyLoadContext(string name, boo isCollectible, string entryAssemlbyPath,) this EntryAssembly property is the first Assemlby this alc load, the constructor will also have AssemblyDependencyResolver but I think maybe this contronctor is not nesssory, because using it we also need to add a SharedAssemblies property, maybe the best is to add a sub class .
  5. to support the Resolving event and avoid dead loop, the AssemblyLoadContext need to add a new private property _currentPredecessor with is a copy of ContextualReflectionScope.Predecessor when EnterContextualReflection() is called. and when .Load( .LoadUnmananedDll() being called each time it will .Pop() an alc , and when this event being fired, this Statc<AssemblyLoadContext> is empty, so it's safe to call AssemlbyLoadContext.Default.LoadFromAssemblyName()

by using Stack, will the perf too slow

for 99% user case , the stack will only contains 1 item, which is the default alc, so it will not fallbak many times for most user , unless there many child of alc

steveharter commented 4 years ago

Due to 5.0 schedule constraints, this is being moved to Future.

John0King commented 3 years ago

未标题-1

a image describe the problem .

John0King commented 3 years ago

@steveharter Is there any discussion in your team for this issue ?

if you use .net 5.0.100 sdk and use a nuget package newer version micrsoft.extension.* package , the dll from nuget will be copy and cause a assembly redirection, but it will not for a plugin alc.

default ALC (named $0)

plugin ALC (named $1)

you can see that in $1 , it fallback to $0 to find the needed assemly, but after it goes into $0 it can't go back, it will find $0.Configuration.Json, and in your plugin , you use $1.Configuration.Json , and this will not case assembly redirection.

ghost commented 3 years ago

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

Issue Details
## background In most time we need alc fallback when doing plugin dynamic load. When a assembly can not be find it should look to it's parent alc instead of just default alc. current issues like #13472 , #37231 also has the issue in alc fallback. after I look to those issue I also notice that people in .net team seems not “support” to use the `CurrentContextualReflectionContext` , and I do see a valuable usage when I create the [sub app for asp.net core](https://github.com/John0King/LazyMan.ModularLoader/blob/220c510fe868ae6ababe26b5b28ded067cd996d4/src/LazyMan.ModularLoader.AspNetCore/Infrastructure/PipelineCacheManager.cs#L40) ## the problem when create my library `sub app for asp.net core`, I need to share two type from the `Host` and the `SubApp` ===> `HttpContext` and `RequestDelegate` , but after assembly fallback to use the `HostAlc`'s Assembly (the current HostAlc is also the default alc), for example: `ConfigureLogging()` on `IHostBuilder` the `IHostBuilder` and the parameter will also use from `default alc`, then it end up type `IHostBuilder` is not the type `IHostBuilder` , the reason this issue happen is because a few assembly from `` is being copy or not copy to the app directory. (this is uncommon from a clear plugin loader which the `abstract` will only exist in the `HostAlc`) anyway, after `ConfigureLogging` on `host alc` being called `everything` will call into the `HostAlc` even the `CurrentContextualReflectionContext ` is `Plugin Alc` ## the current solution the current solution is make a full `framework assembly manifest` that force it being loaded to `HostAlc` ## the future solution 1. force to use `AssemblyLoadContext.CurrentContextualReflectionContext` to fallback again to the plugin alc , every time looking for a type look "CurrentContextualReflectionContext" fist 2. `AssemblyLoadContext.Load(AssemlbyName)` and `AssemblyLoadContext.LoadUmanagedDll(string)` to handle the alc fallback by default from `ContextualReflectionScope` , so we need a new static property of `Stack` in `ContextualReflectionScope` itself or `AssemblyLoadContext` , and people can just call `base.Load(assemlbyName)` to do this complex thing eg. ```c# struct ContextualReflectionScope : IDisposeable { // the AssemblyLoadContext.Load and LoadUnmanagedDll "virtual" method need to access this property , // when the stack is empty then raise assembly not fond // this property in struct won't be copied right? internal static Stack Predecessor= new (); internal ContextualReflectionScope(AssemblyLoadContext? activating) { /////// add to stack if(AssemblyLoadContext.CurrentContextualReflectionContext != null) { // this property is not be null for application, but when clr call default alc's // EnterContextualReflection() this property is null see the change in 3 Predecessor = AssemblyLoadContext.CurrentContextualReflectionContext; } AssemblyLoadContext.SetCurrentContextualReflectionContext(activating); _activated = activating; _initialized = true; } Dispose() { if (_initialized) { // Do not clear initialized. Always restore the _predecessor in Dispose() // _initialized = false; //////// note of use .Pop() method AssemblyLoadContext.SetCurrentContextualReflectionContext(Predecessor.Pop()); } } } ``` 3. `CurrentContextualReflectionContext` will default to the `AseemlbyLoadContext.Default` , we are alway in a `ContextualReflectionScope` , so the fallback to default alc is not being affect (see 1) eg. ```c# using(AssemlbyLoadContext.Default.EnterContextualReflection()) { Program.Main(args) } ``` 4. `Assemlby? AssemblyLoadContext.EntryAssembly` and optional constructor `AssemblyLoadContext(string name, boo isCollectible, string entryAssemlbyPath,)` this `EntryAssembly` property is the first Assemlby this alc load, the constructor will also have `AssemblyDependencyResolver` but I think maybe this contronctor is not nesssory, because using it we also need to add a `SharedAssemblies` property, maybe the best is to add a sub class . 5. to support the `Resolving` event and avoid dead loop, the `AssemblyLoadContext` need to add a new private property `_currentPredecessor` with is a copy of `ContextualReflectionScope.Predecessor` when `EnterContextualReflection()` is called. and when `.Load( .LoadUnmananedDll()` being called each time it will `.Pop()` an alc , and when this event being fired, this `Statc` is empty, so it's safe to call `AssemlbyLoadContext.Default.LoadFromAssemblyName()` ## by using Stack, will the perf too slow for 99% user case , the stack will only contains 1 item, which is the default alc, so it will not fallbak many times for most user , unless there many child of alc
Author: John0King
Assignees: -
Labels: `api-needs-work`, `area-AssemblyLoader-coreclr`, `area-System.Reflection`, `untriaged`
Milestone: Future
steveharter commented 3 years ago

@vitek-karas @jeffschwMSFT any thoughts on this proposal?

vitek-karas commented 3 years ago

It's pretty simple to implement the parent/child relationship in a custom ALC. In the Load method simply instead of returning null, call the parent's ALC's Load instead. And track the parent ALC from the child ALC (member field). If we were to do this in the framework, this is pretty much the extent of the change we would do.

I'm not opposed in principal to introduce this into the framework, I just don't see a big need for it. Multi-level nested ALCs are not that common, and if they're needed the added complexity is minimal even without the framework supporting this directly.

vitek-karas commented 3 years ago

that people in .net team seems not “support” to use the CurrentContextualReflectionContext

Can I ask where did you get this impression from? We added this feature in 3.0 timeframe, so not that long ago... so we definitely "support" it. It's true that it would be better if it was not needed - it's preferable to be explicit about load contexts in all operations which rely on them. The magical "current context" is problematic for many reasons, but it's a necessary workaround given the current state of the code both in the frameworks and in external libraries/apps.

John0King commented 3 years ago

@vitek-karas the problem of one AssemblyLoadContext is that you can not load different version of dependency when you implement a plugin architecture , for example: AutoMapper each major version have break change, and plugins may use different version, and this is not a problem in Php/Js and C++, because Php simply use file system and as long as the file is not conflict then it will work, and js/ts and use webpack to pack the node_model into 1 file (like static linking), Cpp can use static link library.

but in .net ,the only way to load different version of same library is to use different AssemblyLoadContext,
and now the major issue is to make some type across different ALC, due to the fact that .net do not have header file , and nuget will load every dependency into Alc , and there no clear BCL (Microsft.Extensions.* is not BCL only, and Microsoft.AspNetCore.* is from different Runtime Libraray) , there no way to implement a Public-oriented plugin architecture

and I already describe the issue , we obviously can not force people to not reference any Microsoft.Extension.* in a plugin ! and in fact any nuget package can have the same issue.

the solution I thought is to make CurrentContextualReflectionContext to behave like the AssemblyLoadContext.Default , so that will be multiple assembly redirection in different CurrentContextualReflectionContext.

eg.

current ALC --> parent ALVC -> ..... -> current ALC 

=======example ==========

class Foo1{  public Foo2 B{get;set;}  }
class Foo2{}

plugin$1 ----> Default ALC (Found)   --(looking for Foo2)->   plugin$1
[Foo2#1 ]  ···········  [Foo1, Foo2#0]

result:    in default alc , it treat Foo2#1 a different type than Foo2#0,
and in current plugin :  Foo1  is reference Foo2#1

in this example , Foo1 is the actual type that shared in different ALC, but it's dependency can be load different version

and this is the current type looking behaviors in .net core 3.x and .net 5

=======current .net 5 example  (bad one) ==========

class Foo1{  public Foo2 B{get;set;}  }
class Foo2{}

plugin$1 ----> Default ALC (Found)   --(looking for Foo2)->  DefaultALC
[Foo2#1 ]  ···········  [Foo1, Foo2#0]       ············   

result  :  plugin$1 use Foo2#1,  and Foo1 reference Foo2#0 , so Foo2#1 is not Foo2#0

but as long as we can solve this issue , any thought is welcome, maybe the simplest way is CurrentContextualReflectionContext.SharedAssmelies.AddRange( DependencyScaner.ScanDependencies( typeof(MyType).Assmebly ) )

vitek-karas commented 3 years ago

The current assumption is that plugins have a clearly defined interface between them and the host. And that plugins don't talk to each other. It's technically possible to do all those things as well, but it gets complicated.

In this sense I don't see how this is different from the "Static linking" approach (other than it's obviously easier to use). Basically each plugin needs to carry all of its dependencies with it, EXCEPT the dependencies which the host provides (this is basically part of the interface between the host and the plugin). Each plugin gets isolated in its own ALC - all of the dependencies which it carried with it are also isolated.

This enforces the contract with the host, where only the defined interface is shared (no plugin should carry the assemblies from that interface).

Again similar to static-linking, statically linked C++ library will have a clearly defined interface (the ABI it exposes) and a set of external dependencies it requires (anything it loads as dynamic library and doesn't carry with it statically linked in). The only difference between C++ and .NET is that it's easier to do versioning of the host/plugin interfaces in C++ in some cases (since there's no type identity, if the struct matches it will work). On the .NET side if you need to version the host/plugin interface it will probably mean a need for multiple versions of the interface assemblies - but it depends on the actual interface and design.

I agree that this is a change from .NET Framework, there it was kind of a "load whatever you want" which sometimes worked (and if it didn't one got stuck in binding redirect hell). In .NET Core the isolation is VERY explicit and so it typically enforces a clearly defined contracts between the components involved (plugins, host).

John0King commented 3 years ago

@vitek-karas

The current assumption is that plugins have a clearly defined interface between them and the host.

the truth is we almost can't in asp.net core. for an asp.net core plugin , it need to share a few type IApplicationBuilder and HttpContext , but the host also has many dependency , and one is Microsft.Extension.Configuration.Abstraction ,
first, it's a nuget package, and second , it not a key assembly of plugin , but the host may carry this assmebly and may not. if you just use Microsoft.AspNetCore.App , then the host app not have this type , and if you use any dependent-ed nuget pakcage, the host will have this type as well , and it's also the same with plugin.

if your plugin have this assembly and your host have this assembly as well , then the problem happened.

  1. your plugin ALC will load your version of Microsft.Extension.Configuration.Abstraction short name as MECA
  2. the host have MECA as well.
  3. any MECA type you write in your plugin is the plugin one
  4. any type look happend in host type , will use the host one.

the result is that , the fallback to Default ALC became a assembly black hole, and any assembly fall in and will can not get out.

so that means , the plugin must not use any assembly that host use. and that;s mean , if the host use 100 assembly , then any plugin must not use those 100 assembly as well, even the plugin architecture shared is just 3 assemblies !!!!!

davidfowl commented 3 years ago

That's a constraint of the plug-in system. The host can't "pin" dependencies or plugins can't load different versions of those assemblies. Especially if types are exchanged between the plug-in and the host, this "restriction" makes sense.

Now if you're saying that those types aren't exchanged and are totally self-contained in each plug-in then I agree there should be a way to make that work. I think it's possible today but not at all obvious (likely you need the host's deps file to not to include the dependencies that you want loads less by plug-in contexts).

vitek-karas commented 3 years ago

That's why I described that the list of assemblies which the host wants/needs to share with all plugins is effectively part of the contract. For example if I have an Interface.dll which defines the interface between the host and the plugin, then the full dependency closure of this assembly should be provided by the host - so that all plugins and host agree on those types.

This effectively encourages the interface to be minimalistic, so that the closure is as small as possible. Again not any different from the C++ static linking style - if the native library has lot of dynamic dependencies (which are used in the interface) then it will have the same problem.

The one unfortunate thing today is that the host can't declare/enforce which assemblies should be shared. But the custom ALC which loads the plugins can do this if it wants to (and that ALC is effectively part of the host). Currently the plugins should ideally know this up-front and build accordingly. Any dependencies which are to be shared should use Private=false which will avoid putting those assemblies into the output of the plugin.

John0King commented 3 years ago

@vitek-karas @davidfowl @steveharter
1st, let me apologies for my bad englisth. Here is some thing I'm thinking for a common used plugin/module framework. https://github.com/John0King/LazyMan.ModularLoader/issues/2.

from a script language, the isolation is pretty simple, all the isolation is done by the file system. for example

//ts
import { Foo } from "Lib1"
import { Foo as Foo2} from "Lib1-copy"
// php

requre_once('../host-shared.php')
// do something with plugin host type

requir_once('./local-shared.php')

// do something with local type

I dont's now about C/C++, but I think it may be the same , isolated by file, at latest it can use static linking with plugin, and dynamic linking with plugin's host app (but I think it may work , but the method and type may not be fully isolated )

I'm wonder that can we using file system to do the isolation ? after all the assemblies in a plugin is identifiable, so why we need to do type redirection in runtime instead of build time ? previously two dll have same namspace and type can use alias (on <reference />) add call using alias:namespace.type, it sound like this is our file system isolation.

vitek-karas commented 3 years ago

Technically C# can do this: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/extern-alias .NET Core doesn't support this out of the box though - you would have to implement custom ALC and adapt the resolution accordingly, but it's technically possible.

But honestly that's not the main problem. The problem is if the explicitly specified "plugin" (by file path) references some other assembly. How is that reference resolved. I actually don't know how ts or php do this - if it's also filepath based, then it's pretty simple to do the same in .NET.

But then the main problem will be sharing of types - ts is basically javascript and so it relies on just shapes of objects (there are really no types). And so if the host's view and plugin's view of a given "type" are similar enough, they will be able to exchange instances of that "type". I honestly don't know PHP well enough to comment on that. .NET on the other hand is strongly typed - each instance has a strictly defined type, and so to exchange instances (without usage of reflection) both host and plugin must share the exact same type definition.

There are ways around this even in .NET, but they're not "nice" - you could use dynamic, or you could build something similar with DispatchProxy.

John0King commented 3 years ago

one problem is that now .net release 2 set of runtime + 2 more deploy mode. runtime: 1) Server Runtime (.net BCL core + asp.net core workload) 2) DesktopRuntime (.net BCL core + Windows workload) 3) AndroidRuntime (.net BCL core + Android workload) 4) ~~IOSRuntime (.Net BCL core + IOS workload)~

deploy mode: 1) Runtime installed + App 2) Self-containd 3) AOT (all static linking , but no dynamic link)

and with .net 6 , there's no BCL assemblies in workload can be known before you deploy it ( "System.Drawing.Common" is an example), and the "framework.extensions.*" always be have an issue "does it already include in host app ? or include those dll in plugin by accidentally reference one of the nuget package"

and another problem is Share the type that need isolation :

Host App -----> a stand alone app as plugin (it's a host of it's won plugin) ----> plugin of plugin

App.exe/dll (+ PluginLoader.dll@v1 ) ----> (standApp.dll + PluginLoader.dll@v2) ---> plugin.dll

in this example, as the developer , we know the standApp.dll need share Plugin.Loader.dll from App, so it can not include the PluginLoader.dll@v2 otherwise it can not provide the type that the host App.exe can recognition, and if it not provide this PluginLoader.dll@v2 , then the standApp.dll may won't work because it need this new version.

I know this may be bad example , but it describe the problem: we know there two type look exactly the same, but we know they are not the same one , but the C#'s type system can not .

and

ts is basically javascript and so it relies on just shapes of objects (there are really no types). And so if the host's view and plugin's view of a given "type" are similar enough, they will be able to exchange instances of that "type"

but in my example , there no type exchange, it just ts/js syntax can recognize those are two different type.


//ts
import { Module } from "module-loader" // package
import { Module as M2} from "./module-loader/index" //locale version

export class MyModule extends Module{
         load(){
                 let loader = new ModuleLoader();
                 loader.load(new XModuleFromSomePlugin())
        }
}

//
export class ModuleLoader{
     load(m M2){
          if(m instance of M2){
                // real check for type , not the shape of object
                m.load();
          }
           else{
                throw new Error('you can not load this, because it's not a module')
           }
    }
}

I conclude to follow conclusion: 1) with nuget system , we lost more control on file system. 2) there no syntax in C#, can be use to help us from pick the right type from right assembly. 3) no core BCL , eg. ( std lib for C++ , es api for javascript , php script env for PHP ) 4) C#'s Assmbly -> module -> type , not make any thing better

The End:

I hope all of you the smart guy , can start create a module/plugin system, can find ways to make this better. As a .Net developer , don't you jealous PHP's WordPress can have such nice plugin system ?