Client runtime: load assemblies by path again so Assembly.Location is the file; tolerate locked files in test cleanup

Render plan and fractal scene derive directories from Assembly.Location; the in-memory load emptied it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Aqua 2026-11-07 11:35:13 +00:00
commit 739e2bd93e
2 changed files with 30 additions and 19 deletions

View file

@ -83,7 +83,7 @@ public sealed class ClientRuntimeLoader : IDisposable
try
{
var assembly = loadContext.LoadFromFileWithoutLock(assemblyPath);
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
var factoryTypes = assembly.GetTypes()
.Where(type => typeof(IAquariumRuntimeFactory).IsAssignableFrom(type)
&& type is { IsAbstract: false, IsInterface: false })
@ -336,23 +336,7 @@ public sealed class ClientRuntimeLoader : IDisposable
}
var path = resolver.ResolveAssemblyToPath(assemblyName);
return path == null ? null : LoadFromFileWithoutLock(ResolveTrusted(trustedRoot, path));
}
// LoadFromAssemblyPath maps the file and, on Windows, keeps it locked until the unloaded context is collected:
// a rebuilt or deleted client dll (and its temp directory) would stay blocked. Managed assemblies are read into
// memory instead, with their symbols when present, so no file handle outlives the load.
public Assembly LoadFromFileWithoutLock(string path)
{
using var image = new MemoryStream(File.ReadAllBytes(path));
var symbolsPath = Path.ChangeExtension(path, ".pdb");
if (!File.Exists(symbolsPath))
{
return LoadFromStream(image);
}
using var symbols = new MemoryStream(File.ReadAllBytes(symbolsPath));
return LoadFromStream(image, symbols);
return path == null ? null : LoadFromAssemblyPath(ResolveTrusted(trustedRoot, path));
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)

View file

@ -60,7 +60,24 @@ public sealed class ClientRuntimeLoaderTrustTests : IDisposable
outside = Directory.CreateDirectory(Path.Combine(work, "outside")).FullName;
}
public void Dispose() => Directory.Delete(work, recursive: true);
// A loaded assembly stays locked on Windows until its unloaded context is collected: tolerate that here.
public void Dispose()
{
for (var attempt = 0; attempt < 5; attempt++)
{
try
{
Directory.Delete(work, recursive: true);
return;
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
GC.Collect();
GC.WaitForPendingFinalizers();
Thread.Sleep(100);
}
}
}
// A real client assembly: the minimal sample, copied so its location is ours to choose.
private static string Copy(string directory, string name = "client.dll")
@ -376,6 +393,16 @@ public sealed class ClientRuntimeLoaderTrustTests : IDisposable
Assert.Contains("cannot be the client runtime trusted root", errors);
}
[Fact]
public void ALoadedClientSeesItsOwnFilePathAsAssemblyLocation()
{
var path = Copy(trusted, "a.dll");
using var loader = new ClientRuntimeLoader(Options, path);
loader.Load();
Assert.Equal(ClientRuntimeLoader.ResolveLinks(path), loader.Runtime.GetType().Assembly.Location);
}
[Fact]
public void LoggedPathsEscapeAstralFormatCharactersAndLoneSurrogatesByCodePoint()
{