Skip to content

Commit eb62161

Browse files
Dangercoderdmiller
authored andcommitted
Fix assembly resolution so host apps no longer need manual pre-loading
classForName's DependencyContext heuristic had three gaps that prevented type resolution for NuGet/framework assemblies not yet loaded by the CLR: 1. Namespace-to-assembly guessing did a single dictionary lookup and gave up. Now walks up the namespace hierarchy (e.g. Microsoft.AspNetCore.Builder -> Microsoft.AspNetCore -> Microsoft) until a match is found. 2. DependencyContext entries stored empty paths, making Assembly.LoadFrom impossible. Now resolves DLL filenames against AppContext.BaseDirectory. 3. The AssemblyResolve handler only checked embedded resources. Now also consults the runtime assembly dictionary and probes the base directory, with a ThreadStatic recursion guard to prevent infinite loops. Includes 4 new tests: namespace walk-up resolution, runtime assembly path verification, counter validation, and graceful failure on unknown types.
1 parent 3edff47 commit eb62161

File tree

2 files changed

+148
-30
lines changed

2 files changed

+148
-30
lines changed

Clojure/Clojure/Lib/RT.cs

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -348,19 +348,50 @@ public override object invoke(object arg1)
348348

349349
#region Initialization
350350

351+
[ThreadStatic]
352+
static bool _isResolving;
353+
351354
static Assembly ResolveAssembly(object sender, ResolveEventArgs args)
352355
{
353-
AssemblyName asmName = new(args.Name);
354-
var name = asmName.Name;
355-
var stream = GetEmbeddedResourceStream(name, out _);
356-
if (stream == null)
356+
if (_isResolving)
357+
return null;
358+
359+
_isResolving = true;
360+
try
357361
{
358-
name += ".dll";
359-
stream = GetEmbeddedResourceStream(name, out _);
362+
AssemblyName asmName = new(args.Name);
363+
var name = asmName.Name;
364+
365+
// 1. Embedded resources (existing behavior)
366+
var stream = GetEmbeddedResourceStream(name, out _);
360367
if (stream == null)
361-
return null;
368+
stream = GetEmbeddedResourceStream(name + ".dll", out _);
369+
if (stream != null)
370+
return Assembly.Load(ReadStreamBytes(stream));
371+
372+
// 2. Check _runtimeAssemblyNames (DependencyContext + TRUSTED_PLATFORM_ASSEMBLIES)
373+
var runtimeNames = _runtimeAssemblyNames.Value;
374+
if (runtimeNames.TryGetValue(asmName, out var runtimePath)
375+
&& !string.IsNullOrWhiteSpace(runtimePath))
376+
{
377+
try { return Assembly.LoadFrom(runtimePath); }
378+
catch { /* Assembly may be corrupt or version-mismatched; fall through */ }
379+
}
380+
381+
// 3. Probe the app's base directory
382+
var probePath = Path.Combine(AppContext.BaseDirectory, name + ".dll");
383+
if (File.Exists(probePath))
384+
{
385+
try { return Assembly.LoadFrom(probePath); }
386+
catch { /* File exists but isn't loadable; give up */ }
387+
}
388+
389+
return null;
390+
}
391+
finally
392+
{
393+
_isResolving = false;
362394
}
363-
return Assembly.Load(ReadStreamBytes(stream));
364395
}
365396

366397
#if MONO
@@ -2797,8 +2828,15 @@ public int GetHashCode(AssemblyName obj)
27972828
{
27982829
try
27992830
{
2800-
var name = new AssemblyName(Path.GetFileNameWithoutExtension(assembly));
2801-
names[name] = string.Empty;
2831+
var asmName = new AssemblyName(Path.GetFileNameWithoutExtension(assembly));
2832+
2833+
// Resolve to actual file path so Assembly.LoadFrom works later.
2834+
// Published apps copy all dependency DLLs to the base directory.
2835+
var fullPath = Path.Combine(AppContext.BaseDirectory, Path.GetFileName(assembly));
2836+
if (File.Exists(fullPath))
2837+
names[asmName] = fullPath;
2838+
else if (!names.ContainsKey(asmName))
2839+
names[asmName] = string.Empty; // fallback: Assembly.Load will be tried
28022840
}
28032841
catch { }
28042842
}
@@ -2877,6 +2915,9 @@ internal static Type classForName(string p, Namespace ns, bool canCallClrTypeSpe
28772915
// Search through currently loaded assemblies.
28782916
AppDomain domain = AppDomain.CurrentDomain;
28792917
Assembly[] loadedAssemblies = domain.GetAssemblies();
2918+
var loadedAssemblyNames = new HashSet<string>(
2919+
loadedAssemblies.Select(a => a.GetName().Name),
2920+
StringComparer.OrdinalIgnoreCase);
28802921

28812922
// Search by namespace-qualified name in loaded assemblies.
28822923
foreach (Assembly assy in loadedAssemblies)
@@ -2920,34 +2961,44 @@ internal static Type classForName(string p, Namespace ns, bool canCallClrTypeSpe
29202961
{
29212962
var runtimeAssemblyNames = _runtimeAssemblyNames.Value;
29222963

2923-
try
2964+
// Try the full namespace as assembly name, then walk up the hierarchy.
2965+
// e.g., "Microsoft.AspNetCore.Builder" -> "Microsoft.AspNetCore" -> "Microsoft"
2966+
string probe = assemblyNameString;
2967+
while (probe != null)
29242968
{
2925-
var targetAssemblyName = new AssemblyName(assemblyNameString);
2926-
2927-
// try if the namespace directly matches the assembly name.
2928-
if (runtimeAssemblyNames.TryGetValue(targetAssemblyName, out var path))
2969+
try
29292970
{
2971+
var targetAssemblyName = new AssemblyName(probe);
29302972

2931-
if (loadedAssemblies.Any(a => a.GetName().Name.Equals(assemblyNameString, StringComparison.OrdinalIgnoreCase)))
2932-
{
2933-
// Skip if this assembly is already loaded.
2934-
}
2935-
else
2973+
if (runtimeAssemblyNames.TryGetValue(targetAssemblyName, out var path))
29362974
{
2937-
NumRuntimeAssemblyLoads++;
2938-
var assy = string.IsNullOrWhiteSpace(path) ? Assembly.Load(targetAssemblyName) : Assembly.LoadFrom(path);
2939-
var type = assy.GetType(p, false);
2940-
if (type != null)
2975+
if (loadedAssemblyNames.Contains(probe))
29412976
{
2942-
NumRuntimeAssemblyFinds++;
2943-
return type;
2977+
// Already loaded; earlier scan didn't find type. Keep walking.
2978+
}
2979+
else
2980+
{
2981+
NumRuntimeAssemblyLoads++;
2982+
var assy = string.IsNullOrWhiteSpace(path)
2983+
? Assembly.Load(targetAssemblyName)
2984+
: Assembly.LoadFrom(path);
2985+
var type = assy.GetType(p, false);
2986+
if (type != null)
2987+
{
2988+
NumRuntimeAssemblyFinds++;
2989+
return type;
2990+
}
2991+
// Assembly loaded but type not found. Continue walking up.
29442992
}
29452993
}
29462994
}
2947-
}
2948-
catch
2949-
{
2950-
// Ignore failures to load assembly.
2995+
catch
2996+
{
2997+
// Ignore failures to load this particular assembly. Keep trying.
2998+
}
2999+
3000+
int lastDot = probe.LastIndexOf('.');
3001+
probe = lastDot > 0 ? probe.Substring(0, lastDot) : null;
29513002
}
29523003
}
29533004
}

Clojure/Csharp.Tests/SharedAssemblyLoadingTests.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using clojure.lang;
22
using NUnit.Framework;
33
using System;
4+
using System.Collections.Generic;
5+
using System.Reflection;
46

57
namespace Csharp.Tests;
68

@@ -102,4 +104,69 @@ public void TestCanLoadNuGetPackageTypes()
102104
"Should be able to load types from NuGet packages");
103105

104106
}
107+
108+
[Test]
109+
public void TestNamespaceWalkUpResolvesType()
110+
{
111+
// System.Text.Json.Serialization.JsonConverter lives in assembly System.Text.Json,
112+
// not System.Text.Json.Serialization. The namespace walk-up should find it by
113+
// stripping "Serialization" and trying "System.Text.Json".
114+
var type = RT.classForName("System.Text.Json.Serialization.JsonConverter");
115+
116+
Assert.That(type, Is.Not.Null,
117+
"Should resolve type via namespace hierarchy walk-up when assembly name != namespace");
118+
Assert.That(type.Assembly.GetName().Name, Is.EqualTo("System.Text.Json"));
119+
}
120+
121+
[Test]
122+
public void TestRuntimeAssemblyNamesHavePaths()
123+
{
124+
// Access _runtimeAssemblyNames via reflection to verify DependencyContext
125+
// entries have been resolved to actual file paths where possible.
126+
var field = typeof(RT).GetField("_runtimeAssemblyNames",
127+
BindingFlags.NonPublic | BindingFlags.Static);
128+
Assert.That(field, Is.Not.Null, "_runtimeAssemblyNames field should exist");
129+
130+
var lazy = field.GetValue(null);
131+
var dict = (Dictionary<AssemblyName, string>)lazy.GetType().GetProperty("Value").GetValue(lazy);
132+
133+
// TPA entries should always have non-empty paths
134+
int withPaths = 0;
135+
foreach (var kvp in dict)
136+
{
137+
if (!string.IsNullOrWhiteSpace(kvp.Value))
138+
withPaths++;
139+
}
140+
141+
Assert.That(withPaths, Is.GreaterThan(0),
142+
"At least some runtime assembly entries should have resolved file paths");
143+
}
144+
145+
[Test]
146+
public void TestClassForNameCountersIncrement()
147+
{
148+
// Reset counters
149+
int failsBefore = RT.NumFails;
150+
151+
// Load a type that requires runtime assembly resolution
152+
var type = RT.classForName("System.Text.Json.JsonSerializer");
153+
Assert.That(type, Is.Not.Null);
154+
155+
// The type should have been found (via loaded assemblies or runtime resolution)
156+
// We just verify no new failures were recorded for this lookup
157+
Assert.That(RT.NumFails, Is.EqualTo(failsBefore),
158+
"Loading a valid type should not increment NumFails");
159+
}
160+
161+
[Test]
162+
public void TestClassForNameReturnsNullForBogusType()
163+
{
164+
int failsBefore = RT.NumFails;
165+
166+
var type = RT.classForName("This.Type.Does.Not.Exist.Anywhere");
167+
168+
Assert.That(type, Is.Null, "Bogus type name should return null");
169+
Assert.That(RT.NumFails, Is.GreaterThan(failsBefore),
170+
"Failed lookup should increment NumFails");
171+
}
105172
}

0 commit comments

Comments
 (0)