Skip to content

Commit 70e4cd5

Browse files
DavidBoikebordingdanielmarbach
authored
Assembly scanner can load types more than once from different AssemblyLoadContexts (#7620)
* Prevent assembly scanner results from having duplicate assemblies and types (#7617) * Add test to reproduce the bug * Use ALC to load referenced assemblies * Use ALC as part of assembly identity * Consider ALC when deduping scanner results --------- Co-authored-by: Daniel Marbach <danielmarbach@users.noreply.github.com> * Fix code analysis for Compile Remove instruction (#7615) --------- Co-authored-by: Brandon Ording <bording@gmail.com> Co-authored-by: Daniel Marbach <danielmarbach@users.noreply.github.com>
1 parent 52672d5 commit 70e4cd5

4 files changed

Lines changed: 106 additions & 14 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
namespace NServiceBus.Core.Tests.AssemblyScanner;
2+
3+
using System;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Runtime.Loader;
7+
using Hosting.Helpers;
8+
using NUnit.Framework;
9+
10+
[TestFixture]
11+
public class When_more_than_one_AssemblyLoadContext_has_scannable_types
12+
{
13+
[Test]
14+
public void Should_only_load_one_copy_of_the_assembly()
15+
{
16+
var scanPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestDlls", "Messages");
17+
var customAssemblyLoadContext = new AssemblyLoadContext("ScannerTestALC", isCollectible: true);
18+
19+
customAssemblyLoadContext.LoadFromAssemblyPath(Path.Combine(scanPath, "Messages.Referencing.Core.dll"));
20+
21+
var scanner = new AssemblyScanner(scanPath);
22+
var result = scanner.GetScannableAssemblies();
23+
24+
var loadedFromScanPath = result.Assemblies
25+
.Where(a =>
26+
!string.IsNullOrWhiteSpace(a.Location) &&
27+
a.Location.StartsWith(scanPath, StringComparison.OrdinalIgnoreCase))
28+
.ToList();
29+
30+
Assert.That(loadedFromScanPath, Is.Not.Empty, "Expected at least one assembly to be loaded from the scan directory.");
31+
32+
var assemblies = loadedFromScanPath.GroupBy(a => a.FullName);
33+
34+
foreach (var assembly in assemblies)
35+
{
36+
var numberOfTimesLoaded = assembly.Count();
37+
Assert.That(numberOfTimesLoaded, Is.EqualTo(1), $"Assembly {assembly.Key} was loaded from more than one AssemblyLoadContext.");
38+
}
39+
40+
var messagesAssembly = loadedFromScanPath.Single(a => a.FullName.StartsWith("Messages.Referencing.Core"));
41+
var loadContext = AssemblyLoadContext.GetLoadContext(messagesAssembly);
42+
43+
Assert.That(loadContext.Name, Is.EqualTo("ScannerTestALC"), "The wrong AssemblyLoadContext was used to load the assembly.");
44+
}
45+
}

src/NServiceBus.Core/Hosting/Helpers/AssemblyScanner.cs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ internal IReadOnlyCollection<Type> TypesToSkip
7272
public AssemblyScannerResults GetScannableAssemblies()
7373
{
7474
var results = new AssemblyScannerResults();
75-
var processed = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase)
75+
var processed = new Dictionary<AssemblyIdentity, bool>
7676
{
77-
{ GetType().Assembly.FullName!, true },
78-
{ typeof(ICommand).Assembly.FullName!, true },
77+
{ GetAssemblyIdentity(GetType().Assembly), true },
78+
{ GetAssemblyIdentity(typeof(ICommand).Assembly), true },
7979
};
8080

8181
if (assemblyToScan is not null)
@@ -200,47 +200,51 @@ bool TryLoadScannableAssembly(string assemblyPath, AssemblyScannerResults result
200200
}
201201
}
202202

203-
bool ScanAssembly(Assembly assembly, Dictionary<string, bool> processed)
203+
bool ScanAssembly(Assembly assembly, Dictionary<AssemblyIdentity, bool> processed)
204204
{
205205
if (assembly.FullName is null)
206206
{
207207
return false;
208208
}
209209

210-
if (processed.TryGetValue(assembly.FullName, out var value))
210+
var identity = GetAssemblyIdentity(assembly);
211+
212+
if (processed.TryGetValue(identity, out var value))
211213
{
212214
return value;
213215
}
214216

215-
processed[assembly.FullName] = false;
217+
processed[identity] = false;
216218

217219
if (ShouldScanDependencies(assembly))
218220
{
221+
var context = AssemblyLoadContext.GetLoadContext(assembly);
222+
219223
foreach (var referencedAssemblyName in assembly.GetReferencedAssemblies())
220224
{
221-
var referencedAssembly = GetReferencedAssembly(referencedAssemblyName);
225+
var referencedAssembly = GetReferencedAssembly(context, referencedAssemblyName);
222226
if (referencedAssembly is not null)
223227
{
224228
var referencesCore = ScanAssembly(referencedAssembly, processed);
225229
if (referencesCore)
226230
{
227-
processed[assembly.FullName] = true;
231+
processed[identity] = true;
228232
break;
229233
}
230234
}
231235
}
232236
}
233237

234-
return processed[assembly.FullName];
238+
return processed[identity];
235239
}
236240

237-
static Assembly? GetReferencedAssembly(AssemblyName assemblyName)
241+
static Assembly? GetReferencedAssembly(AssemblyLoadContext? context, AssemblyName assemblyName)
238242
{
239243
Assembly? referencedAssembly = null;
240244

241245
try
242246
{
243-
referencedAssembly = Assembly.Load(assemblyName);
247+
referencedAssembly = context?.LoadFromAssemblyName(assemblyName);
244248
}
245249
catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException or FileLoadException) { }
246250

@@ -411,4 +415,9 @@ bool ShouldScanDependencies(Assembly assembly)
411415
// And other windows azure stuff
412416
"Microsoft.WindowsAzure"
413417
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
418+
419+
static AssemblyIdentity GetAssemblyIdentity(Assembly assembly) =>
420+
new(assembly.FullName!, AssemblyLoadContext.GetLoadContext(assembly));
421+
422+
readonly record struct AssemblyIdentity(string FullName, AssemblyLoadContext? LoadContext);
414423
}

