Modernization: stack-overflow fix, frozen collections, drop net9.0, centralized build config - #370
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #370 +/- ##
==========================================
- Coverage 98.01% 98.00% -0.01%
==========================================
Files 39 39
Lines 52792 52798 +6
Branches 1097 1101 +4
==========================================
+ Hits 51743 51747 +4
- Misses 796 797 +1
- Partials 253 254 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
📊 Benchmark Results
PR branch
|
| Method | PhoneNumberCount | Mean | Error | StdDev | Gen0 | Allocated |
|---|---|---|---|---|---|---|
| InputDigitPerKeystroke | 1000 | 4.391 ms | 0.0092 ms | 0.0086 ms | 54.6875 | 3.87 MB |
BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), Arm64 RyuJIT armv8.0-a
Job-AMQORM : .NET 10.0.10 (10.0.10, 10.0.1026.32716), Arm64 RyuJIT armv8.0-a
Runtime=.NET 10.0 InvocationCount=1 IterationCount=20
LaunchCount=1 RunStrategy=ColdStart UnrollFactor=1
WarmupCount=1
| Method | Mean | Error | StdDev | Allocated |
|---|---|---|---|---|
| CreateInstance | 322.5 μs | 98.04 μs | 112.9 μs | 75.05 KB |
| CreateInstanceAndLoadAllRegions | 6,927.2 μs | 338.03 μs | 389.3 μs | 1575.98 KB |
| FirstRegionLookup | 368.7 μs | 129.30 μs | 148.9 μs | 80.11 KB |
BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), Arm64 RyuJIT armv8.0-a
.NET 10.0 : .NET 10.0.10 (10.0.10, 10.0.1026.32716), Arm64 RyuJIT armv8.0-a
Job=.NET 10.0 Runtime=.NET 10.0
| Method | PhoneNumberCount | Mean | Error | StdDev | Gen0 | Allocated |
|---|---|---|---|---|---|---|
| ExtractPossibleNumber_CleanInput | 1000 | 21.15 μs | 0.015 μs | 0.014 μs | - | - |
| ExtractPossibleNumber_WithLeadingJunk | 1000 | 38.29 μs | 0.032 μs | 0.028 μs | 0.6714 | 48360 B |
BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), Arm64 RyuJIT armv8.0-a
.NET 10.0 : .NET 10.0.10 (10.0.10, 10.0.1026.32716), Arm64 RyuJIT armv8.0-a
Job=.NET 10.0 Runtime=.NET 10.0
| Method | PhoneNumberCount | Mean | Error | StdDev | Gen0 | Allocated |
|---|---|---|---|---|---|---|
| FindNumbers_Valid | 100 | 138.8 μs | 0.22 μs | 0.18 μs | 0.9766 | 69.93 KB |
| FindNumbers_StrictGrouping | 100 | 306.9 μs | 0.69 μs | 0.65 μs | 1.4648 | 123.2 KB |
BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), Arm64 RyuJIT armv8.0-a
.NET 10.0 : .NET 10.0.10 (10.0.10, 10.0.1026.32716), Arm64 RyuJIT armv8.0-a
Job=.NET 10.0 Runtime=.NET 10.0
| Method | PhoneNumberCount | Mean | Error | StdDev | Gen0 | Allocated |
|---|---|---|---|---|---|---|
| ParseValidateAndFormatPhoneNumbers | 1000 | 2.548 ms | 0.0065 ms | 0.0061 ms | 7.8125 | 580.51 KB |
| ParseValidateAndFormatPhoneNumbers | 10000 | 25.705 ms | 0.2723 ms | 0.2547 ms | 62.5000 | 5798.53 KB |
wmundev
approved these changes
Aug 5, 2026
wmundev
left a comment
Collaborator
There was a problem hiding this comment.
nice changes, looks good!
This was referenced Aug 14, 2026
Closed
Closed
This was referenced Aug 23, 2026
This was referenced Aug 24, 2026
Closed
twcclegg
added a commit
that referenced
this pull request
Aug 26, 2026
…MapFromString
5 cases used a leftover try { ...; Assert.True(false); } catch
(Exception) { } pattern where every other case in the same method
already uses the cleaner Assert.Throws<Exception>(() => ...) idiom for
the exact same kind of assertion. Made these 5 consistent with the
rest (cs/catch-of-all-exceptions, alerts #279-#283).
Left TestEquals_WhenNull_ReturnsFalse alone (cs/null-argument-to-equals,
alert #370): MetadataFilter.Equals uses `obj is not MetadataFilter
other` pattern matching, which handles a null argument safely (no
NRE) - the test is correctly verifying that exact contract, not an
accidental risky Equals(null) call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This was referenced Aug 27, 2026
Closed
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thirteen commits, worst-first. Measured on the parse path: ~2% faster
(
ParseValidateAndFormat−1.6% at 1000 numbers, −2.6% at 10000, both sides measured on the samerunner), costing +18.25 KB one-time allocation per
PhoneNumberUtilwith per-parse allocationunchanged.
Correctness
fix: stop oversized input overflowing the stack in Normalize—Normalize,NormalizeDigitsOnly,NormalizeDiallableCharsOnlyandConvertAlphaCharactersInNumberdidstackalloc char[number.Length]on caller-supplied strings with no length guard, so a large inputkilled the process with an uncatchable
StackOverflowException.ParsehasMAX_INPUT_STRING_LENGTH = 250; these four public statics had nothing. Buffers over 256 chars nowcome from
ArrayPool. The buffer stays sliced to exactlynumber.Length— the normalizers rely onrunning out of room to drop characters whose numeric value needs more digits than the character it
came from. netstandard2.0 uses
StringBuilderand was never affected.fix: return a copy from GetSupportedRegions and freeze the region sets— the getter returnedthe instance's own
HashSet<string>, andPhoneNumberUtilis a process-wide singleton, so anycaller could
Remove()a region and break validation for the rest of the process. Java returnsCollections.unmodifiableSethere. The return type can't change without a binary break (andIReadOnlySetdoesn't exist on netstandard2.0), so it returns a copy; the method has no internalcallers. That also unblocked freezing
supportedRegionsandnanpaRegions.fix: stop parallel inner builds racing on clean of the generated bins—dotnet cleanon across-targeting project dispatches Clean to each inner TFM build, so three
RemoveDircalls racedon one shared
obj/metadataand failed with MSB3231. Pre-existing; nothing in CI randotnet cleanuntil the determinism step below. Both clean targets now run on the outer build only.fix: copy the region code map on all targets and reject unknown normalize modes— self-reviewfixes: the frozen-map change had made net8+ copy the constructor's dictionary while netstandard2.0
kept aliasing it, and the mode switch would have silently returned
""for an unhandled case.Performance
perf: use frozen collections for the short number and timezone lookups—FrozenSetforShortNumbersRegionCodeSetandFrozenDictionaryfor the timezone map on net8+, plusContainsKeyTryGetValueinLookUpPrefix. That loop runs once per digit, so it wasup to ~24 probes of an
ImmutableDictionaryper query; theTryGetValuehalf helps every TFM.perf: freeze the country calling code to region code map— int keys, ~215 entries, read onevery parse (
ContainsKeyup to 3× inMaybeExtractCountryCode, plusTryGetValueinIsValidNumber,GetRegionCodeForCountryCode,HasValidCountryCallingCode). Behind a file-scopedtype alias so the per-target difference sits in one place.
perf: size the normalize stack buffer to the input—stackalloczero-initializes, so thefixed 256-char buffer from the first commit cleared 512 bytes on every call where the original
cleared only
number.Length * 2.Build and CI
chore: drop the out-of-support net9.0 target— there are zeroNET8_0/NET9_0/NET10_0conditionals, so those three assets compiled from identical source. .NET 9 left support 2026-05-12;
net9 consumers resolve the net8.0 asset. Cuts ~25% off build and the test matrix.
chore: centralize build settings and package versions—Directory.Build.propsplus CentralPackage Management, collapsing dependabot's seven nuget entries to one.
LangVersiongoespreview→latestso an SDK bump can't change the semantics of already-shipped code. Both demoworkflows'
paths:filters gained the props files, or they'd inherit settings no PR job builds.chore: keep nuget audit findings from failing the build—NU1901–NU1904become warningsand
NuGetAuditMode=allcovers transitives. Otherwise a newly published advisory against thenetstandard2.0-only deps fails the release build days after a commit that touched nothing.
build: pin the sdk major and verify builds are reproducible—global.jsonpins .NET 10 withallowPrerelease: false. New CI step packs, cleans, packs again and compares assembly hashes;cleaning also drops the generated metadata bins, so it covers MetadataBuilder's output too.
ci: benchmark the base commit on the same runner as the branch— the PR base is checked into aworktree and measured in the same job. The cached baseline came from a different machine, and
cross-machine variance is a few percent — the same size as the effects being measured, which is why
the previous run reported the two
ParseValidateAndFormatsizes moving in opposite directions.Removing the cache also removes the cron that existed only to keep it inside GitHub's 7-day eviction
window. ~10 min per PR instead of ~5. Missing base results now fail the job rather than posting a
one-sided comment.
refactor: guard nullable annotations on netstandard2.0 rather than a net version— six#if NET6_0_OR_GREATERblocks only selected betweenT?andTsignatures, tracking the csproj's<Nullable>condition rather than any API floor. They now say!NETSTANDARD2_0, which is what thecsproj actually tests. The three NET6 guards that do gate real APIs (
string.Concat(ReadOnlySpan),StringBuilder.Append(StringBuilder, int, int),HashSet<T>(int)) are unchanged, as are the NET7[GeneratedRegex]and NET8 Frozen guards.Reviewer notes
ExtractPossibleNumber_CleanInputmoved ±4% between runs despite this PR not touching it — code layout and JIT effects. Read the
parse result as credible because both input sizes agree in direction and magnitude, not because a
single delta cleared its error bars. The
ColdStart*benchmarks carry ±35% error and can'tsupport any conclusion.
GetRegionCodesForCountryCodestill returns the internalList<string>asIReadOnlyList<string>— safe unless a caller casts back, where Java usesunmodifiableList.Left alone because it is on an internal path (
PhoneNumberOfflineGeocoder.cs:137), so guardingit costs a wrapper allocation per geocode.
or filesystem enumeration order.
Deliberately not done
LocaleData.Data— nestedImmutableDictionary, two slow probes per geocode call, the largestread win left. Needs the change in
lib/DumpLocale.javarather than the generated file, andfreezing something that size is where construction cost bites. Separate PR so its numbers isolate.
AnalysisModeon the main library (weaker than the Extensions helper today) — will produce apile of CA warnings on the ported core, which
TreatWarningsAsErrorsturns into a build break.EmbeddedResourceglobs — MSBuild may already sort glob results; confirming needsa cross-machine comparison.
read for cosmetic gain.