Files
tooll3--t3/Core/Compilation/TixlAssemblyLoadContext.cs
2026-07-13 13:13:17 +08:00

687 lines
25 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Threading;
using T3.Core.IO;
using T3.Core.Logging;
using T3.Core.Settings;
namespace T3.Core.Compilation;
/// <summary>
/// This is the actual place where assemblies are loaded and dependencies are resolved on a per-dll level.
/// Inheriting from <see cref="AssemblyLoadContext"/> allows us to load assemblies in a custom way, which is required
/// as assemblies are loaded from different locations for each package.
///
/// Each package has its own <see cref="TixlAssemblyLoadContext"/> that is used to load the assemblies of that package. If a package relies on another package
/// from a CSProj-level, the dependency's load context and dlls are added to the dependent's load context such that the dependent's dlls can be loaded
/// referencing the types provided by the dependency.
///
/// For example, the LibEditor package has a dependency on Lib. When LibEditor is loaded, the Lib package is loaded first via LibEditor's load context. Then
/// the loading procedure continues until LibEditor is fully loaded with all its dependencies.
///
/// Unfortunately this process is very complex, and is not thoroughly tested with large dependency chains.
/// </summary>
internal sealed partial class TixlAssemblyLoadContext : AssemblyLoadContext
{
public event EventHandler? UnloadBegan;
internal event EventHandler? UnloadBeganInternal;
private readonly Lock _dependencyLock = new();
internal AssemblyTreeNode? Root { get; private set; }
private readonly List<AssemblyLoadContext> _dependencyContexts = [];
private static readonly List<AssemblyTreeNode> _coreNodes = [];
private static readonly List<TixlAssemblyLoadContext> _loadContexts = [];
private static readonly Lock _loadContextLock = new();
private readonly DllImportResolver _dllImportResolver;
private bool _unloaded;
private static List<AssemblyTreeNode> CoreNodes => _coreNodes;
public readonly string MainDirectory;
private static string ShadowCopyRootFolder => Path.Combine(FileLocations.TempFolder, "ShadowCopy");
private static string RootShadowCopyDir => Path.Combine(ShadowCopyRootFolder, $"{Environment.ProcessId}");
private readonly string _shadowCopyDirectory;
private readonly bool _shouldCopyBinaries;
static TixlAssemblyLoadContext()
{
CleanUpStaleShadowCopies();
Directory.CreateDirectory(RootShadowCopyDir);
(AssemblyLoadContext Context, (Assembly Assembly, AssemblyName name)[] assemblies)[]? allAssemblies = All
.Select(ctx => (
ctx: ctx,
assemblies: ctx.Assemblies
.Select(x => (asm: x, name: x.GetName()))
.ToArray()))
.ToArray();
// create "root" nodes for each assembly context - one per context and one per directory for each context
foreach (var ctxGroup in allAssemblies)
{
List<string> directories = new();
foreach (var assemblyDef in ctxGroup.assemblies)
{
string? directory;
try
{
directory = Path.GetDirectoryName(assemblyDef.Assembly.Location);
}
catch
{
continue;
}
if (directory == null || directories.Contains(directory))
continue;
directories.Add(directory);
var node = new AssemblyTreeNode(assemblyDef.Assembly, ctxGroup.Context, false, true, null); // no native resolver bc they already have one
_coreNodes.Add(node);
}
}
DllImportResolver resolver = NativeDllResolverStatic;
// add references to each core node where applicable, reusing existing nodes to create the tree
for (var index = 0; index < _coreNodes.Count; index++)
{
var node = _coreNodes[index];
var dependencies = node.Assembly.GetReferencedAssemblies();
foreach (var dependencyName in dependencies)
{
foreach (var ctxGroup in allAssemblies)
{
foreach (var asmAndName in ctxGroup.assemblies)
{
if (asmAndName.name != dependencyName)
continue;
AssemblyTreeNode? depNode = null;
var nameStr = dependencyName.GetName();
foreach (var coreNode in _coreNodes)
{
if (coreNode.TryFindExisting(nameStr, out depNode))
break;
}
depNode ??= new AssemblyTreeNode(asmAndName.Assembly, ctxGroup.Context, false, false, resolver);
node.AddReferenceTo(depNode);
}
}
}
}
}
internal TixlAssemblyLoadContext(string assemblyName, string directory, bool isReadOnly) :
base(assemblyName, true)
{
if (CoreSettings.Config.LogCompilationDetails)
Log.Debug($"{Name}: Creating new assembly load context for {assemblyName}");
if (CoreSettings.Config.LogAssemblyLoadingDetails)
{
Unloading += (_) => { Log.Debug($"{Name!}: Unloading assembly context"); };
}
lock (_loadContextLock)
{
_loadContexts.Add(this);
}
MainDirectory = directory;
_shadowCopyDirectory = Path.Combine(RootShadowCopyDir, Name!, DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"));
_shouldCopyBinaries = !isReadOnly;
_dllImportResolver = NativeDllResolver;
var path = Path.Combine(directory, Name!) + ".dll";
try
{
var asm = LoadAssembly(path, this);
Root = new AssemblyTreeNode(asm, this, true, true, _dllImportResolver);
if (CoreSettings.Config.LogAssemblyLoadingDetails)
Log.Debug($"{Name} : Loaded root assembly {asm.FullName} from '{path}'");
_dependencyContext = Microsoft.Extensions.DependencyModel.DependencyContext.Load(Root!.Assembly);
}
catch (Exception e)
{
Log.Error($"{Name!}: Failed to load root assembly {Name}: {e}");
}
}
/// <summary>
/// A single place to define how we're loading managed assemblies.
/// An unnecessary abstraction, but useful for testing different loading strategies.
/// </summary>
/// <param name="path">The path to the managed dll</param>
/// <param name="ctx">The context to load the dll into</param>
/// <returns>The loaded assembly</returns>
/// <inheritdoc cref="AssemblyLoadContext.LoadFromAssemblyPath"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static Assembly LoadAssembly(string path, AssemblyLoadContext ctx)
{
// try a shadow copy first
if (ctx is not TixlAssemblyLoadContext { _shouldCopyBinaries: true } tixlCtx)
{
if (CoreSettings.Config.LogAssemblyLoadingDetails)
Log.Debug($"{ctx.Name}: Loading assembly from '{path}'...");
return ctx.LoadFromAssemblyPath(path);
}
var shadowCopyDirectory = tixlCtx._shadowCopyDirectory;
if (tixlCtx._shouldCopyBinaries && !Directory.Exists(shadowCopyDirectory))
{
Directory.CreateDirectory(shadowCopyDirectory);
if (CoreSettings.Config.LogAssemblyLoadingDetails)
Log.Debug($"{tixlCtx.Name!}: Created shadow copy directory at {shadowCopyDirectory}");
// copy all dlls recursively in the main directory to the shadow copy directory
// being sure to ignore symbol and resource folders
CopyFilesInDirectory(tixlCtx.MainDirectory, tixlCtx.MainDirectory, shadowCopyDirectory, false);
// now search subfolders, excluding ignored folders
foreach (var dir in Directory.EnumerateDirectories(tixlCtx.MainDirectory, "*", SearchOption.TopDirectoryOnly))
{
var directoryName = Path.GetFileName(dir);
if (directoryName.StartsWith('.') ||
directoryName.Equals("bin", StringComparison.Ordinal) ||
directoryName.Equals("obj", StringComparison.Ordinal) ||
directoryName.Equals(FileLocations.ReleaseSymbolsSubfolder, StringComparison.Ordinal) ||
directoryName.Equals(FileLocations.SymbolUiSubFolder, StringComparison.Ordinal) ||
directoryName.Equals(FileLocations.AssetsSubfolder, StringComparison.Ordinal) ||
directoryName.Equals(FileLocations.SourceCodeSubFolder, StringComparison.Ordinal))
{
continue; // skip hidden, bin, obj and resources folders
}
CopyFilesInDirectory(tixlCtx.MainDirectory, dir, shadowCopyDirectory, true);
}
}
// replace path with the shadow copy directory
var relativePath = Path.GetRelativePath(tixlCtx.MainDirectory, path);
path = Path.Combine(shadowCopyDirectory, relativePath);
if (CoreSettings.Config.LogAssemblyLoadingDetails)
Log.Debug($"{ctx.Name}: Loading assembly from '{path}'...");
return ctx.LoadFromAssemblyPath(path);
static void CopyFilesInDirectory(string rootDirectory, string directory, string shadowCopyDirectory, bool recursive)
{
var newDirectory = directory != rootDirectory
? Path.Combine(shadowCopyDirectory, Path.GetRelativePath(rootDirectory, directory))
: shadowCopyDirectory;
Directory.CreateDirectory(newDirectory);
foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly))
{
if (!file.EndsWith(".dll") &&
!file.EndsWith(".exe") &&
!file.EndsWith(".pdb") &&
!file.EndsWith(".so") &&
!file.EndsWith(".xml") &&
!file.EndsWith(".json"))
{
continue;
}
var newPath = Path.Combine(newDirectory, Path.GetFileName(file));
File.Copy(file, newPath, true);
}
if (!recursive)
return;
foreach (var subDir in Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly))
{
CopyFilesInDirectory(rootDirectory, subDir, shadowCopyDirectory, true);
}
}
}
// called if Load method returns null - searches other contexts and nuget packages
private Assembly? OnResolving(AssemblyName asmName)
{
var name = asmName.GetName();
#if DEBUG
if (_unloaded)
{
Log.Error($"{Name!}: Attempted to resolve assembly {name} after unload");
return null;
}
#endif
// try other assembly contexts
lock (_loadContextLock)
{
// try to find existing in others
foreach (var ctx in _loadContexts)
{
if (ctx == this)
continue;
var root = ctx.Root;
if (root == null)
continue;
if (root.TryFindExisting(name, out var asmNode))
{
// add the dependency to our context
AddDependency(asmNode);
LogResolution(asmNode.Assembly, asmName);
return asmNode.Assembly;
}
}
// try to find unreferenced in others
foreach (var ctx in _loadContexts)
{
if (ctx == this)
continue;
var root = ctx.Root;
if (root == null)
continue;
if (root.TryFindUnreferenced(name, out var asmNode))
{
// add the dependency to our context
AddDependency(asmNode);
LogResolution(asmNode.Assembly, asmName);
return asmNode.Assembly;
}
}
}
// check nuget packages
var result = SearchNugetForAssemblies(asmName, name);
LogResolution(result, asmName);
return result;
void LogResolution(Assembly? resultAsm, AssemblyName searchName)
{
if (!CoreSettings.Config.LogAssemblyLoadingDetails)
return;
if (resultAsm != null)
{
// check versions of the assembly - if different, log a warning.
// todo: actually do something with this information later
if (CoreSettings.Config.LogAssemblyVersionMismatches)
{
var assemblyNameOfResult = resultAsm.GetName();
if (assemblyNameOfResult.Version != searchName.Version)
{
Log.Warning($"Assembly {searchName.Name} loaded with different version: {assemblyNameOfResult.Version} vs {searchName.Version}");
}
}
if (resultAsm.GetName().Name != searchName.Name)
{
Log.Error($"{Name!}: Resolved assembly name mismatch: {resultAsm.GetName().Name} != {searchName.Name}");
}
else
{
Log.Debug($"{Name!}: Resolved assembly {resultAsm.GetName().Name}");
}
}
else
{
Log.Error($"{Name!}: Failed to resolve assembly '{searchName.Name}'");
}
}
}
protected override Assembly? Load(AssemblyName assemblyName)
{
#if DEBUG
if (_unloaded)
{
Log.Error($"{Name!}: Attempted to load assembly {assemblyName} after unload");
return null;
}
#endif
var name = assemblyName.GetName();
foreach (var coreRef in CoreNodes)
{
if (coreRef.TryFindExisting(name, out var coreAssembly))
{
AddDependency(coreAssembly);
return coreAssembly.Assembly;
}
if (coreRef.TryFindUnreferenced(name, out coreAssembly))
{
AddDependency(coreAssembly);
return coreAssembly.Assembly;
}
}
if (Root is null)
{
Log.Error($"{Name!}: Root is null, cannot resolve assembly {name}");
return null;
}
if (Root!.TryFindExisting(name, out var node))
{
AddDependency(node);
return node.Assembly;
}
if (Root!.TryFindUnreferenced(name, out node))
{
AddDependency(node);
return node.Assembly;
}
return OnResolving(assemblyName);
}
private static IntPtr NativeDllResolverStatic(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
//Log.Debug($"{assembly.FullName}: Resolving native dll {libraryName} for assembly {assembly.FullName}");
const DllImportSearchPath defaultSearchPath = DllImportSearchPath.AssemblyDirectory
| DllImportSearchPath.UseDllDirectoryForDependencies
| DllImportSearchPath.ApplicationDirectory;
var search = searchPath ?? defaultSearchPath;
if (NativeLibrary.TryLoad(libraryName, assembly, search, out var handle))
{
//Log.Debug($"{assembly.FullName!}: Successfully resolved native dll {libraryName}");
return handle;
}
Log.Error($"{assembly.FullName!}: Failed to resolve native dll {libraryName} relative to assembly '{assembly.Location}'");
return IntPtr.Zero;
}
private IntPtr NativeDllResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
//Log.Debug($"{assembly.FullName}: Resolving native dll {libraryName} for assembly {assembly.FullName}");
const DllImportSearchPath defaultSearchPath = DllImportSearchPath.AssemblyDirectory
| DllImportSearchPath.UseDllDirectoryForDependencies
| DllImportSearchPath.ApplicationDirectory;
var search = searchPath ?? defaultSearchPath;
if (NativeLibrary.TryLoad(libraryName, assembly, search, out var handle))
{
//Log.Debug($"{assembly.FullName!}: Successfully resolved native dll {libraryName}");
return handle;
}
if (assembly != Root!.Assembly && NativeLibrary.TryLoad(libraryName, Root.Assembly, search, out handle))
{
return handle;
}
Log.Error($"{assembly.FullName!}: Failed to resolve native dll {libraryName} relative to assembly '{assembly.Location}'");
return IntPtr.Zero;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
// manual dll resolution with the potential of having nothing but the name of the dll sans the extension
string fullPath;
bool pathFullyQualified = false;
if (Path.IsPathFullyQualified(unmanagedDllName))
{
fullPath = unmanagedDllName;
pathFullyQualified = true;
}
else if (!unmanagedDllName.EndsWith(".dll"))
{
fullPath = Path.Combine(MainDirectory, unmanagedDllName + ".dll");
}
else
{
fullPath = Path.Combine(MainDirectory, unmanagedDllName);
}
if (File.Exists(fullPath))
{
try
{
return LoadUnmanagedDllFromPath(fullPath);
}
catch (Exception e)
{
Log.Error($"{Name!}: Failed to load unmanaged dll {unmanagedDllName} from path {fullPath}: {e}");
return IntPtr.Zero;
}
}
// check for the .so extension on linux/mac
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ||
RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD))
{
var unixPath = pathFullyQualified
? Path.ChangeExtension(fullPath, ".so")
: Path.Combine(MainDirectory, unmanagedDllName + ".so");
if (File.Exists(unixPath))
{
try
{
return LoadUnmanagedDllFromPath(unixPath);
}
catch (Exception e)
{
Log.Error($"{Name!}: Failed to load unmanaged dll {unmanagedDllName} from unix path {unixPath}: {e}");
return IntPtr.Zero;
}
}
}
return IntPtr.Zero;
}
private void AddDependency(AssemblyTreeNode node)
{
_ = Root!.AddReferenceTo(node);
var ctx = node.LoadContext;
if (ctx == this)
return;
lock (_dependencyLock)
{
if (!_dependencyContexts.Contains(ctx))
{
// subscribe to the unload event of the dependency context
if (ctx is TixlAssemblyLoadContext tixlCtx)
{
tixlCtx.UnloadBeganInternal += OnDependencyUnloaded;
}
else
{
ctx.Unloading += OnNonTixlDependencyUnloaded;
}
_dependencyContexts.Add(ctx);
if (CoreSettings.Config.LogAssemblyLoadingDetails)
Log.Debug($"{Name!}: Added dependency {node.Name} from {ctx.Name}");
}
}
}
private void OnNonTixlDependencyUnloaded(AssemblyLoadContext ctx)
{
ctx.Unloading -= OnNonTixlDependencyUnloaded;
RemoveDependency(ctx);
}
private void OnDependencyUnloaded(object? sender, EventArgs e)
{
var ctx = (TixlAssemblyLoadContext)sender!;
ctx.UnloadBeganInternal -= OnDependencyUnloaded;
RemoveDependency(ctx);
}
private void RemoveDependency(AssemblyLoadContext ctx)
{
lock (_dependencyLock)
{
_dependencyContexts.Remove(ctx);
BeginUnload(); // begin unloading ourselves too
}
}
internal void BeginUnload()
{
if (_unloaded)
{
var stackTrace = new System.Diagnostics.StackTrace();
var frames = stackTrace.GetFrames();
if (frames is { Length: > 0 } && frames.Any(f =>
f.GetMethod()?.DeclaringType?.FullName == "System.Runtime.Loader.AssemblyLoadContext"
&& f.GetMethod()?.Name == "OnProcessExit"))
{
Log.Debug($"{Name}: BeginUnload called during shutdown but was already unloaded.");
return; // Suppress exception during shutdown
}
throw new InvalidOperationException($"Assembly context {Name} already unloaded");
}
_unloaded = true;
lock (_dependencyLock)
{
// unsubscribe from all our dependencies
for (int i = _dependencyContexts.Count - 1; i >= 0; i--)
{
var ctx = _dependencyContexts[i];
if (ctx is TixlAssemblyLoadContext tixlCtx)
{
tixlCtx.UnloadBeganInternal -= OnDependencyUnloaded;
}
else
{
ctx.Unloading -= OnNonTixlDependencyUnloaded;
}
_dependencyContexts.RemoveAt(i);
}
}
lock (_loadContextLock)
{
_loadContexts.Remove(this);
}
Root = null; // dereference our assembly as we will need to reload it
try
{
UnloadBeganInternal?.Invoke(this, EventArgs.Empty);
}
catch (Exception e)
{
Log.Error($"{Name!}: Exception thrown on assembly unload (internal): {e}");
}
try
{
UnloadBegan?.Invoke(this, EventArgs.Empty);
}
catch (Exception e)
{
Log.Error($"{Name!}: Exception thrown on assembly unload: {e}");
}
Unload();
}
/// <summary>
/// Each run shadow-copies its assemblies into a per-process subfolder; closed or crashed instances never
/// clean theirs up, so they pile up across rebuilds. Remove every folder that isn't owned by a still-running
/// process — those are skipped because their assemblies are loaded and locked.
/// </summary>
private static void CleanUpStaleShadowCopies()
{
// Runs in the static constructor: any escaping exception would poison the type initializer
// and make every project fail to load, so cleanup must never throw.
try
{
var rootFolder = ShadowCopyRootFolder;
if (!Directory.Exists(rootFolder))
return;
var currentProcessId = Environment.ProcessId;
foreach (var processFolder in Directory.EnumerateDirectories(rootFolder))
{
var folderName = Path.GetFileName(processFolder);
if (int.TryParse(folderName, out var processId)
&& processId != currentProcessId
&& IsProcessRunning(processId))
{
continue;
}
try
{
Directory.Delete(processFolder, true);
}
catch (Exception e)
{
Log.Debug($"Failed to delete shadow copy directory {processFolder}: {e.Message}");
}
}
}
catch (Exception e)
{
Log.Warning($"Failed to clean up stale shadow copies: {e.Message}");
}
}
private static bool IsProcessRunning(int processId)
{
try
{
using var process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch (ArgumentException)
{
return false;
}
catch (Exception)
{
// E.g. access denied when the pid was reused by an elevated or protected process.
// If we can't tell, assume it's running and keep its folder - it gets cleaned next run.
return true;
}
}
}
internal static class AssemblyNameExtensions
{
public static string GetName(this AssemblyName asmName) => asmName.FullName;
}