Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

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

Open Beginner friendly
#69,758 1 comment 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
72/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
csharp
Domain
backend

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

area-minimal NativeAOT
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 RequestDelegatePopulatesParametersWithDefaultValues has had a double.NegativeZero row since #51193 (RequestDelegateCreationTests.SpecialTypes.cs#L178), and it passes for the generator. It compares with Assert.Equal (#L261), and (-0.0).Equals(0.0) is true.
  • -0D and -0.0M are negative zeros: double.IsNegative(-0D) and decimal.IsNegative(-0.0M) are true. Writing a double with a D suffix, as a float is written with F, would therefore give it its sign back under the .NET SDK; the decimal needs its sign written explicitly.
  • Not tested: a build whose compiler runs on .NET Framework. There ToString("R", CultureInfo.InvariantCulture) returns 0 for a negative zero, double and float alike (checked in Windows PowerShell 5.1, on .NET Framework 4.8), so a float default may lose its sign as well. I could not build the repro with MSBuild.exe: my Build Tools installation does not resolve Microsoft.NET.Sdk.Web.
  • Read, not run: release/9.0 and release/8.0 have the same float, decimal and 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.0 on .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

Open in Codespaces

Starts the project's dev container in your browser, under your own GitHub account.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from dotnet/aspnetcore

All issues in dotnet/aspnetcore

Similar issues

More C# issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.