adllm Insights logo adllm Insights logo

Plugin Isolation with AssemblyLoadContext: Managing Native Dependencies in .NET

Published on by The adllm Team. Last modified: . Tags: .NET AssemblyLoadContext Plugins Native Libraries Dependency Management C# Isolation

Modern .NET applications increasingly rely on plugin architectures to extend functionality, enhance modularity, and allow for independent updates. A cornerstone of a robust plugin system is effective dependency isolation: ensuring that a plugin’s dependencies don’t conflict with the host application or other plugins. While AssemblyLoadContext (ALC) in .NET (Core and later versions) provides powerful mechanisms for isolating managed assemblies, managing native library dependencies introduces unique challenges, especially when plugins need to share common native resources.

This article delves into leveraging AssemblyLoadContext to achieve strong dependency isolation for plugins, with a particular focus on strategies for resolving and managing native libraries. We’ll explore how to build custom ALCs, use AssemblyDependencyResolver for streamlined dependency loading, and implement techniques for both isolating and controllably sharing native libraries. You can find more details on general plugin creation in the .NET documentation on creating applications with plugins.

The Challenge: Isolated Plugins with Native Code

Plugins often come with their own set of managed dependencies (DLLs). If Plugin A requires Newtonsoft.Json v12 and Plugin B needs v13, loading them into the same default application context would lead to conflicts. AssemblyLoadContext solves this by allowing each plugin to load its assemblies into a separate context.

However, the complexity increases when plugins use native libraries (e.g., .dll on Windows, .so on Linux, .dylib on macOS) via P/Invoke:

  1. Native Library Version Conflicts: Similar to managed dependencies, different plugins might require different versions of the same native library.
  2. Process-Wide Loading: Unlike managed assemblies scoped to an ALC, native libraries are typically loaded at the process level by the operating system. This means care must be taken to avoid symbol collisions or unintended side-effects if multiple plugins load different versions of a native library not designed for side-by-side execution.
  3. Shared Native Dependencies: Sometimes, multiple plugins or the host and plugins must share a single instance of a native library (e.g., a hardware SDK, a common cryptography library).
  4. Resolution Paths: Ensuring that each plugin’s ALC can correctly locate its specific native dependencies, which might be platform-specific, is crucial. This is especially true when considering how .NET finds native dependencies.

Core Concepts: AssemblyLoadContext and AssemblyDependencyResolver

At its heart, AssemblyLoadContext is the .NET runtime’s service for locating, loading, and caching managed assemblies. By creating custom ALC instances, developers can isolate loaded assemblies.

Key characteristics include:

  • Isolation: Each ALC instance provides a unique scope for assembly instances. This means AssemblyA version 1.0 in PluginAlc1 is distinct from AssemblyA version 2.0 in PluginAlc2.
  • Customizable Loading Logic: Developers can override methods in a custom ALC to control how assemblies and their dependencies are found and loaded.
  • Native Library Loading: ALCs provide the LoadUnmanagedDll method, which is the primary hook for controlling how native libraries are loaded for P/Invokes originating from assemblies within that ALC.

Introduced in .NET Core 3.0, AssemblyDependencyResolver simplifies dependency resolution for plugins. Initialized with the path to a plugin’s main assembly, it uses the plugin’s .deps.json file (generated during build) to find paths for both managed assemblies and native libraries.

Building a Custom AssemblyLoadContext for Plugins

Let’s construct a PluginLoadContext to isolate a plugin’s dependencies. This context will be responsible for loading the plugin’s assemblies and its native dependencies.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// In your Host application or a shared infrastructure library
using System;
using System.IO; // Required for Path
using System.Reflection;
using System.Runtime.Loader;

public class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;
    private readonly string _pluginMainAssemblyPath;

    // Make the ALC collectible if you need to unload plugins
    public PluginLoadContext(string pluginMainAssemblyPath, 
                             bool isCollectible = false)
        : base(Path.GetFileNameWithoutExtension(pluginMainAssemblyPath) ?? 
               "PluginCtx", // Give a name to the context for debugging
               isCollectible)
    {
        _pluginMainAssemblyPath = pluginMainAssemblyPath;
        // The resolver needs the path to the main plugin assembly
        _resolver = new AssemblyDependencyResolver(pluginMainAssemblyPath);
    }

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        if (assemblyPath != null)
        {
            // Load the assembly into this ALC
            Console.WriteLine($"ALC '{Name}': Loading managed " +
                              $"'{assemblyName.Name}'");
            Console.WriteLine($"           from '{assemblyPath}'.");
            return LoadFromAssemblyPath(assemblyPath);
        }
        // If not found by resolver, defer to default ALC (e.g., for shared
        // framework assemblies or host-provided shared types)
        Console.WriteLine($"ALC '{Name}': Deferring load of managed " +
                          $"'{assemblyName.Name}'.");
        return null;
    }

    protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
    {
        string? libraryPath = 
            _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
        if (libraryPath != null)
        {
            // Load the native library. The OS handles actual loading,
            // this call associates P/Invoke resolution with this ALC.
            Console.WriteLine($"ALC '{Name}': Loading native " +
                              $"'{unmanagedDllName}'");
            Console.WriteLine($"           from '{libraryPath}'.");
            return LoadUnmanagedDllFromPath(libraryPath);
        }
        // If not resolved by our plugin's dependencies, defer to default
        // OS search paths or allow other ALCs to handle it.
        // Returning IntPtr.Zero tells the runtime to use its default policy.
        Console.WriteLine($"ALC '{Name}': Deferring load of native " +
                          $"'{unmanagedDllName}'.");
        return IntPtr.Zero;
    }
}

Explanation:

  1. Constructor:
    • Takes the full path to the plugin’s main DLL (pluginMainAssemblyPath).
    • Assigns a name to the ALC using base(name, isCollectible), which is helpful for debugging.
    • Optionally allows the ALC to be isCollectible for unloading.
    • Initializes AssemblyDependencyResolver with the plugin’s path.
  2. Load(AssemblyName assemblyName) Override:
    • Uses _resolver.ResolveAssemblyToPath to find managed dependencies listed in the plugin’s .deps.json.
    • If found, LoadFromAssemblyPath loads it into the current PluginLoadContext.
    • Returning null allows the runtime to try resolving the assembly in AssemblyLoadContext.Default or other contexts, crucial for shared framework assemblies or host-provided shared contract assemblies.
  3. LoadUnmanagedDll(string unmanagedDllName) Override:
    • Uses _resolver.ResolveUnmanagedDllToPath to find native libraries associated with the plugin (also via .deps.json).
    • If found, LoadUnmanagedDllFromPath attempts to load the native library. The OS typically loads native libraries process-wide, but this call associates the P/Invoke resolution with this ALC.
    • Returning IntPtr.Zero instructs the runtime to use its default native library search mechanism. This is important if the native library is expected to be already loaded by the host or found via system paths.

Loading a Plugin

Here’s how you might load a plugin into its own PluginLoadContext:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// In your Host application
public Assembly LoadPlugin(string pluginDllPath)
{
    // Ensure pluginDllPath is an absolute path
    string fullPluginPath = Path.GetFullPath(pluginDllPath);
    
    // Create a new context for each plugin for isolation
    // Set isCollectible to true if unloading is required
    var loadContext = new PluginLoadContext(fullPluginPath, 
                                            isCollectible: true);
    
    // Load the main plugin assembly by its name
    // Assumes plugin DLL name matches assembly name
    var assemblyName = 
        new AssemblyName(Path.GetFileNameWithoutExtension(fullPluginPath));
    Console.WriteLine($"Host: Loading plugin '{assemblyName}' " +
                      "via its PluginLoadContext.");
    return loadContext.LoadFromAssemblyName(assemblyName);
}

// Example usage:
// string pathToPlugin = "Plugins/MyPlugin/MyPlugin.dll";
// Assembly pluginAssembly = LoadPlugin(pathToPlugin);
// Now use reflection to find and instantiate types from pluginAssembly

This approach creates a new PluginLoadContext for each plugin, ensuring their dependencies are isolated.

Handling Shared Native Libraries

A common scenario is when a native library must be shared across the host and multiple plugins, or among plugins themselves (e.g., a logging library with native components, a graphics driver interface). Naively loading it multiple times can lead to errors or unexpected behavior if the library isn’t designed for it.

