Description
Generic parameters on types emitted into an in-memory dynamic assembly (AssemblyBuilder with AssemblyBuilderAccess.Run) can become bound to the wrong GenericParam metadata rows. It needs two or more emitted types in one module, loaded concurrently. PersistedAssemblyBuilder is not affected.
The result is visible two ways:
- Reflection reports a generic parameter under a different member's name, carrying that member's constraints. A method without a constraint comes back carrying struct.
- Constraint checks then enforce the wrong constraint set and throw an incorrect
VerificationException or TypeLoadException.
This bug surfaced through Castle DynamicProxy and affects various "downstream" projects under load. However, it reproduces without Castle involved.
Reproduction Steps
Repro and full analysis: https://github.com/waf/VerificationExceptionRepro/blob/main/src/ReflectionAudit/Program.cs
The above repository has some other repros of the same exact issue, with varying levels of involvement of the Castle library. But the specific linked repro is independent of Castle and I believe it shows it is a .NET runtime issue.
Run the repro via:
git clone https://github.com/waf/VerificationExceptionRepro
cd VerificationExceptionRepro/src/ReflectionAudit
dotnet build
./bin/Debug/net10.0/ReflectionAudit.exe 150 2 auditall # concurrent, shows the issue
./bin/Debug/net10.0/ReflectionAudit.exe 150 2 auditall serial # control
As input to the repro, we have the following sample classes. Every type parameter has a unique name, so any mix-up is unambiguous. B2 is the only type parameter with constraints:
public class A
{
public virtual void MethodA1<A1>() { }
public virtual void MethodA2<A2>(A2[] args) { }
}
public class B
{
public virtual void MethodB1<B1>() { }
public virtual void MethodB2<B2>(B2[] args) where B2 : struct { }
}
See the "Actual behavior" section below for where things go wrong.
Expected behavior
If there were no bug, then running ./ReflectionAudit.exe 150 2 auditall should report mismatches=0. A method's generic parameter is its own parameter and should not report other method's parameters.
Actual behavior
Actually prints something like:
--- invocation failures ---
70 VerificationException: Method B.MethodB2: type argument 'B1' violates the constraint of type parameter 'B2'.
68 VerificationException: Method B.MethodB2: type argument 'A2' violates the constraint of type parameter 'B2'.
1 VerificationException: Method Castle.Proxies.s1AProxy843fcd019c304f9d8aea846909a8a7a9.MethodA2_callback: type argument 'A1' violates the constraint of type parameter 'B2'.
1 VerificationException: Method Castle.Proxies.s1AProxy4127bee80b5c4d23baeb58871dcb6293.MethodA2_callback: type argument 'A1' violates the constraint of type parameter 'B2'.
--- generic parameters reflection reports under the wrong name ---
1 method s0AProxy6e46ea183b90400a935ff04548da214c.MethodA1: expected 'A1' got 'A2' (attrs=None, tok=0x2A000003)
1 method s0AProxy6e46ea183b90400a935ff04548da214c.MethodA2_callback: expected 'A2' got 'A1' (attrs=None, tok=0x2A000004)
1 type s0A_MethodA2_6e46ea183b90400a935ff04548da214c: expected 'A2' got 'B1' (attrs=None, tok=0x2A000005)
1 method s0BProxyb577ce0edbe74d698785f1b5ab50811c.MethodB1_callback: expected 'B1' got 'B2' (attrs=NotNullableValueTypeConstraint, DefaultConstructorConstraint, tok=0x2A000007)
1 method s0BProxyb577ce0edbe74d698785f1b5ab50811c.MethodB1: expected 'B1' got 'A1' (attrs=None, tok=0x2A000009)
1 method s0BProxyb577ce0edbe74d698785f1b5ab50811c.MethodB2_callback: expected 'B2' got 'A2' (attrs=None, tok=0x2A00000A)
1 method s0BProxyb577ce0edbe74d698785f1b5ab50811c.MethodB2: expected 'B2' got 'B1' (attrs=None, tok=0x2A00000C)
1 type s0B_MethodB1_b577ce0edbe74d698785f1b5ab50811c: expected 'B1' got 'A2' (attrs=None, tok=0x2A000008)
1 type s0B_MethodB2_b577ce0edbe74d698785f1b5ab50811c: expected 'B2' got 'B1' (attrs=None, tok=0x2A00000B)
1 method s1AProxy7fe2cca30072486e85509834ab7da3db.MethodA1_callback: expected 'A1' got 'B2' (attrs=NotNullableValueTypeConstraint, DefaultConstructorConstraint, tok=0x2A00000D)
RESULT mode=concurrent prewarm=False proxyTypes=4 failures=172/600 mismatches=1296/3600 nonMonotonicOwnerColumn=25/25
Looking at that first failure line, we see:
VerificationException: Method B.MethodB2: type argument 'B1' violates the constraint of type parameter 'B2'.
The exception message is correct that B2 is MethodB2's parameter and does require struct, and B1 doesn't satisfy struct. The defect is that B1 is in the type-argument position. The IL passes the callback's own type parameter there, which is a copy of B2 carrying the struct constraint, so it should satisfy the check. There is a stale rid that makes the runtime read that operand as a different method's parameter (see the "Other Information" section below).
Regression?
Judging from various bug reports around the .NET ecosystem, it's been reported on .NET 5, 6, 8, and 10 and does not reproduce on .NET Framework 4.8 and possibly not on earlier version of .NET Core. I have not tested these versions, though, I'm just going from bug reports.
Known Workarounds
Only have one emitted type per dynamic module.
Configuration
.NET 10.0.11, Windows 11 x64, 16 logical cores. Needs more than one core.
Other information
I've done some light investigation/instrumentation as part of creating the repro above. See a log of my investigation in the same repo.
Every type load in a dynamic module calls ReflectionModule::CaptureModuleMetaDataToMemory (vm/ceeload.cpp), via NotifyDebuggerLoad (despite its name, it runs even when there's no debugger attached).
That calls IMetaDataEmit::GetSaveSize, which runs CMiniMdRW::PreSaveFull (md/enc/metamodelrw.cpp), which runs STABLESORTER_WITHREMAP(GenericParam, Owner). In that sort during my experiments, 21 of 24 rows changed position per sort.
The runtime caches GenericParam rids in Module::m_GenericParamToDescMap and TypeVarTypeDesc::m_token, and reads each parameter's name and constraints from the row that rid addresses. Nothing invalidates the map, so the rids go stale. Bindings were correct at first but the table was sorted beneath them.
PersistedAssemblyBuilder never runs the capture, which is why it isn't affected.
Description
Generic parameters on types emitted into an in-memory dynamic assembly (
AssemblyBuilderwithAssemblyBuilderAccess.Run) can become bound to the wrong GenericParam metadata rows. It needs two or more emitted types in one module, loaded concurrently.PersistedAssemblyBuilderis not affected.The result is visible two ways:
VerificationExceptionorTypeLoadException.This bug surfaced through Castle DynamicProxy and affects various "downstream" projects under load. However, it reproduces without Castle involved.
Reproduction Steps
Repro and full analysis: https://github.com/waf/VerificationExceptionRepro/blob/main/src/ReflectionAudit/Program.cs
The above repository has some other repros of the same exact issue, with varying levels of involvement of the Castle library. But the specific linked repro is independent of Castle and I believe it shows it is a .NET runtime issue.
Run the repro via:
As input to the repro, we have the following sample classes. Every type parameter has a unique name, so any mix-up is unambiguous. B2 is the only type parameter with constraints:
See the "Actual behavior" section below for where things go wrong.
Expected behavior
If there were no bug, then running
./ReflectionAudit.exe 150 2 auditallshould reportmismatches=0. A method's generic parameter is its own parameter and should not report other method's parameters.Actual behavior
Actually prints something like:
Looking at that first failure line, we see:
The exception message is correct that B2 is MethodB2's parameter and does require struct, and B1 doesn't satisfy struct. The defect is that B1 is in the type-argument position. The IL passes the callback's own type parameter there, which is a copy of B2 carrying the struct constraint, so it should satisfy the check. There is a stale rid that makes the runtime read that operand as a different method's parameter (see the "Other Information" section below).
Regression?
Judging from various bug reports around the .NET ecosystem, it's been reported on .NET 5, 6, 8, and 10 and does not reproduce on .NET Framework 4.8 and possibly not on earlier version of .NET Core. I have not tested these versions, though, I'm just going from bug reports.
Known Workarounds
Only have one emitted type per dynamic module.
Configuration
.NET 10.0.11, Windows 11 x64, 16 logical cores. Needs more than one core.
Other information
I've done some light investigation/instrumentation as part of creating the repro above. See a log of my investigation in the same repo.
Every type load in a dynamic module calls
ReflectionModule::CaptureModuleMetaDataToMemory(vm/ceeload.cpp), viaNotifyDebuggerLoad(despite its name, it runs even when there's no debugger attached).That calls
IMetaDataEmit::GetSaveSize, which runsCMiniMdRW::PreSaveFull(md/enc/metamodelrw.cpp), which runsSTABLESORTER_WITHREMAP(GenericParam, Owner). In that sort during my experiments, 21 of 24 rows changed position per sort.The runtime caches GenericParam rids in
Module::m_GenericParamToDescMapandTypeVarTypeDesc::m_token, and reads each parameter's name and constraints from the row that rid addresses. Nothing invalidates the map, so the rids go stale. Bindings were correct at first but the table was sorted beneath them.PersistedAssemblyBuildernever runs the capture, which is why it isn't affected.