Skip to content

Commit

Permalink
feature: Shadow copy unmanaged DLLs when hot reloading enabled (#119)
Browse files Browse the repository at this point in the history
When hot reloading is enabled, unmanaged DLLs will be shadow copied to a
unique temp directory per PluginLoader to allow replacing the original file. 
The temp directory is deleted when the AssemblyLoadContext is unloaded.

Fixes #118
  • Loading branch information
KatoStoelen authored and natemcmaster committed Jan 10, 2020
1 parent 22ec113 commit 69e7d30
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 11 deletions.
9 changes: 8 additions & 1 deletion samples/hot-reload/TimestampedPlugin/InfoDisplayer.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
using System;
using System.Linq;
using System.Reflection;
using Microsoft.Data.Sqlite;

namespace TimestampedPlugin
{
public class InfoDisplayer
{
public static void Print()
{
// Use something from Microsoft.Data.Sqlite to trigger loading of native dependency
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = "HELLO"
};

var compileTimestamp = typeof(InfoDisplayer)
.Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
Expand All @@ -16,7 +23,7 @@ public static void Print()
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("TimestampedPlugin: ");
Console.ResetColor();
Console.WriteLine($"this plugin was compiled at {compileTimestamp}");
Console.WriteLine($"this plugin was compiled at {compileTimestamp}. {connectionString.DataSource}!");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>netcoreapp3.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
Expand All @@ -11,4 +11,8 @@
</AssemblyAttribute>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="3.1.0" />
</ItemGroup>

</Project>
23 changes: 19 additions & 4 deletions src/Plugins/Loader/AssemblyLoadContextBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class AssemblyLoadContextBuilder
#if FEATURE_UNLOAD
private bool _isCollectible;
private bool _loadInMemory;
private bool _shadowCopyNativeLibraries;
#endif

/// <summary>
Expand Down Expand Up @@ -64,10 +65,12 @@ public AssemblyLoadContext Build()
_preferDefaultLoadContext,
#if FEATURE_UNLOAD
_isCollectible,
_loadInMemory);
_loadInMemory,
_shadowCopyNativeLibraries);
#else
isCollectible: false,
loadInMemory: false);
loadInMemory: false,
shadowCopyNativeLibraries: false);
#endif
}

Expand Down Expand Up @@ -97,8 +100,8 @@ public AssemblyLoadContextBuilder SetMainAssemblyPath(string path)
/// Replaces the default <see cref="AssemblyLoadContext"/> used by the <see cref="AssemblyLoadContextBuilder"/>.
/// Use this feature if the <see cref="AssemblyLoadContext"/> of the <see cref="Assembly"/> is not the Runtime's default load context.
/// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != <see cref="AssemblyLoadContext.Default"/>
/// </summary>
/// <param name="context">The context to set.</param>
/// </summary>
/// <param name="context">The context to set.</param>
/// <returns>The builder.</returns>
public AssemblyLoadContextBuilder SetDefaultContext(AssemblyLoadContext context)
{
Expand Down Expand Up @@ -279,6 +282,18 @@ public AssemblyLoadContextBuilder PreloadAssembliesIntoMemory()
_loadInMemory = true; // required to prevent dotnet from locking loaded files
return this;
}

/// <summary>
/// Shadow copy native libraries (unmanaged DLLs) to avoid locking of these files.
/// This is not as efficient, so is not enabled by default, but is required for scenarios
/// like hot reloading of plugins dependent on native libraries.
/// </summary>
/// <returns>The builder</returns>
public AssemblyLoadContextBuilder ShadowCopyNativeLibraries()
{
_shadowCopyNativeLibraries = true;
return this;
}
#endif

/// <summary>
Expand Down
64 changes: 59 additions & 5 deletions src/Plugins/Loader/ManagedLoadContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ internal class ManagedLoadContext : AssemblyLoadContext
#if FEATURE_NATIVE_RESOLVER
private readonly AssemblyDependencyResolver _dependencyResolver;
#endif
private readonly bool _shadowCopyNativeLibraries;
private readonly string _unmanagedDllShadowCopyDirectoryPath;

