Skip to content

Commit b613aa1

Browse files
authored
🐛 Fix plugin dependency loading using AssemblyLoadContext (#35)
1 parent 658641e commit b613aa1

File tree

7 files changed

+243
-2
lines changed

7 files changed

+243
-2
lines changed

src/Blake.BuildTools/Blake.BuildTools.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
<!-- Set InternalVisibleTo to allow access to internal members for testing -->
5555
<ItemGroup>
5656
<InternalsVisibleTo Include="Blake.CLI" />
57+
<InternalsVisibleTo Include="Blake.BuildTools.Tests" />
5758
</ItemGroup>
5859

5960
</Project>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Reflection;
2+
using System.Runtime.Loader;
3+
4+
namespace Blake.BuildTools.Utils;
5+
6+
/// <summary>
7+
/// A custom AssemblyLoadContext that provides isolated plugin loading with dependency resolution.
8+
/// Each plugin gets its own load context to avoid dependency conflicts.
9+
/// </summary>
10+
internal class PluginLoadContext : AssemblyLoadContext
11+
{
12+
private readonly AssemblyDependencyResolver _resolver;
13+
14+
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
15+
{
16+
_resolver = new AssemblyDependencyResolver(pluginPath);
17+
}
18+
19+
protected override Assembly? Load(AssemblyName assemblyName)
20+
{
21+
string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
22+
if (assemblyPath != null)
23+
{
24+
return LoadFromAssemblyPath(assemblyPath);
25+
}
26+
27+
// Return null to fall back to default load context for shared dependencies
28+
return null;
29+
}
30+
31+
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
32+
{
33+
string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
34+
if (libraryPath != null)
35+
{
36+
return LoadUnmanagedDllFromPath(libraryPath);
37+
}
38+
39+
return IntPtr.Zero;
40+
}
41+
}

src/Blake.BuildTools/Utils/PluginLoader.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,11 @@ private static void LoadPluginDLLs(List<string> files, List<PluginContext> plugi
185185

186186
try
187187
{
188-
var assembly = Assembly.LoadFrom(file);
188+
// Create a new load context for this plugin to resolve its dependencies
189+
var loadContext = new PluginLoadContext(file);
190+
var assemblyName = new AssemblyName(Path.GetFileNameWithoutExtension(file));
191+
var assembly = loadContext.LoadFromAssemblyName(assemblyName);
192+
189193
var pluginTypes = assembly.GetTypes()
190194
.Where(t => typeof(IBlakePlugin).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);
191195

@@ -199,7 +203,8 @@ private static void LoadPluginDLLs(List<string> files, List<PluginContext> plugi
199203
}
200204
catch (Exception ex)
201205
{
202-
logger?.LogError("Error loading plugin from {file}", file, ex);
206+
logger?.LogError("Error loading plugin from {file}: {message}", file, ex.Message);
207+
logger?.LogDebug(ex, "Full error details for plugin {file}", file);
203208
}
204209
}
205210
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using Blake.BuildTools.Utils;
2+
using Microsoft.Extensions.Logging;
3+
using Xunit;
4+
5+
namespace Blake.BuildTools.Tests.Utils;
6+
7+
public class PluginLoaderTests
8+
{
9+
[Fact]
10+
public void LoadPluginDLLs_WithPluginWithDependencies_LoadsSuccessfully()
11+
{
12+
// Arrange
13+
var logger = new TestLogger();
14+
var pluginPath = Path.GetFullPath(Path.Combine(
15+
Directory.GetCurrentDirectory(),
16+
"..", "..", "..", "..", "..", "tests", "Blake.IntegrationTests",
17+
"TestPluginWithDependencies", "bin", "Debug", "net9.0",
18+
"BlakePlugin.TestPluginWithDependencies.dll"
19+
));
20+
21+
// Skip test if plugin doesn't exist (build not run)
22+
if (!File.Exists(pluginPath))
23+
{
24+
Assert.True(true, "Plugin not built - skipping test");
25+
return;
26+
}
27+
28+
var files = new List<string> { pluginPath };
29+
var plugins = new List<PluginContext>();
30+
31+
// Act & Assert - should not throw exception
32+
var exception = Record.Exception(() =>
33+
{
34+
// Use reflection to call the private method
35+
var method = typeof(PluginLoader).GetMethod("LoadPluginDLLs",
36+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
37+
method?.Invoke(null, new object[] { files, plugins, logger });
38+
});
39+
40+
// Assert
41+
Assert.Null(exception);
42+
Assert.Single(plugins);
43+
Assert.Equal("BlakePlugin.TestPluginWithDependencies", plugins[0].PluginName);
44+
45+
// Ensure no errors were logged
46+
Assert.Empty(logger.ErrorMessages);
47+
}
48+
49+
private class TestLogger : ILogger
50+
{
51+
public List<string> ErrorMessages { get; } = new List<string>();
52+
public List<string> InfoMessages { get; } = new List<string>();
53+
54+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
55+
public bool IsEnabled(LogLevel logLevel) => true;
56+
57+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
58+
{
59+
var message = formatter(state, exception);
60+
if (logLevel == LogLevel.Error)
61+
{
62+
ErrorMessages.Add(message);
63+
}
64+
else if (logLevel == LogLevel.Information)
65+
{
66+
InfoMessages.Add(message);
67+
}
68+
}
69+
}
70+
}

tests/Blake.IntegrationTests/Commands/BlakePluginTests.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,70 @@ public async Task BlakeBake_WithoutProjectFile_SkipsPluginLoading()
412412
Assert.DoesNotContain("plugin", result.ErrorText, StringComparison.OrdinalIgnoreCase);
413413
}
414414

415+
[Fact]
416+
public async Task BlakeBake_WithPluginWithDependencies_LoadsAndExecutesCorrectly()
417+
{
418+
// Arrange
419+
var testDir = CreateTempDirectory("blake-plugins-with-deps");
420+
var projectName = "PluginWithDepsTest";
421+
422+
FileSystemHelper.CreateMinimalBlazorWasmProject(testDir, projectName);
423+
424+
FileSystemHelper.CreateMarkdownFile(
425+
Path.Combine(testDir, "Posts", "test.md"),
426+
"Test Post",
427+
"Content"
428+
);
429+
430+
FileSystemHelper.CreateRazorTemplate(
431+
Path.Combine(testDir, "Posts", "template.razor"),
432+
@"@page ""/posts/{Slug}""
433+
<h1>@Model.Title</h1>
434+
<div>@((MarkupString)Html)</div>"
435+
);
436+
437+
// Build the test plugin with dependencies first
438+
var pluginWithDepsPath = Path.Combine(GetCurrentDirectory(), "tests", "Blake.IntegrationTests", "TestPluginWithDependencies");
439+
var pluginBuildResult = await RunProcessAsync("dotnet", "build", pluginWithDepsPath);
440+
Assert.Equal(0, pluginBuildResult.ExitCode);
441+
442+
// Add plugin project reference to the test project
443+
var csprojPath = Path.Combine(testDir, $"{projectName}.csproj");
444+
var csprojContent = File.ReadAllText(csprojPath);
445+
446+
var pluginProjectPath = Path.Combine(pluginWithDepsPath, "BlakePlugin.TestPluginWithDependencies.csproj");
447+
var relativePath = Path.GetRelativePath(testDir, pluginProjectPath);
448+
449+
var updatedCsproj = csprojContent.Replace("</Project>",
450+
$@" <ItemGroup>
451+
<ProjectReference Include=""{relativePath}"" />
452+
</ItemGroup>
453+
454+
</Project>");
455+
File.WriteAllText(csprojPath, updatedCsproj);
456+
457+
// Act
458+
var result = await RunBlakeCommandAsync($"bake \"{testDir}\" --verbosity Debug");
459+
460+
// Assert
461+
Assert.Equal(0, result.ExitCode);
462+
463+
// Plugin should have created marker files with JSON serialization
464+
FileSystemHelper.AssertFileExists(Path.Combine(testDir, ".plugin-with-deps-before-bake.txt"));
465+
FileSystemHelper.AssertFileExists(Path.Combine(testDir, ".plugin-with-deps-after-bake.txt"));
466+
467+
// Verify the JSON content was written correctly (meaning dependencies loaded)
468+
var beforeContent = File.ReadAllText(Path.Combine(testDir, ".plugin-with-deps-before-bake.txt"));
469+
var afterContent = File.ReadAllText(Path.Combine(testDir, ".plugin-with-deps-after-bake.txt"));
470+
471+
Assert.Contains("Plugin with dependencies loaded successfully", beforeContent);
472+
Assert.Contains("Plugin dependencies working in AfterBakeAsync", afterContent);
473+
474+
// Should show plugin logs
475+
Assert.Contains("TestPluginWithDependencies: BeforeBakeAsync called", result.OutputText);
476+
Assert.Contains("TestPluginWithDependencies: AfterBakeAsync called", result.OutputText);
477+
}
478+
415479
private static string GetCurrentDirectory()
416480
{
417481
var currentDir = Directory.GetCurrentDirectory();
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net9.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<EnableDynamicLoading>true</EnableDynamicLoading>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\..\..\src\Blake.BuildTools\Blake.BuildTools.csproj">
12+
<Private>false</Private>
13+
<ExcludeAssets>runtime</ExcludeAssets>
14+
</ProjectReference>
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
19+
</ItemGroup>
20+
21+
</Project>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using Blake.BuildTools;
2+
using Microsoft.Extensions.Logging;
3+
using Newtonsoft.Json;
4+
5+
namespace BlakePlugin.TestPluginWithDependencies;
6+
7+
public class TestPluginWithDependencies : IBlakePlugin
8+
{
9+
public Task BeforeBakeAsync(BlakeContext context, ILogger? logger = null)
10+
{
11+
logger?.LogInformation("TestPluginWithDependencies: BeforeBakeAsync called");
12+
13+
// Use Newtonsoft.Json to test dependency loading
14+
var testObject = new { Message = "Plugin with dependencies loaded successfully", PageCount = context.MarkdownPages.Count };
15+
var serialized = JsonConvert.SerializeObject(testObject);
16+
17+
logger?.LogInformation("TestPluginWithDependencies: Serialized data: {SerializedData}", serialized);
18+
19+
// Create a marker file to prove the plugin ran with dependencies
20+
var testFilePath = Path.Combine(context.ProjectPath, ".plugin-with-deps-before-bake.txt");
21+
File.WriteAllText(testFilePath, serialized);
22+
23+
return Task.CompletedTask;
24+
}
25+
26+
public Task AfterBakeAsync(BlakeContext context, ILogger? logger = null)
27+
{
28+
logger?.LogInformation("TestPluginWithDependencies: AfterBakeAsync called with {PageCount} generated pages", context.GeneratedPages.Count);
29+
30+
// Use Newtonsoft.Json again to ensure dependency is still available
31+
var testObject = new { Message = "Plugin dependencies working in AfterBakeAsync", GeneratedPageCount = context.GeneratedPages.Count };
32+
var serialized = JsonConvert.SerializeObject(testObject);
33+
34+
var testFilePath = Path.Combine(context.ProjectPath, ".plugin-with-deps-after-bake.txt");
35+
File.WriteAllText(testFilePath, serialized);
36+
37+
return Task.CompletedTask;
38+
}
39+
}

0 commit comments

Comments
 (0)