Exception when loading F# types that implement an interface which uses generics from an assembly

Summary

A runtime ReflectionTypeLoadException occurs when loading an F# assembly containing a type that implements an interface from a shared library, specifically when the interface member involves generic types like Option<'T> (e.g., Microsoft.FSharp.Core.FSharpOption).

The root cause is that the AssemblyLoadContext implementation fails to resolve and load the F# Core library (FSharp.Core.dll) containing the generic definitions required by the plugin. The dependency resolver primarily tracks managed DLLs, but generic F# types often require runtime type construction that exposes strict versioning and visibility constraints between the host, the base interface assembly, and the plugin.

Root Cause

The failure stems from a mismatch in type identity and assembly loading contexts.

  • Missing Dependency Resolution: The custom AssemblyDependencyResolver used in the PluginLoadContext resolves paths for the plugin’s direct dependencies (like PluginBase.dll), but it does not automatically resolve or prioritize the FSharp.Core.dll required to construct Microsoft.FSharp.Core.FSharpOption.
  • Type Identity Mismatch: When the host calls p.GetTypes(), the CLR attempts to load the type definition of SomePlugin. This type implements IPlugin, which expects a return value of FSharpOption<string>.
  • Load Context Isolation: If FSharp.Core is loaded in the default context (or a different version in the plugin context), the type Microsoft.FSharp.Core.FSharpOption loaded in the plugin context is considered a different type than the one expected by the interface definition or the runtime binder. This results in the MissingMethodException regarding the getter get_TestString.

Why This Happens in Real Systems

In production environments, dynamic plugin architectures are common (e.g., modular IDEs, ERP systems, game engines).

  • Strict Isolation: We use AssemblyLoadContext to ensure plugins do not conflict with the main application (DLL Hell). However, this isolation requires explicit handling of shared libraries.
  • F# Type Projections: F# specific types like Option, List, and Map are compiled into the FSharp.Core assembly. When these types cross assembly boundaries (Host -> Interface -> Plugin), all three contexts must agree on the exact version and loader of FSharp.Core.
  • Dependency Graph Complexity: The AssemblyDependencyResolver relies on the .deps.json file generated during build. If this file is missing or incorrect in the plugin output, or if the host forces a specific load order, the F# runtime types are missed.

Real-World Impact

  • Plugin Initialization Failure: The plugin cannot be instantiated or inspected via reflection, causing the entire module loading process to crash.
  • Limitation on Expressive Types: Developers are forced to strip generic F# types from public interfaces, resulting in “stringly-typed” APIs (using strings instead of Option<string>) or complex serialization layers to bypass the type system.
  • Deployment Fragility: The application becomes sensitive to the exact location of FSharp.Core.dll, leading to deployment artifacts that work on the build machine but fail in production containers or client environments.

Example or Code

The original code relies on the AssemblyDependencyResolver which fails to catch FSharp.Core for the custom context. The fix involves explicitly loading FSharp.Core into the context.

open System
open System.IO
open System.Reflection
open System.Runtime.Loader

type PluginLoadContext(pluginPath: string) =
    inherit AssemblyLoadContext()

    let resolver = AssemblyDependencyResolver(pluginPath)

    // CRITICAL FIX: We must explicitly load FSharp.Core into this context
    // to ensure the generic types (Option) are resolved correctly.
    let fsharpCorePath = typeof<option>.Assembly.Location

    override this.Load(assemblyName: AssemblyName) =
        // 1. Explicitly handle FSharp.Core
        if assemblyName.Name = "FSharp.Core" then
            this.LoadFromAssemblyPath(fsharpCorePath)
        else
            // 2. Resolve other dependencies via the resolver
            let assemblyPath = resolver.ResolveAssemblyToPath(assemblyName)
            if assemblyPath  null then
                this.LoadFromAssemblyPath(assemblyPath)
            else
                null

let LoadPlugin (path: string) =
    let pluginLocation = Path.GetFullPath(path.Replace('\\', Path.DirectorySeparatorChar))
    try
        let loadContext = PluginLoadContext pluginLocation
        let assemblyName = AssemblyName(Path.GetFileNameWithoutExtension pluginLocation)
        // Force loading the assembly via the context
        let assembly = loadContext.LoadFromAssemblyName(assemblyName)
        // Return Some assembly
        Some assembly
    with ex ->
        printfn "Error loading plugin: %s" ex.Message
        None

// Execution
match LoadPlugin "Plugins/TestPlugin/TestPlugin.dll" with
| None -> printfn "Plugin failed to load"
| Some p ->
    try
        // This will now work because FSharp.Core is properly loaded in the context
        p.GetTypes() |> Array.iter (fun t -> printfn "%s" t.FullName)
    with
    | :? ReflectionTypeLoadException as ex ->
        printfn "Type Load Exception:"
        for loaderEx in ex.LoaderExceptions do
            printfn "%s" loaderEx.Message

How Senior Engineers Fix It

To resolve this, a senior engineer performs dependency injection at the loader level:

  1. Explicit Shared Assembly Loading: Instead of relying solely on the AssemblyDependencyResolver (which calculates paths based on the .deps.json file), the engineer modifies the AssemblyLoadContext.Load override to explicitly intercept requests for known shared libraries, specifically FSharp.Core.
  2. Context Bridge: They ensure the FSharp.Core assembly currently running in the host process is loaded into the plugin context using this.LoadFromAssemblyPath. This guarantees type identity: Option in the Host == Option in the Plugin.
  3. Dependency Resolution Verification: They verify that the AssemblyDependencyResolver is instantiated with the correct pluginPath (the directory containing the plugin.runtimeconfig.json and deps.json) to correctly resolve NuGet dependencies of the plugin.

Why Juniors Miss It

  • “It Compiles, So It Should Run”: Juniors often assume that if the IDE resolves the types and the build succeeds, the runtime will automatically wire up dependencies. They don’t anticipate the isolation imposed by AssemblyLoadContext.
  • Ignoring F# Core: In C# development, core libraries like System.Runtime are usually handled implicitly or by the runtime host. Juniors may not realize that F# requires explicit handling of FSharp.Core when crossing dynamic assembly boundaries.
  • Over-reliance on Reflection: They use Assembly.LoadFrom without understanding the underlying Load Context rules, leading to “Could not load file or assembly” or “Method not found” errors which are hard to debug without understanding the loader’s internal state.