Request Delegate Generator: a double parameter defaulting to -0.0 receives +0.0 (the generated fallback is the int expression -0); a decimal -0.0m loses its sign too
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 72/100
Research direction
The fallback text comes from the switch in InnerGetDefaultValueString in src/Shared/RoslynUtils/SymbolExtensions.cs, which has separate arms for float (F suffix) and decimal (M suffix) but none for a negative double. Start there, then check how EndpointParameterEmitter.cs writes that text into the fallback. Done means a -0.0 double default yields a negative-zero fallback, and a test in RequestDelegateCreationTests.SpecialTypes.cs checks the sign with double.IsNegative, since the existing Assert.Equal cannot see it.
Written by the indexing model from the issue text.
Description
Is there an existing issue for this?
- I have searched the existing issues
Describe the bug
With EnableRequestDelegateGenerator (on by default under PublishAot and PublishTrimmed), a double parameter whose default value is -0.0 receives +0.0 when the request does not carry it. With the generator off it receives -0.0. One endpoint is enough:
app.MapGet("/double", (double d = -0.0) => double.IsNegative(d) ? "-0.0" : "+0.0");
The generated fallback is d_parsed_temp = -0; (lines 120 and 158 of the generated file). -0 is an int expression: its value is 0, which converts to +0.0. The build reports no warning and nothing throws. I get the same +0.0 for double.NegativeZero, for a const, for a double? parameter, for a method group, for a route value, for a [FromHeader] parameter and for a constructor parameter of an [AsParameters] record.
The text comes from the switch of InnerGetDefaultValueString, the helper behind GetDefaultValueString (SymbolExtensions.cs#L262-L277 at ad1e1ff). A float is written as SymbolDisplay.FormatPrimitive(f, false, false) followed by F, a decimal the same way followed by M, and the infinities and NaN of both floating-point types have arms of their own. Any other double falls to the last arm, SymbolDisplay.FormatPrimitive(defaultValue, false, false), with no suffix. Roslyn formats a double with value.ToString("R", ...) (ObjectDisplay.cs#L490-L509 at 7ac1c03), which gives -0 here. A double whose text has neither a decimal point nor an exponent is therefore written as an integer literal (0.0 gives = 0;); among the values I tried, that only matters for negative zero.
The emitter writes that text as it is, in the fallback (EndpointParameterEmitter.cs#L149-L152) and in the delegate type of the cast (StaticRouteHandlerModel.Emitter.cs#L28), which reads Cast(handler, global::System.String (global::System.Double arg0= -0) => throw null!) on line 181. That cast succeeds; only the fallback value is wrong. release/10.0 has the same switch (SymbolExtensions.cs#L220-L235 at 9aaf828). The switch dates from #51193.
A decimal default of -0.0m loses its sign for a related reason: the fallback is m_parsed_temp = 0.0M;, because decimal.ToString does not print the sign of a negative zero. A float default of -0.0f keeps it: the fallback is f_parsed_temp = -0F;.
I compared the neighbouring literals the same way, generator off against generator on, on both versions: NaN, the infinities, Epsilon, MaxValue and MinValue of double and float, decimals of different scales (1.0m, 1.00m, 0.00m, decimal.MaxValue), escaped characters and strings (quotes, backslash, \0, \n, \u2028, a lone surrogate), long.MinValue, ulong.MaxValue, int.MinValue and bounds of the smaller integer types, and enum members (negative, undefined, [Flags], ulong-based). Among those, only the two negative zeros differ.
The practical impact is small. -0.0 == 0.0 is true, so only code that looks at the sign sees a difference (1 / d, double.IsNegative, formatting, which prints -0), and a default of negative zero is rare. For a decimal it is smaller still: ToString prints 0.0 either way, and only decimal.IsNegative or decimal.GetBits tell the two apart. I report it because the generator and RequestDelegateFactory silently disagree on the value a handler receives.
Workaround: a nullable parameter without a default value, and the default in the handler, (double? d) => ... reading d ?? -0.0.
Expected Behavior
GET /double returns -0.0 and GET /decimal returns -0.0m, as with the generator off.
Steps To Reproduce
Repro project: https://github.com/AdCodicem/dotnet-upstream-repros/tree/main/aspnetcore-rdg-negative-zero-default. ./repro.sh builds and runs it with the generator off, then on, then prints the fallbacks from the generated file; REPRO_TFM=net11.0 ./repro.sh does the same on .NET 11.
Repro.csproj (the repository's version takes the target framework from REPRO_TFM; net10.0 is its default):
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
</Project>
Program.cs:
var builder = WebApplication.CreateSlimBuilder(args);
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:0");
var app = builder.Build();
app.MapGet("/double", (double d = -0.0) => double.IsNegative(d) ? "-0.0" : "+0.0");
app.MapGet("/float", (float f = -0.0f) => float.IsNegative(f) ? "-0.0f" : "+0.0f");
app.MapGet("/decimal", (decimal m = -0.0m) => decimal.IsNegative(m) ? "-0.0m" : "+0.0m");
await app.StartAsync();
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
foreach (var path in new[] { "/double", "/float", "/decimal", "/double?d=-0" })
{
Console.WriteLine($"GET {path} -> " + await client.GetStringAsync(path));
}
await app.StopAsync();
Output of ./repro.sh:
== .NET SDK 10.0.401, net10.0, MINGW64_NT-10.0-26200
== EnableRequestDelegateGenerator=false
GET /double -> -0.0
GET /float -> -0.0f
GET /decimal -> -0.0m
GET /double?d=-0 -> -0.0
== EnableRequestDelegateGenerator=true
GET /double -> +0.0
GET /float -> -0.0f
GET /decimal -> +0.0m
GET /double?d=-0 -> -0.0
== The fallbacks the generator wrote for -0.0, -0.0f and -0.0m (GeneratedRouteBuilderExtensions.g.cs)
d_parsed_temp = -0;
f_parsed_temp = -0F;
m_parsed_temp = 0.0M;
The output is the same on SDK 11.0.100-rc.1.26425.128 with net11.0, its first line apart.
Exceptions (if any)
None. The build and the run succeed; only the value differs.
.NET Version
10.0.401 (also 11.0.100-rc.1.26425.128)
Anything else?
- ASP.NET Core: 10.0.12 and 11.0.0-rc.1.26425.128 (the generator ships in
Microsoft.AspNetCore.App.Ref) - No IDE (CLI); Windows 11 (10.0.26200) x64
- The theory
RequestDelegatePopulatesParametersWithDefaultValueshas had adouble.NegativeZerorow since #51193 (RequestDelegateCreationTests.SpecialTypes.cs#L178), and it passes for the generator. It compares withAssert.Equal(#L261), and(-0.0).Equals(0.0)istrue. -0Dand-0.0Mare negative zeros:double.IsNegative(-0D)anddecimal.IsNegative(-0.0M)aretrue. Writing adoublewith aDsuffix, as afloatis written withF, would therefore give it its sign back under the .NET SDK; thedecimalneeds its sign written explicitly.- Not tested: a build whose compiler runs on .NET Framework. There
ToString("R", CultureInfo.InvariantCulture)returns0for a negative zero,doubleandfloatalike (checked in Windows PowerShell 5.1, on .NET Framework 4.8), so afloatdefault may lose its sign as well. I could not build the repro withMSBuild.exe: my Build Tools installation does not resolveMicrosoft.NET.Sdk.Web. - Read, not run:
release/9.0andrelease/8.0have the samefloat,decimaland last arms. - Related: #51183 and its fix #51193, which wrote this switch; #51241, the follow-up on how default values are formatted; #63086, which added the enum cases. Not the grouping of #69675: a single endpoint shows it, and the generator built from #69752 gives the same
+0.0on .NET 11.
Found while testing #69752, the fix proposed for #69675, which I reported from AdCodicem.ValueObjects. Investigation and write-up assisted by Claude (Anthropic); the linked repro was run on Windows 11, and I reviewed this report.
- Dominant language
- C#
- Stars
- 38.5k
- Forks
- 13.8k
- Avg merge
- 2d 9h
- Merged PRs (30d)
- 227
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from dotnet/aspnetcore
-
area-middleware
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
dotnet/aspnetcore#69849 · 1 comment ·
Maintainers usually reply within 1 day
-
area-signalr
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
dotnet/aspnetcore#69842 · 1 comment ·
Maintainers usually reply within 1 day
-
WebApplicationFactory sometimes throws ObjectDisposedException instead of the app's startup exceptionPossibly taken @Demivan claimed this 9 days ago. Openarea-mvc
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
dotnet/aspnetcore#69684 · 2 comments ·
Maintainers usually reply within 1 day
-
area-networking feature-httpsys
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
dotnet/aspnetcore#69671 · 1 comment ·
Maintainers usually reply within 1 day
-
.NET 10 OpenAPI emits an object constraint for floating-point valuesPossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
dotnet/aspnetcore#69604 · 1 comment ·
Maintainers usually reply within 1 day
All issues in dotnet/aspnetcore
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Maintainers usually reply within 1 day
-
good first issue help wanted ready-for-human
Difficulty 2/5 Half a day Newbie friendliness 62/100
Maintainers usually reply within 1 day
-
ready-for-agent size:S status:todo
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
elsa-workflows/elsa-foundation#2830 ·
Maintainers usually reply within 1 day
-
Review - Maintainer WG-Maintainers-Build
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
PowerShell/PowerShell#28220 · 1 comment ·
Maintainers usually reply within 1 day
-
area: docs eco: dotnet good first issue help wanted lane: free-agent roadmap size: XS type: docs
Difficulty 2/5 Under an hour Newbie friendliness 72/100
arminazimi/evergreen#377 ·
Maintainers usually reply within 1 day