Strategy 1: Host-Provided Shared Native Library

If the host application is responsible for providing and initializing a shared native library, plugins should bind to this already loaded instance.

  1. Host Loads the Native Library: The host explicitly loads the shared native library using NativeLibrary.Load("path/to/shared_native.dll") during its startup. The NativeLibrary class offers methods for this.
  2. Plugin ALC Defers Loading: The plugin’s custom PluginLoadContext (specifically its LoadUnmanagedDll override) should defer the loading of this specific shared native library to the runtime’s default mechanism.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Inside PluginLoadContext.cs (modified LoadUnmanagedDll)
// Ensure this name matches how P/Invoke declares it (often without extension)
private const string SharedNativeLibraryName = "shared_native_lib"; 

protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
    // Normalize the name for comparison if needed (e.g., remove .dll/.so)
    string baseName = Path.GetFileNameWithoutExtension(unmanagedDllName);

    if (baseName.Equals(SharedNativeLibraryName, 
                        StringComparison.OrdinalIgnoreCase))
    {
        Console.WriteLine($"ALC '{Name}': Deferring load of shared " +
                          $"'{unmanagedDllName}'.");
        Console.WriteLine("           Host should provide it.");
        return IntPtr.Zero; // Defer to default/OS loading
    }

    string? libraryPath = 
        _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
    if (libraryPath != null)
    {
        Console.WriteLine($"ALC '{Name}': Loading private " +
                          $"'{unmanagedDllName}' from '{libraryPath}'.");
        return LoadUnmanagedDllFromPath(libraryPath);
    }

    Console.WriteLine($"ALC '{Name}': Could not resolve " +
                      $"'{unmanagedDllName}', deferring load.");
    return IntPtr.Zero;
}

By returning IntPtr.Zero for the SharedNativeLibraryName, the runtime’s default P/Invoke resolution mechanism takes over. If the host has already loaded this library into the process, the OS typically provides a handle to the existing instance.

Strategy 2: First Plugin Loads, Others Share (More Complex)

This is trickier and generally less robust. If the native library isn’t explicitly loaded by the host, the first plugin ALC to request it might load it. Subsequent ALCs requesting the same library (by the same resolved path) would typically get the handle to the already loaded instance from the OS. However, managing its lifecycle (especially unloading) becomes complicated.

Key Consideration for Native Libraries: The operating system loader is the ultimate authority for loading native libraries. AssemblyLoadContext.LoadUnmanagedDll and NativeLibrary.Load are .NET’s ways to interact with this OS-level mechanism. An ALC’s LoadUnmanagedDll primarily resolves a library name to a path. The OS then handles the actual loading from that path. If a library from a specific canonical path is already loaded in the process, the OS usually returns a handle to the existing module.

Fine-Grained Control with NativeLibrary.SetDllImportResolver

For even more precise control over how P/Invoke calls resolve native libraries within a specific assembly, .NET provides NativeLibrary.SetDllImportResolver. This method allows you to register a callback that the runtime invokes when a DllImport cannot be satisfied for a given assembly. The P/Invoke marshalling system checks for a registered DllImportResolver for an assembly before falling back to the AssemblyLoadContext.LoadUnmanagedDll method of the ALC that loaded the assembly. See the .NET documentation on P/Invoke source generation and resolvers for more context.

This is particularly useful if a plugin needs to:

  • Load native libraries from very specific, dynamically determined paths not covered by .deps.json.
  • Implement complex fallback logic for native library resolution.
  • Handle platform-specific naming conventions directly.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// Inside your plugin's initialization code (e.g., in a startup method)
using System.Reflection;
using System.Runtime.InteropServices; // For NativeLibrary, OSPlatform, etc.

public class PluginActivator // Or any suitable class in your plugin
{
    public static void InitializeNativeResolution()
    {
        NativeLibrary.SetDllImportResolver(Assembly.GetExecutingAssembly(), 
            HandleNativeLibraryResolve);
    }

