You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In an optimized build (-c Release), every dependency written inside an async method is missing from the architecture. That's any call, any new, any field access, not only the "an async function is called" case listed in Debug Artifacts / LimitationsOnReleaseTest.AsyncMethodDependencyTest.
The dangerous consequence is on negative rules. NotDependOnAny / NotCallAnypass for a violation written inside an async method, so a Release test run reports green on code that breaks the rule. Most application code in a modern .NET codebase is async, so this silently switches off much of what a layering rule protects. Positive rules (DependOnAny, CallAny) fail instead, as reported in #326.
I think the cause is a heuristic in the loader rather than something the optimizer inherently hides, and it looks fixable (proposal below).
Root cause.HandleAsync (TypeProcessor.HandleAsync in 0.13.4; AddMethodDependencies.HandleAsync in 0.11.x) finds the state machine by taking the declaring type of the first newobj in the kickoff method:
Debug: the state machine is a class, and the kickoff starts with newobj <RunAsync>d__0::.ctor(). Works.
Release: the state machine is a struct. The kickoff does ldloca.s 0 / stfld on a local of that type, and there is no newobj. HandleAsync falls back to methodDefinition = methodBody.Method, so only the stub is analysed and MoveNext never is.
Proposed fix. Resolve the state machine from the attribute the compiler already emits in both configurations, [AsyncStateMachine(typeof(<RunAsync>d__0))]: CustomAttributes[AsyncStateMachineAttribute].ConstructorArguments[0].Value is the state machine's TypeReference, whether it's a class or a struct. The last test in the MWE below does exactly that with Mono.Cecil, and it finds the call in MoveNext in both Debug and Release. The same could apply to HandleIterator / IteratorStateMachineAttribute for robustness, although iterator state machines are classes in both configurations today.
Minimal Working Example
// net10.0 test project: TngTech.ArchUnitNET.xUnit 0.13.4, Mono.Cecil 0.11.6, xunit 2.9.3usingSystem.Runtime.CompilerServices;usingArchUnitNET.Domain;usingArchUnitNET.Loader;usingArchUnitNET.xUnit;usingMono.Cecil;usingMono.Cecil.Cil;usingXunit;usingstaticArchUnitNET.Fluent.ArchRuleDefinition;namespaceRepro{publicstaticclassForbidden{publicstaticvoidTouch(){}}publicclassSyncCaller{publicvoidRun()=>Forbidden.Touch();}publicclassAsyncCaller{// A plain SYNCHRONOUS call, merely written inside an async method.publicasyncTaskRunAsync(){awaitTask.Yield();Forbidden.Touch();}}publicclassReleaseDropsAsyncBodies{privatestaticreadonlyArchitectureArchitecture=newArchLoader().LoadAssembly(typeof(ReleaseDropsAsyncBodies).Assembly).Build();[Fact]// control: passes in Debug and ReleasepublicvoidSync_caller_is_seen()=>Classes().That().Are(typeof(SyncCaller)).Should().DependOnAny(typeof(Forbidden)).Check(Architecture);[Fact]// passes in Debug, FAILS in ReleasepublicvoidAsync_caller_is_seen()=>Classes().That().Are(typeof(AsyncCaller)).Should().DependOnAny(typeof(Forbidden)).Check(Architecture);[Fact]// the consequence: the negative rule fails in Debug (correct) but PASSES in Release (vacuous)publicvoidA_negative_rule_catches_the_async_caller()=>Assert.ThrowsAny<Exception>(()=>Classes().That().Are(typeof(AsyncCaller)).Should().NotDependOnAny(typeof(Forbidden)).Check(Architecture));[Fact]// the proposed fix path: the attribute names the state machine in BOTH configurationspublicvoidThe_attribute_leads_to_MoveNext_in_any_configuration(){usingvarmodule=ModuleDefinition.ReadModule(typeof(AsyncCaller).Assembly.Location);varkickoff=module.GetType(typeof(AsyncCaller).FullName).Methods.Single(m =>m.Name=="RunAsync");varattribute=kickoff.CustomAttributes.Single(a =>a.AttributeType.FullName==typeof(AsyncStateMachineAttribute).FullName);varstateMachine=((TypeReference)attribute.ConstructorArguments[0].Value).Resolve();varmoveNext=stateMachine.Methods.Single(m =>m.Name=="MoveNext");Assert.Contains(moveNext.Body.Instructions, i =>i.OpCode==OpCodes.Call&&i.OperandisMethodReferencem&&m.Name==nameof(Forbidden.Touch));Console.WriteLine($"state machine is a {(stateMachine.IsValueType?"struct":"class")}; "+$"kickoff has newobj: {kickoff.Body.Instructions.Any(i =>i.OpCode==OpCodes.Newobj)}");}}}
Run with dotnet test -c Debug and dotnet test -c Release.
Expected Behavior
Both configurations produce the same dependencies for AsyncCaller: all four tests pass under -c Debugand-c Release.
ArchUnitNET.xUnit.FailedArchRuleException : "Classes that are "Repro.AsyncCaller" should depend on "Repro.Forbidden"" failed:
Repro.AsyncCaller does not depend on any type
Diagnostic output from the last test:
Debug: state machine is a class; kickoff has newobj: True
Release: state machine is a struct; kickoff has newobj: False
ArchUnitNET Version
0.13.4 (also reproduced on 0.11.4)
.NET Version
.NET 10.0 (SDK 10.0.204)
Additional Context
Related: Tests may behave differently in Release configuration #326, which reported the positive-rule side of this and was resolved by documenting the limitation. This issue adds the vacuous negative-rule side, identifies the root cause as the newobj lookup, and proposes a fix that appears to work in both configurations.
The workaround we use: point the architecture tests at unoptimized builds of the analysed projects whatever configuration the test run uses, and refuse optimized assemblies (DebuggableAttribute.IsJITOptimizerDisabled == false) before loading them, so a Release run can't report a pass on a graph missing its async bodies.
Description
In an optimized build (
-c Release), every dependency written inside anasyncmethod is missing from the architecture. That's any call, anynew, any field access, not only the "an async function is called" case listed in Debug Artifacts /LimitationsOnReleaseTest.AsyncMethodDependencyTest.The dangerous consequence is on negative rules.
NotDependOnAny/NotCallAnypass for a violation written inside an async method, so a Release test run reports green on code that breaks the rule. Most application code in a modern .NET codebase is async, so this silently switches off much of what a layering rule protects. Positive rules (DependOnAny,CallAny) fail instead, as reported in #326.I think the cause is a heuristic in the loader rather than something the optimizer inherently hides, and it looks fixable (proposal below).
Root cause.
HandleAsync(TypeProcessor.HandleAsyncin 0.13.4;AddMethodDependencies.HandleAsyncin 0.11.x) finds the state machine by taking the declaring type of the firstnewobjin the kickoff method:newobj <RunAsync>d__0::.ctor(). Works.ldloca.s 0/stfldon a local of that type, and there is nonewobj.HandleAsyncfalls back tomethodDefinition = methodBody.Method, so only the stub is analysed andMoveNextnever is.Proposed fix. Resolve the state machine from the attribute the compiler already emits in both configurations,
[AsyncStateMachine(typeof(<RunAsync>d__0))]:CustomAttributes[AsyncStateMachineAttribute].ConstructorArguments[0].Valueis the state machine'sTypeReference, whether it's a class or a struct. The last test in the MWE below does exactly that with Mono.Cecil, and it finds the call inMoveNextin both Debug and Release. The same could apply toHandleIterator/IteratorStateMachineAttributefor robustness, although iterator state machines are classes in both configurations today.Minimal Working Example
Run with
dotnet test -c Debuganddotnet test -c Release.Expected Behavior
Both configurations produce the same dependencies for
AsyncCaller: all four tests pass under-c Debugand-c Release.Actual Behavior
Sync_caller_is_seenAsync_caller_is_seenA_negative_rule_catches_the_async_callerNotDependOnAnyrule passed)The_attribute_leads_to_MoveNext_in_any_configurationThe Release failure:
Diagnostic output from the last test:
ArchUnitNET Version
0.13.4 (also reproduced on 0.11.4)
.NET Version
.NET 10.0 (SDK 10.0.204)
Additional Context
newobjlookup, and proposes a fix that appears to work in both configurations.DebuggableAttribute.IsJITOptimizerDisabled == false) before loading them, so a Release run can't report a pass on a graph missing its async bodies.