dotnet / runtime

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

[API Proposal]: Add TypeResolve event to AssemblyLoadContext #56510

Open AraHaan opened 3 years ago

AraHaan commented 3 years ago

Background and motivation

As mentioned here https://github.com/dotnet/winforms/issues/5368 AssemblyLoadContext lacks an event where you can subscribe too when a type resolution fails (Like for example you try to load an assembly in AssemblyLoadContext that uses a type in System.Windows.Forms.dll that does not exist anymore on .NET 5 and you then need to reroute it to load a "fake" version of those removed types located in another assembly which actually wraps the types that they were replaced with so those old and possibly unmaintained assemblies would work like usual).

@rickbrew could benefit from this in Paint.NET if this was possible in AssemblyLoadContext currently.

API Proposal

namespace System.Reflection
{
     public class AssemblyLoadContext     {
         // Unlike AppDomain.TypeResolve have this also trigger
         // the event when trying to resolve types from static assemblies
         // (Like System.Windows.Forms.dll) that no longer exists so one can
         // resolve fake wrapper copies to make old code work.
+        public event ResolveEventHandler? TypeResolve;
     }
}

API Usage

namespace LoadContextExample;

using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;

/// <inheritdoc/>
public class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver resolver;

    /// <summary>
    /// Initializes a new instance of the <see cref="PluginLoadContext"/> class.
    /// </summary>
    /// <param name="name">The name of the load context.</param>
    /// <param name="pluginPath">The path the the plugins.</param>
    public PluginLoadContext(string name, string pluginPath)
        : base(name, true)
    {
        this.resolver = new(pluginPath);
        this.TypeResolve += new ResolveEventHandler(HandleTypeResolve);
    }

    /// <inheritdoc/>
    protected override Assembly? Load(AssemblyName assemblyName)
    {
        var isLoadedToDefaultContext = new Func<string, bool>(static name =>
        {
            return Default.Assemblies.Any(assembly => assembly.FullName is not null && assembly.FullName.Equals(name, StringComparison.Ordinal));
        });
        var getFromDefaultContext = new Func<string, Assembly?>(static name =>
        {
            return Default.Assemblies.FirstOrDefault(assembly => assembly.FullName is not null && assembly.FullName.Equals(name, StringComparison.Ordinal));
        });
        if (isLoadedToDefaultContext(assemblyName.FullName))
        {
            // return the assembly from the default context instead of reloading it (is same assembly and version).
            return getFromDefaultContext(assemblyName.FullName);
        }

        var assemblyPath = this.resolver.ResolveAssemblyToPath(assemblyName);
        return (assemblyPath is not null, !File.Exists($"{AppContext.BaseDirectory}{assemblyName.Name}.dll")) switch
        {
            (false, true) => null,
            (false, false) => this.LoadFromAssemblyPath($"{AppContext.BaseDirectory}{assemblyName.Name}.dll"),
            _ => this.LoadFromAssemblyPath(assemblyPath),
        };
    }

    /// <inheritdoc/>
    protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
    {
        var libraryPath = this.resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
        return (libraryPath is not null, !File.Exists($"{AppContext.BaseDirectory}{unmanagedDllName}.dll")) switch
        {
            (false, true) => IntPtr.Zero,
            (false, false) => this.LoadUnmanagedDllFromPath($"{AppContext.BaseDirectory}{unmanagedDllName}.dll"),
            _ => this.LoadUnmanagedDllFromPath(libraryPath),
        };
    }

    static Assembly HandleTypeResolve(object sender, ResolveEventArgs args)
    {
        // In this case they return the entry assembly so the types would
        // be loaded from that if they was removed from BCL code (Example was the
        // removal of old controls in winforms back in .NET Core 3.1).
        // in this case those types in the entry assembly actually are stubs that use
        // the new types that replaced the old and removed ones.
        // from here even if an assembly tried to find a type using a full name (that type
        // later got removed), it would then instead be found and resolved from the entry
        // assembly instead.
        return Assembly.GetEntryAssembly();
    }
}

Risks

This would probably need to be considered for addition in .NET 5.0.x and 6.0 so Paint.NET can use it.

ghost commented 3 years ago

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

Issue Details
### Background and motivation As mentioned here https://github.com/dotnet/winforms/issues/5368 AssemblyLoadContext lacks an event where you can subscribe too when a type resolution fails (Like for example you try to load an assembly in AssemblyLoadContext that uses a type in ``System.Windows.Forms.dll`` that does not exist anymore on .NET 5 and you then need to reroute it to load a "fake" version of those removed types located in another assembly which actually wraps the types that they were replaced with so those old and possibly unmaintained assemblies would work like usual). @rickbrew *could* benefit from this in Paint.NET if this was possible in AssemblyLoadContext currently. ### API Proposal ```C# namespace System.Reflection { public class AssemblyLoadContext { // Unlike AppDomain.TypeResolve have this also trigger // the event when trying to resolve types from static assemblies // (Like System.Windows.Forms.dll) that no longer exists so one can // resolve fake wrapper copies to make old code work. + public event ResolveEventHandler? TypeResolve; } } ``` ### API Usage ```C# namespace LoadContextExample; /// public class PluginLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver resolver; /// /// Initializes a new instance of the class. /// /// The name of the load context. /// The path the the plugins. public PluginLoadContext(string name, string pluginPath) : base(name, true) { this.resolver = new(pluginPath); this.TypeResolve += new ResolveEventHandler(HandleTypeResolve); } /// protected override Assembly? Load(AssemblyName assemblyName) { var isLoadedToDefaultContext = new Func(static name => { return Default.Assemblies.Any(assembly => assembly.FullName is not null && assembly.FullName.Equals(name, StringComparison.Ordinal)); }); var getFromDefaultContext = new Func(static name => { return Default.Assemblies.FirstOrDefault(assembly => assembly.FullName is not null && assembly.FullName.Equals(name, StringComparison.Ordinal)); }); if (isLoadedToDefaultContext(assemblyName.FullName)) { // return the assembly from the default context instead of reloading it (is same assembly and version). return getFromDefaultContext(assemblyName.FullName); } var assemblyPath = this.resolver.ResolveAssemblyToPath(assemblyName); return (assemblyPath is not null, !File.Exists($"{AppContext.BaseDirectory}{assemblyName.Name}.dll")) switch { (false, true) => null, (false, false) => this.LoadFromAssemblyPath($"{AppContext.BaseDirectory}{assemblyName.Name}.dll"), _ => this.LoadFromAssemblyPath(assemblyPath), }; } /// protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) { var libraryPath = this.resolver.ResolveUnmanagedDllToPath(unmanagedDllName); return libraryPath is not null ? this.LoadUnmanagedDllFromPath(libraryPath) : !File.Exists($"{AppContext.BaseDirectory}{unmanagedDllName}.dll") ? IntPtr.Zero : this.LoadUnmanagedDllFromPath($"{AppContext.BaseDirectory}{unmanagedDllName}.dll"); } static Assembly HandleTypeResolve(object sender, ResolveEventArgs args) { // In this case they return the entry assembly so the types would // be loaded from that if they was removed from BCL code (Example was the // removal of old controls in winforms back in .NET Core 3.1). // in this case those types in the entry assembly actually are stubs that use // the new types that replaced the old and removed ones. // from here even if an assembly tried to find a type using a full name (that type // later got removed), it would then instead be found and resolved from the entry // assembly instead. return Assembly.GetEntryAssembly(); } } ``` ### Risks This would probably need to be considered for addition in .NET 5.0.x and 6.0 so Paint.NET can use it.
Author: AraHaan
Assignees: -
Labels: `api-suggestion`, `area-AssemblyLoader-coreclr`, `untriaged`
Milestone: -
jkotas commented 3 years ago

Like for example you try to load an assembly in AssemblyLoadContext that uses a type in System.Windows.Forms.dll that does not exist anymore on .NET 5

Why is it not an option to recompile these assemblies for .NET 5? It would be highly preferable to trying to patch things up at runtime.

.NET 5.0.x and 6.0

Not going to happen - too late to consider.

AraHaan commented 3 years ago

Like for example you try to load an assembly in AssemblyLoadContext that uses a type in System.Windows.Forms.dll that does not exist anymore on .NET 5

Why is it not an option to recompile these assemblies for .NET 5? It would be highly preferable to trying to patch things up at runtime.

.NET 5.0.x and 6.0

Not going to happen - too late to consider.

Because not always are these assemblies able to be recompiled (in terms of plugins for Paint.NET some of them are covered under their own copyrights), some are unmaintained, and some are outright not open source + has a copyright preventing one from decompiling and recompiling it for .NET 5+ (well in a way that is legal / does not violate it's EULA that is) + unmaintained.

AraHaan commented 3 years ago

Could this be possibly looked into for .NET 7?

jkotas commented 3 years ago

I doubt that we will ever add this feature.

It has very complex interactions with many parts of the system, the motivating scenario is very niche, and the feature does not address it completely - there can be also missing methods, missing interfaces, and behavior changes to patch. Patching the IL is the only way reasonable way to shim for all these incompatibilities.

AraHaan commented 3 years ago

True, but patching the IL is sometimes impossible if R2R is involved without needing to strip R2R first.

As for missing members, one can catch the MemberNotFoundException and then read the message for the missing "type", then load an assembly that stubs those missing members (assuming they are not static ones) from another assembly that adds them back using extension methods, and then retry the call. However doing that properly is almost impossible to do as well on that one so I agree with that part.

rickbrew commented 2 years ago

@AraHaan, collectible AssemblyLoadContexts ignore R2R data, https://github.com/dotnet/runtime/blob/main/docs/design/features/unloadability.md (see "Unsupported Scenarios")

wasabii commented 1 year ago

I want to add something onto this. We (the IKVM project) make great use of AppDomain.TypeResolve to generate types in dynamic assemblies on the fly as required by the runtime. While we don't do things like patch and rename types: we generate them exactly as requested, it is worrying that the API hasn't been moved from AppDomain. It speaks to the existing functionality maybe not being something that folks are committed to for the long term.

The ability to do this is pretty core to IKVM.

I would like to see TypeResolve moved off AppDomain some day. It would make me a bit more confident it was something that was going to stick around.

jkotas commented 1 year ago

The events (including TypeResolve event) on AppDomain type are not going anywhere. We have stopped introducing their duplicates with better names when the AppDomain type was re-introduced with .NET Standard 2.0.

For example, see the discussion about UnhandledException at https://github.com/dotnet/runtime/issues/16468#issuecomment-251832080 .

If you would like to see the earlier decision re-evaluated and propose duplicates for events on AppDomain type somewhere else, it would be best to start a new discussion issue about it.