You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
MetadataValue.TryFormatCanonical is genuinely span-based only for Double and Single; every other kind calls ToCanonicalString() and copies the result. Null, Boolean, String, and Uri already return a literal or stored reference, but the other ten kinds allocate. This affects JsonCloudEventsExtensions.WriteStringAttribute, which still creates a throwaway string for values such as Guid, DateTime, and Int64, and the nine TryGetXxx validators that reformat candidates (Int64, UInt64, Single, four date/time kinds, TimeSpan, and Guid). TryGetDecimal, TryGetChar, and TryGetUri do not reformat and are unaffected.
The serializers ultimately write UTF-8. CanonicalFloatingPointFormatter already renders either encoding from one generic implementation, while #61 deferred its serializer integration: MetadataExtensions.WriteNumberValue still creates a Double string that System.Text.Json transcodes back to ASCII. The remaining non-text formats are also ASCII; only String, Char, and Uri require transcoding.
Add allocation-free canonical formatters for both encodings, expose them through MetadataValue, make them the source for ToCanonicalString and validation, and adopt them in the JSON and CloudEvents writers.
Acceptance Criteria
MetadataValue exposes TryFormatCanonical and TryFormatCanonicalUtf8; neither allocates for any primitive kind, including copying or transcoding String, Char, and Uri into the caller's destination.
One canonical renderer serves both package assets and encodings. Only decimal field extraction and UTF-16-to-UTF-8 transcoding vary by target, and neither decides the output text. There is no target-specific formatter, renderer, cross-encoding route, or intermediate output buffer; every method writes directly to the requested destination. One expected-output corpus passes unchanged against both assets.
A public, XML-documented CanonicalTextFormatter exposes the new primitives in both encodings and per-type maximum lengths bounding both encodings on both assets.
MetadataValue exposes a documented public bound for every bounded primitive in either encoding; JsonCloudEventsExtensions uses it instead of a private magic number.
UTF-16 output exactly matches the previous ToCanonicalString text and count. UTF-8 output is that text's replacement-fallback encoding—lossy only for malformed UTF-16—with equal byte/character counts whenever the canonical text is ASCII.
In both encodings, insufficient capacity returns false, reports zero, and leaves the destination unchanged; Array and Object still throw InvalidOperationException; corrupt DateTime, DateOnly, and TimeOnly payloads still throw InvalidOperationException.
CanonicalTextFormatter.TryFormat(DateTime, …) normalizes Local to UTC like MetadataValue.FromDateTime, so all accepted values fit MaximumDateTimeLength; Utc and Unspecified are unchanged. A direct test covers this.
TryGetXxx round-trip validators compare span-formatted text without allocating a candidate string.
ToCanonicalString remains allocation-free for Null, Boolean, String, and Uri, and remains textually unchanged for every kind.
The JSON metadata writer materializes no canonical string for string-shaped or Double/Single number-shaped values. Its bytes remain unchanged under the default and UnsafeRelaxedJsonEscaping encoders, including non-ASCII text and unpaired surrogates.
Tests cover every kind and encoding: canonical output/count, exact and one-short capacity, boundaries, invalid-payload exceptions, and allocations. Metadata tests pass against the net10.0 and netstandard2.0 library assets.
A net10.0 microbenchmark compares the new Guid and DateTime formatters with framework TryFormat; results are recorded in the pull request as evidence for a deferred follow-up, with no target-specific implementation added here regardless of outcome.
CloudEvents and HTTP write benchmarks contain affected metadata kinds; before/after allocations for both are recorded in the pull request.
THIRD-PARTY-NOTICES.md and the folder README identify all adapted upstream files and adaptations; every adapted source retains the .NET Foundation MIT header.
The netstandard2.0 decimal path documents and allocation-freely enforces its runtime-layout assumption. A violation throws PlatformNotSupportedException from decimal formatting itself without disabling other formatters.
Package release notes mention the new APIs and removed allocations.
Both targets build in Release with warnings as errors, package validation succeeds, the Native AOT sample publishes, and coverage remains above 95%.
Technical Details
Architecture and upstream sources
netstandard2.0 has no affected span-formatting APIs; Polyfill extensions call ToString and copy, and IUtf8SpanFormattable is unavailable. Ship one owned implementation for both assets and encodings, with no NET10_0_OR_GREATER branch to framework formatting. This avoids the asset wire-format divergence fixed for floating point in #58 and keeps one implementation and test surface.
Framework fast paths remain a follow-up informed by the required benchmark. Guid.TryFormat has a vectorized D path and is plausible; the date kinds are less likely because their canonical custom pattern enters the general format interpreter instead of fixed-shape TryFormatO. Do not branch in this issue even if a framework API wins.
Adapt—not rederive—the scalar implementations from the repository's existing dotnet/runtime baseline, tag v6.0.36, commit f1dd57165bfd91875761329ac3a8b17f6606ad18. This line predates the System.Runtime.Intrinsics rewrites and compiles for netstandard2.0; newer sources would require de-vectorization. Numbers/ already establishes the pattern: adapted upstream code under the .NET Foundation MIT header, with provenance in THIRD-PARTY-NOTICES.md and a folder README. Rederiving carries the real risk—the TryGetXxx validators pin these encodings, so a subtly wrong hand-derived rule breaks round-tripping of data already on the wire.
Kind
Upstream source and adaptation
Int64, UInt64
Number.Formatting.cs: TryUInt64ToDecStr, UInt32ToDecChars, Int64DivMod1E9; FormattingHelpers.CountDigits. Direct scalar port over a fixed destination.
Decimal
Number.Formatting.cs: DecimalToNumber; Decimal.DecCalc.cs: DecDivMod1E9. Reuse the existing NumberBuffer; add a scale-preserving renderer.
Date/time kinds
DateTimeFormat.cs: TryFormatO, WriteTwoDecimalDigits, WriteFourDecimalDigits, WriteDigits. Port TryFormatO, not FormatCustomized.
TimeSpan
System.Private.Xml/XsdDuration.cs: TimeSpan constructor and ToString(DurationType), the normative implementation of XmlConvert.ToString(TimeSpan).
Guid
Guid.cs: TryFormat's D branch, HexsToChars, HexConverter.ToCharLower. Numeric-field formatting is endianness-independent.
FormatCustomized brings culture, calendars, Hebrew/Japanese cases, and StringBuilderCache; TryFormatO is a small culture-free fixed template. Adapt it to omit a zero fraction and otherwise trim trailing zeros; replace internal GetDate/GetTimePrecise with ported helpers or public component properties (accepting their repeated tick calculations); drop its local-offset branch for DateTime because local values are normalized, while retaining offsets for DateTimeOffset.
All upstream renderers target char; route ASCII through the repository's TCodeUnit conversion pattern so one body writes either encoding.
Decimal extraction
decimal.GetBits(decimal, Span<int>) is unavailable on netstandard2.0; its array overload allocates. Upstream sidesteps this with Unsafe.As<decimal, DecCalc>, but that guarantee is weaker than it looks: GetBits documents the logical representation, not the private field order, and upstream DecCalc carries an explicit #if BIGENDIAN layout—endianness is part of the assumption rather than incidental to it. Split only extraction:
net10.0 uses the contractual, allocation-free, endian-independent span overload.
netstandard2.0 reinterprets the value, documented as requiring a little-endian runtime with historical flags, hi, lo, mid field order. Digit generation and rendering remain shared.
Validate the legacy assumption once without calling the allocating array overload. Construct
then reinterpret and compare the fields with those arguments and flags 0x80050000. The constructor defines the same logical representation as GetBits; this uses only a struct and integers.
Keep the guard in a decimal-only private helper, store its result in static readonly bool, and throw PlatformNotSupportedException from decimal formatting—not a type initializer, which would wrap it in TypeInitializationException and disable unrelated formatters. The initialized flag folds away. A unit test should compare reinterpretation with decimal.GetBits, but both assets run on the .NET 10 host, so only the runtime guard covers .NET Framework or Mono. Per tests/AGENTS.md, mutants in the initializer are a known static-analysis blind spot. Warmed allocation tests also miss initialization, so first use must be allocation-free by construction.
Canonical contracts
Decimal: preserve scale (19.50m → 19.50); omit the sign of negative zero, matching the framework.
TimeSpan: zero is PT0S; - precedes P; omit zero days; include T only when a time component follows; trim fractional zeros (P2DT3H4M5.06S, PT0.0000001S). Preserve upstream's unchecked((ulong)-ticks) handling of TimeSpan.MinValue.
Date/time:DateTime UTC ends in Z, Unspecified has no designator, matching yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK; DateTimeOffset uses +hh:mm/-hh:mm, never Z. Date/time fractions are omitted at zero and otherwise trimmed.
Guid: lowercase D format.
For DateTime, conditionally call ToUniversalTime() only for Kind.Local and render Z, matching FromDateTime. Document that this input depends on the machine's local time zone. Never normalize Unspecified: ToUniversalTime() would treat it as local and shift it. Conversion saturates at DateTime.MinValue/MaxValue, so it adds no exception. Normalization also keeps the maximum at 28 rather than the 33 characters required by a local ±hh:mm form.
FromDateTime already normalizes Local and TryReadStoredDateTime rejects a stored Local payload, so only direct formatter tests can reach this branch. Assert normalized text/count/bound plus an Unspecified value that is not shifted.
Public API and failure behavior
namespaceLight.PortableResults.Text;publicstaticclassCanonicalTextFormatter{publicconstintMaximumInt64Length=20;publicconstintMaximumUInt64Length=20;publicconstintMaximumDecimalLength=31;publicconstintMaximumCharLength=3;publicconstintMaximumDayNumber=3_652_058;// DateOnly.MaxValue.DayNumberpublicconstlongMaximumTimeOfDayTicks=863_999_999_999;// TimeSpan.TicksPerDay - 1publicconstintMaximumDateTimeLength=28;publicconstintMaximumDateTimeOffsetLength=33;publicconstintMaximumDateLength=10;publicconstintMaximumTimeLength=16;publicconstintMaximumTimeSpanLength=27;publicconstintMaximumGuidLength=36;publicstaticboolTryFormat(charvalue,Span<char>destination,outintcharsWritten);publicstaticboolTryFormat(longvalue,Span<char>destination,outintcharsWritten);publicstaticboolTryFormat(ulongvalue,Span<char>destination,outintcharsWritten);publicstaticboolTryFormat(decimalvalue,Span<char>destination,outintcharsWritten);publicstaticboolTryFormat(DateTimevalue,Span<char>destination,outintcharsWritten);publicstaticboolTryFormat(DateTimeOffsetvalue,Span<char>destination,outintcharsWritten);publicstaticboolTryFormat(TimeSpanvalue,Span<char>destination,outintcharsWritten);publicstaticboolTryFormat(Guidvalue,Span<char>destination,outintcharsWritten);publicstaticboolTryFormatDate(intdayNumber,Span<char>destination,outintcharsWritten);publicstaticboolTryFormatTime(longticks,Span<char>destination,outintcharsWritten);// One Span<byte>/bytesWritten TryFormatUtf8 counterpart for every overload above.publicstaticboolTryFormatUtf8(ReadOnlySpan<char>text,Span<byte>destination,outintbytesWritten);}
These signatures are exact. The char overload prevents a char literal from binding to long (or, if integer methods were renamed, decimal) and rendering 'x' as 120. Other integral types may correctly bind to long. Test overload resolution with a char literal.
false means only insufficient capacity. TryFormatDate accepts [0, MaximumDayNumber]; TryFormatTime accepts [0, MaximumTimeOfDayTicks]; invalid values throw ArgumentOutOfRangeException, consistent with the floating formatter reserving false for capacity and throwing for non-finite input. MetadataValue pre-validates with the same public constants to retain its existing InvalidOperationException and message; test the formatter's otherwise-unreachable range exceptions directly.
MaximumCharLength is 3 because the constants bound both encodings: one BMP code unit, including an unpaired surrogate replaced by U+FFFD, needs at most three UTF-8 bytes. The UTF-16 method writes one. TryFormatDate and TryFormatTime accept stored payloads because DateOnly/TimeOnly BCL types are absent from netstandard2.0. The text-transcoding overload needs no UTF-16 peer because chars use TryCopyTo.
Keep CanonicalFloatingPointFormatter and its API/constants in place. Add to MetadataValue:
The bound is Guid's length and covers both encodings. Document that it excludes unbounded String and Uri; only they may still outgrow this buffer. Char remains bounded at three. CloudEvents therefore retains its materializing fallback.
Rendering, transcoding, and single sourcing
Follow CanonicalFloatingPointFormatter.TryRender: private generic cores constrained to unmanaged, a shared internal conversion helper, and JIT-foldable typeof(TCodeUnit) == typeof(byte) ASCII writes. Do not expose the helper, duplicate it, or guard impossible non-char/byte instantiations (an unreachable throw is a coverage hole). Calculate required length before writing to preserve all-or-nothing behavior.
String, Char, and Uri instead transcode with replacement fallback: unpaired surrogates accepted by FromChar/FromString become U+FFFD. This is intentionally lossy because malformed UTF-16 has no valid UTF-8 representation; false must still mean only insufficient capacity. Counts vary for non-ASCII/malformed text, but match for ASCII; UTF-8 round-trip identity is not promised.
Use Utf8.FromUtf16(replaceInvalidSequences: true) on net10.0. On netstandard2.0, use Encoding's pointer overload under fixed, whose default fallback also emits U+FFFD. Both paths count bytes before writing, requiring a second pass but preventing partial output.
The serializers must not use pre-transcoded UTF-8 for these three kinds. With System.Text.Json 10.0.10 and UnsafeRelaxedJsonEscaping, malformed input differs bytewise:
Both parse equivalently, and both routes converge for valid U+FFFD; the default encoder also hides the difference by escaping non-ASCII. Preserve bytes by letting the writer transcode text-bearing kinds.
Make span formatting primary. ToCanonicalString stack-formats bounded values and creates one string from the written slice, but retains allocation-free fast paths: literals for Null/Boolean, stored references for String/Uri. Share literals as private const string; both span encodings write the same constant through the ASCII helper—do not add separate u8 literals.
Likewise, validator FormatXxx helpers stack-format and compare with MemoryExtensions.SequenceEqual; replace TryGetInt64's inline invariant ToString too.
Serializer adoption
The rule: the writer performs any UTF-16 transcoding itself; serializers hand it UTF-8 only for kinds whose canonical text is ASCII, where the two encodings cannot disagree.
MetadataExtensions.WriteNumberValue stack-formats Double/Single as UTF-8 and calls WriteRawValue(ReadOnlySpan<byte>, skipInputValidation: true), completing Add UTF-8 floating-point formatting #61.
WriteMetadataValue stack-writes ASCII-bounded string shapes (date kinds, TimeSpan, Guid, UInt64) in either encoding.
String/Uri pass their stored string to the writer; Char uses UTF-16 so the writer owns surrogate handling.
JsonCloudEventsExtensions.WriteStringAttribute remains UTF-16, switches its stack size to the public constant, and retains fallback. CloudEvents rejects unpaired surrogates upstream.
HttpHeaderValueFormatter remains unchanged because returning StringValues inherently requires strings.
Benchmarks and tests
Current write fixtures contain only FromString, so their allocation delta is necessarily zero. Add Guid, DateTime, Double, and an Int64 outside the inclusive 32-bit range:
CloudEvents: annotate with SerializeInCloudEventsExtensionAttributes; the range matters because an in-range Int64 uses numeric Integer encoding and bypasses WriteStringAttribute.
HTTP: annotate with SerializeInHttpResponseBody; do not claim a header improvement, because StringValues still materializes text.
Test canonical behavior sociably through MetadataValue, using direct CanonicalTextFormatter tests only for inaccessible inputs such as Local DateTime and invalid raw date/time ranges. Derive expected text from the same framework ToString calls used before this change; derive UTF-8 expectations from those strings (ASCII where applicable), as in #61.
Cover each kind's minimum/maximum, zero/negative zero, decimal scale, TimeSpan.MinValue/MaxValue/Zero and component omission, fractions present/absent for date/time values, and both storable DateTimeKind values. For text kinds cover non-ASCII, surrogate pairs, and unpaired surrogates in both encodings. At writer level assert exact bytes with both JSON encoders; default-only coverage is insufficient because it hides the malformed-input divergence.
Measure allocations with GC.GetAllocatedBytesForCurrentThread around warmed loops for every affected kind and encoding, plus the four allocation-free ToCanonicalString kinds. Extend this to validators using String-kind canonical input; typed values return early and would miss the old allocation. Warm-up hides lazy initialization, hence the allocation-free-construction requirement above.
All new methods have out parameters and therefore fall into Stryker Safe Mode's documented compile-failure blind spot. Mutation score cannot assess them; the pull request must manually map each formatter contract to its constraining tests.
Framework target-specific calls and any resulting optimization remain deferred. Provenance, release notes, benchmark results, build/package/AOT validation, and coverage are required by the acceptance criteria above.
Rationale
MetadataValue.TryFormatCanonicalis genuinely span-based only forDoubleandSingle; every other kind callsToCanonicalString()and copies the result.Null,Boolean,String, andUrialready return a literal or stored reference, but the other ten kinds allocate. This affectsJsonCloudEventsExtensions.WriteStringAttribute, which still creates a throwaway string for values such asGuid,DateTime, andInt64, and the nineTryGetXxxvalidators that reformat candidates (Int64,UInt64,Single, four date/time kinds,TimeSpan, andGuid).TryGetDecimal,TryGetChar, andTryGetUrido not reformat and are unaffected.The serializers ultimately write UTF-8.
CanonicalFloatingPointFormatteralready renders either encoding from one generic implementation, while #61 deferred its serializer integration:MetadataExtensions.WriteNumberValuestill creates aDoublestring that System.Text.Json transcodes back to ASCII. The remaining non-text formats are also ASCII; onlyString,Char, andUrirequire transcoding.Add allocation-free canonical formatters for both encodings, expose them through
MetadataValue, make them the source forToCanonicalStringand validation, and adopt them in the JSON and CloudEvents writers.Acceptance Criteria
MetadataValueexposesTryFormatCanonicalandTryFormatCanonicalUtf8; neither allocates for any primitive kind, including copying or transcodingString,Char, andUriinto the caller's destination.CanonicalTextFormatterexposes the new primitives in both encodings and per-type maximum lengths bounding both encodings on both assets.MetadataValueexposes a documented public bound for every bounded primitive in either encoding;JsonCloudEventsExtensionsuses it instead of a private magic number.ToCanonicalStringtext and count. UTF-8 output is that text's replacement-fallback encoding—lossy only for malformed UTF-16—with equal byte/character counts whenever the canonical text is ASCII.false, reports zero, and leaves the destination unchanged;ArrayandObjectstill throwInvalidOperationException; corruptDateTime,DateOnly, andTimeOnlypayloads still throwInvalidOperationException.CanonicalTextFormatter.TryFormat(DateTime, …)normalizesLocalto UTC likeMetadataValue.FromDateTime, so all accepted values fitMaximumDateTimeLength;UtcandUnspecifiedare unchanged. A direct test covers this.TryGetXxxround-trip validators compare span-formatted text without allocating a candidate string.ToCanonicalStringremains allocation-free forNull,Boolean,String, andUri, and remains textually unchanged for every kind.Double/Singlenumber-shaped values. Its bytes remain unchanged under the default andUnsafeRelaxedJsonEscapingencoders, including non-ASCII text and unpaired surrogates.net10.0andnetstandard2.0library assets.net10.0microbenchmark compares the newGuidandDateTimeformatters with frameworkTryFormat; results are recorded in the pull request as evidence for a deferred follow-up, with no target-specific implementation added here regardless of outcome.THIRD-PARTY-NOTICES.mdand the folder README identify all adapted upstream files and adaptations; every adapted source retains the .NET Foundation MIT header.netstandard2.0decimal path documents and allocation-freely enforces its runtime-layout assumption. A violation throwsPlatformNotSupportedExceptionfrom decimal formatting itself without disabling other formatters.Technical Details
Architecture and upstream sources
netstandard2.0has no affected span-formatting APIs;Polyfillextensions callToStringand copy, andIUtf8SpanFormattableis unavailable. Ship one owned implementation for both assets and encodings, with noNET10_0_OR_GREATERbranch to framework formatting. This avoids the asset wire-format divergence fixed for floating point in #58 and keeps one implementation and test surface.Framework fast paths remain a follow-up informed by the required benchmark.
Guid.TryFormathas a vectorizedDpath and is plausible; the date kinds are less likely because their canonical custom pattern enters the general format interpreter instead of fixed-shapeTryFormatO. Do not branch in this issue even if a framework API wins.Adapt—not rederive—the scalar implementations from the repository's existing
dotnet/runtimebaseline, tagv6.0.36, commitf1dd57165bfd91875761329ac3a8b17f6606ad18. This line predates theSystem.Runtime.Intrinsicsrewrites and compiles fornetstandard2.0; newer sources would require de-vectorization.Numbers/already establishes the pattern: adapted upstream code under the .NET Foundation MIT header, with provenance inTHIRD-PARTY-NOTICES.mdand a folder README. Rederiving carries the real risk—theTryGetXxxvalidators pin these encodings, so a subtly wrong hand-derived rule breaks round-tripping of data already on the wire.Int64,UInt64Number.Formatting.cs:TryUInt64ToDecStr,UInt32ToDecChars,Int64DivMod1E9;FormattingHelpers.CountDigits. Direct scalar port over a fixed destination.DecimalNumber.Formatting.cs:DecimalToNumber;Decimal.DecCalc.cs:DecDivMod1E9. Reuse the existingNumberBuffer; add a scale-preserving renderer.DateTimeFormat.cs:TryFormatO,WriteTwoDecimalDigits,WriteFourDecimalDigits,WriteDigits. PortTryFormatO, notFormatCustomized.TimeSpanSystem.Private.Xml/XsdDuration.cs:TimeSpanconstructor andToString(DurationType), the normative implementation ofXmlConvert.ToString(TimeSpan).GuidGuid.cs:TryFormat'sDbranch,HexsToChars,HexConverter.ToCharLower. Numeric-field formatting is endianness-independent.FormatCustomizedbrings culture, calendars, Hebrew/Japanese cases, andStringBuilderCache;TryFormatOis a small culture-free fixed template. Adapt it to omit a zero fraction and otherwise trim trailing zeros; replace internalGetDate/GetTimePrecisewith ported helpers or public component properties (accepting their repeated tick calculations); drop its local-offset branch forDateTimebecause local values are normalized, while retaining offsets forDateTimeOffset.All upstream renderers target
char; route ASCII through the repository'sTCodeUnitconversion pattern so one body writes either encoding.Decimal extraction
decimal.GetBits(decimal, Span<int>)is unavailable onnetstandard2.0; its array overload allocates. Upstream sidesteps this withUnsafe.As<decimal, DecCalc>, but that guarantee is weaker than it looks:GetBitsdocuments the logical representation, not the private field order, and upstreamDecCalccarries an explicit#if BIGENDIANlayout—endianness is part of the assumption rather than incidental to it. Split only extraction:net10.0uses the contractual, allocation-free, endian-independent span overload.netstandard2.0reinterprets the value, documented as requiring a little-endian runtime with historicalflags,hi,lo,midfield order. Digit generation and rendering remain shared.Validate the legacy assumption once without calling the allocating array overload. Construct
then reinterpret and compare the fields with those arguments and flags
0x80050000. The constructor defines the same logical representation asGetBits; this uses only a struct and integers.Keep the guard in a decimal-only private helper, store its result in
static readonly bool, and throwPlatformNotSupportedExceptionfrom decimal formatting—not a type initializer, which would wrap it inTypeInitializationExceptionand disable unrelated formatters. The initialized flag folds away. A unit test should compare reinterpretation withdecimal.GetBits, but both assets run on the .NET 10 host, so only the runtime guard covers .NET Framework or Mono. Pertests/AGENTS.md, mutants in the initializer are a known static-analysis blind spot. Warmed allocation tests also miss initialization, so first use must be allocation-free by construction.Canonical contracts
19.50m→19.50); omit the sign of negative zero, matching the framework.PT0S;-precedesP; omit zero days; includeTonly when a time component follows; trim fractional zeros (P2DT3H4M5.06S,PT0.0000001S). Preserve upstream'sunchecked((ulong)-ticks)handling ofTimeSpan.MinValue.DateTimeUTC ends inZ, Unspecified has no designator, matchingyyyy-MM-dd'T'HH:mm:ss.FFFFFFFK;DateTimeOffsetuses+hh:mm/-hh:mm, neverZ. Date/time fractions are omitted at zero and otherwise trimmed.Dformat.For
DateTime, conditionally callToUniversalTime()only forKind.Localand renderZ, matchingFromDateTime. Document that this input depends on the machine's local time zone. Never normalize Unspecified:ToUniversalTime()would treat it as local and shift it. Conversion saturates atDateTime.MinValue/MaxValue, so it adds no exception. Normalization also keeps the maximum at 28 rather than the 33 characters required by a local±hh:mmform.FromDateTimealready normalizes Local andTryReadStoredDateTimerejects a stored Local payload, so only direct formatter tests can reach this branch. Assert normalized text/count/bound plus an Unspecified value that is not shifted.Public API and failure behavior
These signatures are exact. The
charoverload prevents a char literal from binding tolong(or, if integer methods were renamed,decimal) and rendering'x'as120. Other integral types may correctly bind tolong. Test overload resolution with a char literal.falsemeans only insufficient capacity.TryFormatDateaccepts[0, MaximumDayNumber];TryFormatTimeaccepts[0, MaximumTimeOfDayTicks]; invalid values throwArgumentOutOfRangeException, consistent with the floating formatter reservingfalsefor capacity and throwing for non-finite input.MetadataValuepre-validates with the same public constants to retain its existingInvalidOperationExceptionand message; test the formatter's otherwise-unreachable range exceptions directly.MaximumCharLengthis 3 because the constants bound both encodings: one BMP code unit, including an unpaired surrogate replaced by U+FFFD, needs at most three UTF-8 bytes. The UTF-16 method writes one.TryFormatDateandTryFormatTimeaccept stored payloads becauseDateOnly/TimeOnlyBCL types are absent fromnetstandard2.0. The text-transcoding overload needs no UTF-16 peer because chars useTryCopyTo.Keep
CanonicalFloatingPointFormatterand its API/constants in place. Add toMetadataValue:The bound is
Guid's length and covers both encodings. Document that it excludes unboundedStringandUri; only they may still outgrow this buffer.Charremains bounded at three. CloudEvents therefore retains its materializing fallback.Rendering, transcoding, and single sourcing
Follow
CanonicalFloatingPointFormatter.TryRender: private generic cores constrained tounmanaged, a shared internal conversion helper, and JIT-foldabletypeof(TCodeUnit) == typeof(byte)ASCII writes. Do not expose the helper, duplicate it, or guard impossible non-char/byteinstantiations (an unreachable throw is a coverage hole). Calculate required length before writing to preserve all-or-nothing behavior.String,Char, andUriinstead transcode with replacement fallback: unpaired surrogates accepted byFromChar/FromStringbecome U+FFFD. This is intentionally lossy because malformed UTF-16 has no valid UTF-8 representation;falsemust still mean only insufficient capacity. Counts vary for non-ASCII/malformed text, but match for ASCII; UTF-8 round-trip identity is not promised.Use
Utf8.FromUtf16(replaceInvalidSequences: true)onnet10.0. Onnetstandard2.0, useEncoding's pointer overload underfixed, whose default fallback also emits U+FFFD. Both paths count bytes before writing, requiring a second pass but preventing partial output.The serializers must not use pre-transcoded UTF-8 for these three kinds. With System.Text.Json 10.0.10 and
UnsafeRelaxedJsonEscaping, malformed input differs bytewise:Both parse equivalently, and both routes converge for valid U+FFFD; the default encoder also hides the difference by escaping non-ASCII. Preserve bytes by letting the writer transcode text-bearing kinds.
Make span formatting primary.
ToCanonicalStringstack-formats bounded values and creates one string from the written slice, but retains allocation-free fast paths: literals forNull/Boolean, stored references forString/Uri. Share literals asprivate const string; both span encodings write the same constant through the ASCII helper—do not add separateu8literals.Likewise, validator
FormatXxxhelpers stack-format and compare withMemoryExtensions.SequenceEqual; replaceTryGetInt64's inline invariantToStringtoo.Serializer adoption
The rule: the writer performs any UTF-16 transcoding itself; serializers hand it UTF-8 only for kinds whose canonical text is ASCII, where the two encodings cannot disagree.
MetadataExtensions.WriteNumberValuestack-formatsDouble/Singleas UTF-8 and callsWriteRawValue(ReadOnlySpan<byte>, skipInputValidation: true), completing Add UTF-8 floating-point formatting #61.WriteMetadataValuestack-writes ASCII-bounded string shapes (date kinds,TimeSpan,Guid,UInt64) in either encoding.String/Uripass their stored string to the writer;Charuses UTF-16 so the writer owns surrogate handling.JsonCloudEventsExtensions.WriteStringAttributeremains UTF-16, switches its stack size to the public constant, and retains fallback. CloudEvents rejects unpaired surrogates upstream.HttpHeaderValueFormatterremains unchanged because returningStringValuesinherently requires strings.Benchmarks and tests
Current write fixtures contain only
FromString, so their allocation delta is necessarily zero. AddGuid,DateTime,Double, and anInt64outside the inclusive 32-bit range:SerializeInCloudEventsExtensionAttributes; the range matters because an in-rangeInt64uses numericIntegerencoding and bypassesWriteStringAttribute.SerializeInHttpResponseBody; do not claim a header improvement, becauseStringValuesstill materializes text.Test canonical behavior sociably through
MetadataValue, using directCanonicalTextFormattertests only for inaccessible inputs such as LocalDateTimeand invalid raw date/time ranges. Derive expected text from the same frameworkToStringcalls used before this change; derive UTF-8 expectations from those strings (ASCII where applicable), as in #61.Cover each kind's minimum/maximum, zero/negative zero, decimal scale,
TimeSpan.MinValue/MaxValue/Zeroand component omission, fractions present/absent for date/time values, and both storableDateTimeKindvalues. For text kinds cover non-ASCII, surrogate pairs, and unpaired surrogates in both encodings. At writer level assert exact bytes with both JSON encoders; default-only coverage is insufficient because it hides the malformed-input divergence.Measure allocations with
GC.GetAllocatedBytesForCurrentThreadaround warmed loops for every affected kind and encoding, plus the four allocation-freeToCanonicalStringkinds. Extend this to validators usingString-kind canonical input; typed values return early and would miss the old allocation. Warm-up hides lazy initialization, hence the allocation-free-construction requirement above.All new methods have
outparameters and therefore fall into Stryker Safe Mode's documented compile-failure blind spot. Mutation score cannot assess them; the pull request must manually map each formatter contract to its constraining tests.Framework target-specific calls and any resulting optimization remain deferred. Provenance, release notes, benchmark results, build/package/AOT validation, and coverage are required by the acceptance criteria above.