public ManagedLoadContext(string mainAssemblyPath,
IReadOnlyDictionary<string, ManagedLibrary> managedAssemblies,
Expand All @@ -44,7 +46,8 @@ public ManagedLoadContext(string mainAssemblyPath,
AssemblyLoadContext defaultLoadContext,
bool preferDefaultLoadContext,
bool isCollectible,
bool loadInMemory)
bool loadInMemory,
bool shadowCopyNativeLibraries)
#if FEATURE_UNLOAD
: base(Path.GetFileNameWithoutExtension(mainAssemblyPath), isCollectible)
#endif
Expand All @@ -71,6 +74,14 @@ public ManagedLoadContext(string mainAssemblyPath,
_resourceRoots = new[] { _basePath }
.Concat(resourceProbingPaths)
.ToArray();

_shadowCopyNativeLibraries = shadowCopyNativeLibraries;
_unmanagedDllShadowCopyDirectoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

if (shadowCopyNativeLibraries)
{
Unloading += _ => OnUnloaded();
}
}

/// <summary>
Expand Down Expand Up @@ -175,7 +186,7 @@ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
var resolvedPath = _dependencyResolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (!string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath))
{
return LoadUnmanagedDllFromPath(resolvedPath);
return LoadUnmanagedDllFromResolvedPath(resolvedPath, normalizePath: false);
}
#endif

Expand Down Expand Up @@ -306,10 +317,53 @@ private bool SearchForLibrary(NativeLibrary library, string prefix, out string?
return false;
}

private IntPtr LoadUnmanagedDllFromResolvedPath(string unmanagedDllPath)
private IntPtr LoadUnmanagedDllFromResolvedPath(string unmanagedDllPath, bool normalizePath = true)
{
if (normalizePath)
{
unmanagedDllPath = Path.GetFullPath(unmanagedDllPath);
}

return _shadowCopyNativeLibraries
? LoadUnmanagedDllFromShadowCopy(unmanagedDllPath)
: LoadUnmanagedDllFromPath(unmanagedDllPath);
}

private IntPtr LoadUnmanagedDllFromShadowCopy(string unmanagedDllPath)
{
var shadowCopyDllPath = CreateShadowCopy(unmanagedDllPath);

return LoadUnmanagedDllFromPath(shadowCopyDllPath);
}

private string CreateShadowCopy(string dllPath)
{
var normalized = Path.GetFullPath(unmanagedDllPath);
return LoadUnmanagedDllFromPath(normalized);
Directory.CreateDirectory(_unmanagedDllShadowCopyDirectoryPath);

var dllFileName = Path.GetFileName(dllPath);
var shadowCopyPath = Path.Combine(_unmanagedDllShadowCopyDirectoryPath, dllFileName);

File.Copy(dllPath, shadowCopyPath);

return shadowCopyPath;
}

private void OnUnloaded()
{
if (!_shadowCopyNativeLibraries || !Directory.Exists(_unmanagedDllShadowCopyDirectoryPath))
{
return;
}

// Attempt to delete shadow copies
try
{
Directory.Delete(_unmanagedDllShadowCopyDirectoryPath, recursive: true);
}
catch (Exception)
{
// Files might be locked by host process. Nothing we can do about it, I guess.
}
}
}
}
1 change: 1 addition & 0 deletions src/Plugins/PluginLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ private static AssemblyLoadContextBuilder CreateLoadContextBuilder(PluginConfig
if (config.EnableHotReload)
{
builder.PreloadAssembliesIntoMemory();
builder.ShadowCopyNativeLibraries();
}
#endif

Expand Down
1 change: 1 addition & 0 deletions src/Plugins/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
McMaster.NETCore.Plugins.Loader.AssemblyLoadContextBuilder.ShadowCopyNativeLibraries() -> McMaster.NETCore.Plugins.Loader.AssemblyLoadContextBuilder

0 comments on commit 69e7d30

Please sign in to comment.