From b9635269b6b7330656ed5083a66c9bebcda4eb9f Mon Sep 17 00:00:00 2001 From: Adarsh Choudhary Date: Sun, 27 Sep 2026 23:07:15 +0200 Subject: [PATCH] fix: find async state machines in optimized builds HandleAsync located the state machine by taking the first newobj in the async method whose type has a MoveNext method. In optimized builds the compiler emits async state machines as structs, which are initialized in place instead of created with newobj. Nothing matched, the fallback analysed the small outer method instead of MoveNext, and every dependency in the method body was lost. HandleIterator used the same lookup; it only worked because iterator state machines are always classes. Read the state machine type from the method's AsyncStateMachineAttribute or IteratorStateMachineAttribute instead, and keep the existing fallback for when it cannot be resolved. None of the test assemblies set , so they always had class state machines. Add OptimizedAssembly, which is always optimized, with an async and an iterator method, and a guard test that its async state machine really is a struct. MethodCallDependenciesAreFoundInAsyncMethod was skipped in Release builds because of this bug; it now passes there, so run it again and drop the SkipInReleaseBuildTheory attribute that only it used. Fixes #498 Signed-off-by: Adarsh Choudhary --- ArchUnit.sln | 15 ++++ .../Loader/MonoCecilMemberExtensions.cs | 26 ++++++ ArchUnitNET/Loader/TypeProcessor.cs | 36 +++------ ArchUnitNETTests/ArchUnitNETTests.csproj | 1 + .../OptimizedStateMachineDependenciesTests.cs | 79 +++++++++++++++++++ .../Members/MethodCallDependencyTests.cs | 2 +- ArchUnitNETTests/SkipInReleaseBuild.cs | 10 --- ArchUnitNETTests/StaticTestArchitectures.cs | 4 + .../OptimizedAssembly.csproj | 15 ++++ .../OptimizedAssembly/StateMachineClasses.cs | 24 ++++++ 10 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 ArchUnitNETTests/Dependencies/OptimizedStateMachineDependenciesTests.cs create mode 100644 TestAssemblies/OptimizedAssembly/OptimizedAssembly.csproj create mode 100644 TestAssemblies/OptimizedAssembly/StateMachineClasses.cs diff --git a/ArchUnit.sln b/ArchUnit.sln index 5f7c40a35..1c6cf1d57 100644 --- a/ArchUnit.sln +++ b/ArchUnit.sln @@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClassAssembly", "TestAssemb EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlicesTestAssembly", "TestAssemblies\SlicesTestAssembly\SlicesTestAssembly.csproj", "{E421FA12-BBB9-4A8B-BD29-8757836079A5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OptimizedAssembly", "TestAssemblies\OptimizedAssembly\OptimizedAssembly.csproj", "{74857E33-09C5-43C7-AA27-E9373DEC1380}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A780B3D6-C525-4270-ABF1-63BA65EA1593}" ProjectSection(SolutionItems) = preProject README.md = README.md @@ -441,6 +443,18 @@ Global {E421FA12-BBB9-4A8B-BD29-8757836079A5}.Release|x64.Build.0 = Release|Any CPU {E421FA12-BBB9-4A8B-BD29-8757836079A5}.Release|x86.ActiveCfg = Release|Any CPU {E421FA12-BBB9-4A8B-BD29-8757836079A5}.Release|x86.Build.0 = Release|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Debug|x64.ActiveCfg = Debug|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Debug|x64.Build.0 = Debug|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Debug|x86.ActiveCfg = Debug|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Debug|x86.Build.0 = Debug|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Release|Any CPU.Build.0 = Release|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Release|x64.ActiveCfg = Release|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Release|x64.Build.0 = Release|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Release|x86.ActiveCfg = Release|Any CPU + {74857E33-09C5-43C7-AA27-E9373DEC1380}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -459,6 +473,7 @@ Global {1D0187EB-9D04-4CF3-AE63-0C0E97FCB49C} = {B1191F18-91CB-4387-B775-A5EB64D3AC30} {2C04EB93-5AE0-474C-B328-145A734A5E2B} = {B1191F18-91CB-4387-B775-A5EB64D3AC30} {E421FA12-BBB9-4A8B-BD29-8757836079A5} = {B1191F18-91CB-4387-B775-A5EB64D3AC30} + {74857E33-09C5-43C7-AA27-E9373DEC1380} = {B1191F18-91CB-4387-B775-A5EB64D3AC30} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D1A8274D-32D6-44DB-9BB3-1A5B273709AF} diff --git a/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs b/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs index 061dc5a28..654a9c8eb 100644 --- a/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs +++ b/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs @@ -289,6 +289,32 @@ internal static bool IsAsync(this MethodDefinition methodDefinition) ); } + /// + /// Resolves the compiler-generated state machine type of an async or iterator method + /// from its or + /// . Unlike + /// looking for the newobj that creates it, this also works for the struct state + /// machines the compiler emits for async methods in optimized builds. + /// + [CanBeNull] + internal static TypeDefinition GetStateMachineType(this MethodDefinition methodDefinition) + { + var stateMachineAttribute = methodDefinition.CustomAttributes.FirstOrDefault(att => + att.AttributeType.FullName + == typeof(System.Runtime.CompilerServices.AsyncStateMachineAttribute).FullName + || att.AttributeType.FullName + == typeof(System.Runtime.CompilerServices.IteratorStateMachineAttribute).FullName + ); + if (stateMachineAttribute == null || !stateMachineAttribute.HasConstructorArguments) + { + return null; + } + + var stateMachineType = + stateMachineAttribute.ConstructorArguments[0].Value as TypeReference; + return stateMachineType?.Resolve(); + } + internal static bool IsCompilerGenerated(this MemberReference memberReference) { if (memberReference.Name.HasCompilerGeneratedName()) diff --git a/ArchUnitNET/Loader/TypeProcessor.cs b/ArchUnitNET/Loader/TypeProcessor.cs index c765cdd8a..193812176 100644 --- a/ArchUnitNET/Loader/TypeProcessor.cs +++ b/ArchUnitNET/Loader/TypeProcessor.cs @@ -864,23 +864,18 @@ private static void HandleIterator( DomainResolver domainResolver ) { - var compilerGeneratedGeneratorObject = methodBody - .Instructions.Where(inst => inst.IsNewObjectOp()) - .Select(inst => ((MethodReference)inst.Operand).DeclaringType.Resolve()) - .FirstOrDefault(type => - type != null - && type.Methods.Any(method => method.Name == nameof(IEnumerator.MoveNext)) - ); + var compilerGeneratedGeneratorObject = methodBody.Method.GetStateMachineType(); + var moveNextMethod = compilerGeneratedGeneratorObject?.Methods.FirstOrDefault(method => + method.Name == nameof(IEnumerator.MoveNext) + ); - if (compilerGeneratedGeneratorObject == null) + if (moveNextMethod == null) { methodDefinition = methodBody.Method; return; } - methodDefinition = compilerGeneratedGeneratorObject.Methods.First(method => - method.Name == nameof(IEnumerator.MoveNext) - ); + methodDefinition = moveNextMethod; visitedMethodReferences.Add(methodDefinition); methodBody = methodDefinition.Body; @@ -909,25 +904,18 @@ private static void HandleAsync( DomainResolver domainResolver ) { - var compilerGeneratedGeneratorObject = methodBody - .Instructions.Where(inst => inst.IsNewObjectOp()) - .Select(inst => ((MethodReference)inst.Operand).DeclaringType.Resolve()) - .FirstOrDefault(type => - type != null - && type.Methods.Any(method => - method.Name == nameof(IAsyncStateMachine.MoveNext) - ) - ); + var compilerGeneratedGeneratorObject = methodBody.Method.GetStateMachineType(); + var moveNextMethod = compilerGeneratedGeneratorObject?.Methods.FirstOrDefault(method => + method.Name == nameof(IAsyncStateMachine.MoveNext) + ); - if (compilerGeneratedGeneratorObject == null) + if (moveNextMethod == null) { methodDefinition = methodBody.Method; return; } - methodDefinition = compilerGeneratedGeneratorObject.Methods.First(method => - method.Name == nameof(IAsyncStateMachine.MoveNext) - ); + methodDefinition = moveNextMethod; visitedMethodReferences.Add(methodDefinition); methodBody = methodDefinition.Body; diff --git a/ArchUnitNETTests/ArchUnitNETTests.csproj b/ArchUnitNETTests/ArchUnitNETTests.csproj index c56d20a5a..8528cec9c 100644 --- a/ArchUnitNETTests/ArchUnitNETTests.csproj +++ b/ArchUnitNETTests/ArchUnitNETTests.csproj @@ -36,6 +36,7 @@ + diff --git a/ArchUnitNETTests/Dependencies/OptimizedStateMachineDependenciesTests.cs b/ArchUnitNETTests/Dependencies/OptimizedStateMachineDependenciesTests.cs new file mode 100644 index 000000000..7dcbe2b19 --- /dev/null +++ b/ArchUnitNETTests/Dependencies/OptimizedStateMachineDependenciesTests.cs @@ -0,0 +1,79 @@ +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using ArchUnitNET.Domain; +using ArchUnitNET.Domain.Extensions; +using OptimizedAssembly; +using Xunit; +using static ArchUnitNET.Fluent.ArchRuleDefinition; + +namespace ArchUnitNETTests.Dependencies +{ + /// + /// Async and iterator methods in an optimized build. There the compiler emits the async + /// state machine as a struct, which is initialized in place rather than created with + /// newobj, so it has to be found through the method's state machine attribute. + /// + public class OptimizedStateMachineDependenciesTests + { + private static readonly Architecture Architecture = + StaticTestArchitectures.OptimizedArchitecture; + + private readonly MethodMember _calledMethod; + + public OptimizedStateMachineDependenciesTests() + { + _calledMethod = Architecture + .GetClassOfType(typeof(CalledClass)) + .GetMethodMembersWithName(nameof(CalledClass.CalledMethod) + "()") + .First(); + } + + [Fact] + public void AsyncStateMachineIsAStruct() + { + var stateMachineType = typeof(ClassWithAsyncMethod) + .GetMethod(nameof(ClassWithAsyncMethod.AsyncMethod)) + .GetCustomAttribute() + .StateMachineType; + + Assert.True(stateMachineType.IsValueType); + } + + [Fact] + public void AsyncMethodBodyDependenciesAreFound() + { + var asyncMethod = Architecture + .GetClassOfType(typeof(ClassWithAsyncMethod)) + .GetMethodMembersWithName(nameof(ClassWithAsyncMethod.AsyncMethod) + "()") + .First(); + + Assert.Contains(_calledMethod, asyncMethod.GetCalledMethods()); + + var rule = Classes() + .That() + .HaveFullName(typeof(ClassWithAsyncMethod).FullName) + .Should() + .NotDependOnAny(typeof(CalledClass)); + Assert.False(rule.HasNoViolations(Architecture)); + } + + [Fact] + public void IteratorMethodBodyDependenciesAreFound() + { + var iteratorMethod = Architecture + .GetClassOfType(typeof(ClassWithIteratorMethod)) + .GetMethodMembersWithName(nameof(ClassWithIteratorMethod.IteratorMethod) + "()") + .First(); + + Assert.Contains(_calledMethod, iteratorMethod.GetCalledMethods()); + + var rule = Classes() + .That() + .HaveFullName(typeof(ClassWithIteratorMethod).FullName) + .Should() + .NotDependOnAny(typeof(CalledClass)); + Assert.False(rule.HasNoViolations(Architecture)); + } + } +} diff --git a/ArchUnitNETTests/Domain/Dependencies/Members/MethodCallDependencyTests.cs b/ArchUnitNETTests/Domain/Dependencies/Members/MethodCallDependencyTests.cs index b6b1c7796..6e0ed9a7f 100644 --- a/ArchUnitNETTests/Domain/Dependencies/Members/MethodCallDependencyTests.cs +++ b/ArchUnitNETTests/Domain/Dependencies/Members/MethodCallDependencyTests.cs @@ -96,7 +96,7 @@ MethodCallDependency expectedDependency Assert.Contains(expectedDependency, originMember.GetMethodCallDependencies()); } - [SkipInReleaseBuildTheory] + [Theory] [ClassData(typeof(MethodDependencyTestBuild.MethodCallDependencyInAsyncMethodTestData))] public void MethodCallDependenciesAreFoundInAsyncMethod( IMember originMember, diff --git a/ArchUnitNETTests/SkipInReleaseBuild.cs b/ArchUnitNETTests/SkipInReleaseBuild.cs index de07925c5..3d5e150b2 100644 --- a/ArchUnitNETTests/SkipInReleaseBuild.cs +++ b/ArchUnitNETTests/SkipInReleaseBuild.cs @@ -8,16 +8,6 @@ public SkipInReleaseBuild() { #if !DEBUG Skip = "This test only works in debug build"; -#endif - } - } - - public sealed class SkipInReleaseBuildTheory : TheoryAttribute - { - public SkipInReleaseBuildTheory() - { -#if !DEBUG - Skip = "This test only works in debug build"; #endif } } diff --git a/ArchUnitNETTests/StaticTestArchitectures.cs b/ArchUnitNETTests/StaticTestArchitectures.cs index b9dcef78b..7b49790f7 100644 --- a/ArchUnitNETTests/StaticTestArchitectures.cs +++ b/ArchUnitNETTests/StaticTestArchitectures.cs @@ -102,6 +102,10 @@ public static class StaticTestArchitectures ) .Build(); + public static readonly Architecture OptimizedArchitecture = new ArchLoader() + .LoadAssemblies(typeof(OptimizedAssembly.ClassWithAsyncMethod).Assembly) + .Build(); + public static readonly Architecture ArchUnitNETTestAssemblyArchitecture = new ArchLoader() .LoadAssemblies(typeof(Class1).Assembly) .Build(); diff --git a/TestAssemblies/OptimizedAssembly/OptimizedAssembly.csproj b/TestAssemblies/OptimizedAssembly/OptimizedAssembly.csproj new file mode 100644 index 000000000..19fc7471b --- /dev/null +++ b/TestAssemblies/OptimizedAssembly/OptimizedAssembly.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + true + false + True + ..\..\strongNameKey.snk + 1.0.0.0 + + true + + diff --git a/TestAssemblies/OptimizedAssembly/StateMachineClasses.cs b/TestAssemblies/OptimizedAssembly/StateMachineClasses.cs new file mode 100644 index 000000000..03b54ed0b --- /dev/null +++ b/TestAssemblies/OptimizedAssembly/StateMachineClasses.cs @@ -0,0 +1,24 @@ +namespace OptimizedAssembly; + +public class CalledClass +{ + public static void CalledMethod() { } +} + +public class ClassWithAsyncMethod +{ + public async Task AsyncMethod() + { + await Task.Yield(); + CalledClass.CalledMethod(); + } +} + +public class ClassWithIteratorMethod +{ + public IEnumerable IteratorMethod() + { + CalledClass.CalledMethod(); + yield return 0; + } +}