[ReadyToRun/crossgen2] switch takes invalid default branch
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
## 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`):
```csharp
// 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](https://github.com/user-attachments/files/31542851/JitReproSwitchEnum.zip)
Contributor guide
Assessment
This issue has not been assessed yet.