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
- Unzip the attached project.
- 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
- Run
win-x64\JitReproSwitchEnum.exe.
- 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
Environment
JitReproSwitchEnum.zip(self-contained console app, no external package references)Summary
A
switchstatement over anenum-typed local variable silently takes thedefaultcase even though a direct==comparison against the exact same enum member, evaluated immediately before theswitch, returnstrue. 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=truepublish targeting .NET 11. It does not reproduce:dotnet build -c Release(JIT only, no crossgen2) targeting .NET 11,PublishReadyToRuntargeting .NET 11,PublishReadyToRun=true) targeting .NET 10.Surprising additional finding: setting
DOTNET_ReadyToRun=0at run time on the affected self-contained +PublishReadyToRun=truebinary (verified to actually be set, via anechoright 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 viadotnet --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
opis a local variable of typeFilterConditionOperator(anenumbacked byint, ~101 members, values 0-100). Immediately before theswitchbelow, a direct comparison confirmsopequalsEqualUserExternalId(value78):What goes wrong: on a fraction of calls (~23% in our test), execution falls into
defaultforop == FilterConditionOperator.EqualUserExternalId, even though the==comparison one line above theswitch— on the same, unmodified local variable — returnstruefor that same call. There is no exception or crash at theswitchitself; 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 theswitchchoosing the wrong branch.) The full switch/enum are in the attached repro.Repro
Attached
JitReproSwitchEnum.zipcontains a minimal-ish, self-contained console app (Program.cs+.csproj) extracted from a real production method (SQL filter-condition builder over a largeenumwith ~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 (multipleswitchstatements and early-exit branches over the sameopvariable) — we did not simplify further before testing, since reducing that complexity away might make the defect harder to trigger.Program.cs'sMain()calls the extracted method 500,000 times with a fixed input (FilterConditionOperator.EqualUserExternalId, numeric value78) and counts how many calls return an unexpected result (an exception is thrown instead of a normal comparison string).Steps to reproduce
win-x64\JitReproSwitchEnum.exe.if3(EqualUserExternalId:True)— a directop == FilterConditionOperator.EqualUserExternalIdcomparison evaluated immediately before theswitchreturnsTrue— followed by,cd— meaning the very nextswitch (op)statement on the same variable fell through to itsdefaultcase instead of the matchingcase FilterConditionOperator.EqualUserExternalId:label.Isolation matrix
dotnet build -c Releasedotnet publish --self-containeddotnet publish --self-containedDOTNET_ReadyToRun=0(confirmed set)dotnet publish --self-containeddotnet publish --self-contained -p:PublishReadyToRun=trueAdditional diagnostics we were able to run
DOTNET_ReadyToRun=0on the R2R-published binary (env var confirmed set viaechoimmediately 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-runtimeson 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.coreclr.dllshipped in the self-contained publish output and the one in the installed shared framework are the exact same build:PublishReadyToRun=truepublish 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)
DOTNET_JitDisasmor the crossgen2-equivalent) for the affected method, to compare the actual generated code for theswitchin 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