    private static IntPtr HandleNativeLibraryResolve(string libraryName, 
        Assembly assembly, DllImportSearchPath? searchPath)
    {
        string resolvedPath = string.Empty;
        if (libraryName == "MySpecialNativeLib")
        {
            string pluginDir = Path.GetDirectoryName(assembly.Location)!;
            string nativeSubDir = "native_libs"; // Example subdirectory

            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                resolvedPath = Path.Combine(pluginDir, nativeSubDir, 
                    "win-x64", $"{libraryName}.dll");
            }
            else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
            {
                resolvedPath = Path.Combine(pluginDir, nativeSubDir,
                    "linux-x64", $"lib{libraryName}.so");
            }
            // Add similar blocks for macOS (.dylib) and other architectures

            if (!string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath))
            {
                Console.WriteLine("Plugin Resolver: Loading " +
                                  $"'{libraryName}' from '{resolvedPath}'.");
                return NativeLibrary.Load(resolvedPath);
            }
            Console.WriteLine("Plugin Resolver: Could not find " +
                              $"'{libraryName}' at '{resolvedPath}'.");
        }
        // Fallback to default resolution mechanism (ALC, OS paths)
        return IntPtr.Zero; 
    }
}

// Call PluginActivator.InitializeNativeResolution() when plugin loads.

When to Use LoadUnmanagedDll vs. SetDllImportResolver:

  • AssemblyLoadContext.LoadUnmanagedDll: Broader control for all P/Invokes from assemblies within that ALC. Good for general plugin dependency resolution via .deps.json.
  • NativeLibrary.SetDllImportResolver: Finer-grained, assembly-specific control. Useful for complex scenarios or when a plugin needs to manage its native dependencies in a very particular way, bypassing or augmenting the ALC’s default behavior for specific libraries.

Sharing Types Between Host and Plugins

When plugins are isolated in their own ALCs, a Type from the host (e.g., IPluginService in Host.exe) is different from the “same” Type if the plugin loads its own copy of Host.Interfaces.dll. This leads to InvalidCastException.

Solution: Define shared interfaces and types in a separate contracts assembly (e.g., Plugin.Contracts.dll).

  1. The host references this contracts assembly.
  2. Plugins reference this contracts assembly.
  3. Crucially, configure the plugin’s ALC to prefer loading these shared types from the host’s ALC (AssemblyLoadContext.Default).

The McMaster.NETCore.Plugins library (available on GitHub) offers an elegant way to handle this with its sharedTypes parameter. If implementing manually, your PluginLoadContext.Load method would need logic similar to the following (simplified):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Inside PluginLoadContext.cs - modified Load method (conceptual)
protected override Assembly? Load(AssemblyName assemblyName)
{
    var sharedAssemblyNames = new HashSet<string> 
    { 
        "Plugin.Contracts", // Name of your contracts assembly
        "Microsoft.Extensions.Logging.Abstractions" // Example shared framework lib
    };

    if (sharedAssemblyNames.Contains(assemblyName.Name!))
    {
        try
        {
            // Attempt to load from default context first
            var defaultAssembly = 
                AssemblyLoadContext.Default.LoadFromAssemblyName(assemblyName);
            if (defaultAssembly != null) 
            {
                Console.WriteLine($"ALC '{Name}': Loaded shared " +
                                  $"'{assemblyName.Name}' from Default ALC.");
                return defaultAssembly;
            }
        }
        catch (FileNotFoundException)
        { 
            // Not in default, fall through to plugin-specific loading
            Console.WriteLine($"ALC '{Name}': Shared '{assemblyName.Name}' " +
                              "not in Default ALC, trying resolver.");
        }
    }

    // Standard resolver logic
    string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
    if (assemblyPath != null)
    {
        Console.WriteLine($"ALC '{Name}': Loading managed " +
                          $"'{assemblyName.Name}' from '{assemblyPath}'.");
        return LoadFromAssemblyPath(assemblyPath);
    }
    return null;
}

Alternatively, prevent the shared assembly (e.g., Plugin.Contracts.dll) from being copied to the plugin’s output directory. This forces the ALC’s _resolver to fail for that assembly, and if the Load override then returns null, the runtime will try the default context. Use <Private>false</Private> or <Publish>false</Publish> metadata on the ProjectReference in the plugin’s .csproj file.

