Repository navigation
[iOS] CoreCLR: a GC during the first call through an interpreted instantiating stub leaves the callee a stale this #136472
Description
Description
On iOS, ReadyToRun code (composite, the SDK default) calls a generic virtual method whose implementation is shared generic code through an instantiating stub, and the interpreter runs that stub. If a GC happens during the first call through such a stub, the callee sometimes gets a stale this. It shows up as a NullReferenceException on a field the constructor always sets, or as a crash in MethodDesc::ResolveGenericVirtualMethod or in the GC mark phase.
EF Core makes these calls whenever a query runs for the first time, so an EF Core app can crash a few seconds after launch. We hit it in production on an iPad.
It reproduces on RC2 and on the release/11.0 build 11.0.0-rtm.26523.122, which already includes #134751.
Reproduction Steps
gvm-gc-repro.zip needs macOS with Xcode, an iOS simulator runtime, and the .NET 11 SDK with the ios workload.
unzip gvm-gc-repro.zip && cd gvm-gc-repro
./run.sh # the runtime that comes with the SDK
RUNTIME=11.0.0-rtm.26523.122 ./run.sh # another runtime pack and crossgen2, from the general-testing feedrun.sh builds the app in Release for the simulator, creates a temporary simulator, cold-launches the app 20 times in each mode and prints a table. It takes about 10 minutes per runtime on an M1 Mac. The README describes the modes.
The core of it: at startup the app runs 60 roots like this on 8 threads, while another thread forces a gen0 GC every millisecond.
public interface IVisitor { object Visit<T>(int depth) where T : class; }
public sealed class Visitor : IVisitor
{
private readonly object[] _ballast = new object[16];
public object Visit<T>(int depth) where T : class
{
var items = new List<T>(4);
for (var i = 0; i < _ballast.Length; i++) _ballast[i] = new byte[64]; // NullReferenceException here
if (depth == 0) return items;
IVisitor next = new Visitor(); // a young receiver
return next.Visit<List<T>>(depth - 1) is List<List<T>> ? items : throw new InvalidOperationException();
}
}
static void Root<T>() where T : class => _ = ((IVisitor)new Visitor()).Visit<T>(8); // Root<C0> ... Root<C59>Expected behavior
Every launch finishes without an exception or a crash.
Actual behavior
Failed launches out of 20, from run.sh on an M1 iMac (iPhone 18 Pro simulator, iOS 27.0):
| Runtime | gvm |
gvm,nogc |
gvm,warm |
ef |
|---|---|---|---|---|
| 11.0.0-rc.2.26504.105 | 5 | 0 | 0 | 15 (11 crashes) |
| 11.0.0-rtm.26523.122 | 3 | 0 | 0 | 13 (11 crashes) |
gvm,nogchas no forced GCs.gvm,warmcalls every stub once before the GCs start. Neither fails, so a failure needs both a GC and the first call through a stub.gvmfails with aNullReferenceExceptionor anArrayTypeMismatchExceptioninVisitor.Visit[T], where it reads_ballastthrough a stalethis. The constructor always sets_ballastto anobject[].effails with aNullReferenceExceptionorIndexOutOfRangeExceptioninside EF Core, or crashes (Got a SIGSEGV while executing native code, sometimesSIGBUS), either on the thread inGC.Collector on a worker thread. Native stacks of such crashes:
Native stacks of two ef crashes (RC2, simulator, symbolicated with atos)
MethodTable::GetRestoredSlot(unsigned int) + 20 ; fault address 0x28
MethodDesc::ResolveGenericVirtualMethod(Object**, MethodTable*) + 240
MethodDesc::GetMultiCallableAddrOfVirtualizedCode + 16
ResolveVirtualFunctionPointer + 628
VirtualDispatchHelpers.VirtualFunctionPointerSlow + 116 (R2R)
QueryCompilationContext.CreateQueryExecutor<__Canon> + 76 (R2R)
CallJittedMethodRetI8 / InvokeCalliStub / InterpExecMethod / ExecuteInterpretedMethod / InterpreterStubRetI8 (x3)
CompiledQueryCache.GetOrAddQuery<__Canon> + 364 (R2R)
WKS::gc_heap::mark_object_simple(unsigned char**) + 148
WKS::GCHeap::Promote(Object**, ScanContext*, unsigned int) + 292
TGcInfoDecoder<InterpreterGcInfoEncoding>::ReportUntrackedSlots(...) + 80
TGcInfoDecoder<InterpreterGcInfoEncoding>::EnumerateLiveSlots(...) + 5764
InterpreterCodeManager::EnumGcRefs(...) + 196
GcStackCrawlCallBack / StackWalkFramesEx / ScanStackRoots / GcScanRoots / mark_phase
Production crash (iPad15,7, iOS 27.0.1, RC2): SIGABRT on a .NET TP Worker 4 s after launch
EntityQueryable<__Canon>.GetAsyncEnumerator + 0x9c (R2R)
InterpreterStubRetI8 / ExecuteInterpretedMethod / InterpExecMethod / InvokeCalliStub / CallJittedMethodRetI8
EntityQueryProvider.ExecuteAsync<__Canon> + 0x50 (R2R)
VirtualDispatchHelpers.VirtualFunctionPointer + 0x34 ; ldr x0, [x21]: the method table of this._queryCompiler
PAL_DispatchException / HandleHardwareException / EH.RhThrowHwEx / EH.DispatchEx / FailFastIfCorruptingStateException / EEPolicy::HandleFatalError
At the same moment another thread was compiling an EF Core DynamicMethod in the interpreter (CILInterp::compileMethod → InterpCompiler::BuildGCInfo).
Regression?
Unknown. Both builds we tried, RC2 and 11.0.0-rtm.26523.122, reproduce it.
Known Workarounds
Leaving the assemblies whose ReadyToRun code makes these calls out of ReadyToRun (PublishReadyToRunExclude) stops it, because interpreted callers don't go through an instantiating stub. In our app that meant the EF Core assemblies, our EF model assembly and System.Linq.Expressions. In a stress run of our app, failed launches went from about 40% to 0 of 29, but app startup got about 1.1 s slower and first queries about 1.6x slower, so we haven't shipped it.
Configuration
- .NET SDK 11.0.100-rc.2.26504.105, workload set 11.0.100-rc.2.26513.2, Microsoft.iOS.Sdk 27.0.12212-net11-rc.2
- Runtime packs 11.0.0-rc.2.26504.105 and 11.0.0-rtm.26523.122
- Simulator: iOS 27.0 (iPhone 18 Pro) and iOS 26.5 (iPad Pro 11-inch (M5)), on an M1 iMac with macOS 27.0.1 and Xcode 27.1
- Device: iPad15,7, iOS 27.0.1
- ARM64, Release, ReadyToRun composite,
DynamicCodeSupport=true,TrimMode=partial
Other information
- It looks specific to platforms without dynamic code. With a JIT,
FEATURE_PORTABLE_SHUFFLE_THUNKSmakes instantiating stubs native code. On iOS,MakeInstantiatingStubWorkerfalls back toCreateInstantiatingILStub, and the interpreter runs the stub. - Since only the first call fails, our guess is a GC hole while the interpreter enters a new instantiating stub, during its compilation or the transition into it. The receiver is a young object that a gen0 GC moves.
- Possibly related: SIGSEGV in GC stack walk (MethodDesc::GetSig) with mixed interpreter/ReadyToRun execution on iOS (.NET 11 RC1) #135203 (fixed by [interpreter] Resolve open virtual delegates invoked through the shuffle thunk (wasm, Apple mobile) #134699 and [release/11.0] [interpreter] Resolve open virtual delegates invoked through the shuffle thunk (wasm, Apple mobile) #134751; the build above includes the fix and still fails) and CreateILDelegateShuffleThunk's calli cannot pass the hidden argument that open virtual delegate dispatch stubs expect #134733.
Metadata
Metadata
Assignees
Type
Projects
- StatusShow more project fieldsNo status
Activity
dotnet-policy-service commentedon Oct 26, 2026
Tagging subscribers to this area: @agocke
See info in area-owners.md if you want to be subscribed.
christallire commentedon Oct 26, 2026
The bot labeled this area-VM-coreclr, but it looks like area-CodeGen-Interpreter-coreclr and os-ios, like dotnet/runtime#135203: every failing path runs through the interpreter (instantiating IL stubs executed by the interpreter, GC scanning interpreter frames).
dotnet-policy-service commentedon Oct 26, 2026
Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.
christallire commentedon Oct 27, 2026
Since the milestone is 12.0.0, we'd like to ask that this be considered for 11.0 servicing. Otherwise every app that ships on iOS with .NET 11 carries this until November 2027.
Impact.
ExpressionVisitor.VisitLambda<T>), and so do third-party libraries.object[]and gets an array of another type (ArrayTypeMismatchException), so code can also go on with the wrong object.VirtualFunctionPointer, in the GC mark phase or as an impossibleNullReferenceException, far from the cause, so app developers are unlikely to trace them to the runtime.No workaround covers it.
GC.TryStartNoGCRegionaround risky work: it is process-wide and limited by an allocation budget, so it only covers windows the app chooses.We're preparing the last two for our own code because nothing else is available. All of them only lower the odds: without a runtime fix, an iOS app ships hoping that a GC never lands on a first call.
BrzVlad commentedon Oct 27, 2026
@christallire Sorry for the confusion with placing the milestone as .net 12. Regressions are always meant to be backported, which this bug clearly is (.NET10 should work fine since it doesn't use this code path). Submitted a PR with the fix. I expect it to be available in the first servicing release, in December.