Skip to content

[ReadyToRun/crossgen2] switch takes invalid default branch #132879

Description

@slakul

Environment

  • .NET SDK:
    Version:           11.0.100-preview.7.26381.103
    Commit:            e2c1e00b3d
    Workload version:  11.0.100-preview.7.26410.2
    MSBuild version:   18.10.0-1.26381.103+e2c1e00b3
    
  • Runtime Environment:
    OS Name:     Windows
    OS Version:  10.0.26200
    OS Platform: Windows
    RID:         win-x64
    
  • Publish command that reproduces:
    dotnet publish -c Release --runtime win-x64 --output win-x64 --self-contained -p:PublishReadyToRun=true
    
  • Repro attached: JitReproSwitchEnum.zip (self-contained console app, no external package references)

Summary

A switch statement over an enum-typed local variable silently takes the default case even though a direct == comparison against the exact same enum member, evaluated immediately before the switch, returns true. This causes an exception to be thrown ("Unknown FilterConditionOperator...") in what should be a normal, successful code path.

The defect only reproduces in a self-contained, PublishReadyToRun=true publish targeting .NET 11. It does not reproduce:

  • in a plain dotnet build -c Release (JIT only, no crossgen2) targeting .NET 11,
  • in a self-contained publish without PublishReadyToRun targeting .NET 11,
  • in the same publish command (self-contained + PublishReadyToRun=true) targeting .NET 10.

Surprising additional finding: setting DOTNET_ReadyToRun=0 at run time on the affected self-contained + PublishReadyToRun=true binary (verified to actually be set, via an echo right before launching the process) does not fix the defect. This was expected to force the runtime to ignore the precompiled R2R native code and JIT the method fresh from IL, which should have made the binary behave identically to the non-R2R publish. It did not.

This means the regression is triggered by publishing with -p:PublishReadyToRun=true, but is not simply "wrong native code gets executed at run time" — since telling the runtime to ignore that native code entirely does not help. This suggests crossgen2 may be altering something in the published assembly (IL and/or metadata, not just adding a native code section) during the R2R compilation step itself, in a way that persists even when the R2R native code is not used. This is speculation on our part — we don't have the tooling to inspect this further (e.g. diffing IL/metadata of the assembly before/after the R2R publish step) and would appreciate the JIT/crossgen2 team's help narrowing this down.

