Skip to content

Commit 22e1c0f

Browse files
Fix concurrency bug in NamespacedHierarchicalStore.computeIfAbsent
`computeIfAbsent` previously invoked `defaultCreator` while holding the store's internal map lock. Under parallel execution this could cause threads using the store to block each other and temporarily see missing or incorrectly initialized state for values created via `computeIfAbsent`. The implementation now wraps `defaultCreator` in a `MemoizingSupplier` and installs that supplier via the map operation, evaluating it only after the update has completed. This avoids running user code while holding the lock and aligns `computeIfAbsent`'s behavior with the deprecated `getOrComputeIfAbsent`, preserving the intended "one initialization per key" semantics. Issue: #5171 Signed-off-by: martinfrancois <f.martin@fastmail.com>
1 parent 500daea commit 22e1c0f

File tree

3 files changed

+161
-8
lines changed

3 files changed

+161
-8
lines changed

documentation/modules/ROOT/partials/release-notes/release-notes-6.0.2.adoc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ repository on GitHub.
1717
==== Bug Fixes
1818

1919
* Make `ConsoleLauncher` compatible with JDK 26 by avoiding final field mutations.
20+
* Fix a concurrency bug in `NamespacedHierarchicalStore.computeIfAbsent` where
21+
the `defaultCreator` function was executed while holding the store's internal
22+
map lock. Under parallel execution, this could cause threads using the store to
23+
block each other and temporarily see a missing or incorrectly initialized state
24+
for values created via `computeIfAbsent`. The method now evaluates
25+
`defaultCreator` outside the critical section using a memoizing supplier,
26+
aligning its behavior with the deprecated `getOrComputeIfAbsent`.
2027

2128
[[v6.0.2-junit-platform-deprecations-and-breaking-changes]]
2229
==== Deprecations and Breaking Changes

junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ public void close() {
237237
* closed
238238
* @since 6.0
239239
*/
240+
@SuppressWarnings("ReferenceEquality")
240241
@API(status = MAINTAINED, since = "6.0")
241242
public <K, V> Object computeIfAbsent(N namespace, K key, Function<? super K, ? extends V> defaultCreator) {
242243
Preconditions.notNull(defaultCreator, "defaultCreator must not be null");
@@ -245,18 +246,21 @@ public <K, V> Object computeIfAbsent(N namespace, K key, Function<? super K, ? e
245246
var result = StoredValue.evaluateIfNotNull(storedValue);
246247
if (result == null) {
247248
StoredValue newStoredValue = this.storedValues.compute(compositeKey, (__, oldStoredValue) -> {
248-
if (StoredValue.evaluateIfNotNull(oldStoredValue) == null) {
249-
rejectIfClosed();
250-
var computedValue = Preconditions.notNull(defaultCreator.apply(key),
251-
"defaultCreator must not return null");
252-
return newStoredValue(() -> {
249+
if (oldStoredValue == null || oldStoredValue == storedValue) {
250+
return newStoredValue(new MemoizingSupplier(() -> {
253251
rejectIfClosed();
254-
return computedValue;
255-
});
252+
return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null");
253+
}));
256254
}
257255
return oldStoredValue;
258256
});
259-
return requireNonNull(newStoredValue.evaluate());
257+
try {
258+
return requireNonNull(newStoredValue.evaluate());
259+
}
260+
catch (Throwable t) {
261+
this.storedValues.remove(compositeKey, newStoredValue);
262+
throw t;
263+
}
260264
}
261265
return result;
262266
}

platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
import static org.mockito.Mockito.verifyNoMoreInteractions;
2727

2828
import java.util.List;
29+
import java.util.concurrent.CountDownLatch;
30+
import java.util.concurrent.TimeUnit;
31+
import java.util.concurrent.atomic.AtomicBoolean;
2932
import java.util.concurrent.atomic.AtomicInteger;
3033
import java.util.function.Function;
3134

@@ -416,6 +419,115 @@ void simulateRaceConditionInComputeIfAbsent() throws Exception {
416419
assertEquals(1, counter.get());
417420
assertThat(values).hasSize(threads).containsOnly(1);
418421
}
422+
423+
@SuppressWarnings("deprecation")
424+
@Test
425+
void getOrComputeIfAbsentDoesNotDeadlockWithCollidingKeys() throws Exception {
426+
try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
427+
var firstComputationStarted = new CountDownLatch(1);
428+
var secondComputationAllowedToFinish = new CountDownLatch(1);
429+
var firstThreadTimedOut = new AtomicBoolean(false);
430+
431+
Thread first = new Thread(
432+
() -> localStore.getOrComputeIfAbsent(namespace, new CollidingKey("k1"), __ -> {
433+
firstComputationStarted.countDown();
434+
try {
435+
if (!secondComputationAllowedToFinish.await(200, TimeUnit.MILLISECONDS)) {
436+
firstThreadTimedOut.set(true);
437+
}
438+
}
439+
catch (InterruptedException e) {
440+
Thread.currentThread().interrupt();
441+
}
442+
return "value1";
443+
}));
444+
445+
Thread second = new Thread(() -> {
446+
try {
447+
firstComputationStarted.await();
448+
}
449+
catch (InterruptedException e) {
450+
Thread.currentThread().interrupt();
451+
}
452+
localStore.getOrComputeIfAbsent(namespace, new CollidingKey("k2"), __ -> {
453+
secondComputationAllowedToFinish.countDown();
454+
return "value2";
455+
});
456+
});
457+
458+
first.start();
459+
second.start();
460+
461+
first.join(1000);
462+
second.join(1000);
463+
464+
assertThat(firstThreadTimedOut).as(
465+
"getOrComputeIfAbsent should not block subsequent computations on colliding keys").isFalse();
466+
}
467+
}
468+
469+
@Test
470+
void computeIfAbsentCanDeadlockWithCollidingKeys() throws Exception {
471+
try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
472+
var firstComputationStarted = new CountDownLatch(1);
473+
var secondComputationAllowedToFinish = new CountDownLatch(1);
474+
var firstThreadTimedOut = new AtomicBoolean(false);
475+
476+
Thread first = new Thread(() -> localStore.computeIfAbsent(namespace, new CollidingKey("k1"), __ -> {
477+
firstComputationStarted.countDown();
478+
try {
479+
if (!secondComputationAllowedToFinish.await(200, TimeUnit.MILLISECONDS)) {
480+
firstThreadTimedOut.set(true);
481+
}
482+
}
483+
catch (InterruptedException e) {
484+
Thread.currentThread().interrupt();
485+
}
486+
return "value1";
487+
}));
488+
489+
Thread second = new Thread(() -> {
490+
try {
491+
firstComputationStarted.await();
492+
}
493+
catch (InterruptedException e) {
494+
Thread.currentThread().interrupt();
495+
}
496+
localStore.computeIfAbsent(namespace, new CollidingKey("k2"), __ -> {
497+
secondComputationAllowedToFinish.countDown();
498+
return "value2";
499+
});
500+
});
501+
502+
first.start();
503+
second.start();
504+
505+
first.join(1000);
506+
second.join(1000);
507+
508+
assertThat(firstThreadTimedOut).as(
509+
"computeIfAbsent should not block subsequent computations on colliding keys").isFalse();
510+
}
511+
}
512+
513+
@Test
514+
void computeIfAbsentOverridesParentNullValue() {
515+
// computeIfAbsent must treat a null value from the parent store as logically absent,
516+
// so the child store can install and keep its own non-null value for the same key.
517+
try (var parent = new NamespacedHierarchicalStore<String>(null);
518+
var child = new NamespacedHierarchicalStore<String>(parent)) {
519+
520+
parent.put(namespace, key, null);
521+
522+
assertNull(parent.get(namespace, key));
523+
assertNull(child.get(namespace, key));
524+
525+
Object childValue = child.computeIfAbsent(namespace, key, __ -> "value");
526+
527+
assertEquals("value", childValue);
528+
assertEquals("value", child.get(namespace, key));
529+
}
530+
}
419531
}
420532

421533
@Nested
@@ -663,6 +775,36 @@ private void assertClosed() {
663775

664776
}
665777

778+
private static final class CollidingKey {
779+
780+
private final String value;
781+
782+
private CollidingKey(String value) {
783+
this.value = value;
784+
}
785+
786+
@Override
787+
public int hashCode() {
788+
return 42;
789+
}
790+
791+
@Override
792+
public boolean equals(Object obj) {
793+
if (this == obj) {
794+
return true;
795+
}
796+
if (!(obj instanceof CollidingKey other)) {
797+
return false;
798+
}
799+
return this.value.equals(other.value);
800+
}
801+
802+
@Override
803+
public String toString() {
804+
return this.value;
805+
}
806+
}
807+
666808
private static Object createObject(String display) {
667809
return new Object() {
668810

0 commit comments

Comments
 (0)