Allow configuring global default quantities#1692
Conversation
Extracted from #1544 (#1544) because choosing the global quantity catalog is an independent setup capability, not a requirement of fractional QuantityValue. Keeping it separate makes the singleton initialization and compatibility implications explicit and allows this commit to be deferred without blocking the isolated factory APIs. Changes: - Lazily create UnitsNetSetup.Default from the existing setup builder. - Add ConfigureDefaults for selecting the global catalog before first use. - Synchronize configuration and creation so concurrent first access cannot observe a partially configured builder. - Reject configuration after the singleton has been created. Tests: - Add a dedicated test assembly so global singleton state is isolated from the existing suite. - Verify configured catalog selection, default cache/parser wiring, excluded units, and rejection of reconfiguration. - Run the regression on net10.0 and net48 (1 test per target).
ReviewNice, focused PR — the singleton/thread-safety reasoning is careful and the Breaking changesNone. Style/conventions
Generated codeNo generator or generated-code changes in this PR — out of scope for that part of the review. Code quality / potential issues
Test coverage
PerformanceNo concerns — the lock/lazy overhead only applies to first access of SecurityNone identified. |
Run the isolated global setup test in net48 CI and clarify the lazy locking, API discoverability, and process-wide test constraint.
Run the default-first scenario in a dedicated test process so the global singleton state is isolated, including on net48 CI.
Review: Allow configuring global default quantities (#1692)Solid, well-scoped change with good isolated test coverage for the singleton edge cases. One concurrency issue worth fixing before merge; a few smaller notes below. 🐛 Potential deadlock in
|
|
Review: Allow configuring global default quantities (PR #1692) Reviewed against AGENTS.md conventions. Small, well-scoped PR (extracted from #1544). POTENTIAL DEADLOCK (highest priority): In UnitsNet/CustomCode/UnitsNetSetup.cs, BuildDefault() acquires DefaultConfigurationLock from inside the Lazy factory callback, i.e. while the Lazy instance's own internal execution lock is already held. Meanwhile ConfigureDefaults() acquires DefaultConfigurationLock first, then at the end calls DefaultConfiguration.Value, which needs to enter that same Lazy internal lock. Scenario: Thread A calls UnitsNetSetup.Default for the first time (or any of the many forwarding call sites such as Quantity.Parse, UnitAbbreviationsCache.Default, UnitParser.Default). This enters Lazy.Value, acquiring the Lazy internal lock, then calls BuildDefault(), which blocks waiting for DefaultConfigurationLock. Concurrently, Thread B calls ConfigureDefaults(...): it acquires DefaultConfigurationLock, runs the user callback, sets _defaultConfigurationBuilder, then calls DefaultConfiguration.Value, which blocks waiting for the Lazy internal lock held by Thread A. Thread A now waits on a lock held by B, and B waits on a lock held by A: deadlock. This is exactly the race ConfigureDefaults is meant to guard against (the IsValueCreated checks exist specifically to reject configuration after first use), so it is a realistic startup race rather than a purely theoretical one, e.g. one thread starts formatting or parsing a quantity while another thread is still calling ConfigureDefaults during app initialization. Possible fix directions: avoid calling DefaultConfiguration.Value while still holding DefaultConfigurationLock inside ConfigureDefaults, or avoid re-entering a lock inside BuildDefault at all (e.g. use Volatile.Read/Volatile.Write on the builder field instead of a full lock, since Lazy already guarantees single execution of the factory). OTHER OBSERVATIONS: Correctness (non-blocking path): the double IsValueCreated check in ConfigureDefaults, before and after invoking the caller's configuration callback, correctly catches reentrant calls to Default from within that callback (via the reentrant lock keyword), which would otherwise silently return a stale value built from the old builder. Good design, just undermined by the deadlock above. Behavior change (minor, non-breaking): switching from a static constructor to Lazy changes failure semantics subtly. Previously a failure during static init raised TypeInitializationException and poisoned all static members of the type; now only Default itself fails repeatedly (Lazy caches exceptions by default) while Create and ConfigureDefaults remain usable. Likely a positive change, but worth calling out as intentional since it changes exception type and scope for anyone currently catching TypeInitializationException. Test coverage: good use of two separate test assemblies, UnitsNet.GlobalSetup.Tests and UnitsNet.GlobalSetup.DefaultFirst.Tests, to work around Default being a permanent process-wide singleton that cannot be reset between tests. The XML doc remarks explaining why each assembly has a single test is a nice touch. Missing: no coverage for the concurrent-access race described above (understandably hard to make a reliable, non-flaky test for, but worth a comment or follow-up issue), and no test for ConfigureDefaults where the configuration callback itself throws. Style and conventions: XML docs, exception types, and the builder pattern match existing conventions in the file. The new csproj files match the boilerplate of sibling test projects such as UnitsNet.NumberExtensions.Tests. The net48-compatibility.yml workflow was correctly updated to include both new test projects. Breaking changes: none in the public API. ConfigureDefaults is purely additive, and the Default property's signature is unchanged. Generated code and new quantities: not applicable, this PR does not touch Common/UnitDefinitions or generated code. Overall this is a well-designed, minimal API addition with a good test isolation strategy. The deadlock is the one issue worth resolving, or explicitly ruling out with reasoning, before merging. |
Review: Allow configuring global default quantities (#1692)Scope: this PR only touches Breaking changesNone. Potential bug: race window between
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1692 +/- ##
======================================
- Coverage 96% 96% -1%
======================================
Files 455 455
Lines 29540 29553 +13
======================================
+ Hits 28502 28514 +12
- Misses 1038 1039 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Run both isolated global setup test projects in the primary test pipeline and cover the ConfigureDefaults null guard.
Extracted from #1544 because choosing the global quantity catalog is an independent setup capability, not a requirement of fractional QuantityValue. Keeping it separate makes the singleton initialization and compatibility implications explicit and allows this commit to be deferred without blocking the isolated factory APIs.
Changes:
Tests: