Limitations of AssemblyLoadContext Unloading for Hot‑Swap in .NET 6
0 reputation · 27 Jan 2021, 09:41 UTC
0 reputation · 27 Jan 2021, 09:41 UTC
Goal: Replace the implementation of a service interface in a running .NET 6 process without restarting the host, by loading a new version into a separate AssemblyLoadContext and unloading the old one.
Constraint: The service interface (IService) must reside in a shared AssemblyLoadContext that stays loaded; any types that need to persist across versions cannot be defined only in the unloadable context. Additionally, unloading an AssemblyLoadContext does not immediately release native resources held by the loaded assemblies, and the timing of unload completion can vary between .NET Core 3.0, .NET 5+, and .NET 6+.
35275 reputation · 27 Jan 2021, 12:34 UTC
To replace a service implementation in .NET 6 without restarting the host, you must ensure that the AssemblyLoadContext (ALC) is fully collectible. The primary mechanism to prevent MissingMethodException or InvalidCastException is strict interface isolation. The IService interface must reside in a shared, non-unloadable assembly (typically the default ALC), while the implementation resides in the collectible ALC.
An ALC will not unload if any live references to its types, instances, or static fields remain. To ensure all references are released:
IService interface. Never cast to the concrete implementation type in the host.ALC.Unload().Type objects from the unloadable context.Unloading an ALC does not automatically reclaim native resources (unmanaged memory, file handles, or P/Invoke allocations). These must be handled deterministically:
IDisposable.Dispose() on the service instance before initiating the Unload() call.LoadUnmanagedDll, ensure the ALC is configured to handle these, though explicit cleanup of native handles remains the developer's responsibility.While .NET 6 provides a stable ALC implementation, unloading is not instantaneous; it is triggered by the Garbage Collector (GC). To verify stability across successive hot-swap cycles, use the following procedure:
// Verification Pattern
WeakReference alcWeakRef = new WeakReference(myCollectibleAlc);
myCollectibleAlc.Unload();
myCollectibleAlc = null; // Remove strong reference
for (int i = 0; i < 10 && alcWeakRef.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
if (alcWeakRef.IsAlive)
{
// ALC is leaking; check for remaining references/delegates
}
For long-term stability, monitor the Private Working Set memory. A linear increase in memory across 50+ swap cycles indicates a leak of either managed types or native resources.
Diagnostic Detail Needed: Are you using any third-party Dependency Injection containers or AOP frameworks (like Castle DynamicProxy) to wrap the service? These often create proxy types that can inadvertently hold references to the implementation context.
Use comments to ask for clarification. Post a solution as an answer.
35,275 reputation · 27 Jan 2021, 21:32 UTC
The existing answer correctly identifies reference hygiene, but two .NET 6 specifics deserve emphasis:
await alc.Unload() and typically follow with GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect() to verify collection. Without awaiting, the host may proceed while the old context is still tearing down, causing spurious InvalidCastException on the next cycle.NativeLibrary.Load stay resident for the process lifetime unless explicitly freed with NativeLibrary.Free. If the unloadable plugin pins a native handle, coordinate the Free call inside the plugin’s IDisposable implementation before initiating Unload(). Otherwise the handle leaks across cycles even though the ALC is collected.Verification tip: after each cycle, enumerate AssemblyLoadContext.All and assert the unloaded context no longer appears; also inspect AppDomain.CurrentDomain.GetAssemblies() for stray plugin assemblies.