Files
2026-07-13 13:13:17 +08:00

470 lines
16 KiB
C#

#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading;
using T3.Core.IO;
using T3.Core.Logging;
using T3.Core.Utils;
using T3.Serialization;
namespace T3.Core.Compilation;
/// <summary>
/// This class is used as the primary entry point for loading assemblies and extracting information about the types within them.
/// This is where we find all of the operators and their slots, as well as any other type implementations that are relevant to tooll.
/// This is also where C# dependencies need to be resolved, which is why each instance of this class has a reference to a <see cref="TixlAssemblyLoadContext"/>.
/// </summary>
public sealed partial class AssemblyInformation
{
public string Name
{
get => _name ?? RootNamespace; // Fallback to RootNamespace if _name is null
private set => _name = value;
}
private string? _name;
public string RootNamespace
{
get
{
if (TryGetReleaseInfo(out var releaseInfo))
return releaseInfo.RootNamespace;
return string.Empty;
}
}
public string Directory => _directory!;
internal Guid Id =>
TryGetReleaseInfo(out var releaseInfo)
? releaseInfo.PackageId
: Guid.Empty;
public bool IsLoaded => _loadContext != null;
/// <summary>
/// True if the package has an OperatorPackage.json but no compiled assembly DLL beside it (e.g. an
/// asset-only package exported to the Player). Such packages still need to be registered as
/// <see cref="T3.Core.Model.SymbolPackage"/> instances so their assets can be resolved, but they
/// have no managed types to load.
/// </summary>
public bool IsAssetsOnly { get; private set; }
private bool _loadedTypes;
public event Action<AssemblyInformation>? Unloaded;
public event Action<AssemblyInformation>? UnloadComplete;
public event Action<AssemblyInformation>? Loaded;
internal const BindingFlags ConstructorBindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.CreateInstance;
private WeakReference? _ctxReference;
private WeakReference? _asmReference;
public IReadOnlyDictionary<Guid, OperatorTypeInfo> OperatorTypeInfo => _operatorTypeInfo;
private readonly ConcurrentDictionary<Guid, OperatorTypeInfo> _operatorTypeInfo = new();
private readonly Dictionary<string, Type> _types = new();
public IReadOnlySet<string> Namespaces => _namespaces;
private readonly HashSet<string> _namespaces = [];
internal bool ShouldShareResources;
internal TixlAssemblyLoadContext? LoadContext => _loadContext;
private TixlAssemblyLoadContext? _loadContext;
private readonly Lock _assemblyLock = new();
public static AssemblyInformation CreateUninitialized()
{
return new AssemblyInformation();
}
private string? _directory;
private bool _isReadOnly;
private bool _initialized;
private ReleaseInfo? _releaseInfo;
/// <summary>
/// Constructor used for creating an uninitialized instance of <see cref="AssemblyInformation"/>.
/// Useful for creating an instance without initializing it immediately - at time of writing, this is how editable symbol projects are created.
/// </summary>
private AssemblyInformation()
{
Name = null!;
}
/// <summary>
/// Constructor used for creating a read-only instance of <see cref="AssemblyInformation"/> with the given directory.
/// </summary>
/// <param name="directory"></param>
public AssemblyInformation(string directory)
{
ArgumentNullException.ThrowIfNull(directory);
Initialize(directory, true);
Name=string.Empty;
}
public void Initialize(string directory, bool isReadOnly)
{
if(_initialized)
throw new InvalidOperationException($"Cannot initialize assembly information for {Name} - already initialized");
_isReadOnly = isReadOnly;
_directory = directory;
_initialized = true;
if(CoreSettings.Config.LogCompilationDetails)
Log.Debug($"{Name}: Assembly information initialized");
}
/// <summary>
/// Runs this assembly's module initializer(s) if it is loaded — used to eagerly fire a package's
/// <c>[ModuleInitializer]</c> (e.g. cross-load-context service registration) without instantiating any of
/// its operators. No-op when the assembly isn't loaded or has no module initializer; idempotent (the CLR
/// runs a module's initializer at most once).
/// </summary>
public void RunModuleInitializers()
{
var assembly = _loadContext?.Root?.Assembly;
if (assembly == null)
return;
try
{
System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor(assembly.ManifestModule.ModuleHandle);
}
catch (Exception e)
{
Log.Warning($"{Name}: module initializer failed - {e.Message}");
}
}
/// <summary>
/// The entry point for loading the assembly and extracting information about the types within it - particularly the operators.
/// However, loading an assembly's types in this way will also trigger the <see cref="TixlAssemblyLoadContext"/> so that its dependencies are resolved.
/// </summary>
internal bool TryLoadTypes()
{
lock (_assemblyLock)
{
if (_loadContext == null && !IsAssetsOnly)
{
GenerateLoadContext();
if (_loadContext == null && !IsAssetsOnly)
return false;
}
if (IsAssetsOnly)
{
// Assets-only packages have no managed types - report success with an empty type set.
_loadedTypes = true;
ShouldShareResources = false;
return true;
}
var rootNode = _loadContext!.Root;
if (rootNode != null && _loadedTypes)
{
Log.Debug($"{Name}: Already loaded types");
return true;
}
if (rootNode == null)
{
Log.Error($"Failed to get assembly for {Name}");
ShouldShareResources = false;
return false;
}
try
{
var types = rootNode.Assembly.GetTypes();
LoadTypes(types, rootNode.Assembly, out ShouldShareResources, _operatorTypeInfo, _namespaces, _types);
_loadedTypes = true;
return true;
}
catch (Exception e)
{
Log.Warning($"Failed to load types from assembly {rootNode.Assembly.FullName}\n{e.Message}\n{e.StackTrace}");
_types.Clear();
ShouldShareResources = false;
_loadedTypes = true;
return false;
}
}
}
public IEnumerable<Type> TypesInheritingFrom(Type type)
{
lock (_assemblyLock)
{
if (!_loadedTypes && !TryLoadTypes())
{
return [];
}
return _types!.Values.Where(t => t.IsAssignableTo(type));
}
}
public void ChangeAssemblyDirectory(string directory)
{
if(_loadContext != null)
{
throw new InvalidOperationException($"Cannot change directory of assembly {Name} while it is loaded");
}
_directory = directory;
}
private void Unloading(AssemblyLoadContext obj) // todo: this references prevents gc unload from within this function
{
Log.Debug($"{Name}: Assembly actually unloading");
obj.Unloading -= Unloading;
/*
* We need to dereference the assembly to allow its types to be unloaded, allowing us to recompile into the same directory.
* This is done by forcing a garbage collection and waiting for finalizers to complete.
* If the references are still alive after a certain number of attempts, we log a warning.
*/
/*
obj = null!;
const int maxTryCount = 10;
int i = 0;
for (; _asmReference!.IsAlive && (i < maxTryCount); i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
if (i >= maxTryCount)
{
Log.Warning($"Failed to unload assembly {Name} - reference is still alive after {maxTryCount} attempts");
}
else
{
Log.Debug($"{Name}: Assembly completed unloaded after {i} attempts");
}
int t = 0;
for(; t < maxTryCount && _ctxReference!.IsAlive; t++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
if (t >= maxTryCount)
{
Log.Warning($"Failed to unload context {Name} - reference is still alive after {maxTryCount} attempts");
}
else
{
Log.Debug($"{Name}: Context completed unload after {t} attempts");
}
var name = Name;
var existingReferencesToMe = AssemblyLoadContext.All
.Where(x => x.Assemblies.Any(asm => asm.FullName != null && asm.FullName.Contains(name)))
.ToArray();
if (existingReferencesToMe.Length != 0)
{
var sb = new StringBuilder();
sb.Append("Assembly Info ")
.Append(name)
.Append(" failed to free its context. ")
.Append(existingReferencesToMe.Length)
.Append(" other contexts have references to it:\n");
foreach(var refHolder in existingReferencesToMe)
{
sb.Append('\t')
.Append(refHolder.Name)
.Append('\n');
}
Log.Error(sb.ToString());
}
*/
_ctxReference = null;
_asmReference = null;
try
{
UnloadComplete?.Invoke(this);
}
catch (Exception ex)
{
Log.Error($"Failed to invoke {nameof(UnloadComplete)} for {Name}: {ex}");
}
}
private void OnUnloadBegan(object? sender, EventArgs e)
{
lock (_assemblyLock)
{
_loadContext!.UnloadBegan -= OnUnloadBegan;
_loadContext = null;
_loadedTypes = false;
_releaseInfo = null;
_operatorTypeInfo.Clear();
_types.Clear(); // explicitly dereference all our types
_namespaces.Clear();
Log.Debug($"{Name}: Assembly information unloaded");
try
{
Unloaded?.Invoke(this);
}
catch (Exception ex)
{
Log.Error($"Failed to invoke Unloaded event for {Name}: {ex}");
}
}
}
/// <summary>
/// Does all within its power to unload the assembly from its load context.
/// In order for an assembly to properly be unloaded, ALL references to it, including existing instances, references to its types, etc,
/// must be released and dereferenced.
/// </summary>
public void Unload()
{
if (!IsLoaded)
return;
_loadContext?.BeginUnload();
_loadContext = null;
}
/// <summary>
/// Tries to get the release info for the package by looking for <see cref="RuntimeAssemblies.PackageInfoFileName"/> in the directory of the assembly.
/// </summary>
/// <param name="releaseInfo"></param>
/// <returns></returns>
public bool TryGetReleaseInfo([NotNullWhen(true)] out ReleaseInfo? releaseInfo)
{
if (_releaseInfo == null && _directory != null)
{
TryLoadReleaseInfo(_directory, out _releaseInfo);
}
releaseInfo = _releaseInfo;
return releaseInfo != null;
}
public static bool TryLoadReleaseInfo(string directory, [NotNullWhen(true)] out ReleaseInfo? releaseInfo)
{
var filePath = Path.Combine(directory, ReleaseInfo.FileName);
if (!JsonUtils.TryLoadingJson<ReleaseInfoSerialized>(filePath, out var releaseInfoSerialized))
{
Log.Warning($"Could not load package info from path {filePath}");
releaseInfo = null;
return false;
}
releaseInfo = releaseInfoSerialized.ToReleaseInfo();
return true;
}
/// <summary>
/// Returns true if the given package reference matches the given release info.
/// </summary>
public static bool Matches(OperatorPackageReference reference, ReleaseInfo releaseInfo)
{
if (reference.ResourcesOnly)
return false;
var identity = reference.Identity;
var assemblyFileName = releaseInfo.AssemblyFileName;
// todo : version checks
return identity.SequenceEqual(assemblyFileName);
}
/// <summary>
/// Creates an instance of the given type using this assembly via (slow) reflection.
/// </summary>
public object? CreateInstance(Type constructorInfoInstanceType)
{
var assembly = _loadContext!.Root?.Assembly;
if (assembly == null)
{
Log.Error($"Failed to get assembly for {Name}");
return null;
}
return assembly.CreateInstance(constructorInfoInstanceType.FullName!);
}
public void GenerateLoadContext()
{
if(!_initialized)
throw new InvalidOperationException($"Cannot generate load context for {Name} - not initialized");
lock (_assemblyLock)
{
if (_loadContext != null)
return;
if (_directory == null)
{
throw new InvalidOperationException($"Cannot create load context for {Name} - directory is null");
}
ReleaseInfo? releaseInfo;
try
{
if (!TryLoadReleaseInfo(_directory, out releaseInfo))
{
throw new Exception($"Failed to load release info from {_directory} - does it need to be compiled?");
}
// Assets-only packages (e.g. exported to the Player) ship just an OperatorPackage.json
// with no compiled assembly. Register them without creating a load context so the
// asset registry can still resolve their files.
var assemblyPath = Path.Combine(_directory, releaseInfo.AssemblyFileName + ".dll");
if (!File.Exists(assemblyPath))
{
Log.Debug($"{releaseInfo.AssemblyFileName}: No assembly file at \"{assemblyPath}\" - treating as assets-only package");
IsAssetsOnly = true;
_releaseInfo = releaseInfo;
Name = releaseInfo.AssemblyFileName;
return;
}
_loadContext = new TixlAssemblyLoadContext(releaseInfo.AssemblyFileName, Directory, _isReadOnly);
var asm = _loadContext.Root!.Assembly;
_asmReference = new WeakReference(asm, true);
_ctxReference = new WeakReference(_loadContext, true);
}
catch (Exception e)
{
// Log e itself (not just Message) so inner exceptions of e.g. TypeInitializationException show up
Log.Error($"Failed to create assembly load context for {Name}\n{e}");
_loadContext = null;
return;
}
_releaseInfo = releaseInfo;
_loadContext.UnloadBegan += OnUnloadBegan;
_loadContext.Unloading += Unloading;
Name = _loadContext!.Name!;
try
{
Loaded?.Invoke(this);
}
catch (Exception ex)
{
Log.Error($"Failed to invoke Loaded event for {Name}: {ex}");
}
}
}
}