From ff41665593d64d18196aed3f202ef1a1944a4e4f Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Mon, 26 Oct 2026 18:38:42 +0100 Subject: [PATCH] test: detect CoreCLR accessor and IL generator contract changes --- .../Program.cs | 17 +- .../RuntimeAccessorContractTests.cs | 159 ++++++++++++++++++ test/RuntimeAccessorContracts.md | 26 +++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 test/FastExpressionCompiler.UnitTests/RuntimeAccessorContractTests.cs create mode 100644 test/RuntimeAccessorContracts.md diff --git a/test/FastExpressionCompiler.TestsRunner/Program.cs b/test/FastExpressionCompiler.TestsRunner/Program.cs index 0f60c49fd..04bb88274 100644 --- a/test/FastExpressionCompiler.TestsRunner/Program.cs +++ b/test/FastExpressionCompiler.TestsRunner/Program.cs @@ -9,8 +9,12 @@ namespace FastExpressionCompiler.UnitTests { public class Program { - public static void Main() + public static void Main(string[] args) { + RunRuntimeAccessorContracts(); + if (Array.IndexOf(args, "--runtime-accessor-contracts") >= 0) + return; + // TestTools.AllowPrintCS = false; // TestTools.AllowPrintIL = false; // TestTools.AllowPrintExpression = false; @@ -81,6 +85,17 @@ public static void Main() RunAllTests(); } + private static void RunRuntimeAccessorContracts() + { + var standard = new TestRun(TestFlags.RethrowException); + standard.Run(new RuntimeAccessorContractTests()); + var light = new LightExpression.TestRun(LightExpression.TestFlags.RethrowException); + light.Run(new LightExpression.UnitTests.RuntimeAccessorContractTests()); + if (standard.FailedTestCount != 0 || light.FailedTestCount != 0) + throw new InvalidOperationException("Runtime accessor contracts failed; see the member/assertion diagnostics above."); + Console.WriteLine($"Runtime accessor contracts passed: {standard.TotalTestCount} Standard, {light.TotalTestCount} LightExpression."); + } + public static void RunAllTests() { var failed = false; diff --git a/test/FastExpressionCompiler.UnitTests/RuntimeAccessorContractTests.cs b/test/FastExpressionCompiler.UnitTests/RuntimeAccessorContractTests.cs new file mode 100644 index 000000000..230ef8aa1 --- /dev/null +++ b/test/FastExpressionCompiler.UnitTests/RuntimeAccessorContractTests.cs @@ -0,0 +1,159 @@ +#if NET8_0_OR_GREATER +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +#if NET10_0_OR_GREATER +using System.Runtime.CompilerServices; +#endif + +#if LIGHT_EXPRESSION +namespace FastExpressionCompiler.LightExpression.UnitTests; +#else +namespace FastExpressionCompiler.UnitTests; +#endif + +// These are intentionally strict contracts for the CoreCLR versions exercised by CI. +// A runtime update must not silently disable an optimization while correctness tests pass. +public struct RuntimeAccessorContractTests : ITestX +{ + private const BindingFlags NonPublicStatic = BindingFlags.NonPublic | BindingFlags.Static; + private const BindingFlags InstanceMembers = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + public void Run(TestRun t) + { + Local_allocation_and_generator_reuse_remain_available(t); +#if NET10_0_OR_GREATER + Accessor_declarations_match_the_runtime_members(t); + Production_accessors_bind_and_emit_a_working_method(t); +#endif + } + + public void Local_allocation_and_generator_reuse_remain_available(TestContext t) + { + var hacks = typeof(DynamicMethodHacks); + t.IsNotNull(hacks.GetField("GetNextLocalVarLocation", NonPublicStatic).GetValue(null)); + t.IsNotNull(hacks.GetField("ReuseDynamicILGenerator", NonPublicStatic).GetValue(null)); + var pool = hacks.GetField("_pooledILGenerator", NonPublicStatic); + var previous = pool.GetValue(null); + pool.SetValue(null, null); + try + { + var first = new DynamicMethod("first", typeof(int), new[] { typeof(int) }, typeof(RuntimeAccessorContractTests), true); + var il = DynamicMethodHacks.RentPooledOrNewILGenerator(first, typeof(int), new[] { typeof(int) }); + t.AreEqual(0, il.GetNextLocalVarIndex(typeof(int))); + il.BeginExceptionBlock(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, typeof(Math).GetMethod(nameof(Math.Abs), new[] { typeof(int) })); + il.Emit(OpCodes.Stloc_0); + il.BeginFinallyBlock(); + il.Emit(OpCodes.Nop); + il.EndExceptionBlock(); + il.Emit(OpCodes.Ldloc_0); + il.Emit(OpCodes.Ret); + var f = (Func)first.CreateDelegate(typeof(Func)); + t.AreEqual(42, f(-42)); + DynamicMethodHacks.FreePooledILGenerator(first, il); + + // Change the signature and local type after tokens, exception regions and locals were used. + var second = new DynamicMethod("second", typeof(string), new[] { typeof(string) }, typeof(RuntimeAccessorContractTests), true); + var reused = DynamicMethodHacks.RentPooledOrNewILGenerator(second, typeof(string), new[] { typeof(string) }); + t.IsTrue(ReferenceEquals(il, reused)); + t.AreEqual(0, reused.GetNextLocalVarIndex(typeof(string))); + reused.Emit(OpCodes.Ldarg_0); + reused.Emit(OpCodes.Stloc_0); + reused.Emit(OpCodes.Ldloc_0); + reused.Emit(OpCodes.Ret); + var g = (Func)second.CreateDelegate(typeof(Func)); + t.AreEqual("second", g("second")); + t.AreEqual(17, f(-17)); // Reuse must not change the already-created method. + DynamicMethodHacks.FreePooledILGenerator(second, reused); + } + finally + { + pool.SetValue(null, previous); + } + } + +#if NET10_0_OR_GREATER + public void Accessor_declarations_match_the_runtime_members(TestContext t) + { + var count = 0; + foreach (var accessor in typeof(DynamicMethodHacks).GetMethods(NonPublicStatic)) + { + var attribute = accessor.GetCustomAttribute(); + if (attribute == null) + continue; + ++count; + var parameters = accessor.GetParameters(); + var ownerName = parameters[0].GetCustomAttribute().TypeName; + var owner = typeof(ILGenerator).Assembly.GetType(ownerName, throwOnError: true); + // UnsafeAccessor does not search base classes. Check the exact declaring type. + if (attribute.Kind == UnsafeAccessorKind.Field) + { + var field = owner.GetField(attribute.Name, InstanceMembers | BindingFlags.DeclaredOnly); + if (field == null) + throw new MissingFieldException(owner.FullName, attribute.Name); + t.IsTrue(!field.IsStatic); + t.AreEqual(field.FieldType.MakeByRefType(), accessor.ReturnType); + t.AreEqual(1, parameters.Length); + } + else + { + t.AreEqual(UnsafeAccessorKind.Method, attribute.Kind); + var argumentTypes = new Type[parameters.Length - 1]; + for (var i = 1; i < parameters.Length; ++i) + argumentTypes[i - 1] = parameters[i].ParameterType; + var method = owner.GetMethod(attribute.Name, InstanceMembers | BindingFlags.DeclaredOnly, null, argumentTypes, null); + if (method == null) + throw new MissingMethodException(owner.FullName, attribute.Name); + t.IsTrue(!method.IsStatic); + t.AreEqual(method.ReturnType, accessor.ReturnType); + } + } + t.AreEqual(6, count); + } + + private delegate ref T FieldAccessor(object instance); + + private static T Bind(string name) where T : Delegate => + (T)typeof(DynamicMethodHacks).GetMethod(name, NonPublicStatic).CreateDelegate(typeof(T)); + + public void Production_accessors_bind_and_emit_a_working_method(TestContext t) + { + // Bind the production declarations, not duplicate UnsafeAccessor stubs in the test assembly. + var signature = Bind>("GetMLocalSignature"); + var localCount = Bind>("GetMLocalCount"); + var length = Bind>("GetMLength"); + var stream = Bind>("GetMILStream"); + var tokens = Bind>>("GetMTokens"); + var updateStack = Bind>("UpdateStackSize"); + var method = new DynamicMethod("accessors", typeof(int), Type.EmptyTypes, typeof(RuntimeAccessorContractTests), true); + var il = method.GetILGenerator(32); + t.AreEqual(0, localCount(il)); + signature(il).AddArgument(typeof(int), false); + ++localCount(il); + t.AreEqual(1, localCount(il)); + + var scope = il.GetType().GetField("m_scope", InstanceMembers).GetValue(il); + var table = tokens(scope); + table.Add(typeof(Math).GetMethod(nameof(Math.Abs), new[] { typeof(int) }).MethodHandle); + var token = (table.Count - 1) | 0x06000000; + var bytes = stream(il); + ref var position = ref length(il); + t.AreEqual(0, position); + bytes[position++] = (byte)OpCodes.Ldc_I4_S.Value; + bytes[position++] = unchecked((byte)-42); + updateStack(il, OpCodes.Ldc_I4_S, 1); + bytes[position++] = (byte)OpCodes.Call.Value; + for (var shift = 0; shift != 32; shift += 8) + bytes[position++] = (byte)(token >> shift); + updateStack(il, OpCodes.Call, 0); // Abs consumes and returns one value. + il.Emit(OpCodes.Stloc_0); + il.Emit(OpCodes.Ldloc_0); + il.Emit(OpCodes.Ret); + t.AreEqual(42, ((Func)method.CreateDelegate(typeof(Func)))()); + } +#endif +} +#endif diff --git a/test/RuntimeAccessorContracts.md b/test/RuntimeAccessorContracts.md new file mode 100644 index 000000000..adba6f7c0 --- /dev/null +++ b/test/RuntimeAccessorContracts.md @@ -0,0 +1,26 @@ +# Runtime accessor contracts (#560) + +`RuntimeAccessorContractTests` runs for Standard and LightExpression before the existing .NET 8/10 suites. The existing Windows/Linux CI matrix therefore exercises it without an additional workflow. Contract failures produce a nonzero exit, including assertions that the TestX runner normally records rather than throws. + +To run only these checks: + +```sh +dotnet run -c Release -f net10.0 --project test/FastExpressionCompiler.TestsRunner -- --runtime-accessor-contracts +dotnet run -c Release -f net8.0 --project test/FastExpressionCompiler.TestsRunner -- --runtime-accessor-contracts +``` + +These intentionally strict tests target the CoreCLR implementations used by CI: + +- .NET 8/10: local-allocation and IL-generator-reuse helpers must remain available. Reuse must actually return the same generator, reset locals/signatures/tokens/exception-region state for a different method, and preserve the previously created delegate. +- .NET 10: inspect all six production `UnsafeAccessor` declarations. Resolve the owner from `UnsafeAccessorType`, require the member on that exact declaring type, and check field types/byref returns or method parameter/return types and instance/static shape. +- .NET 10: bind the production accessors themselves, then use all six to create locals, write IL, update stack accounting, and install a method token. The resulting delegate must execute correctly. This catches binding/JIT failures as well as metadata changes. + +The tests run against the executing runtime; building alone does not validate its private members. They do not promise compatibility with runtimes that CI has not exercised. Framework 4.7.2 has a different implementation and does not contain these .NET 10 accessors, so this fixture is excluded there. An intentional change to FEC's accessors or supported runtime contract should update the fixture too. + +## Why keep UnsafeAccessor? + +Issue [#560](https://github.com/dadhi/FastExpressionCompiler/issues/560) proposes compiler checks using [IgnoresAccessChecksToGenerator](https://github.com/aelij/IgnoresAccessChecksToGenerator). Its current publicizer exposes internal members but leaves private members private. An extension could expose private members and nested types in generated build-time references; that is possible in principle. + +The isolated stock-package 0.8.0 probe on Windows ARM64/.NET 10.0.9 found two practical obstacles: normal framework reference assemblies omit `RuntimeILGenerator`, and feeding the installed `System.Private.CoreLib` implementation to the generator fails with `Writing mixed-mode assemblies is not supported`. FEC accesses both internal members (for example `m_localCount`) and private fields (`m_length`, `m_ILStream`). Supporting those accesses requires more than adding the package: usable implementation-derived metadata references and private-member publicizing are needed. The installed runtime assembly should not be replaced. + +Direct typed helpers could potentially reduce startup reflection/DynamicMethod generation and delegate dispatch in FEC's reuse path. Replacing an existing UnsafeAccessor field access alone is not an established speedup. Specialized/batched emission is a separate optimization. None of those performance ideas are implemented or measured by this test-only change.