src/NServiceBus.Core/Hosting/Helpers/AssemblyScannerResults.cs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ namespace NServiceBus.Hosting.Helpers;
55
using System.Collections.Generic;
66
using System.Linq;
77
using System.Reflection;
8+
using System.Runtime.Loader;
89

910
/// <summary>
1011
/// Holds <see cref="AssemblyScanner.GetScannableAssemblies" /> results.
@@ -46,7 +47,44 @@ public AssemblyScannerResults()
4647

4748
internal void RemoveDuplicates()
4849
{
49-
Assemblies = [.. Assemblies.Distinct()];
50-
Types = [.. Types.Distinct()];
50+
if (AssemblyLoadContext.All.Count() == 1)
51+
{
52+
Assemblies = [.. Assemblies.Distinct()];
53+
Types = [.. Types.Distinct()];
54+
55+
return;
56+
}
57+
58+
var preferredAssemblies = new Dictionary<string, Assembly>(StringComparer.OrdinalIgnoreCase);
59+
60+
foreach (var assembly in Assemblies)
61+
{
62+
var fullName = assembly.FullName;
63+
64+
if (fullName is null)
65+
{
66+
continue;
67+
}
68+
69+
if (!preferredAssemblies.TryGetValue(fullName, out var existing))
70+
{
71+
preferredAssemblies[fullName] = assembly;
72+
continue;
73+
}
74+
75+
preferredAssemblies[fullName] = PreferAssembly(existing, assembly);
76+
}
77+
78+
Assemblies = [.. preferredAssemblies.Values];
79+
80+
var assemblySet = Assemblies.ToHashSet();
81+
Types = [.. Types.Where(t => assemblySet.Contains(t.Assembly)).Distinct()];
82+
}
83+
84+
static Assembly PreferAssembly(Assembly left, Assembly right)
85+
{
86+
var leftIsDefault = AssemblyLoadContext.GetLoadContext(left) == AssemblyLoadContext.Default;
87+
88+
return leftIsDefault ? right : left;
5189
}
5290
}

src/NServiceBus.Core/NServiceBus.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
</PropertyGroup>
3434

3535
<ItemGroup Label="Remove this ItemGroup when FastExpressionCompiler stops included test code in the package">
36-
<Compile Remove="$(PkgFastExpressionCompiler_Internal_src)\**\TestTools.cs" />
36+
<Compile Remove="$(PkgFastExpressionCompiler_Internal_src)\**\TestTools.cs" Condition="'$(PkgFastExpressionCompiler_Internal_src)' != ''" />
3737
</ItemGroup>
3838

3939
<ItemGroup>

0 commit comments

Comments
 (0)