ICU-23403 Fix data race in CurrencyPrecision.withCurrency#3978
ICU-23403 Fix data race in CurrencyPrecision.withCurrency#3978Manishearth wants to merge 1 commit into
Conversation
85c657c to
6590255
Compare
|
Notice: the branch changed across the force-push!
~ Your Friendly Jira-GitHub PR Checker Bot |
|
I'm not sure how to best test this. Gemini generated the following complicated test Details @Test
public void testCurrencyPrecisionConcurrency() throws InterruptedException {
final Currency usd = Currency.getInstance("USD"); // 2 decimals (maps to FIXED_FRAC_2)
final CurrencyPrecision cp1 = Precision.currency(CurrencyUsage.STANDARD)
.trailingZeroDisplay(TrailingZeroDisplay.HIDE_IF_WHOLE);
final CurrencyPrecision cp2 = Precision.currency(CurrencyUsage.STANDARD)
.trailingZeroDisplay(TrailingZeroDisplay.AUTO);
int numThreads = 10;
final int iterations = 5000;
Thread[] threads = new Thread[numThreads];
final java.util.concurrent.atomic.AtomicInteger failures = new java.util.concurrent.atomic.AtomicInteger(0);
for (int i = 0; i < numThreads; i++) {
final int threadId = i;
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < iterations; j++) {
if (threadId % 2 == 0) {
Precision p = cp1.withCurrency(usd);
String formatted = NumberFormatter.with()
.precision(p)
.locale(Locale.US)
.unit(usd)
.format(123.00)
.toString();
// USD standard format for 123.00 is "$123.00".
// If HIDE_IF_WHOLE is working, it should be "$123".
if (!formatted.equals("$123")) {
failures.incrementAndGet();
}
} else {
Precision p = cp2.withCurrency(usd);
String formatted = NumberFormatter.with()
.precision(p)
.locale(Locale.US)
.unit(usd)
.format(123.00)
.toString();
// If AUTO is working, it should be "$123.00".
if (!formatted.equals("$123.00")) {
failures.incrementAndGet();
}
}
}
}
});
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
assertEquals("Concurrently configured precisions should not corrupt each other's formatting behavior", 0, failures.get());
} |
|
Thanks! Discussion:
|
This is not intended to be a mutable object. The problem is code setting a private field at a distance. |
This helps nothing. Freezable is an ICU4J abstraction that relies on code implementing it correctly. It does not protect against code from somewhere else in the program setting private fields. |
|
You don't need to look far to see that the field is designed to be private and immutable. I don't remember the reason they are package-private instead of private, and why they are not public abstract class Precision {
/* package-private final */ MathContext mathContext;
/* package-private final */ TrailingZeroDisplay trailingZeroDisplay; |
62b0118 to
1ba32a5
Compare
|
Notice: the branch changed across the force-push!
~ Your Friendly Jira-GitHub PR Checker Bot |
|
Tried to add a simple test. We already had a |
- Update Precision.trailingZeroDisplay to return `this` directly if the requested trailingZeroDisplay setting is already satisfied (treating null and AUTO as equivalent). - Update CurrencyPrecision.withCurrency to use trailingZeroDisplay. This ensures that we only clone the underlying shared static Precision instance (e.g. FIXED_FRAC_0) when trailingZeroDisplay differs, preventing data races while avoiding unnecessary allocations. - Add testCurrencyPrecisionCacheMutation to NumberFormatterApiTest.java to verify that modifying trailingZeroDisplay does not mutate shared static cache instances.
1ba32a5 to
763ae7c
Compare
|
Notice: the branch changed across the force-push!
~ Your Friendly Jira-GitHub PR Checker Bot |
| */ | ||
| public Precision trailingZeroDisplay(TrailingZeroDisplay trailingZeroDisplay) { | ||
| // In Precision, trailingZeroDisplay is null by default, which behaves identically | ||
| // to AUTO (see setResolvedMinFraction). Treat null and AUTO as equivalent to |
There was a problem hiding this comment.
the LLM noticed this null-auto correspondence, I verified it, btu I'm not 100% sure, this isn't my codebase
|
The test won't test against the concurrency issue but it ought to test against the underlying bug. Good enough. |
Clone the Precision object returned by constructFromCurrency before mutating its trailingZeroDisplay field. This prevents mutating shared static instances like FIXED_FRAC_0 or FIXED_FRAC_2 when multiple threads concurrently call withCurrency.
This was investigated and fixed by Gemini.
Checklist