diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs index 0d8afed6..2b2ce6ad 100644 --- a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -58,6 +58,65 @@ public void GetDerivedClassesFindsTypesInReferencingAssemblies() ReflectionUtils.GetDerivedClasses()); } + [Fact] + public void GetDerivedClassesReturnsAFreshListEachCall() + { + List first = ReflectionUtils.GetDerivedClasses(); + first.Clear(); + + Assert.Contains(typeof(DerivedLogShape), ReflectionUtils.GetDerivedClasses()); + Assert.NotSame(ReflectionUtils.GetDerivedClasses(), ReflectionUtils.GetDerivedClasses()); + } + + [Fact] + public void GetDerivedClassesGivesTheSameTypesAfterInvalidation() + { + var before = ReflectionUtils.GetDerivedClasses(); + ReflectionUtils.InvalidateDerivedClassCache(); + var after = ReflectionUtils.GetDerivedClasses(); + + Assert.Equal(before.OrderBy(t => t.FullName), after.OrderBy(t => t.FullName)); + } + + [Fact] + public void GetDerivedClassesSeesTypesAddedToADynamicAssemblyAfterCaching() + { + var name = new AssemblyName($"S1API.ReflectionUtilsTests.Late.{Guid.NewGuid():N}"); + var assembly = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule(name.Name!); + ReflectionUtils.GetDerivedClasses(); // cache built while the dynamic assembly has no types + + TypeBuilder builder = module.DefineType("LateLog", TypeAttributes.Public | TypeAttributes.Class, typeof(Log)); + var ctor = builder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var il = ctor.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldstr, "late"); + il.Emit(OpCodes.Call, typeof(Log).GetConstructor(new[] { typeof(string) })!); + il.Emit(OpCodes.Ret); + Type late = builder.CreateType()!; + + Assert.Contains(late, ReflectionUtils.GetDerivedClasses()); + } + + [Fact] + public void GetDerivedClassesSeesAnAssemblyLoadedAfterCaching() + { + ReflectionUtils.GetDerivedClasses(); + var name = new AssemblyName($"S1API.ReflectionUtilsTests.NewAssembly.{Guid.NewGuid():N}"); + var assembly = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule(name.Name!); + TypeBuilder builder = module.DefineType("NewAssemblyLog", TypeAttributes.Public | TypeAttributes.Class, typeof(Log)); + var ctor = builder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var il = ctor.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldstr, "new-assembly"); + il.Emit(OpCodes.Call, typeof(Log).GetConstructor(new[] { typeof(string) })!); + il.Emit(OpCodes.Ret); + Type late = builder.CreateType()!; + + Assert.Contains(late, ReflectionUtils.GetDerivedClasses()); + } + [Fact] public void DerivedTypeScanExcludesAssembliesWithoutAReferencePathToTheBaseAssembly() { diff --git a/S1API/Internal/Utils/ReflectionUtils.cs b/S1API/Internal/Utils/ReflectionUtils.cs index a2ec62db..4c21dec7 100644 --- a/S1API/Internal/Utils/ReflectionUtils.cs +++ b/S1API/Internal/Utils/ReflectionUtils.cs @@ -30,48 +30,131 @@ internal static class ReflectionUtils /// A list of all types derived from the base class. internal static List GetDerivedClasses() { - List derivedClasses = new List(); Type baseType = typeof(TBaseClass); + ScanPlan? plan; + long generation; + lock (DerivedClassCacheLock) + { + DerivedClassCache.TryGetValue(baseType, out plan); + generation = _assemblyGeneration; + } + + if (plan == null) + { + plan = BuildScanPlan(baseType); + lock (DerivedClassCacheLock) + { + // Not stored if an assembly loaded while scanning. + if (generation == _assemblyGeneration) + DerivedClassCache[baseType] = plan; + } + } + + // A fresh list every call: some callers sort or modify the result. + var derivedClasses = new List(); + IReadOnlyDictionary? index = null; + foreach (ScanPlanEntry entry in plan.Entries) + { + if (entry.Types != null) + { + derivedClasses.AddRange(entry.Types); + } + else if (CanContainTypesDerivedFrom(entry.Assembly, baseType.Assembly.GetName(), + index ??= IndexAssembliesBySimpleName(AppDomain.CurrentDomain.GetAssemblies()))) + { + AddDerivedTypes(baseType, entry.Assembly, derivedClasses); + } + } + return derivedClasses; + } + + // Ordinary assemblies' results are kept until another assembly loads. Dynamic assemblies (every modded game + // has some) can gain types without an AssemblyLoad event, so they are re-scanned on every call. + private sealed class ScanPlanEntry + { + internal Assembly Assembly = null!; + internal Type[]? Types; // null: dynamic, scanned per call + } + + private sealed class ScanPlan + { + internal ScanPlanEntry[] Entries = Array.Empty(); + } + + private static readonly object DerivedClassCacheLock = new object(); + private static readonly Dictionary DerivedClassCache = new Dictionary(); + private static long _assemblyGeneration; + + static ReflectionUtils() + { + AppDomain.CurrentDomain.AssemblyLoad += (_, _) => InvalidateDerivedClassCache(); + } + + internal static void InvalidateDerivedClassCache() + { + lock (DerivedClassCacheLock) + { + _assemblyGeneration++; + DerivedClassCache.Clear(); + } + } + + private static ScanPlan BuildScanPlan(Type baseType) + { Assembly baseAssembly = baseType.Assembly; Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); IReadOnlyDictionary assembliesBySimpleName = IndexAssembliesBySimpleName(loadedAssemblies); - Assembly[] applicableAssemblies = loadedAssemblies - .Where(assembly => assembly == baseAssembly || !ShouldSkipAssembly(assembly)) - .Where(assembly => CanContainTypesDerivedFrom( - assembly, - baseAssembly.GetName(), - assembliesBySimpleName)) - .ToArray(); + var entries = new List(); + int scanned = 0; + foreach (Assembly assembly in loadedAssemblies) + { + if (assembly != baseAssembly && ShouldSkipAssembly(assembly)) + continue; + if (assembly.IsDynamic) + { + // Kept even if it can't contain derived types yet; eligibility is re-checked per call. + entries.Add(new ScanPlanEntry { Assembly = assembly }); + continue; + } + if (!CanContainTypesDerivedFrom(assembly, baseAssembly.GetName(), assembliesBySimpleName)) + continue; + var types = new List(); + AddDerivedTypes(baseType, assembly, types); + entries.Add(new ScanPlanEntry { Assembly = assembly, Types = types.ToArray() }); + scanned++; + } Logger.Debug( - $"[S1API][Reflection] Scanning {applicableAssemblies.Length} of {loadedAssemblies.Length} " + - $"loaded assemblies for types derived from '{baseType.FullName}'."); + $"[S1API][Reflection] Scanned {scanned} of {loadedAssemblies.Length} loaded assemblies for types " + + $"derived from '{baseType.FullName}'."); + return new ScanPlan { Entries = entries.ToArray() }; + } - foreach (Assembly assembly in applicableAssemblies) - foreach (Type type in SafeGetTypes(assembly)) + private static void AddDerivedTypes(Type baseType, Assembly assembly, List derivedClasses) + { + foreach (Type type in SafeGetTypes(assembly)) + { + try { - try - { - if (type == null) - continue; - if (baseType.IsAssignableFrom(type) - && type != baseType - && !type.IsAbstract) - { - derivedClasses.Add(type); - } - } - catch (TypeLoadException) - { - // Ideally, we'd log this, but can be noisy and we've got no logger elsewhere - } - catch (Exception) + if (type == null) + continue; + if (baseType.IsAssignableFrom(type) + && type != baseType + && !type.IsAbstract) { - // Catch-all for anything else (e.g., MissingMethodException) + derivedClasses.Add(type); } } - return derivedClasses; + catch (TypeLoadException) + { + // Ideally, we'd log this, but can be noisy and we've got no logger elsewhere + } + catch (Exception) + { + // Catch-all for anything else (e.g., MissingMethodException) + } + } } internal static bool CanContainTypesDerivedFrom(