Make InternalUnitDefinition.round thread-safe#1669
Merged
Conversation
The shared static DecimalFormat used by round() is not thread-safe.
Concurrent calls (background math-mapping refresh + UI work) raced
through its internal DigitList and produced character-level
interleavings of two concurrent format outputs, e.g. "11E.11-83E-83"
where two threads each formatted ~"1E-83". Double.parseDouble then
threw NumberFormatException, which the catch (ParseException) didn't
catch, so it escaped through StructureAnalyzer.refresh up to
SimulationWorkspaceModelInfo.populateDataSymbolMetadata, which the
user saw as "Failed to determine metadata for data symbols". This
was the most common surface failure in a recent support-email
corpus (52 of ~400 traces, 7 distinct user incidents).
Replace the format/parse round-trip with a stateless, locale-fixed
implementation:
Double.parseDouble(String.format(Locale.US, "%.12e", value))
%.12e produces 12 fraction digits in scientific notation matching
the previous DecimalFormat pattern's #0.0#E0# with
setMaximumFractionDigits(12). Special values (NaN, infinities) are
short-circuited.
Removes the static numberFormatForRounding field and its
configuration line in the static initializer; no other references
exist.
Adds InternalUnitDefinitionRoundTest with three @tag("Fast") tests:
- ordinary values: idempotent and within 1e-11 relative error
- special values: NaN, infinities, signed zeros preserved
- concurrent access: 16 threads x 5000 iterations with no failures
(regression test for the race condition)
All 6545 existing vcell-math fast tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
danv61
approved these changes
Apr 30, 2026
Contributor
danv61
left a comment
There was a problem hiding this comment.
Good catch! Nice tests, especially the multi-threaded stress test.
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.
Summary
InternalUnitDefinition.round(double)shared a single staticDecimalFormatinstance across all callers.DecimalFormatis documented as not thread-safe — its internalDigitListfield is mutated during bothformat()andparse(). Concurrent calls (background math-mapping refresh + UI work) raced through that state and produced character-level interleavings of two threads' format outputs, e.g."11E.11-83E-83"(="1E-83"formatted twice with digits interleaved).Double.parseDoublethen threwNumberFormatException, which thecatch (ParseException)did not catch, so it escaped throughStructureAnalyzer.refreshand surfaced to the user as "Failed to determine metadata for data symbols" when opening the ODE data viewer.This was the most common surface failure in a recent corpus of user-submitted support emails: 52 of ~400 traces, across 7 distinct user incidents.
Fix
Replace the format/parse round-trip with a stateless, locale-fixed implementation:
%.12eproduces 12 fraction digits in scientific notation, matching the previousDecimalFormatpattern#0.0#E0#withsetMaximumFractionDigits(12). Special values (NaN, infinities) are short-circuited.Also removes the now-unused static
numberFormatForRoundingfield and its configuration line in the static initializer. No other code references it.Evidence the user-visible failure messages were genuine corruption (not bad input)
From the support-email corpus, distinct
NumberFormatExceptionmessages observed across 5 independent user incidents:Each is a different interleaving of two concurrent format outputs — independent races, not one bad value.
Test plan
InternalUnitDefinitionRoundTest(@Tag("Fast")) with three tests:NaN, ±infinities, ±0.0 preservedround()with noRuntimeExceptionthrown (regression test for the race)vcell-mathFasttests still passmvn compile test-compile -pl vcell-core -amsucceeds (no downstream breakage)Note on related work
There's a second cluster in the same support-email corpus —
MathSymbolMapping.putthrowing NPE insideTreeMap.fixAfterInsertion(7 incidents) — that is suspected to be another concurrency issue. That's not addressed here; if confirmed it should be a separate fix.🤖 Generated with Claude Code