We also confirmed only a single matching shared runtime is installed (Microsoft.NETCore.App 11.0.0-preview.7.26381.103, matching the SDK's own version exactly via dotnet --list-runtimes), so this does not appear to be a runtime-version mismatch between the self-contained deployment and the framework-dependent build.

The code in question

op is a local variable of type FilterConditionOperator (an enum backed by int, ~101 members, values 0-100). Immediately before the switch below, a direct comparison confirms op equals EqualUserExternalId (value 78):

// tr.Append($",if3(EqualUserExternalId:{op == FilterConditionOperator.EqualUserExternalId})");
// -> logs "if3(EqualUserExternalId:True)"

switch (op)
{
    case FilterConditionOperator.EqualUserId:
    case FilterConditionOperator.EqualUserExternalId:   // op's actual value (78) - should land here
    case FilterConditionOperator.Equal:
    case FilterConditionOperator.EqualDeviceId:
        sb.Append('='); break;

    case FilterConditionOperator.NotEqual:
    case FilterConditionOperator.NotEqualUserId:
    case FilterConditionOperator.NotEqualUserExternalId:
    case FilterConditionOperator.NotEqualDeviceId:
        sb.Append("<>"); break;

    case FilterConditionOperator.GreaterThan: sb.Append('>'); break;
    case FilterConditionOperator.LessThan: sb.Append('<'); break;
    case FilterConditionOperator.GreaterEqual: sb.Append(">="); break;
    case FilterConditionOperator.LessEqual: sb.Append("<="); break;

    case FilterConditionOperator.Like:
    case FilterConditionOperator.BeginsWith:
    case FilterConditionOperator.EndsWith:
    case FilterConditionOperator.Contains:
        sb.Append(" LIKE "); break;

    case FilterConditionOperator.NotLike:
    case FilterConditionOperator.DoesNotBeginWith:
    case FilterConditionOperator.DoesNotEndWith:
    case FilterConditionOperator.DoesNotContain:
        sb.Append(" NOT LIKE "); break;

    default:
        // <-- execution incorrectly lands HERE for op == EqualUserExternalId, on ~23% of calls,
        //     only in the affected (self-contained + PublishReadyToRun=true) build.
        matched = false; break;
}

What goes wrong: on a fraction of calls (~23% in our test), execution falls into default for op == FilterConditionOperator.EqualUserExternalId, even though the == comparison one line above the switch — on the same, unmodified local variable — returns true for that same call. There is no exception or crash at the switch itself; the wrong branch is simply taken silently. (An exception is thrown later, several lines down, once the method has exhausted every other fallback case for an "unmatched" operator — that's how the bug surfaces observably in this particular method, but the actual defect is the switch choosing the wrong branch.) The full switch/enum are in the attached repro.

Repro

Attached JitReproSwitchEnum.zip contains a minimal-ish, self-contained console app (Program.cs + .csproj) extracted from a real production method (SQL filter-condition builder over a large enum with ~100 members). The enum and the method body are included verbatim; all external dependencies (a metadata-binding layer, etc.) are replaced with minimal stand-ins that preserve the original control-flow shape (multiple switch statements and early-exit branches over the same op variable) — we did not simplify further before testing, since reducing that complexity away might make the defect harder to trigger.

Program.cs's Main() calls the extracted method 500,000 times with a fixed input (FilterConditionOperator.EqualUserExternalId, numeric value 78) and counts how many calls return an unexpected result (an exception is thrown instead of a normal comparison string).

Steps to reproduce

  1. Unzip the attached project.
  2. Publish exactly as below (adjust RID if needed, but win-x64 is what was tested):
    dotnet publish -c Release --runtime win-x64 --output win-x64 --self-contained -p:PublishReadyToRun=true
    
  3. Run win-x64\JitReproSwitchEnum.exe.
  4. Observe output — a representative run:
    Correct result (sample): [t0.ExternalId]=@p_EXT-1
    Summary: 384589/500000 OK, 115411 failures.
    >>> BUG REPRODUCED. <<<
    
    Sample failure message from inside the loop:
    [iter    N] FAIL: exception: Unknown FilterConditionOperator: EqualUserExternalId (78), partial condition=[t0.ExternalId] | TRACE: Trace: EqualUserExternalId (78),m=False,if1,if2,if3(EqualUserExternalId:True),m=True,cd,m=False,if4,m=True,if5,if6
    
    Note in the trace: if3(EqualUserExternalId:True) — a direct op == FilterConditionOperator.EqualUserExternalId comparison evaluated immediately before the switch returns True — followed by ,cd — meaning the very next switch (op) statement on the same variable fell through to its default case instead of the matching case FilterConditionOperator.EqualUserExternalId: label.

Isolation matrix

Build PublishReadyToRun Run-time env var TFM Result
dotnet build -c Release n/a (JIT only) net11.0 0 errors / 500,000
dotnet publish --self-contained true net11.0 ~115,000 errors / 500,000 (~23%)
dotnet publish --self-contained true DOTNET_ReadyToRun=0 (confirmed set) net11.0 still fails (comparable rate)
dotnet publish --self-contained false / omitted net11.0 0 errors / 500,000
dotnet publish --self-contained -p:PublishReadyToRun=true true net10.0 0 errors / 500,000

Additional diagnostics we were able to run

  • DOTNET_ReadyToRun=0 on the R2R-published binary (env var confirmed set via echo immediately before launch): did not fix the defect — see isolation matrix above. This was surprising, since we expected it to make the binary behave like the non-R2R publish.
  • dotnet --list-runtimes on the test machine shows a single matching .NET 11 shared runtime (Microsoft.NETCore.App 11.0.0-preview.7.26381.103), identical to the SDK version — ruling out a runtime-version mismatch between the self-contained bundle and a framework-dependent run as the explanation.
  • Confirmed the coreclr.dll shipped in the self-contained publish output and the one in the installed shared framework are the exact same build:
    PS> (Get-Item .\win-x64\coreclr.dll).VersionInfo.ProductVersion
    11,0,26,38203 @Commit: e2c1e00b3d0f96afb892fb261d5921565b400246
    
    PS> (Get-Item "C:\Program Files\dotnet\shared\Microsoft.NETCore.App\11.0.0-preview.7.26381.103\coreclr.dll").VersionInfo.ProductVersion
    11,0,26,38203 @Commit: e2c1e00b3d0f96afb892fb261d5921565b400246
    
    Same product version, same commit hash. So the difference in behavior between the framework-dependent build and the self-contained + PublishReadyToRun=true publish cannot be explained by the two running different coreclr/JIT builds — it really does come down to something about the publish step itself (crossgen2 having run over the assembly), not which runtime binary executes it afterwards.

Diagnostics we were NOT able to run ourselves (would help narrow this down)

  • Diffing the IL/metadata of the affected method (or the whole assembly) between the self-contained-without-R2R publish and the self-contained-with-R2R publish, to check whether crossgen2 alters anything beyond adding native code sections.
  • JIT/crossgen2 disassembly (DOTNET_JitDisasm or the crossgen2-equivalent) for the affected method, to compare the actual generated code for the switch in question across the working and failing configurations.

Happy to run either of these, or any other targeted diagnostic, if someone can point us to the right tool/flags.

JitReproSwitchEnum.zip

Metadata

Metadata

Assignees

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Type

No type

Projects

Status
No status

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions