From 6f434135efe66aa596acac9ac4a0233a0e7d209f Mon Sep 17 00:00:00 2001 From: soqb Date: Tue, 6 May 2025 01:10:24 +0100 Subject: [PATCH 1/7] feat: support for finalizers and stop leaking injected objects --- .gitignore | 3 + Il2CppInterop.Runtime/DelegateSupport.cs | 39 +++--- .../Injection/ClassInjector.cs | 120 ++++++++++++------ .../InteropTypes/Il2CppObjectBase.cs | 58 +++++++-- .../Runtime/Il2CppObjectPool.cs | 89 +++++++++++-- 5 files changed, 225 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index ed478515..3e1853d2 100644 --- a/.gitignore +++ b/.gitignore @@ -223,6 +223,9 @@ _pkginfo.txt # but keep track of directories ending in .cache !?*.[Cc]ache/ +# Helix Editor local configuration files +/.helix/ + # Others ClientBin/ ~$* diff --git a/Il2CppInterop.Runtime/DelegateSupport.cs b/Il2CppInterop.Runtime/DelegateSupport.cs index 88a6528b..fb060f75 100644 --- a/Il2CppInterop.Runtime/DelegateSupport.cs +++ b/Il2CppInterop.Runtime/DelegateSupport.cs @@ -8,6 +8,7 @@ using Il2CppInterop.Common; using Il2CppInterop.Runtime.Injection; using Il2CppInterop.Runtime.InteropTypes; +using Il2CppInterop.Runtime.InteropTypes.Fields; using Il2CppInterop.Runtime.Runtime; using Microsoft.Extensions.Logging; using Object = Il2CppSystem.Object; @@ -133,10 +134,14 @@ private static Delegate GenerateNativeToManagedTrampoline(Il2CppSystem.Reflectio bodyBuilder.Emit(OpCodes.Ldarg_0); bodyBuilder.Emit(OpCodes.Call, - typeof(ClassInjectorBase).GetMethod(nameof(ClassInjectorBase.GetMonoObjectFromIl2CppPointer))!); - bodyBuilder.Emit(OpCodes.Castclass, typeof(Il2CppToMonoDelegateReference)); + typeof(Il2CppObjectPool).GetMethod(nameof(Il2CppObjectPool.Get))! + .MakeGenericMethod(typeof(Il2CppToMonoDelegateReference))); bodyBuilder.Emit(OpCodes.Ldfld, - typeof(Il2CppToMonoDelegateReference).GetField(nameof(Il2CppToMonoDelegateReference.ReferencedDelegate))); + typeof(Il2CppToMonoDelegateReference).GetField(nameof(Il2CppToMonoDelegateReference.MethodInfo))); + bodyBuilder.Emit(OpCodes.Call, + typeof(Il2CppValueField).GetMethod(nameof(Il2CppValueField.Get))); + bodyBuilder.Emit(OpCodes.Call, + typeof(DelegateSupport).GetMethod(nameof(DelegateSupport.GetStoredDelegate), BindingFlags.NonPublic | BindingFlags.Static)); for (var i = 0; i < managedParameters.Length; i++) { @@ -190,15 +195,9 @@ private static Delegate GenerateNativeToManagedTrampoline(Il2CppSystem.Reflectio bodyBuilder.Emit(OpCodes.Stloc, returnLocal); } - var exceptionLocal = bodyBuilder.DeclareLocal(typeof(Exception)); bodyBuilder.BeginCatchBlock(typeof(Exception)); - bodyBuilder.Emit(OpCodes.Stloc, exceptionLocal); + bodyBuilder.Emit(OpCodes.Call, typeof(DelegateSupport).GetMethod(nameof(LogException), BindingFlags.Static | BindingFlags.NonPublic)!); bodyBuilder.Emit(OpCodes.Ldstr, "Exception in IL2CPP-to-Managed trampoline, not passing it to il2cpp: "); - bodyBuilder.Emit(OpCodes.Ldloc, exceptionLocal); - bodyBuilder.Emit(OpCodes.Callvirt, typeof(object).GetMethod(nameof(ToString))!); - bodyBuilder.Emit(OpCodes.Call, - typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!); - bodyBuilder.Emit(OpCodes.Call, typeof(DelegateSupport).GetMethod(nameof(LogError), BindingFlags.Static | BindingFlags.NonPublic)!); bodyBuilder.EndExceptionBlock(); @@ -209,9 +208,9 @@ private static Delegate GenerateNativeToManagedTrampoline(Il2CppSystem.Reflectio return trampoline.CreateDelegate(GetOrCreateDelegateType(signature, managedMethod)); } - private static void LogError(string message) + private static void LogException(Exception exception) { - Logger.Instance.LogError("{Message}", message); + Logger.Instance.LogError($"Exception in IL2CPP-to-Managed trampoline, not passing it to il2cpp: {exception}"); } public static TIl2Cpp? ConvertDelegate(Delegate @delegate) where TIl2Cpp : Il2CppObjectBase @@ -289,6 +288,8 @@ private static void LogError(string message) methodInfo.IsMarshalledFromNative = true; var delegateReference = new Il2CppToMonoDelegateReference(@delegate, methodInfo.Pointer); + // Leak the object so we never have to do this again. + GCHandle.Alloc(delegateReference); Il2CppSystem.Delegate converted; if (UnityVersionHandler.MustUseDelegateConstructor) @@ -317,6 +318,9 @@ private static void LogError(string message) return converted.Cast(); } + private static readonly ConcurrentDictionary s_storedDelegates = new(); + private static Delegate GetStoredDelegate(IntPtr methodInfoPtr) => s_storedDelegates[methodInfoPtr]; + internal class MethodSignature : IEquatable { public readonly bool ConstructedFromNative; @@ -390,8 +394,7 @@ public override bool Equals(object obj) private class Il2CppToMonoDelegateReference : Object { - public IntPtr MethodInfo; - public Delegate ReferencedDelegate; + public Il2CppValueField MethodInfo; public Il2CppToMonoDelegateReference(IntPtr obj0) : base(obj0) { @@ -402,15 +405,15 @@ public Il2CppToMonoDelegateReference(Delegate referencedDelegate, IntPtr methodI { ClassInjector.DerivedConstructorBody(this); - ReferencedDelegate = referencedDelegate; - MethodInfo = methodInfo; + MethodInfo!.Set(methodInfo); + s_storedDelegates[methodInfo] = referencedDelegate; } ~Il2CppToMonoDelegateReference() { Marshal.FreeHGlobal(MethodInfo); - MethodInfo = IntPtr.Zero; - ReferencedDelegate = null; + MethodInfo.Set(IntPtr.Zero); + s_storedDelegates.TryRemove(MethodInfo, out _); } } } diff --git a/Il2CppInterop.Runtime/Injection/ClassInjector.cs b/Il2CppInterop.Runtime/Injection/ClassInjector.cs index 6db08110..011a13ae 100644 --- a/Il2CppInterop.Runtime/Injection/ClassInjector.cs +++ b/Il2CppInterop.Runtime/Injection/ClassInjector.cs @@ -78,15 +78,6 @@ public static unsafe partial class ClassInjector private static readonly ConcurrentDictionary<(Type type, FieldAttributes attrs), IntPtr> _injectedFieldTypes = new(); - private static readonly VoidCtorDelegate FinalizeDelegate = Finalize; - - public static void ProcessNewObject(Il2CppObjectBase obj) - { - var pointer = obj.Pointer; - var handle = GCHandle.Alloc(obj, GCHandleType.Normal); - AssignGcHandle(pointer, handle); - } - public static IntPtr DerivedConstructorPointer() { return IL2CPP.il2cpp_object_new(Il2CppClassPointerStore @@ -97,7 +88,8 @@ public static void DerivedConstructorBody(Il2CppObjectBase objectBase) { if (objectBase.isWrapped) return; - var fields = objectBase.GetType() + var type = objectBase.GetType(); + var fields = type .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) .Where(IsFieldEligible) .ToArray(); @@ -107,16 +99,12 @@ public static void DerivedConstructorBody(Il2CppObjectBase objectBase) new[] { typeof(Il2CppObjectBase), typeof(string) }, Array.Empty()) .Invoke(new object[] { objectBase, field.Name }) ); - var ownGcHandle = GCHandle.Alloc(objectBase, GCHandleType.Normal); - AssignGcHandle(objectBase.Pointer, ownGcHandle); + AddToPool(objectBase); + if (TypeShouldFinalizeManually(type)) + Il2CppFinalizers.OverrideFinalize(objectBase, objectBase.CreateFinalizerContainer()); } - public static void AssignGcHandle(IntPtr pointer, GCHandle gcHandle) - { - var handleAsPointer = GCHandle.ToIntPtr(gcHandle); - if (pointer == IntPtr.Zero) throw new NullReferenceException(nameof(pointer)); - ClassInjectorBase.GetInjectedData(pointer)->managedGcHandle = GCHandle.ToIntPtr(gcHandle); - } + public static void AddToPool(Il2CppObjectBase obj) => Il2CppObjectPool.InternWeak(obj); public static bool IsTypeRegisteredInIl2Cpp() where T : class @@ -311,7 +299,7 @@ public static void RegisterTypeInIl2Cpp(Type type, RegisterTypeOptions options) var methodPointerArray = (Il2CppMethodInfo**)Marshal.AllocHGlobal(methodCount * IntPtr.Size); classPointer.Methods = methodPointerArray; - methodPointerArray[0] = ConvertStaticMethod(FinalizeDelegate, "Finalize", classPointer); + methodPointerArray[0] = ConvertStaticMethod(CreateFinalizeMethod(type), "Finalize", classPointer); var finalizeMethod = UnityVersionHandler.Wrap(methodPointerArray[0]); var fieldsToInitialize = type .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) @@ -734,6 +722,61 @@ private static bool IsMethodEligible(MethodInfo method) return converted.MethodInfoPointer; } + private static void DefaultFinalize(IntPtr ptr) { } + + private static bool TypeShouldFinalizeManually(Type? targetType) + { + while (targetType != null && targetType != typeof(Il2CppObjectBase)) + { + if (targetType.GetMethod("Finalize", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) != null) + { + // Finalize method in some subclass: + return true; + } + targetType = targetType.BaseType; + } + + return false; + } + + private static VoidCtorDelegate CreateFinalizeMethod(Type targetType) + { + if (!TypeShouldFinalizeManually(targetType)) return DefaultFinalize; + + var method = new DynamicMethod("FinalizeIl2CppObject", MethodAttributes.Public | MethodAttributes.Static, + CallingConventions.Standard, typeof(void), new[] { typeof(IntPtr) }, targetType, true); + + var body = method.GetILGenerator(); + + // Check whether finalizing is necessary *before* getting the managed object: + Label escape = body.DefineLabel(); + body.Emit(OpCodes.Ldarg_0); + body.Emit(OpCodes.Call, + typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.ShouldFinalize), + BindingFlags.NonPublic | BindingFlags.Static)!); + body.Emit(OpCodes.Brfalse_S, escape); + + // Finalize from within the object pool: + body.BeginExceptionBlock(); + body.Emit(OpCodes.Ldarg_0); + body.Emit(OpCodes.Call, + typeof(Il2CppObjectPool).GetMethod(nameof(Il2CppObjectPool.Get))!.MakeGenericMethod(targetType)); + body.Emit(OpCodes.Call, + typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.Finalize), + BindingFlags.NonPublic | BindingFlags.Static)!); + body.BeginCatchBlock(typeof(Exception)); + body.Emit(OpCodes.Ldstr, "Injected object finalizer"); + body.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(LogException), BindingFlags.Static | BindingFlags.NonPublic)!); + body.EndExceptionBlock(); + + body.MarkLabel(escape); + body.Emit(OpCodes.Ret); + + var @delegate = (VoidCtorDelegate)method.CreateDelegate(typeof(VoidCtorDelegate)); + GCHandle.Alloc(@delegate); // pin it forever + return @delegate; + } + private static VoidCtorDelegate CreateEmptyCtor(Type targetType, FieldInfo[] fieldsToInitialize) { var method = new DynamicMethod("FromIl2CppCtorDelegate", MethodAttributes.Public | MethodAttributes.Static, @@ -786,7 +829,20 @@ private static VoidCtorDelegate CreateEmptyCtor(Type targetType, FieldInfo[] fie body.Emit(OpCodes.Stfld, field); } - body.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(ProcessNewObject))!); + body.Emit(OpCodes.Dup); + body.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(AddToPool))!); + if (TypeShouldFinalizeManually(targetType)) + { + body.Emit(OpCodes.Dup); + body.Emit(OpCodes.Call, typeof(Il2CppObjectBase).GetMethod(nameof(Il2CppObjectBase.CreateFinalizerContainer), + BindingFlags.NonPublic | BindingFlags.Instance)!); + body.Emit(OpCodes.Call, typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.OverrideFinalize), + BindingFlags.NonPublic | BindingFlags.Static)!); + } + else + { + body.Emit(OpCodes.Pop); + } body.Emit(OpCodes.Ret); @@ -795,12 +851,6 @@ private static VoidCtorDelegate CreateEmptyCtor(Type targetType, FieldInfo[] fie return @delegate; } - public static void Finalize(IntPtr ptr) - { - var gcHandle = ClassInjectorBase.GetGcHandlePtrFromIl2CppObject(ptr); - GCHandle.FromIntPtr(gcHandle).Free(); - } - private static Delegate GetOrCreateInvoker(MethodInfo monoMethod) { return InvokerCache.GetOrAdd(ExtractSignature(monoMethod), @@ -937,8 +987,7 @@ private static Delegate CreateTrampoline(MethodInfo monoMethod) body.Emit(OpCodes.Ldarg_0); body.Emit(OpCodes.Call, - typeof(ClassInjectorBase).GetMethod(nameof(ClassInjectorBase.GetMonoObjectFromIl2CppPointer))!); - body.Emit(OpCodes.Castclass, monoMethod.DeclaringType); + typeof(Il2CppObjectPool).GetMethod(nameof(Il2CppObjectPool.Get))!.MakeGenericMethod(monoMethod.DeclaringType)); var indirectVariables = new LocalBuilder[managedParameters.Length]; @@ -1030,16 +1079,9 @@ void HandleTypeConversion(Type type) } // body.Emit(OpCodes.Ret); // breaks coreclr - var exceptionLocal = body.DeclareLocal(typeof(Exception)); body.BeginCatchBlock(typeof(Exception)); - body.Emit(OpCodes.Stloc, exceptionLocal); - body.Emit(OpCodes.Ldstr, "Exception in IL2CPP-to-Managed trampoline, not passing it to il2cpp: "); - body.Emit(OpCodes.Ldloc, exceptionLocal); - body.Emit(OpCodes.Callvirt, typeof(object).GetMethod(nameof(ToString))!); - body.Emit(OpCodes.Call, - typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!); - body.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(LogError), BindingFlags.Static | BindingFlags.NonPublic)!); - + body.Emit(OpCodes.Ldstr, "IL2CPP-to-Managed trampoline"); + body.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(LogException), BindingFlags.Static | BindingFlags.NonPublic)!); body.EndExceptionBlock(); if (managedReturnVariable != null) @@ -1058,9 +1100,9 @@ void HandleTypeConversion(Type type) return @delegate; } - private static void LogError(string message) + private static void LogException(Exception ex, string location) { - Logger.Instance.LogError("{Message}", message); + Logger.Instance.LogError($"Exception in {location}, not passing it to il2cpp: {ex}"); } private static string ExtractSignature(MethodInfo monoMethod) diff --git a/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs b/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs index 08e09523..a116a462 100644 --- a/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs +++ b/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs @@ -3,17 +3,27 @@ using System.Reflection.Emit; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Runtime.Serialization; +using Il2CppInterop.Common; using Il2CppInterop.Runtime.Runtime; +using Microsoft.Extensions.Logging; namespace Il2CppInterop.Runtime.InteropTypes; +public enum Il2CppObjectFinalizerState +{ + Now = 0, + NotYet = 1, + NotAgainPlease = 2, +} + public class Il2CppObjectBase { private static readonly MethodInfo _unboxMethod = typeof(Il2CppObjectBase).GetMethod(nameof(Unbox)); internal bool isWrapped; internal IntPtr pooledPtr; + internal Il2CppObjectFinalizerState finalizerState; + private bool wasDestroyed; private nint myGcHandle; public Il2CppObjectBase(IntPtr pointer) @@ -21,6 +31,27 @@ public Il2CppObjectBase(IntPtr pointer) CreateGCHandle(pointer); } + ~Il2CppObjectBase() + { + switch (finalizerState) + { + case Il2CppObjectFinalizerState.Now: + Il2CppObjectPool.Free(myGcHandle, pooledPtr); + break; + case Il2CppObjectFinalizerState.NotYet: + throw new NotSupportedException("Object was garbage collected too early"); + case Il2CppObjectFinalizerState.NotAgainPlease: + Logger.Instance.LogWarning($"Object {this} was garbage collected multiple times."); + break; + } + + // In the worst, worst, worst case scenario, + // a poorly-behaved finalizer might resurrect the object. + // For this case, we don't want any double-frees so we do something else. + GC.SuppressFinalize(this); + finalizerState = Il2CppObjectFinalizerState.NotAgainPlease; + } + public IntPtr ObjectClass => IL2CPP.il2cpp_object_get_class(Pointer); public IntPtr Pointer @@ -56,6 +87,15 @@ internal void CreateGCHandle(IntPtr objHdl) myGcHandle = IL2CPP.il2cpp_gchandle_new(objHdl, false); } + internal FinalizerContainer CreateFinalizerContainer() + { + return new FinalizerContainer() + { + gcHandle = myGcHandle, + pooledPtr = pooledPtr, + }; + } + public T Cast() where T : Il2CppObjectBase { return TryCast() ?? throw new InvalidCastException( @@ -156,19 +196,11 @@ private static Func Create() if (!IL2CPP.il2cpp_class_is_assignable_from(nestedTypeClassPointer, ownClass)) return null; - if (RuntimeSpecificsStore.IsInjected(ownClass)) - { - if (ClassInjectorBase.GetMonoObjectFromIl2CppPointer(Pointer) is T monoObject) return monoObject; - } + // if (RuntimeSpecificsStore.IsInjected(ownClass)) + // { + // if (ClassInjectorBase.GetMonoObjectFromIl2CppPointer(Pointer) is T monoObject) return monoObject; + // } return InitializerStore.Initializer(Pointer); } - - ~Il2CppObjectBase() - { - IL2CPP.il2cpp_gchandle_free(myGcHandle); - - if (pooledPtr == IntPtr.Zero) return; - Il2CppObjectPool.Remove(pooledPtr); - } } diff --git a/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs b/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs index bd70a8ad..e7863abe 100644 --- a/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs +++ b/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs @@ -1,11 +1,55 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Runtime.CompilerServices; +using Il2CppInterop.Common; using Il2CppInterop.Runtime.InteropTypes; +using Microsoft.Extensions.Logging; using Object = Il2CppSystem.Object; namespace Il2CppInterop.Runtime.Runtime; +// NB: Since we may call GC.SuppressFinalize on managed objects, we need another object (this one) to handle the cleanup. +// The objects' lifetimes are linked by the s_finalizers ephemeron on Il2CppFinalizers. +internal class FinalizerContainer +{ + public nint gcHandle; + public IntPtr pooledPtr; + + ~FinalizerContainer() => Il2CppObjectPool.Free(gcHandle, pooledPtr); +} + +internal static class Il2CppFinalizers +{ + // Dying objects are never duplicated. + internal static readonly ConcurrentDictionary s_dying = new(); + internal static readonly ConditionalWeakTable s_finalizers = new(); + + internal static void OnDeath(IntPtr ptr) + { + s_dying.Remove(ptr, out _); + } + + internal static bool ShouldFinalize(IntPtr ptr) + { + return !s_dying.ContainsKey(ptr); + } + + internal static void Finalize(Il2CppObjectBase obj) + { + obj.finalizerState = Il2CppObjectFinalizerState.Now; + s_dying[obj.Pointer] = 0; + GC.ReRegisterForFinalize(obj); + } + + internal static void OverrideFinalize(Il2CppObjectBase obj, FinalizerContainer finalizer) + { + GC.SuppressFinalize(obj); + obj.finalizerState = Il2CppObjectFinalizerState.NotYet; + s_finalizers.Add(obj, finalizer); + } +} + public static class Il2CppObjectPool { internal static bool DisableCaching { get; set; } @@ -17,24 +61,41 @@ internal static void Remove(IntPtr ptr) s_cache.TryRemove(ptr, out _); } - public static T Get(IntPtr ptr) + internal static void Free(nint unmanagedGcHandle, IntPtr ptr) { - if (ptr == IntPtr.Zero) return default; + IL2CPP.il2cpp_gchandle_free(unmanagedGcHandle); + if (ptr != IntPtr.Zero) Il2CppObjectPool.Remove(ptr); + } - var ownClass = IL2CPP.il2cpp_object_get_class(ptr); - if (RuntimeSpecificsStore.IsInjected(ownClass)) - { - var monoObject = ClassInjectorBase.GetMonoObjectFromIl2CppPointer(ptr); - if (monoObject is T monoObjectT) return monoObjectT; - } + public static void InternWeak(Il2CppObjectBase obj) + { + IntPtr ptr = obj.Pointer; + obj.pooledPtr = ptr; + s_cache[ptr] = new(obj); + } + + internal static bool ReferenceIsDead(IntPtr ptr) + { + return s_cache.TryGetValue(ptr, out var reference) && !reference.TryGetTarget(out _); + } + + public static T Get(IntPtr ptr) + { + if (ptr == IntPtr.Zero || Il2CppFinalizers.s_dying.ContainsKey(ptr)) return default; if (DisableCaching) return Il2CppObjectBase.InitializerStore.Initializer(ptr); - if (s_cache.TryGetValue(ptr, out var reference) && reference.TryGetTarget(out var cachedObject)) + if (s_cache.TryGetValue(ptr, out var reference)) { - if (cachedObject is T cachedObjectT) return cachedObjectT; - cachedObject.pooledPtr = IntPtr.Zero; - // This leaves the case when you cast to an interface handled as if nothing was cached + if (reference.TryGetTarget(out var cachedObject)) + { + if (cachedObject is T cachedObjectT) return cachedObjectT; + + // This leaves the case when you cast to an interface, handled as if nothing was cached + } + + // If the cached object no longer exists, delete the irrelevant entry if we can: + Remove(ptr); } var newObj = Il2CppObjectBase.InitializerStore.Initializer(ptr); @@ -48,8 +109,8 @@ public static T Get(IntPtr ptr) } var il2CppObjectBase = Unsafe.As(ref newObj); - s_cache[ptr] = new WeakReference(il2CppObjectBase); - il2CppObjectBase.pooledPtr = ptr; + if (il2CppObjectBase.Pointer != ptr) Logger.Instance.LogError("Pointer interned at wrong address!"); + InternWeak(il2CppObjectBase); return newObj; } } From 4d87b31f88be964d74012ddab07f7ccd59145506 Mon Sep 17 00:00:00 2001 From: soqb Date: Thu, 26 Jun 2025 15:37:59 +0100 Subject: [PATCH 2/7] feat: support for finalizers and stop leaking injected objects (ii) --- Documentation/Class-Injection.md | 72 ++++++-- Il2CppInterop.Runtime/DelegateSupport.cs | 12 +- .../Injection/ClassInjector.cs | 166 +++++------------ .../InteropTypes/Il2CppObjectBase.cs | 98 +--------- .../Runtime/Il2CppObjectPool.cs | 69 +------ .../Runtime/ObjectLifecycle.cs | 174 ++++++++++++++++++ 6 files changed, 304 insertions(+), 287 deletions(-) create mode 100644 Il2CppInterop.Runtime/Runtime/ObjectLifecycle.cs diff --git a/Documentation/Class-Injection.md b/Documentation/Class-Injection.md index c9d6dfc7..4913308a 100644 --- a/Documentation/Class-Injection.md +++ b/Documentation/Class-Injection.md @@ -79,25 +79,70 @@ var someInstance = new MyClass(pointer); * `[HideFromIl2Cpp]` can be used to prevent a method from being exposed to il2cpp -## Caveats - -* Injected class instances are handled by IL2CPP garbage collection. This means that an object may be collected even if - it's referenced from managed domain. Attempting to use that object afterwards will result - in `ObjectCollectedException`. Conversely, managed representation of injected object will not be garbage collected as - long as it's referenced from IL2CPP domain. -* It might be possible to create a cross-domain reference loop that will prevent objects from being garbage collected. - Avoid doing anything that will result in injected class instances (indirectly) storing references to itself. The - simplest example of how to leak memory is this: +## Garbage Collection + +* Managed instances of injected classes hold strong handles to their unmanaged counterparts. + This means it is unlikely for an unmanaged object to be collected while it is referenced from the managed domain. + If this rare case does occur, an attempt to use to injected instance will throw an `ObjectCollectedException`. +* If there are no extant references to the managed instance, it will be garbage collected, + releasing the strong handle it holds and allowing the underlying unmanaged object to eventually be collected in turn. + When this occurs, however, the finalizer for the managed instance will not be run, + delaying execution until the unmanaged object is also ready to be garbage collected. +* The unmanaged-to-managed mapping is a one-to-many relationship. While during typical execution there will be exactly zero, + or exactly one extant managed object, in certain situations, such as when the object pool is disabled, there may be any number. +* Due to the implementation of finalizers, calling `System.GC.SuppressFinalize` or `System.GC.ReRegisterForFinalize` is invalid + for all injected types with finalizers, leading to `ObjectCollectedExeption`s at best, and memory safety issues at worst. + The methods on `Il2CppSystem.GC` will always work as intended, however. + As a corrolary, once an managed instance is finalized, it is no longer valid, + so the "resurrection pattern" is not functional for injected types. + +
+A detailed example of finalizer behavior for injected classes +
+ +Consider the following example: ```c# -class Injected: Il2CppSystem.Object { - Il2CppSystem.Collections.Generic.List list = new ...; - public Injected() { - list.Add(this); // reference to itself through an IL2CPP list. This will prevent both this and list from being garbage collected, ever. +class Foo : Il2CppSystem.Object +{ + public Foo(IntPtr ptr) : base(ptr) { } + public Foo() : this(ClassInjector.DerivedConstructorPointer()) + { + ClassInjector.DerivedConstructorBody(this); + } + ~Foo() + { + // ... } } + +Foo foobar = new(); +// ... function ends ... ``` +In the above example, the events occur in this order: + +1. The parameterless `Foo` constructor is called: + * An unmanaged instance of the injected class is created. + * A strong handle to the unmanaged instance is put into the managed instance of `Foo`. + * The finalizer of the managed instance itself is suppressed, + but a hook is installed to watch for its collection. +2. The managed instance of `Foo` goes out of scope, + allowing the managed instance to be garbage collected (without running its finalizer). +3. Some time later, the managed instance is collected and the hook is triggered: + * The strong handle to the unmanaged instance is released + * Assuming no other managed or unmanaged references to the object exist, + the unmanaged instance of `Foo` can now be garbage collected. +4. Before this happens, the unmanaged object's finalizer is called: + * A fresh managed instance of `Foo` is created and _its_ finalizer is called directly. + * This new managed instance is immediately invalidated and forgotten. +5. The unmanaged object, which has no extant references, is garbage collected with its managed finalizer having run exactly once. + +Note that this complexity is present only for injected classes with finalizers. +Injected classes without finalizers are collected following a more standard procedure. + +
+ ## Fields injection > TODO: Describe how field injection works based on [#24](https://github.com/BepInEx/Il2CppAssemblyUnhollower/pull/24) @@ -107,4 +152,3 @@ class Injected: Il2CppSystem.Object { * Not all members are exposed to Il2Cpp side - no properties, events or static methods will be visible to Il2Cpp reflection. Fields are exported, but the feature is fairly limited. * Only a limited set of types is supported for method signatures - \ No newline at end of file diff --git a/Il2CppInterop.Runtime/DelegateSupport.cs b/Il2CppInterop.Runtime/DelegateSupport.cs index fb060f75..72ed65f6 100644 --- a/Il2CppInterop.Runtime/DelegateSupport.cs +++ b/Il2CppInterop.Runtime/DelegateSupport.cs @@ -5,12 +5,10 @@ using System.Reflection.Emit; using System.Runtime.InteropServices; using System.Text; -using Il2CppInterop.Common; using Il2CppInterop.Runtime.Injection; using Il2CppInterop.Runtime.InteropTypes; using Il2CppInterop.Runtime.InteropTypes.Fields; using Il2CppInterop.Runtime.Runtime; -using Microsoft.Extensions.Logging; using Object = Il2CppSystem.Object; using ValueType = Il2CppSystem.ValueType; @@ -196,8 +194,9 @@ private static Delegate GenerateNativeToManagedTrampoline(Il2CppSystem.Reflectio } bodyBuilder.BeginCatchBlock(typeof(Exception)); - bodyBuilder.Emit(OpCodes.Call, typeof(DelegateSupport).GetMethod(nameof(LogException), BindingFlags.Static | BindingFlags.NonPublic)!); - bodyBuilder.Emit(OpCodes.Ldstr, "Exception in IL2CPP-to-Managed trampoline, not passing it to il2cpp: "); + bodyBuilder.Emit(OpCodes.Ldstr, "IL2CPP-to-Managed delegate trampoline"); + bodyBuilder.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(ClassInjector.LogException), + BindingFlags.Static | BindingFlags.NonPublic)!); bodyBuilder.EndExceptionBlock(); @@ -208,11 +207,6 @@ private static Delegate GenerateNativeToManagedTrampoline(Il2CppSystem.Reflectio return trampoline.CreateDelegate(GetOrCreateDelegateType(signature, managedMethod)); } - private static void LogException(Exception exception) - { - Logger.Instance.LogError($"Exception in IL2CPP-to-Managed trampoline, not passing it to il2cpp: {exception}"); - } - public static TIl2Cpp? ConvertDelegate(Delegate @delegate) where TIl2Cpp : Il2CppObjectBase { if (@delegate == null) diff --git a/Il2CppInterop.Runtime/Injection/ClassInjector.cs b/Il2CppInterop.Runtime/Injection/ClassInjector.cs index 011a13ae..d9ae43e9 100644 --- a/Il2CppInterop.Runtime/Injection/ClassInjector.cs +++ b/Il2CppInterop.Runtime/Injection/ClassInjector.cs @@ -5,7 +5,6 @@ using System.Reflection; using System.Reflection.Emit; using System.Runtime.InteropServices; -using System.Runtime.Serialization; using System.Text; using Il2CppInterop.Common; using Il2CppInterop.Runtime.Attributes; @@ -74,6 +73,8 @@ public static unsafe partial class ClassInjector InflatedMethodFromContextDictionary = new(); private static readonly ConcurrentDictionary InvokerCache = new(); + private static readonly ConcurrentDictionary DerivedConstructorBodyCache = new(); + internal static readonly ConcurrentDictionary ManualFinalizeCache = new(); private static readonly ConcurrentDictionary<(Type type, FieldAttributes attrs), IntPtr> _injectedFieldTypes = new(); @@ -86,26 +87,26 @@ public static IntPtr DerivedConstructorPointer() public static void DerivedConstructorBody(Il2CppObjectBase objectBase) { - if (objectBase.isWrapped) - return; - var type = objectBase.GetType(); + var ctor = DerivedConstructorBodyCache.GetOrAdd(objectBase.GetType(), CreateDerivedConstructorBodyDelegate); + ctor(objectBase); + } + + private static InjectedInitializationDelegate CreateDerivedConstructorBodyDelegate(Type type) + { + var method = new DynamicMethod("DerivedConstructorBodyDelegate", MethodAttributes.Public | MethodAttributes.Static, + CallingConventions.Standard, typeof(void), new[] { typeof(Il2CppObjectBase) }, type, true); + var fields = type - .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) + .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Where(IsFieldEligible) .ToArray(); - foreach (var field in fields) - field.SetValue(objectBase, field.FieldType.GetConstructor( - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, - new[] { typeof(Il2CppObjectBase), typeof(string) }, Array.Empty()) - .Invoke(new object[] { objectBase, field.Name }) - ); - AddToPool(objectBase); - if (TypeShouldFinalizeManually(type)) - Il2CppFinalizers.OverrideFinalize(objectBase, objectBase.CreateFinalizerContainer()); - } - - public static void AddToPool(Il2CppObjectBase obj) => Il2CppObjectPool.InternWeak(obj); + var il = method.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + Il2CppObjectInitializer.EmitInjectedInitialization(il, type, fields); + il.Emit(OpCodes.Ret); + return method.CreateDelegate(); + } public static bool IsTypeRegisteredInIl2Cpp() where T : class { @@ -208,6 +209,20 @@ public static void RegisterTypeInIl2Cpp(Type type, RegisterTypeOptions options) $"Type with FullName {type.FullName} is already injected. Don't inject the same type twice, or use a different namespace"); } + MethodInfo? DiscernIfTypeIsManuallyFinalizable(Type type) + { + if (type == typeof(Il2CppObjectBase)) + return null; + if (type.GetMethod("Finalize", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) is MethodInfo m) + return m; + return type.BaseType != null ? DiscernIfTypeIsManuallyFinalizable(type.BaseType) : null; + } + + if (DiscernIfTypeIsManuallyFinalizable(type) is MethodInfo finalize) + { + ManualFinalizeCache[type] = finalize.CreateDelegate(typeof(Action<>).MakeGenericType(type)); + } + var interfaceFunctionCount = interfaces.Sum(i => i.MethodCount); var classPointer = UnityVersionHandler.NewClass(baseClassPointer.VtableCount + interfaceFunctionCount); @@ -301,12 +316,8 @@ public static void RegisterTypeInIl2Cpp(Type type, RegisterTypeOptions options) methodPointerArray[0] = ConvertStaticMethod(CreateFinalizeMethod(type), "Finalize", classPointer); var finalizeMethod = UnityVersionHandler.Wrap(methodPointerArray[0]); - var fieldsToInitialize = type - .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .Where(IsFieldEligible) - .ToArray(); - if (!type.IsAbstract) methodPointerArray[1] = ConvertStaticMethod(CreateEmptyCtor(type, fieldsToInitialize), ".ctor", classPointer); + if (!type.IsAbstract) methodPointerArray[1] = ConvertStaticMethod(CreateEmptyCtor(type), ".ctor", classPointer); var infos = new Dictionary<(string, int, bool), int>(eligibleMethods.Length); for (var i = 0; i < eligibleMethods.Length; i++) { @@ -525,7 +536,7 @@ private static bool IsTypeSupported(Type type) return typeof(Il2CppObjectBase).IsAssignableFrom(type); } - private static bool IsFieldEligible(FieldInfo field) + internal static bool IsFieldEligible(FieldInfo field) { if (!field.FieldType.IsGenericType) return field.FieldType == typeof(Il2CppStringField); var genericTypeDef = field.FieldType.GetGenericTypeDefinition(); @@ -724,129 +735,52 @@ private static bool IsMethodEligible(MethodInfo method) private static void DefaultFinalize(IntPtr ptr) { } - private static bool TypeShouldFinalizeManually(Type? targetType) + internal static bool IsTypeManuallyFinalizable(Type targetType) { - while (targetType != null && targetType != typeof(Il2CppObjectBase)) - { - if (targetType.GetMethod("Finalize", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) != null) - { - // Finalize method in some subclass: - return true; - } - targetType = targetType.BaseType; - } - - return false; + return ManualFinalizeCache.ContainsKey(targetType); } private static VoidCtorDelegate CreateFinalizeMethod(Type targetType) { - if (!TypeShouldFinalizeManually(targetType)) return DefaultFinalize; + if (!IsTypeManuallyFinalizable(targetType)) return DefaultFinalize; var method = new DynamicMethod("FinalizeIl2CppObject", MethodAttributes.Public | MethodAttributes.Static, CallingConventions.Standard, typeof(void), new[] { typeof(IntPtr) }, targetType, true); var body = method.GetILGenerator(); - - // Check whether finalizing is necessary *before* getting the managed object: - Label escape = body.DefineLabel(); - body.Emit(OpCodes.Ldarg_0); - body.Emit(OpCodes.Call, - typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.ShouldFinalize), - BindingFlags.NonPublic | BindingFlags.Static)!); - body.Emit(OpCodes.Brfalse_S, escape); + Label tryBlock = body.BeginExceptionBlock(); // Finalize from within the object pool: - body.BeginExceptionBlock(); body.Emit(OpCodes.Ldarg_0); body.Emit(OpCodes.Call, - typeof(Il2CppObjectPool).GetMethod(nameof(Il2CppObjectPool.Get))!.MakeGenericMethod(targetType)); - body.Emit(OpCodes.Call, - typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.Finalize), - BindingFlags.NonPublic | BindingFlags.Static)!); + typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.RunFinalizer), + BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(targetType)); + + // Catch any exceptions: body.BeginCatchBlock(typeof(Exception)); body.Emit(OpCodes.Ldstr, "Injected object finalizer"); body.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(LogException), BindingFlags.Static | BindingFlags.NonPublic)!); body.EndExceptionBlock(); - body.MarkLabel(escape); body.Emit(OpCodes.Ret); - var @delegate = (VoidCtorDelegate)method.CreateDelegate(typeof(VoidCtorDelegate)); + var @delegate = method.CreateDelegate(); GCHandle.Alloc(@delegate); // pin it forever return @delegate; } - private static VoidCtorDelegate CreateEmptyCtor(Type targetType, FieldInfo[] fieldsToInitialize) + private static VoidCtorDelegate CreateEmptyCtor(Type targetType) { var method = new DynamicMethod("FromIl2CppCtorDelegate", MethodAttributes.Public | MethodAttributes.Static, CallingConventions.Standard, typeof(void), new[] { typeof(IntPtr) }, targetType, true); var body = method.GetILGenerator(); - - var monoCtor = targetType.GetConstructor(new[] { typeof(IntPtr) }); - if (monoCtor != null) - { - body.Emit(OpCodes.Ldarg_0); - body.Emit(OpCodes.Newobj, monoCtor); - } - else - { - var local = body.DeclareLocal(targetType); - body.Emit(OpCodes.Ldtoken, targetType); - body.Emit(OpCodes.Call, - typeof(Type).GetMethod(nameof(Type.GetTypeFromHandle), BindingFlags.Public | BindingFlags.Static)!); - body.Emit(OpCodes.Call, - typeof(FormatterServices).GetMethod(nameof(FormatterServices.GetUninitializedObject), - BindingFlags.Public | BindingFlags.Static)!); - body.Emit(OpCodes.Stloc, local); - body.Emit(OpCodes.Ldloc, local); - body.Emit(OpCodes.Ldarg_0); - body.Emit(OpCodes.Call, - typeof(Il2CppObjectBase).GetMethod(nameof(Il2CppObjectBase.CreateGCHandle), - BindingFlags.NonPublic | BindingFlags.Instance)!); - body.Emit(OpCodes.Ldloc, local); - body.Emit(OpCodes.Ldc_I4_1); - body.Emit(OpCodes.Stfld, - typeof(Il2CppObjectBase).GetField(nameof(Il2CppObjectBase.isWrapped), - BindingFlags.NonPublic | BindingFlags.Instance)!); - body.Emit(OpCodes.Ldloc, local); - body.Emit(OpCodes.Call, - targetType.GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, - Type.EmptyTypes, Array.Empty())!); - body.Emit(OpCodes.Ldloc, local); - } - - foreach (var field in fieldsToInitialize) - { - body.Emit(OpCodes.Dup); - body.Emit(OpCodes.Dup); - body.Emit(OpCodes.Ldstr, field.Name); - body.Emit(OpCodes.Newobj, field.FieldType.GetConstructor( - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, - new[] { typeof(Il2CppObjectBase), typeof(string) }, Array.Empty()) - ); - body.Emit(OpCodes.Stfld, field); - } - - body.Emit(OpCodes.Dup); - body.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(AddToPool))!); - if (TypeShouldFinalizeManually(targetType)) - { - body.Emit(OpCodes.Dup); - body.Emit(OpCodes.Call, typeof(Il2CppObjectBase).GetMethod(nameof(Il2CppObjectBase.CreateFinalizerContainer), - BindingFlags.NonPublic | BindingFlags.Instance)!); - body.Emit(OpCodes.Call, typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.OverrideFinalize), - BindingFlags.NonPublic | BindingFlags.Static)!); - } - else - { - body.Emit(OpCodes.Pop); - } - + body.Emit(OpCodes.Ldarg_0); + body.Emit(OpCodes.Call, typeof(Il2CppObjectInitializer).GetMethod(nameof(Il2CppObjectInitializer.New), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(targetType)); + body.Emit(OpCodes.Pop); body.Emit(OpCodes.Ret); - var @delegate = (VoidCtorDelegate)method.CreateDelegate(typeof(VoidCtorDelegate)); + var @delegate = method.CreateDelegate(); GCHandle.Alloc(@delegate); // pin it forever return @delegate; } @@ -1100,7 +1034,7 @@ void HandleTypeConversion(Type type) return @delegate; } - private static void LogException(Exception ex, string location) + internal static void LogException(Exception ex, string location) { Logger.Instance.LogError($"Exception in {location}, not passing it to il2cpp: {ex}"); } @@ -1210,6 +1144,8 @@ internal static Type SystemTypeFromIl2CppType(Il2CppTypeStruct* typePointer) return RewriteType(type); } + private delegate void InjectedInitializationDelegate(Il2CppObjectBase instance); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void InvokerDelegateMetadataV29(IntPtr methodPointer, Il2CppMethodInfo* methodInfo, IntPtr obj, IntPtr* args, IntPtr* returnValue); diff --git a/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs b/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs index a116a462..b6e34d34 100644 --- a/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs +++ b/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs @@ -1,6 +1,4 @@ using System; -using System.Reflection; -using System.Reflection.Emit; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Il2CppInterop.Common; @@ -11,14 +9,12 @@ namespace Il2CppInterop.Runtime.InteropTypes; public enum Il2CppObjectFinalizerState { - Now = 0, - NotYet = 1, - NotAgainPlease = 2, + Free = 0, + Glued = 1, } public class Il2CppObjectBase { - private static readonly MethodInfo _unboxMethod = typeof(Il2CppObjectBase).GetMethod(nameof(Unbox)); internal bool isWrapped; internal IntPtr pooledPtr; internal Il2CppObjectFinalizerState finalizerState; @@ -35,21 +31,12 @@ public Il2CppObjectBase(IntPtr pointer) { switch (finalizerState) { - case Il2CppObjectFinalizerState.Now: + case Il2CppObjectFinalizerState.Free: Il2CppObjectPool.Free(myGcHandle, pooledPtr); break; - case Il2CppObjectFinalizerState.NotYet: - throw new NotSupportedException("Object was garbage collected too early"); - case Il2CppObjectFinalizerState.NotAgainPlease: - Logger.Instance.LogWarning($"Object {this} was garbage collected multiple times."); - break; + case Il2CppObjectFinalizerState.Glued: + throw new NotSupportedException("Object was garbage collected too early. Perhaps GC.ReRegisterForFinalize was incorrectly called?"); } - - // In the worst, worst, worst case scenario, - // a poorly-behaved finalizer might resurrect the object. - // For this case, we don't want any double-frees so we do something else. - GC.SuppressFinalize(this); - finalizerState = Il2CppObjectFinalizerState.NotAgainPlease; } public IntPtr ObjectClass => IL2CPP.il2cpp_object_get_class(Pointer); @@ -85,6 +72,7 @@ internal void CreateGCHandle(IntPtr objHdl) return; myGcHandle = IL2CPP.il2cpp_gchandle_new(objHdl, false); + isWrapped = true; } internal FinalizerContainer CreateFinalizerContainer() @@ -92,7 +80,7 @@ internal FinalizerContainer CreateFinalizerContainer() return new FinalizerContainer() { gcHandle = myGcHandle, - pooledPtr = pooledPtr, + ptr = Pointer, }; } @@ -121,71 +109,6 @@ public T Unbox() where T : unmanaged return UnboxUnsafe(Pointer); } - private static readonly Type[] _intPtrTypeArray = { typeof(IntPtr) }; - private static readonly MethodInfo _getUninitializedObject = typeof(RuntimeHelpers).GetMethod(nameof(RuntimeHelpers.GetUninitializedObject))!; - private static readonly MethodInfo _getTypeFromHandle = typeof(Type).GetMethod(nameof(Type.GetTypeFromHandle))!; - private static readonly MethodInfo _createGCHandle = typeof(Il2CppObjectBase).GetMethod(nameof(CreateGCHandle), BindingFlags.Instance | BindingFlags.NonPublic)!; - private static readonly FieldInfo _isWrapped = typeof(Il2CppObjectBase).GetField(nameof(isWrapped), BindingFlags.Instance | BindingFlags.NonPublic)!; - - internal static class InitializerStore - { - private static Func? _initializer; - - private static Func Create() - { - var type = Il2CppClassPointerStore.CreatedTypeRedirect ?? typeof(T); - - var dynamicMethod = new DynamicMethod($"Initializer<{typeof(T).AssemblyQualifiedName}>", type, _intPtrTypeArray); - dynamicMethod.DefineParameter(0, ParameterAttributes.None, "pointer"); - - var il = dynamicMethod.GetILGenerator(); - - if (type.GetConstructor(new[] { typeof(IntPtr) }) is { } pointerConstructor) - { - // Base case: Il2Cpp constructor => call it directly - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Newobj, pointerConstructor); - } - else - { - // Special case: We have a parameterless constructor - // However, it could be be user-made or implicit - // In that case we set the GCHandle and then call the ctor and let GC destroy any objects created by DerivedConstructorPointer - - // var obj = (T)RuntimeHelpers.GetUninitializedObject(type); - il.Emit(OpCodes.Ldtoken, type); - il.Emit(OpCodes.Call, _getTypeFromHandle); - il.Emit(OpCodes.Call, _getUninitializedObject); - il.Emit(OpCodes.Castclass, type); - - // obj.CreateGCHandle(pointer); - il.Emit(OpCodes.Dup); - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Callvirt, _createGCHandle); - - // obj.isWrapped = true; - il.Emit(OpCodes.Dup); - il.Emit(OpCodes.Ldc_I4_1); - il.Emit(OpCodes.Stfld, _isWrapped); - - var parameterlessConstructor = type.GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, Type.EmptyTypes); - if (parameterlessConstructor != null) - { - // obj..ctor(); - il.Emit(OpCodes.Dup); - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Callvirt, parameterlessConstructor); - } - } - - il.Emit(OpCodes.Ret); - - return dynamicMethod.CreateDelegate>(); - } - - public static Func Initializer => _initializer ??= Create(); - } - public T? TryCast() where T : Il2CppObjectBase { var nestedTypeClassPointer = Il2CppClassPointerStore.NativeClassPtr; @@ -196,11 +119,6 @@ private static Func Create() if (!IL2CPP.il2cpp_class_is_assignable_from(nestedTypeClassPointer, ownClass)) return null; - // if (RuntimeSpecificsStore.IsInjected(ownClass)) - // { - // if (ClassInjectorBase.GetMonoObjectFromIl2CppPointer(Pointer) is T monoObject) return monoObject; - // } - - return InitializerStore.Initializer(Pointer); + return Il2CppObjectInitializer.New(Pointer); } } diff --git a/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs b/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs index e7863abe..bccebb67 100644 --- a/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs +++ b/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs @@ -1,58 +1,13 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using Il2CppInterop.Common; using Il2CppInterop.Runtime.InteropTypes; -using Microsoft.Extensions.Logging; using Object = Il2CppSystem.Object; namespace Il2CppInterop.Runtime.Runtime; -// NB: Since we may call GC.SuppressFinalize on managed objects, we need another object (this one) to handle the cleanup. -// The objects' lifetimes are linked by the s_finalizers ephemeron on Il2CppFinalizers. -internal class FinalizerContainer -{ - public nint gcHandle; - public IntPtr pooledPtr; - - ~FinalizerContainer() => Il2CppObjectPool.Free(gcHandle, pooledPtr); -} - -internal static class Il2CppFinalizers -{ - // Dying objects are never duplicated. - internal static readonly ConcurrentDictionary s_dying = new(); - internal static readonly ConditionalWeakTable s_finalizers = new(); - - internal static void OnDeath(IntPtr ptr) - { - s_dying.Remove(ptr, out _); - } - - internal static bool ShouldFinalize(IntPtr ptr) - { - return !s_dying.ContainsKey(ptr); - } - - internal static void Finalize(Il2CppObjectBase obj) - { - obj.finalizerState = Il2CppObjectFinalizerState.Now; - s_dying[obj.Pointer] = 0; - GC.ReRegisterForFinalize(obj); - } - - internal static void OverrideFinalize(Il2CppObjectBase obj, FinalizerContainer finalizer) - { - GC.SuppressFinalize(obj); - obj.finalizerState = Il2CppObjectFinalizerState.NotYet; - s_finalizers.Add(obj, finalizer); - } -} - public static class Il2CppObjectPool { - internal static bool DisableCaching { get; set; } + public static bool DisableCaching { get; set; } private static readonly ConcurrentDictionary> s_cache = new(); @@ -63,8 +18,9 @@ internal static void Remove(IntPtr ptr) internal static void Free(nint unmanagedGcHandle, IntPtr ptr) { - IL2CPP.il2cpp_gchandle_free(unmanagedGcHandle); - if (ptr != IntPtr.Zero) Il2CppObjectPool.Remove(ptr); + if (IL2CPP.il2cpp_gchandle_get_target(unmanagedGcHandle) != IntPtr.Zero) + IL2CPP.il2cpp_gchandle_free(unmanagedGcHandle); + Remove(ptr); } public static void InternWeak(Il2CppObjectBase obj) @@ -74,16 +30,14 @@ public static void InternWeak(Il2CppObjectBase obj) s_cache[ptr] = new(obj); } - internal static bool ReferenceIsDead(IntPtr ptr) - { - return s_cache.TryGetValue(ptr, out var reference) && !reference.TryGetTarget(out _); - } - public static T Get(IntPtr ptr) { - if (ptr == IntPtr.Zero || Il2CppFinalizers.s_dying.ContainsKey(ptr)) return default; + if (ptr == IntPtr.Zero || Il2CppFinalizers.s_dying.ContainsKey(ptr)) + { + return default!; + } - if (DisableCaching) return Il2CppObjectBase.InitializerStore.Initializer(ptr); + if (DisableCaching) return Il2CppObjectInitializer.New(ptr); if (s_cache.TryGetValue(ptr, out var reference)) { @@ -98,7 +52,7 @@ public static T Get(IntPtr ptr) Remove(ptr); } - var newObj = Il2CppObjectBase.InitializerStore.Initializer(ptr); + var newObj = Il2CppObjectInitializer.New(ptr); unsafe { var nativeClassStruct = UnityVersionHandler.Wrap((Il2CppClass*)Il2CppClassPointerStore.NativeClassPtr); @@ -108,9 +62,6 @@ public static T Get(IntPtr ptr) } } - var il2CppObjectBase = Unsafe.As(ref newObj); - if (il2CppObjectBase.Pointer != ptr) Logger.Instance.LogError("Pointer interned at wrong address!"); - InternWeak(il2CppObjectBase); return newObj; } } diff --git a/Il2CppInterop.Runtime/Runtime/ObjectLifecycle.cs b/Il2CppInterop.Runtime/Runtime/ObjectLifecycle.cs new file mode 100644 index 00000000..b5f5968e --- /dev/null +++ b/Il2CppInterop.Runtime/Runtime/ObjectLifecycle.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using Il2CppInterop.Runtime; +using Il2CppInterop.Runtime.Injection; +using Il2CppInterop.Runtime.InteropTypes; +using Il2CppInterop.Runtime.Runtime; + +internal static class Il2CppObjectInitializer +{ + internal static T New(IntPtr ptr) + { + return InitializerStore.Initialize(ptr); + } + + /// Identical to New except skipping the glue code for injected finalizers. + internal static T NewWithoutGlue(IntPtr ptr) where T : Il2CppObjectBase + { + return InitializerStore.InitializeWithoutGlue(ptr); + } + + private static readonly MethodInfo _getUninitializedObject = typeof(RuntimeHelpers).GetMethod(nameof(RuntimeHelpers.GetUninitializedObject))!; + private static readonly MethodInfo _getTypeFromHandle = typeof(Type).GetMethod(nameof(Type.GetTypeFromHandle))!; + private static readonly MethodInfo _createGCHandle = typeof(Il2CppObjectBase).GetMethod(nameof(Il2CppObjectBase.CreateGCHandle), BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static void EmitCtorCall(ILGenerator il, Type type) + { + if (type.GetConstructor(new[] { typeof(IntPtr) }) is { } pointerConstructor) + { + // Base case: Il2Cpp constructor => call it directly + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Newobj, pointerConstructor); + } + else + { + // Special case: We have a parameterless constructor + // However, it could be be user-made or implicit + // In that case we set the GCHandle and then call the ctor and let GC destroy any objects created by DerivedConstructorPointer + + // var obj = (T)RuntimeHelpers.GetUninitializedObject(type); + il.Emit(OpCodes.Ldtoken, type); + il.Emit(OpCodes.Call, _getTypeFromHandle); + il.Emit(OpCodes.Call, _getUninitializedObject); + il.Emit(OpCodes.Castclass, type); + + // obj.CreateGCHandle(pointer); + il.Emit(OpCodes.Dup); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, _createGCHandle); + + var parameterlessConstructor = type.GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, Type.EmptyTypes); + if (parameterlessConstructor != null) + { + // obj..ctor(); + il.Emit(OpCodes.Dup); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Callvirt, parameterlessConstructor); + } + } + } + + + internal static void EmitInjectedInitialization(ILGenerator il, Type type, FieldInfo[] fieldsToInitialize) + { + EmitFieldInitialization(il, type, fieldsToInitialize); + EmitGCHandling(il, type, false); + } + + private static readonly MethodInfo _internWeak = typeof(Il2CppObjectPool).GetMethod(nameof(Il2CppObjectPool.InternWeak))!; + private static readonly MethodInfo _glue = typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.FinalizerGlue), BindingFlags.NonPublic | BindingFlags.Static)!; + + private static void EmitGCHandling(ILGenerator il, Type type, bool useGlue) + { + if (useGlue && ClassInjector.IsTypeManuallyFinalizable(type)) + { + il.Emit(OpCodes.Dup); + il.Emit(OpCodes.Call, _internWeak); + il.Emit(OpCodes.Call, _glue); + } + else + { + il.Emit(OpCodes.Call, _internWeak); + } + } + + private static void EmitFieldInitialization(ILGenerator il, Type type, FieldInfo[] fieldsToInitialize) + { + foreach (var field in fieldsToInitialize) + { + il.Emit(OpCodes.Dup); + il.Emit(OpCodes.Dup); + il.Emit(OpCodes.Ldstr, field.Name); + il.Emit(OpCodes.Newobj, field.FieldType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, + new[] { typeof(Il2CppObjectBase), typeof(string) }, Array.Empty())! + ); + il.Emit(OpCodes.Stfld, field); + } + } + + private static readonly Type[] _intPtrTypeArray = { typeof(IntPtr) }; + + private static class InitializerStore + { + public delegate T Initializer(IntPtr pointer); + + public static Initializer? initialize; + public static Initializer? initializeWithoutGlue; + public static Initializer Initialize => initialize ??= Create(true); + public static Initializer InitializeWithoutGlue => initializeWithoutGlue ??= Create(false); + + private static Initializer Create(bool useGlue) + { + var type = Il2CppClassPointerStore.CreatedTypeRedirect ?? typeof(T); + + var fieldsToInitialize = type + .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(ClassInjector.IsFieldEligible) + .ToArray(); + + string methodName = useGlue ? "Initialize" : "InitializeWithoutGlue"; + var dynamicMethod = new DynamicMethod($"{methodName}<{typeof(T).AssemblyQualifiedName}>", type, _intPtrTypeArray); + dynamicMethod.DefineParameter(0, ParameterAttributes.None, "pointer"); + + var il = dynamicMethod.GetILGenerator(); + EmitCtorCall(il, type); + EmitFieldInitialization(il, type, fieldsToInitialize); + il.Emit(OpCodes.Dup); + EmitGCHandling(il, type, useGlue); + il.Emit(OpCodes.Ret); + + return dynamicMethod.CreateDelegate(); + } + } +} + +// NB: Since a managed object might be garbage collected before its unmanaged counterpart is ready, +// we need another object (this one) to handle the cleanup. +// The objects' lifetimes are linked by the s_finalizers ephemeron on Il2CppFinalizers. +internal class FinalizerContainer +{ + public nint gcHandle; + public IntPtr ptr; + + ~FinalizerContainer() + { + Il2CppObjectPool.Free(gcHandle, ptr); + } +} + +internal static class Il2CppFinalizers +{ + internal static readonly ConcurrentDictionary s_dying = new(); + internal static readonly ConditionalWeakTable s_ephemeron = new(); + + internal static void RunFinalizer(IntPtr ptr) where T : Il2CppObjectBase + { + T ephemeral = Il2CppObjectInitializer.NewWithoutGlue(ptr); + Action finalize = (Action)ClassInjector.ManualFinalizeCache[typeof(T)]; + finalize(ephemeral); + GC.SuppressFinalize(ephemeral); + } + + internal static void FinalizerGlue(Il2CppObjectBase obj) + { + s_ephemeron.Add(obj, obj.CreateFinalizerContainer()); + obj.finalizerState = Il2CppObjectFinalizerState.Glued; + GC.SuppressFinalize(obj); + } +} + From d905c920de2cc5c9db6b255501689e450f266d93 Mon Sep 17 00:00:00 2001 From: soqb Date: Mon, 1 Sep 2025 09:13:15 +0100 Subject: [PATCH 3/7] fix(gc): revert cache flag publicization --- Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs b/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs index bccebb67..b223c724 100644 --- a/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs +++ b/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs @@ -7,7 +7,7 @@ namespace Il2CppInterop.Runtime.Runtime; public static class Il2CppObjectPool { - public static bool DisableCaching { get; set; } + internal static bool DisableCaching { get; set; } private static readonly ConcurrentDictionary> s_cache = new(); From a29f0f6c02a689d2217f254823c65d44d2d91be5 Mon Sep 17 00:00:00 2001 From: soqb Date: Mon, 1 Sep 2025 12:23:00 +0100 Subject: [PATCH 4/7] feat(gc): use dedicated private method for finalizers. made much more robust# --- Documentation/Class-Injection.md | 7 +- Il2CppInterop.Runtime/DelegateSupport.cs | 2 +- .../Injection/ClassInjector.cs | 54 ++++++++++---- .../InteropTypes/Il2CppObjectBase.cs | 43 +++++------ ...ifecycle.cs => Il2CppObjectInitializer.cs} | 73 +++++-------------- .../Runtime/Il2CppObjectPool.cs | 4 +- 6 files changed, 86 insertions(+), 97 deletions(-) rename Il2CppInterop.Runtime/Runtime/{ObjectLifecycle.cs => Il2CppObjectInitializer.cs} (64%) diff --git a/Documentation/Class-Injection.md b/Documentation/Class-Injection.md index 4913308a..8adc17d9 100644 --- a/Documentation/Class-Injection.md +++ b/Documentation/Class-Injection.md @@ -55,13 +55,13 @@ public class MyClass : SomeIL2CPPClass { // Used by IL2CPP when creating new instances of this class public MyClass(IntPtr ptr) : base(ptr) { } - + // Used by managed code when creating new instances of this class public MyClass() : base(ClassInjector.DerivedConstructorPointer()) { ClassInjector.DerivedConstructorBody(this); } - + // Any other methods } @@ -110,7 +110,8 @@ class Foo : Il2CppSystem.Object { ClassInjector.DerivedConstructorBody(this); } - ~Foo() + + private void Il2CppFinalize() { // ... } diff --git a/Il2CppInterop.Runtime/DelegateSupport.cs b/Il2CppInterop.Runtime/DelegateSupport.cs index 72ed65f6..8bce992b 100644 --- a/Il2CppInterop.Runtime/DelegateSupport.cs +++ b/Il2CppInterop.Runtime/DelegateSupport.cs @@ -403,7 +403,7 @@ public Il2CppToMonoDelegateReference(Delegate referencedDelegate, IntPtr methodI s_storedDelegates[methodInfo] = referencedDelegate; } - ~Il2CppToMonoDelegateReference() + private void Il2CppFinalize() { Marshal.FreeHGlobal(MethodInfo); MethodInfo.Set(IntPtr.Zero); diff --git a/Il2CppInterop.Runtime/Injection/ClassInjector.cs b/Il2CppInterop.Runtime/Injection/ClassInjector.cs index d9ae43e9..795f0485 100644 --- a/Il2CppInterop.Runtime/Injection/ClassInjector.cs +++ b/Il2CppInterop.Runtime/Injection/ClassInjector.cs @@ -63,6 +63,17 @@ public class RegisterTypeOptions public Il2CppInterfaceCollection? Interfaces { get; init; } = null; } +internal record FinalizerChain(Delegate finalizer, FinalizerChain? next) +{ + public FinalizerChain? next = next; + + public void Execute(T obj) where T : Il2CppObjectBase + { + ((Action)finalizer)(obj); + next?.Execute(obj); + } +} + public static unsafe partial class ClassInjector { /// type.FullName @@ -74,7 +85,7 @@ public static unsafe partial class ClassInjector private static readonly ConcurrentDictionary InvokerCache = new(); private static readonly ConcurrentDictionary DerivedConstructorBodyCache = new(); - internal static readonly ConcurrentDictionary ManualFinalizeCache = new(); + internal static readonly ConcurrentDictionary FinalizerChainCache = new(); private static readonly ConcurrentDictionary<(Type type, FieldAttributes attrs), IntPtr> _injectedFieldTypes = new(); @@ -209,20 +220,31 @@ public static void RegisterTypeInIl2Cpp(Type type, RegisterTypeOptions options) $"Type with FullName {type.FullName} is already injected. Don't inject the same type twice, or use a different namespace"); } - MethodInfo? DiscernIfTypeIsManuallyFinalizable(Type type) + static void InternFinalizerChain(Type? type) { - if (type == typeof(Il2CppObjectBase)) - return null; - if (type.GetMethod("Finalize", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) is MethodInfo m) - return m; - return type.BaseType != null ? DiscernIfTypeIsManuallyFinalizable(type.BaseType) : null; - } + FinalizerChain? last = null; + while (type != null) + { + if (FinalizerChainCache.TryGetValue(type, out FinalizerChain? next)) + { + if (last != null) last.next = next; + break; + } - if (DiscernIfTypeIsManuallyFinalizable(type) is MethodInfo finalize) - { - ManualFinalizeCache[type] = finalize.CreateDelegate(typeof(Action<>).MakeGenericType(type)); + if (type.GetMethod("Il2CppFinalize", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) is MethodInfo m) + { + next = new(m.CreateDelegate(typeof(Action<>).MakeGenericType(type)), last); + if (last != null) last.next = next; + FinalizerChainCache[type] = next; + last = next; + } + + type = type.BaseType; + } } + InternFinalizerChain(type); + var interfaceFunctionCount = interfaces.Sum(i => i.MethodCount); var classPointer = UnityVersionHandler.NewClass(baseClassPointer.VtableCount + interfaceFunctionCount); @@ -737,7 +759,13 @@ private static void DefaultFinalize(IntPtr ptr) { } internal static bool IsTypeManuallyFinalizable(Type targetType) { - return ManualFinalizeCache.ContainsKey(targetType); + return FinalizerChainCache.ContainsKey(targetType); + } + + private static void RunFinalizer(IntPtr ptr) where T : Il2CppObjectBase + { + T ephemeral = Il2CppObjectInitializer.NewWeak(ptr); + ClassInjector.FinalizerChainCache[typeof(T)]!.Execute(ephemeral); } private static VoidCtorDelegate CreateFinalizeMethod(Type targetType) @@ -753,7 +781,7 @@ private static VoidCtorDelegate CreateFinalizeMethod(Type targetType) // Finalize from within the object pool: body.Emit(OpCodes.Ldarg_0); body.Emit(OpCodes.Call, - typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.RunFinalizer), + typeof(ClassInjector).GetMethod(nameof(ClassInjector.RunFinalizer), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(targetType)); // Catch any exceptions: diff --git a/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs b/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs index b6e34d34..8b312ce8 100644 --- a/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs +++ b/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs @@ -1,23 +1,14 @@ using System; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using Il2CppInterop.Common; using Il2CppInterop.Runtime.Runtime; -using Microsoft.Extensions.Logging; namespace Il2CppInterop.Runtime.InteropTypes; -public enum Il2CppObjectFinalizerState -{ - Free = 0, - Glued = 1, -} - public class Il2CppObjectBase { internal bool isWrapped; internal IntPtr pooledPtr; - internal Il2CppObjectFinalizerState finalizerState; private bool wasDestroyed; private nint myGcHandle; @@ -29,14 +20,7 @@ public Il2CppObjectBase(IntPtr pointer) ~Il2CppObjectBase() { - switch (finalizerState) - { - case Il2CppObjectFinalizerState.Free: - Il2CppObjectPool.Free(myGcHandle, pooledPtr); - break; - case Il2CppObjectFinalizerState.Glued: - throw new NotSupportedException("Object was garbage collected too early. Perhaps GC.ReRegisterForFinalize was incorrectly called?"); - } + Il2CppObjectPool.Free(myGcHandle, pooledPtr); } public IntPtr ObjectClass => IL2CPP.il2cpp_object_get_class(Pointer); @@ -72,16 +56,24 @@ internal void CreateGCHandle(IntPtr objHdl) return; myGcHandle = IL2CPP.il2cpp_gchandle_new(objHdl, false); + isWrapped = true; } - internal FinalizerContainer CreateFinalizerContainer() + internal static void Downgrade(Il2CppObjectBase obj) { - return new FinalizerContainer() - { - gcHandle = myGcHandle, - ptr = Pointer, - }; + nint strong = obj.myGcHandle; + obj.myGcHandle = IL2CPP.il2cpp_gchandle_new_weakref(obj.Pointer, false); + IL2CPP.il2cpp_gchandle_free(strong); + } + + internal static void Upgrade(Il2CppObjectBase obj) + { + nint weak = obj.myGcHandle; + obj.myGcHandle = IL2CPP.il2cpp_gchandle_new(obj.Pointer, false); + IL2CPP.il2cpp_gchandle_free(weak); + Il2CppObjectPool.Intern(obj); + } public T Cast() where T : Il2CppObjectBase @@ -121,4 +113,9 @@ public T Unbox() where T : unmanaged return Il2CppObjectInitializer.New(Pointer); } + + public static void Resurrect(Il2CppObjectBase obj) + { + Upgrade(obj); + } } diff --git a/Il2CppInterop.Runtime/Runtime/ObjectLifecycle.cs b/Il2CppInterop.Runtime/Runtime/Il2CppObjectInitializer.cs similarity index 64% rename from Il2CppInterop.Runtime/Runtime/ObjectLifecycle.cs rename to Il2CppInterop.Runtime/Runtime/Il2CppObjectInitializer.cs index b5f5968e..74dbbaac 100644 --- a/Il2CppInterop.Runtime/Runtime/ObjectLifecycle.cs +++ b/Il2CppInterop.Runtime/Runtime/Il2CppObjectInitializer.cs @@ -1,13 +1,12 @@ using System; -using System.Collections.Concurrent; using System.Linq; using System.Reflection; using System.Reflection.Emit; using System.Runtime.CompilerServices; -using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.Injection; using Il2CppInterop.Runtime.InteropTypes; -using Il2CppInterop.Runtime.Runtime; + +namespace Il2CppInterop.Runtime.Runtime; internal static class Il2CppObjectInitializer { @@ -16,10 +15,12 @@ internal static T New(IntPtr ptr) return InitializerStore.Initialize(ptr); } - /// Identical to New except skipping the glue code for injected finalizers. - internal static T NewWithoutGlue(IntPtr ptr) where T : Il2CppObjectBase + /// + /// Creates a proxy to a new T without holding a strong handle. + /// + internal static T NewWeak(IntPtr ptr) where T : Il2CppObjectBase { - return InitializerStore.InitializeWithoutGlue(ptr); + return InitializerStore.InitializeWeak(ptr); } private static readonly MethodInfo _getUninitializedObject = typeof(RuntimeHelpers).GetMethod(nameof(RuntimeHelpers.GetUninitializedObject))!; @@ -69,20 +70,18 @@ internal static void EmitInjectedInitialization(ILGenerator il, Type type, Field EmitGCHandling(il, type, false); } - private static readonly MethodInfo _internWeak = typeof(Il2CppObjectPool).GetMethod(nameof(Il2CppObjectPool.InternWeak))!; - private static readonly MethodInfo _glue = typeof(Il2CppFinalizers).GetMethod(nameof(Il2CppFinalizers.FinalizerGlue), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo _intern = typeof(Il2CppObjectPool).GetMethod(nameof(Il2CppObjectPool.Intern))!; + private static readonly MethodInfo _downgrade = typeof(Il2CppObjectBase).GetMethod(nameof(Il2CppObjectBase.Downgrade), BindingFlags.NonPublic | BindingFlags.Static)!; - private static void EmitGCHandling(ILGenerator il, Type type, bool useGlue) + private static void EmitGCHandling(ILGenerator il, Type type, bool weak) { - if (useGlue && ClassInjector.IsTypeManuallyFinalizable(type)) + if (!weak) { - il.Emit(OpCodes.Dup); - il.Emit(OpCodes.Call, _internWeak); - il.Emit(OpCodes.Call, _glue); + il.Emit(OpCodes.Call, _intern); } else { - il.Emit(OpCodes.Call, _internWeak); + il.Emit(OpCodes.Call, _downgrade); } } @@ -109,10 +108,10 @@ private static class InitializerStore public static Initializer? initialize; public static Initializer? initializeWithoutGlue; - public static Initializer Initialize => initialize ??= Create(true); - public static Initializer InitializeWithoutGlue => initializeWithoutGlue ??= Create(false); + public static Initializer Initialize => initialize ??= Create(false); + public static Initializer InitializeWeak => initializeWithoutGlue ??= Create(true); - private static Initializer Create(bool useGlue) + private static Initializer Create(bool weak) { var type = Il2CppClassPointerStore.CreatedTypeRedirect ?? typeof(T); @@ -121,7 +120,7 @@ private static Initializer Create(bool useGlue) .Where(ClassInjector.IsFieldEligible) .ToArray(); - string methodName = useGlue ? "Initialize" : "InitializeWithoutGlue"; + string methodName = weak ? "InitializeWeak" : "Initialize"; var dynamicMethod = new DynamicMethod($"{methodName}<{typeof(T).AssemblyQualifiedName}>", type, _intPtrTypeArray); dynamicMethod.DefineParameter(0, ParameterAttributes.None, "pointer"); @@ -129,46 +128,10 @@ private static Initializer Create(bool useGlue) EmitCtorCall(il, type); EmitFieldInitialization(il, type, fieldsToInitialize); il.Emit(OpCodes.Dup); - EmitGCHandling(il, type, useGlue); + EmitGCHandling(il, type, weak); il.Emit(OpCodes.Ret); return dynamicMethod.CreateDelegate(); } } } - -// NB: Since a managed object might be garbage collected before its unmanaged counterpart is ready, -// we need another object (this one) to handle the cleanup. -// The objects' lifetimes are linked by the s_finalizers ephemeron on Il2CppFinalizers. -internal class FinalizerContainer -{ - public nint gcHandle; - public IntPtr ptr; - - ~FinalizerContainer() - { - Il2CppObjectPool.Free(gcHandle, ptr); - } -} - -internal static class Il2CppFinalizers -{ - internal static readonly ConcurrentDictionary s_dying = new(); - internal static readonly ConditionalWeakTable s_ephemeron = new(); - - internal static void RunFinalizer(IntPtr ptr) where T : Il2CppObjectBase - { - T ephemeral = Il2CppObjectInitializer.NewWithoutGlue(ptr); - Action finalize = (Action)ClassInjector.ManualFinalizeCache[typeof(T)]; - finalize(ephemeral); - GC.SuppressFinalize(ephemeral); - } - - internal static void FinalizerGlue(Il2CppObjectBase obj) - { - s_ephemeron.Add(obj, obj.CreateFinalizerContainer()); - obj.finalizerState = Il2CppObjectFinalizerState.Glued; - GC.SuppressFinalize(obj); - } -} - diff --git a/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs b/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs index b223c724..276d8834 100644 --- a/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs +++ b/Il2CppInterop.Runtime/Runtime/Il2CppObjectPool.cs @@ -23,7 +23,7 @@ internal static void Free(nint unmanagedGcHandle, IntPtr ptr) Remove(ptr); } - public static void InternWeak(Il2CppObjectBase obj) + public static void Intern(Il2CppObjectBase obj) { IntPtr ptr = obj.Pointer; obj.pooledPtr = ptr; @@ -32,7 +32,7 @@ public static void InternWeak(Il2CppObjectBase obj) public static T Get(IntPtr ptr) { - if (ptr == IntPtr.Zero || Il2CppFinalizers.s_dying.ContainsKey(ptr)) + if (ptr == IntPtr.Zero) { return default!; } From 4a4ce6403a590f432b709cd9119b9ded26394074 Mon Sep 17 00:00:00 2001 From: soqb Date: Mon, 1 Sep 2025 12:56:49 +0100 Subject: [PATCH 5/7] fix(gc): adjust documentation and move resurrection method. --- Documentation/Class-Injection.md | 52 +++++++++++-------- .../Injection/ClassInjector.cs | 5 ++ .../InteropTypes/Il2CppObjectBase.cs | 5 -- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/Documentation/Class-Injection.md b/Documentation/Class-Injection.md index 8adc17d9..007a9919 100644 --- a/Documentation/Class-Injection.md +++ b/Documentation/Class-Injection.md @@ -88,17 +88,19 @@ var someInstance = new MyClass(pointer); releasing the strong handle it holds and allowing the underlying unmanaged object to eventually be collected in turn. When this occurs, however, the finalizer for the managed instance will not be run, delaying execution until the unmanaged object is also ready to be garbage collected. -* The unmanaged-to-managed mapping is a one-to-many relationship. While during typical execution there will be exactly zero, - or exactly one extant managed object, in certain situations, such as when the object pool is disabled, there may be any number. -* Due to the implementation of finalizers, calling `System.GC.SuppressFinalize` or `System.GC.ReRegisterForFinalize` is invalid - for all injected types with finalizers, leading to `ObjectCollectedExeption`s at best, and memory safety issues at worst. - The methods on `Il2CppSystem.GC` will always work as intended, however. - As a corrolary, once an managed instance is finalized, it is no longer valid, - so the "resurrection pattern" is not functional for injected types. +* The unmanaged-to-managed mapping is a one-to-many relationship. + While during typical execution there will be exactly zero, or exactly one extant managed object, + in certain situations, such as when the object pool is disabled, there may be any number.# +* Finalizers are supported for injected classes, but note that the managed injector (`~MyClass`) is not invoked predictably. + If you want to implement a custom finalizer, define a `private void Il2CppFinalize()` method on your class (see below). +* Due to the implementation of finalizers, the "resurrection pattern" does not translate directly to injected types. + If you do not call `ClassInjector.ResurrectObject` during an object's finalizer, + the unmanaged object will be garbage collected and future use will throw `ObjectCollectedException`s. + **NB:** You will still also need to call `Il2CppSystem.GC.ReRegisterForFinalize` if applicable.
A detailed example of finalizer behavior for injected classes -
+
The new managed instance has its `Il2CppFinalize` method called Consider the following example: @@ -117,30 +119,38 @@ class Foo : Il2CppSystem.Object } } +ClassInjector.RegisterTypeInIl2Cpp(); + Foo foobar = new(); // ... function ends ... ``` In the above example, the events occur in this order: -1. The parameterless `Foo` constructor is called: +1. `Foo` is injected into the il2cpp domain: + * The class injector walks the superclasses of `Foo` and collects all the `Il2CppFinalize` methods. + * These finalizers are organised in a chain from subclass to superclass. + * The injected class is created with an unmanaged finalizer which we can hook into later. +2. The parameterless `Foo` constructor is called: * An unmanaged instance of the injected class is created. * A strong handle to the unmanaged instance is put into the managed instance of `Foo`. - * The finalizer of the managed instance itself is suppressed, - but a hook is installed to watch for its collection. -2. The managed instance of `Foo` goes out of scope, - allowing the managed instance to be garbage collected (without running its finalizer). -3. Some time later, the managed instance is collected and the hook is triggered: - * The strong handle to the unmanaged instance is released - * Assuming no other managed or unmanaged references to the object exist, +3. The managed instance of `Foo` goes out of scope, allowing this instance to be garbage collected. +4. Some time later, the managed GC will run its finalizer: + * Eventually, the GC reaches the finalizer of `Il2CppObjectBase` + where the strong handle to the unmanaged instance is released. + * Assuming no other managed or unmanaged references to the unmanaged object exist, the unmanaged instance of `Foo` can now be garbage collected. -4. Before this happens, the unmanaged object's finalizer is called: - * A fresh managed instance of `Foo` is created and _its_ finalizer is called directly. - * This new managed instance is immediately invalidated and forgotten. -5. The unmanaged object, which has no extant references, is garbage collected with its managed finalizer having run exactly once. + * Here, the managed instance of `Foo` is destroyed by the garbage collector, + but the unmanaged instance still exists temporarily. +5. When the unmanaged GC acknowleges this, the unmanaged object's finalizer is called, firing the hook we installed: + * A fresh managed instance of `Foo` is created. + * This fresh instance is "downgraded" and its strong handle becomes a weak handle, + preventing this instance from blocking the garbage collection of the unmanaged instance. + * We follow the links in the finalizer chain, running the user-specified finalization method (finally!). +6. The unmanaged object, which has no extant references, is garbage collected with its managed finalizer having run exactly once. Note that this complexity is present only for injected classes with finalizers. -Injected classes without finalizers are collected following a more standard procedure. +Injected classes without an `Il2CppFinalize` method are collected without running any hooks.
diff --git a/Il2CppInterop.Runtime/Injection/ClassInjector.cs b/Il2CppInterop.Runtime/Injection/ClassInjector.cs index 795f0485..59d7e756 100644 --- a/Il2CppInterop.Runtime/Injection/ClassInjector.cs +++ b/Il2CppInterop.Runtime/Injection/ClassInjector.cs @@ -119,6 +119,11 @@ private static InjectedInitializationDelegate CreateDerivedConstructorBodyDelega return method.CreateDelegate(); } + public static void ResurrectObject(Il2CppObjectBase obj) + { + Il2CppObjectBase.Upgrade(obj); + } + public static bool IsTypeRegisteredInIl2Cpp() where T : class { return IsTypeRegisteredInIl2Cpp(typeof(T)); diff --git a/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs b/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs index 8b312ce8..e01c5149 100644 --- a/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs +++ b/Il2CppInterop.Runtime/InteropTypes/Il2CppObjectBase.cs @@ -113,9 +113,4 @@ public T Unbox() where T : unmanaged return Il2CppObjectInitializer.New(Pointer); } - - public static void Resurrect(Il2CppObjectBase obj) - { - Upgrade(obj); - } } From 2e48c743b3ded3352165299ce3d05e929a052d38 Mon Sep 17 00:00:00 2001 From: soqb Date: Mon, 1 Sep 2025 12:59:00 +0100 Subject: [PATCH 6/7] fix(gc): harden finalizer search --- Il2CppInterop.Runtime/Injection/ClassInjector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Il2CppInterop.Runtime/Injection/ClassInjector.cs b/Il2CppInterop.Runtime/Injection/ClassInjector.cs index 59d7e756..7955324d 100644 --- a/Il2CppInterop.Runtime/Injection/ClassInjector.cs +++ b/Il2CppInterop.Runtime/Injection/ClassInjector.cs @@ -236,7 +236,7 @@ static void InternFinalizerChain(Type? type) break; } - if (type.GetMethod("Il2CppFinalize", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) is MethodInfo m) + if (type.GetMethod("Il2CppFinalize", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, new Type[0]) is MethodInfo m) { next = new(m.CreateDelegate(typeof(Action<>).MakeGenericType(type)), last); if (last != null) last.next = next; From bba83b30f1f31b3f7b024bbe9a066ab48f6b61f5 Mon Sep 17 00:00:00 2001 From: soqb Date: Mon, 1 Sep 2025 13:01:19 +0100 Subject: [PATCH 7/7] fix(gc): how did that get there? --- Documentation/Class-Injection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/Class-Injection.md b/Documentation/Class-Injection.md index 007a9919..c625e741 100644 --- a/Documentation/Class-Injection.md +++ b/Documentation/Class-Injection.md @@ -100,7 +100,7 @@ var someInstance = new MyClass(pointer);
A detailed example of finalizer behavior for injected classes -
The new managed instance has its `Il2CppFinalize` method called +
Consider the following example: