Skip to content

[iOS] CoreCLR: a GC during the first call through an interpreted instantiating stub leaves the callee a stale this #136472

Description

@christallire

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 feed

run.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,nogc has no forced GCs. gvm,warm calls every stub once before the GCs start. Neither fails, so a failure needs both a GC and the first call through a stub.
  • gvm fails with a NullReferenceException or an ArrayTypeMismatchException in Visitor.Visit[T], where it reads _ballast through a stale this. The constructor always sets _ballast to an object[].
  • ef fails with a NullReferenceException or IndexOutOfRangeException inside EF Core, or crashes (Got a SIGSEGV while executing native code, sometimes SIGBUS), either on the thread in GC.Collect or 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

Activity

  1. dotnet-policy-service commented on Oct 26, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @agocke
    See info in area-owners.md if you want to be subscribed.

  2. christallire commented on Oct 26, 2026

    @christallire
    Author

    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).

  3. dotnet-policy-service commented on Oct 26, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
    See info in area-owners.md if you want to be subscribed.

  4. removed
    untriagedNew issue has not been triaged by the area owner
    on Oct 26, 2026
  5. added this to the 12.0.0 milestone on Oct 26, 2026
  6. self-assigned this
    on Oct 26, 2026
  7. christallire commented on Oct 27, 2026

    @christallire
    Author

    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.

    • This isn't specific to EF Core. ReadyToRun code that calls a generic virtual method implemented by shared generic code can hit it, and the attached repro uses no library at all. The BCL makes such calls itself (ExpressionVisitor.VisitLambda<T>), and so do third-party libraries.
    • Startup is only where the odds are highest. Every first call through such a stub is exposed, on every launch, so the first time a user opens a screen is too. The chance per launch is low, but crashes grow with users and launches: at scale the question isn't whether an app crashes but how often.
    • The callee runs with a reference to a moved object. Besides crashes, the repro reads a field that is always object[] and gets an array of another type (ArrayTypeMismatchException), so code can also go on with the wrong object.
    • The failures surface in VirtualFunctionPointer, in the GC mark phase or as an impossible NullReferenceException, far from the cause, so app developers are unlikely to trace them to the runtime.

    No workaround covers it.

    • Leaving assemblies out of ReadyToRun: iOS has no JIT, so that code stays interpreted for the life of the process. It cost us 1.1 s at startup and made first queries 1.6x slower, and it only protects the assemblies left out.
    • GC.TryStartNoGCRegion around risky work: it is process-wide and limited by an allocation budget, so it only covers windows the app chooses.
    • Calling every stub once before a GC can happen: the instantiations depend on runtime types, and an app can't enumerate them inside the BCL or third-party libraries.

    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.

  8. BrzVlad commented on Oct 27, 2026

    @BrzVlad
    Member

    @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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

  • Status
    No status

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions