Skip to content

Hot Reload: "BadImageFormatException: Bad IL range" on interpreter-only CoreCLR (iOS/Mac Catalyst) after EnC delta, regression from #130159 #132804

Closed

Description

@rolfbjarne

Description

Since the .NET 11 runtime picked up dotnet/runtime#130159 ("Use CodeVersioning for EnC"), Hot Reload crashes on the CoreCLR configurations that have no JIT (iOS and Mac Catalyst, i.e. the interpreter-only Apple mobile builds). Immediately after a delta is applied, the first call into an updated method throws:

dotnet watch ⌚ File updated: /tmp/hrtest/AdditionalFile.cs
Unhandled exception. System.BadImageFormatException: Bad IL range.
   at HotReloadTestApp.Program.DoSomething(Int32 i)
   at HotReloadTestApp.Program.RunTestLogic()
   at HotReloadTestApp.Program.Main(String[] args)
dotnet watch ❌ [HotReloadTestApp (net11.0-maccatalyst)] Exited with error code 134

ApplyUpdate itself succeeds — dotnet watch --verbose reports Applying updates to module ..., Invoking metadata update handlers, Updates applied. with no error. The crash happens later, when the updated method is next invoked and its code is prepared.

This is 100% deterministic, both in CI and locally. It has been failing every dependency-flow PR in dotnet/macios for several days; we are tracking the CI side in dotnet/macios#26470 and have had to disable the affected test cases.

Analysis

Bad IL range (BFA_BAD_IL_RANGE) is only thrown from PEAssembly::GetIL (src/coreclr/vm/peassembly.cpp), reached via Module::GetIL(GetRVA()). So the runtime looked up the updated method's IL in the baseline PE image, using the RVA from the metadata delta. After a delta is applied, that MethodDef RVA is delta-relative (a small offset into the delta IL stream), so interpreting it against the baseline image is out of range — hence the error. In other words, the delta IL was never picked up when preparing the updated method.

That lines up exactly with what #130159 changed. EditAndContinueModule::ApplyEditAndContinue now stores the delta IL as an ILCodeVersion (src/coreclr/vm/encee.cpp):

ILCodeVersion ilCodeVersion;
CodeVersionManager *pCodeVersionManager = GetCodeVersionManager();
{
    CodeVersionManager::LockHolder codeVersioningLockHolder;
    if (FAILED(hr = pCodeVersionManager->AddILCodeVersion(this, token, &ilCodeVersion, FALSE, CodeVersionSource::kEnC, m_applyChangesCount)))
        ...
    ilCodeVersion.SetIL((COR_ILMETHOD*)&pLocalILMemory[dwMethodRVA]);
    ilCodeVersion.SetRejitState(RejitFlags::kStateActive);
}

and that IL is reachable only through the versioned config (src/coreclr/vm/prestub.cpp):

COR_ILMETHOD* VersionedPrepareCodeConfig::GetILHeader()
{
    STANDARD_VM_CONTRACT;
    return m_ilCodeVersion.GetIL();
}

whereas the default config falls back to the baseline RVA lookup that throws:

COR_ILMETHOD* PrepareCodeConfig::GetILHeader()
{
    STANDARD_VM_CONTRACT;
    return m_pMethodDesc->GetILHeader();
}

Both go through the same GetAndVerifyMetadataILHeader → pConfig->GetILHeader() call, so the symptom means the updated method is being prepared with a non-versioned PrepareCodeConfig.

Why only the no-JIT configurations?

I want to explicitly rule out the obvious guess, because it's wrong: it is not that code versioning is unavailable on these targets.

  • MethodDesc::IsVersionable() is IsEligibleForTieredCompilation() || IsEligibleForReJIT() || IsEligibleForEnC(), and IsEligibleForEnC() only requires InEnCEnabledModule() && IsIL() && !IsWrapperStub() — no dependency on tiering (src/coreclr/vm/method.hpp).
  • FEATURE_CODE_VERSIONING is enabled by FEATURE_TIERED_COMPILATION OR FEATURE_REJIT OR FEATURE_METADATA_UPDATER, and FEATURE_METADATA_UPDATER is on for arm64 — so code versioning is compiled in on these targets (src/coreclr/clrdefinitions.cmake).

The actual difference is that FEATURE_DYNAMIC_CODE_COMPILED is off for Apple mobile (src/coreclr/clrfeatures.cmake), which is also what turns FEATURE_TIERED_COMPILATION, FEATURE_REJIT and FEATURE_PGO off there:

if (FEATURE_DYNAMIC_CODE_COMPILED)
  set(FEATURE_TIERED_COMPILATION 1)
  set(FEATURE_REJIT 1)
  set(FEATURE_PGO 1)
endif()

So there is no JIT and these apps run the CoreCLR interpreter. My conclusion is that some interpreter-side code-preparation path doesn't consult the active ILCodeVersion and ends up with a default PrepareCodeConfig. I haven't tracked it down to the exact call site — that's where I'd appreciate help from someone who knows the interpreter entry points (MethodDesc::GetInterpreterCode / InterpreterPrecode / the InvokeManagedMethod path in prestub.cpp).

Corroborating evidence that the JIT path is fine: I built an equivalent plain console app with the same SDK and the same partial-method edit pattern and ran it on desktop osx-arm64 — Hot Reload works correctly there. Only the interpreter-only Apple targets fail.

Repro

Reproduces reliably in the dotnet/macios test suite:

git clone https://github.com/dotnet/macios
cd macios && ./configure --enable-maccatalyst && make world
cd tests/dotnet/UnitTests
dotnet test --filter "FullyQualifiedName~DotNetWatch"

Minimal manual repro (what produced the log above), from tests/dotnet/HotReloadTestApp/MacCatalyst:

AdditionalFile=/tmp/hrtest/AdditionalFile.cs \
UseMonoRuntime=false RunWithOpen=false EnableSandbox=false \
HOTRELOAD_TEST_APP_LOGFILE=/tmp/hrtest/output.log \
dotnet watch --non-interactive --disable-build-servers

then edit /tmp/hrtest/AdditionalFile.cs so the partial method assigns a different value. The app prints the variable once a second, receives the edit, and crashes instead of printing the new value.

The app is built with UseInterpreter=true, MtouchLink=None, Registrar=partial-static (Hot Reload requires no trimming). Note MtouchLink=None means the assembly is not rewritten in any way — I verified the HotReloadTestApp.dll in the app bundle is byte-identical to the compiler output, so this is not a tooling/rewriting artifact.

Ruled out

  • ReadyToRun — user assemblies are already excluded from the composite image, and building with PublishReadyToRun=false still crashes.
  • Assembly rewriting — obj/, linked/, bin/ and the app bundle copies of HotReloadTestApp.dll all have the same SHA1.
  • DebuggableAttribute — identical blob (01 00 07 01 00 00 00 00) in the failing app and the working desktop console app.
  • Tiered compilation being merely disabled — DOTNET_TieredCompilation=0 on the desktop console app does not reproduce. The feature being compiled out is not the same as it being switched off.

Regression information

Configuration

  • Runtime: dotnet/runtime 067d74c23c41161cedb93f672be3de0f2452a3d0 (via VMR dotnet/dotnet@7cdb217445905f3342bbb0266a4497b9a014389a), packages 11.0.0-rc.1.26411.119
  • SDK: 11.0.100-rc.1.26411.119
  • Targets: net11.0-maccatalyst and net11.0-ios, arm64, CoreCLR + interpreter
  • Host: macOS, Apple Silicon
  • Not affected: desktop macOS CoreCLR (JIT), and Mono on all platforms — the corresponding useMonoRuntime: true test cases still pass.

