Summary
On release/10.0, several LCGMethodResolver callbacks in src/coreclr/vm/dynamicmethod.cpp fetch a managed object from the resolver as the return value of a MethodDescCallSite::Call_RetOBJECTREF. That reference is not reported as a GC root across the managed→native return, so a gen0 collection triggered by another thread can relocate or reclaim the SOH array before the caller roots it, leaving the native code with a stale OBJECTREF. Under concurrent DynamicMethod compilation with gen0 pressure this corrupts the heap; without the checked-build validation it surfaces later as an intermittent SIGSEGV at some unrelated victim read.
main replaced this convention across the resolver callbacks in two PRs tracked under #123864: #124303 (the MethodDescCallSite callbacks, including GetCodeInfo and GetLocalSig) and its follow-up #124921 (the PREPARE_SIMPLE_VIRTUAL_CALLSITE callbacks, including ResolveSignature). The values now come back through a GCPROTECT'd out-param the callee writes, rather than the return register. That removes the bug. Neither has shipped to release/10.0.
We verified both halves of this on a checked release/10.0 build (details under Verification): the bug reproduces at GetCodeInfo, a backport of only the GetCodeInfo conversion relocates the identical crash to the sibling GetLocalSig, and converting both removes it.
Impact
We hit this as intermittent test-host crashes on .NET 10 in a commercial product's CI (Gearset, a Salesforce DevOps tool): a SIGSEGV reported as "test host process crashed" while every test passes. The suite compiles LINQ Expressions and Moq mocks (LCG DynamicMethod emit) while the test runner executes fixtures in parallel under GC load - the shape the repro models. It's rare per run but frequent enough across a day's builds to force manual re-runs and stall our merge queue, and it can't be worked around from managed code. We're on released 10.0.x, so the main fix only reaches us through a servicing backport.
Fault
The code in 10.0.9, GetCodeInfo:
MethodDescCallSite getCodeInfo(METHOD__RESOLVER__GET_CODE_INFO, m_managedResolver);
OBJECTREF resolver = ObjectFromHandle(m_managedResolver);
...
U1ARRAYREF dataArray = (U1ARRAYREF) getCodeInfo.Call_RetOBJECTREF(args); // <- stale on return
DWORD codeSize = dataArray->GetNumComponents();
Under a checked build the OBJECTREF validation faults while constructing dataArray, on the return value itself, before any use of it. The MethodTable read from the object is a debug fill pattern (0xcc…), confirming the array was moved or collected:
MethodTable::SanityCheck (this=0xccccccccccccccc8) methodtable.cpp:6483 <- 0xcc fill
Object::ValidateInner object.cpp:548
MethodDescCallSite::Call_RetOBJECTREF callhelpers.h:449
LCGMethodResolver::GetCodeInfo dynamicmethod.cpp:1187
A gen0 GC is concurrently in flight on another thread, triggered by ordinary SOH allocation:
WKS::GCHeap::GarbageCollectGeneration (gen=0, reason=reason_alloc_soh) gc.cpp:51609
WKS::gc_heap::trigger_gc_for_alloc gc.cpp:19221
Because the fault is on the return value, a GCPROTECT inside GetCodeInfo cannot help: the array has to be produced into a GC-protected slot the callee writes, which is what #124303 does.
The same return-register convention is used by the other resolver callbacks in this file. Two of them hold the returned reference across a subsequent allocation and are therefore vulnerable to the same window:
GetCodeInfo - holds dataArray across new BYTE[codeSize], then memcpy from it (dynamicmethod.cpp:1187).
GetLocalSig - holds dataArray across new COR_SIGNATURE[localSigSize], then memcpy from it (dynamicmethod.cpp:1235).
ResolveSignature / ResolveSignatureForVarArg hold a U1ARRAYREF across m_jitTempData.New() in the same shape (by code inspection - our repro exercises GetCodeInfo and GetLocalSig, not these). GetJitContext, GetStringLiteral, GetEHInfo and ResolveToken use the convention but consume the reference immediately with no intervening allocation. #124303 converts the MethodDescCallSite callbacks (GetJitContext, GetCodeInfo, GetLocalSig, GetStringLiteral); #124921 converts the PREPARE_SIMPLE_VIRTUAL_CALLSITE ones (ResolveToken, ResolveSignature, ResolveSignatureForVarArg, GetEHInfo).
Verification
We built a checked linux-x64 coreclr from the v10.0.9 tag and looped the attached repro under it - 4 concurrent instances on a 16-core box, workstation GC, HeapVerify=1, small gen0, no GCStress - applying the conversions incrementally:
| coreclr build |
Fault site |
Result |
| Unpatched 10.0.9 |
GetCodeInfo (dynamicmethod.cpp:1187) |
~1 fault per 7-14 passes per worker |
#124303 GetCodeInfo conversion only |
GetLocalSig (dynamicmethod.cpp:1235) |
GetCodeInfo frame gone; identical crash relocates to GetLocalSig, first fault at ~74 passes per worker |
GetCodeInfo + GetLocalSig conversions |
none |
239 passes per worker (956 total), zero faults |
The relocated crash after the GetCodeInfo-only conversion has the identical signature (Call_RetOBJECTREF → ValidateInner, 0xcc-filled MethodTable, concurrent reason_alloc_soh gen0 GC), differing only in the faulting frame (GetLocalSig instead of GetCodeInfo). This is direct evidence that the fix must cover the sibling callbacks, not GetCodeInfo alone: a GetCodeInfo-only backport moves the crash rather than removing it.
The same fault showed up in dotnet/runtime CI and @jkotas analyzed it in #119140 (the LCGMethodResolver::GetLocalSig → Call_RetOBJECTREF stack). Our repro hits that path on demand with a freed (0xcc-fill) MethodTable, and the conversions stop it - evidence of a real missing GC-root on the register return rather than a racy IsHeapPointer. (This is separate from the runtime-async RuntimeAsyncTask crash later filed on the same issue, which these PRs do not touch.)
Reproduction
Standalone project, no third-party dependencies: https://gist.github.com/gearsetdave/a3bd7d222c4f5cbe6f76135fb25aa301 - two concurrent thread pools:
- Compile threads each build a fresh
Expression.Lambda<Func<int,int,int>> and call .Compile(). That emits an LCG DynamicMethod and calls CreateDelegate, which forces the JIT compile that runs through GetCodeInfo and GetLocalSig; a fresh lambda per iteration defeats caching so emit never stops.
- Allocator threads each allocate and drop a small SOH array, keeping gen0 collections frequent. Compilation alone does not allocate enough to drive them; the allocator threads are what make it reproduce.
Run under a checked release/10.0 coreclr, workstation GC (DOTNET_gcServer=0 DOTNET_gcConcurrent=0), DOTNET_TieredCompilation=0, small DOTNET_GCgen0size, DOTNET_HeapVerify=1, no DOTNET_GCStress. On 8 cores it faults within minutes. A normal optimized build usually runs to completion; the checked OBJECTREF validation is what surfaces the stale reference at the return site rather than at a later victim-side read.
Request
Please backport both #124303 and its follow-up #124921 to release/10.0 servicing, so the whole resolver-callback surface is converted. It is a silent heap-corruption bug with no application-level workaround: the GC is what relocates the array, so serializing compilation does not close it and raising the gen0 budget only lowers the rate. Backporting GetCodeInfo alone is not enough - the incremental test above shows the crash relocating to GetLocalSig, and ResolveSignature / ResolveSignatureForVarArg (converted only by #124921) share the same held-across-allocation shape.
Summary
On
release/10.0, severalLCGMethodResolvercallbacks insrc/coreclr/vm/dynamicmethod.cppfetch a managed object from the resolver as the return value of aMethodDescCallSite::Call_RetOBJECTREF. That reference is not reported as a GC root across the managed→native return, so a gen0 collection triggered by another thread can relocate or reclaim the SOH array before the caller roots it, leaving the native code with a staleOBJECTREF. Under concurrentDynamicMethodcompilation with gen0 pressure this corrupts the heap; without the checked-build validation it surfaces later as an intermittent SIGSEGV at some unrelated victim read.mainreplaced this convention across the resolver callbacks in two PRs tracked under #123864: #124303 (theMethodDescCallSitecallbacks, includingGetCodeInfoandGetLocalSig) and its follow-up #124921 (thePREPARE_SIMPLE_VIRTUAL_CALLSITEcallbacks, includingResolveSignature). The values now come back through aGCPROTECT'd out-param the callee writes, rather than the return register. That removes the bug. Neither has shipped torelease/10.0.We verified both halves of this on a checked
release/10.0build (details under Verification): the bug reproduces atGetCodeInfo, a backport of only theGetCodeInfoconversion relocates the identical crash to the siblingGetLocalSig, and converting both removes it.Impact
We hit this as intermittent test-host crashes on .NET 10 in a commercial product's CI (Gearset, a Salesforce DevOps tool): a SIGSEGV reported as "test host process crashed" while every test passes. The suite compiles LINQ
Expressions and Moq mocks (LCGDynamicMethodemit) while the test runner executes fixtures in parallel under GC load - the shape the repro models. It's rare per run but frequent enough across a day's builds to force manual re-runs and stall our merge queue, and it can't be worked around from managed code. We're on released 10.0.x, so themainfix only reaches us through a servicing backport.Fault
The code in 10.0.9,
GetCodeInfo:Under a checked build the
OBJECTREFvalidation faults while constructingdataArray, on the return value itself, before any use of it. The MethodTable read from the object is a debug fill pattern (0xcc…), confirming the array was moved or collected:A gen0 GC is concurrently in flight on another thread, triggered by ordinary SOH allocation:
Because the fault is on the return value, a
GCPROTECTinsideGetCodeInfocannot help: the array has to be produced into a GC-protected slot the callee writes, which is what #124303 does.The same return-register convention is used by the other resolver callbacks in this file. Two of them hold the returned reference across a subsequent allocation and are therefore vulnerable to the same window:
GetCodeInfo- holdsdataArrayacrossnew BYTE[codeSize], thenmemcpyfrom it (dynamicmethod.cpp:1187).GetLocalSig- holdsdataArrayacrossnew COR_SIGNATURE[localSigSize], thenmemcpyfrom it (dynamicmethod.cpp:1235).ResolveSignature/ResolveSignatureForVarArghold aU1ARRAYREFacrossm_jitTempData.New()in the same shape (by code inspection - our repro exercisesGetCodeInfoandGetLocalSig, not these).GetJitContext,GetStringLiteral,GetEHInfoandResolveTokenuse the convention but consume the reference immediately with no intervening allocation. #124303 converts theMethodDescCallSitecallbacks (GetJitContext,GetCodeInfo,GetLocalSig,GetStringLiteral); #124921 converts thePREPARE_SIMPLE_VIRTUAL_CALLSITEones (ResolveToken,ResolveSignature,ResolveSignatureForVarArg,GetEHInfo).Verification
We built a checked linux-x64 coreclr from the
v10.0.9tag and looped the attached repro under it - 4 concurrent instances on a 16-core box, workstation GC,HeapVerify=1, small gen0, noGCStress- applying the conversions incrementally:GetCodeInfo(dynamicmethod.cpp:1187)GetCodeInfoconversion onlyGetLocalSig(dynamicmethod.cpp:1235)GetCodeInfoframe gone; identical crash relocates toGetLocalSig, first fault at ~74 passes per workerGetCodeInfo+GetLocalSigconversionsThe relocated crash after the
GetCodeInfo-only conversion has the identical signature (Call_RetOBJECTREF→ValidateInner,0xcc-filled MethodTable, concurrentreason_alloc_sohgen0 GC), differing only in the faulting frame (GetLocalSiginstead ofGetCodeInfo). This is direct evidence that the fix must cover the sibling callbacks, notGetCodeInfoalone: aGetCodeInfo-only backport moves the crash rather than removing it.The same fault showed up in dotnet/runtime CI and @jkotas analyzed it in #119140 (the
LCGMethodResolver::GetLocalSig→Call_RetOBJECTREFstack). Our repro hits that path on demand with a freed (0xcc-fill) MethodTable, and the conversions stop it - evidence of a real missing GC-root on the register return rather than a racyIsHeapPointer. (This is separate from the runtime-asyncRuntimeAsyncTaskcrash later filed on the same issue, which these PRs do not touch.)Reproduction
Standalone project, no third-party dependencies: https://gist.github.com/gearsetdave/a3bd7d222c4f5cbe6f76135fb25aa301 - two concurrent thread pools:
Expression.Lambda<Func<int,int,int>>and call.Compile(). That emits an LCGDynamicMethodand callsCreateDelegate, which forces the JIT compile that runs throughGetCodeInfoandGetLocalSig; a fresh lambda per iteration defeats caching so emit never stops.Run under a checked
release/10.0coreclr, workstation GC (DOTNET_gcServer=0 DOTNET_gcConcurrent=0),DOTNET_TieredCompilation=0, smallDOTNET_GCgen0size,DOTNET_HeapVerify=1, noDOTNET_GCStress. On 8 cores it faults within minutes. A normal optimized build usually runs to completion; the checkedOBJECTREFvalidation is what surfaces the stale reference at the return site rather than at a later victim-side read.Request
Please backport both #124303 and its follow-up #124921 to
release/10.0servicing, so the whole resolver-callback surface is converted. It is a silent heap-corruption bug with no application-level workaround: the GC is what relocates the array, so serializing compilation does not close it and raising the gen0 budget only lowers the rate. BackportingGetCodeInfoalone is not enough - the incremental test above shows the crash relocating toGetLocalSig, andResolveSignature/ResolveSignatureForVarArg(converted only by #124921) share the same held-across-allocation shape.