1
2
3
4
5
6
7
8
<!-- In Plugin.csproj -->
<ItemGroup>
  <ProjectReference Include="..\Plugin.Contracts\Plugin.Contracts.csproj">
    <Private>false</Private>
    <!-- ExcludeAssets can also prevent copying runtime assets -->
    <ExcludeAssets>runtime</ExcludeAssets> 
  </ProjectReference>
</ItemGroup>

Unloading Plugins and Native Libraries

For scenarios requiring plugins to be updated or removed without restarting the host, ALCs can be made “collectible.” This is detailed in the .NET guide on assembly unloadability.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Creating a collectible ALC was shown in PluginLoadContext constructor:
// public PluginLoadContext(string pluginPath, bool isCollectible = false)
// Pass isCollectible: true when creating PluginLoadContext instance.

// To unload a collectible ALC (assuming 'loadContext' is a reference to it):
// 1. Ensure all references to types/assemblies from this context are removed.
//    This includes instances, event handlers, static references, DI container
//    registrations (e.g., Autofac's `BeginLoadContextLifetimeScope`).
// 2. Call Unload()
// loadContext.Unload(); 
// 3. The ALC will be fully collected when GC runs and WeakReferences to it die.
//    For testing, you might force GC:
// GC.Collect();
// GC.WaitForPendingFinalizers();

Caveats with Native Libraries and Unloading:

  • Default Behavior: Native libraries loaded via P/Invoke (even through LoadUnmanagedDllFromPath) are generally not automatically unloaded when the ALC is unloaded. This is by design, as many native libraries are not safe to unload and reload repeatedly, potentially causing crashes or resource leaks.
  • Explicit Unloading: If a native library is designed to be unloadable, you must manage its lifetime explicitly using NativeLibrary.Load() to get a handle and NativeLibrary.Free(handle) to unload it. This typically requires the plugin itself to manage these calls, perhaps via IDisposable patterns tied to the plugin’s lifecycle.
  • File Locks: Even if an ALC is unloaded, native DLL files on disk might remain locked by the process if they were loaded.

Common Pitfalls and Best Practices

  • Type Identity Errors: Always ensure shared types are loaded from a common ALC to avoid InvalidCastException.
  • Native Library Paths: Ensure .deps.json correctly lists native assets. For complex scenarios, SetDllImportResolver offers more control. Incorrect paths or missing libraries are common sources of DllNotFoundException.
  • Transitive Native Dependencies: If PluginNativeA.dll itself loads HelperNativeB.dll, LoadUnmanagedDll for PluginNativeA doesn’t automatically manage HelperNativeB. Its loading depends on OS search paths or how PluginNativeA is built (e.g., RPATH on Linux). This can be a significant challenge.
  • Unloading Complexity: Truly making an ALC unloadable requires meticulous management of all references to its types and assemblies. Native library unloading adds another layer of complexity.
  • Framework Assemblies: Avoid loading copies of framework assemblies (e.g., System.*, Microsoft.Extensions.*) into custom ALCs. Defer to AssemblyLoadContext.Default.
  • Debugging: Use AssemblyLoadContext.Resolving and AssemblyLoadContext.ResolvingUnmanagedDll events for diagnostics. Environment variables like COREHOST_TRACE=1 can provide detailed load logs.
  • Security: AssemblyLoadContext does not provide a security boundary. Code in any ALC runs with the same permissions as the host. For security isolation, process boundaries are necessary.

Conclusion

AssemblyLoadContext is an indispensable tool for building modular and maintainable .NET applications with plugin architectures. It provides robust isolation for managed dependencies and offers extensible hooks like LoadUnmanagedDll and integration with AssemblyDependencyResolver for managing native libraries.

Successfully handling native dependencies, especially shared ones, requires a clear strategy. Whether it’s host-provided shared libraries, ALC-resolved private native assets, or fine-grained control via NativeLibrary.SetDllImportResolver, understanding the interplay between your ALC, the .NET runtime, and the operating system’s native library loader is key. While unloading contexts with native libraries presents challenges, careful design and explicit lifecycle management can mitigate issues, enabling dynamic and resilient plugin systems that embrace both managed and native code.