Activity

  1. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Aug 26, 2026
  2. dotnet-policy-service commented on Aug 26, 2026

    @dotnet-policy-service
    Contributor

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

  3. dotnet-policy-service commented on Aug 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. rolfbjarne commented on Aug 26, 2026

    @rolfbjarne
    MemberAuthor

    Root cause found (supersedes the speculation in the issue body)

    I built an instrumented CoreCLR at the exact commit the app runs on (067d74c23c41) and traced this to the end. The problem is not in PEAssembly::GetIL or in the delta application; it's an IL code version lookup that is incorrectly gated on FEATURE_REJIT.

    The chain

    On the platforms without a JIT (Apple mobile, WASM), FEATURE_DYNAMIC_CODE_COMPILED is off, so src/coreclr/clrfeatures.cmake never sets FEATURE_REJIT:

    if (FEATURE_DYNAMIC_CODE_COMPILED)
      set(FEATURE_TIERED_COMPILATION 1)
      set(FEATURE_REJIT 1)
      set(FEATURE_PGO 1)
    endif()

    But FEATURE_CODE_VERSIONING is on, because FEATURE_METADATA_UPDATER is enabled (clrdefinitions.cmake:58). So EnC creates real, non-default ILCodeVersion nodes on these platforms.

    CodeVersionManager::GetILCodeVersion(PTR_MethodDesc, ReJITID) (vm/codeversion.cpp) is guarded on FEATURE_REJIT, and in the #else branch it unconditionally returns the default IL code version:

    #else // FEATURE_REJIT
        _ASSERTE(rejitId == 0);
        return ILCodeVersion(dac_cast<PTR_Module>(pMethod->GetModule()), pMethod->GetMemberDef());
    #endif

    So after the update is applied:

    1. EditAndContinueModule::ApplyEditAndContinue correctly adds the EnC ILCodeVersion and makes it active.
    2. The method is reset to its prestub, and code preparation runs NativeCodeVersionNode::GetILCodeVersion() → GetILCodeVersion(pMD, GetILVersionId()).
    3. That returns the default version instead of the EnC one.
    4. ILCodeVersion::GetIL() then takes the default-version path: GetMethodImplProps(methodDef, &rva) + pModule->GetIL(rva). After a delta, that RVA is delta-relative (0x4 in the repro), so it is out of range in the baseline image → BFA_BAD_IL_RANGE.

    Instrumented evidence

    [ENCDIAG] ApplyEnC AddILCodeVersion module=0x10807d450 token=0x06000001 deltaRVA=4
    [ENCDIAG] SetActiveILCodeVersions module=0x10807d450 token=0x06000001 state=0x782ec48900 setIsDefault=0 nowIsDefault=0
    [ENCDIAG] GetActiveILCodeVersion module=0x10807d450 token=0x06000001 state=0x782ec48900 activeIsDefault=0
    dotnet watch 🔥 C# and Razor changes applied in 588ms.
    [ENCDIAG] GetActiveILCodeVersion module=0x10807d450 token=0x06000001 state=0x782ec48900 activeIsDefault=0
    [ENCDIAG] VersionedPrepareCodeConfig::GetILHeader token=0x06000001 isDefaultVer=1 source=0
    [ENCDIAG] *** BAD IL RANGE *** rva=0x4
    Unhandled exception. System.BadImageFormatException: Bad IL range.
    

    Note the contradiction on the last three lines: the active IL version is correctly the EnC one (activeIsDefault=0), but the version that code preparation actually ends up with is the default one (isDefaultVer=1 source=0 — source=0 is kUnknown, i.e. not kEnC=2). That's the NativeCodeVersion → ILCodeVersion mapping going through the broken lookup.

    Fix

    Don't make the lookup conditional on FEATURE_REJIT. A rejitId of 0 always means the default version (ILCodeVersion::GetVersionId() returns 0 for the default version), so short-circuiting that case preserves existing behavior for both configurations while making non-default versions findable when FEATURE_REJIT is off:

    if (rejitId == 0)
    {
        return ILCodeVersion(dac_cast<PTR_Module>(pMethod->GetModule()), pMethod->GetMemberDef());
    }
    
    ILCodeVersionCollection collection = GetILCodeVersions(pMethod);
    for (ILCodeVersionIterator cur = collection.Begin(), end = collection.End(); cur != end; cur++)
    {
        if (cur->GetVersionId() == rejitId)
            return *cur;
    }
    return ILCodeVersion();

    Verified: with this change built for maccatalyst-arm64 and swapped into the app bundle, the same dotnet watch repro now applies the edit and the app exits cleanly:

    dotnet watch 🔥 C# and Razor changes applied in 576ms.
    2026-08-27 01:54:38.278 HotReloadTestApp[15315:3769295] 77 Variable=Variable has changed
    dotnet watch ⌚ [HotReloadTestApp (net11.0-maccatalyst)] Exited
    

    I'll put up a PR.

    Side note

    One thing I did not chase down, but which may be worth a look: debugger-attached runs do not reproduce this — with lldb attached hot reload succeeds. That made this bug undiagnosable with a debugger and is presumably some other EnC path being taken when the debugger is present.

  5. removed
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Aug 27, 2026
  6. locked and limited conversation to collaborators on Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions