Skip to content

Commit 4207b3a

Browse files
Address pull request comments
Signed-off-by: martinfrancois <f.martin@fastmail.com>
1 parent 87d7037 commit 4207b3a

File tree

2 files changed

+227
-9
lines changed

2 files changed

+227
-9
lines changed

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ 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`.
20+
* Fix a concurrency issue in `NamespacedHierarchicalStore.computeIfAbsent` where
21+
`defaultCreator` was executed while holding the internal map's bucket lock,
22+
causing threads accessing different keys in the same hash bucket to block each
23+
other during parallel test execution
24+
(see link:{junit-framework-repo}+/issues/5171+[issue #5171] and
25+
link:https://github.com/assertj/assertj/issues/1996[assertj/assertj#1996]).
26+
* `NamespacedHierarchicalStore.computeIfAbsent` no longer deadlocks when
27+
`defaultCreator` accesses other keys that collide in the same hash bucket.
2728

2829
[[v6.0.2-junit-platform-deprecations-and-breaking-changes]]
2930
==== Deprecations and Breaking Changes
@@ -43,7 +44,7 @@ repository on GitHub.
4344
==== Bug Fixes
4445

4546
* Allow using `@ResourceLock` on classes annotated with `@ClassTemplate` (or
46-
`@ParameterizedClass`).
47+
`@ParameterizedClass`).
4748

4849
[[v6.0.2-junit-jupiter-deprecations-and-breaking-changes]]
4950
==== Deprecations and Breaking Changes

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

Lines changed: 218 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.util.concurrent.TimeUnit;
3131
import java.util.concurrent.atomic.AtomicBoolean;
3232
import java.util.concurrent.atomic.AtomicInteger;
33+
import java.util.concurrent.atomic.AtomicReference;
3334
import java.util.function.Function;
3435

3536
import org.junit.jupiter.api.BeforeEach;
@@ -420,6 +421,109 @@ void simulateRaceConditionInComputeIfAbsent() throws Exception {
420421
assertThat(values).hasSize(threads).containsOnly(1);
421422
}
422423

424+
@Test
425+
void simulateRaceConditionInComputeIfAbsentWithCollidingKeys() throws Exception {
426+
// 20 threads: 10 will access key1, 10 will access key2
427+
int threads = 20;
428+
int threadsPerKey = threads / 2;
429+
430+
// Both keys have the same hashCode, forcing them into the same bucket
431+
var key1 = new CollidingKey("k1");
432+
var key2 = new CollidingKey("k2");
433+
var chooser = new AtomicInteger();
434+
435+
// Track how many times each key's defaultCreator is invoked
436+
var creatorCallsForKey1 = new AtomicInteger();
437+
var creatorCallsForKey2 = new AtomicInteger();
438+
439+
try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
440+
executeConcurrently(threads, () -> {
441+
// Alternate between key1 and key2
442+
CollidingKey key = (chooser.getAndIncrement() % 2 == 0 ? key1 : key2);
443+
444+
// Each key's value is an AtomicInteger counter
445+
AtomicInteger counter = (AtomicInteger) localStore.computeIfAbsent(namespace, key, __ -> {
446+
if (key.equals(key1)) {
447+
creatorCallsForKey1.incrementAndGet();
448+
}
449+
else {
450+
creatorCallsForKey2.incrementAndGet();
451+
}
452+
return new AtomicInteger();
453+
});
454+
455+
// Each thread increments the shared counter for its key
456+
counter.incrementAndGet();
457+
return 1;
458+
});
459+
460+
assertThat(creatorCallsForKey1.get()).as(
461+
"defaultCreator for key1 should be called exactly once").isEqualTo(1);
462+
assertThat(creatorCallsForKey2.get()).as(
463+
"defaultCreator for key2 should be called exactly once").isEqualTo(1);
464+
465+
AtomicInteger counter1 = (AtomicInteger) requireNonNull(localStore.get(namespace, key1));
466+
AtomicInteger counter2 = (AtomicInteger) requireNonNull(localStore.get(namespace, key2));
467+
assertThat(counter1.get()).as("all %d threads for key1 should have incremented the same counter",
468+
threadsPerKey).isEqualTo(threadsPerKey);
469+
assertThat(counter2.get()).as("all %d threads for key2 should have incremented the same counter",
470+
threadsPerKey).isEqualTo(threadsPerKey);
471+
}
472+
}
473+
474+
@Test
475+
void computeIfAbsentWithCollidingKeysDoesNotBlockConcurrentAccess() throws Exception {
476+
try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
477+
var key1ComputationStarted = new CountDownLatch(1);
478+
var key2ComputationStarted = new CountDownLatch(1);
479+
var key1Result = new AtomicReference<Object>();
480+
var key2Result = new AtomicReference<Object>();
481+
var key2WasBlocked = new AtomicBoolean(false);
482+
483+
Thread thread1 = new Thread(() -> {
484+
Object result = localStore.computeIfAbsent(namespace, new CollidingKey("key1"), __ -> {
485+
key1ComputationStarted.countDown();
486+
try {
487+
// Wait to ensure thread2 has a chance to start its computation
488+
if (!key2ComputationStarted.await(500, TimeUnit.MILLISECONDS)) {
489+
key2WasBlocked.set(true);
490+
}
491+
}
492+
catch (InterruptedException e) {
493+
Thread.currentThread().interrupt();
494+
}
495+
return "value1";
496+
});
497+
key1Result.set(result);
498+
});
499+
500+
Thread thread2 = new Thread(() -> {
501+
try {
502+
key1ComputationStarted.await(1, TimeUnit.SECONDS);
503+
}
504+
catch (InterruptedException e) {
505+
Thread.currentThread().interrupt();
506+
return;
507+
}
508+
Object result = localStore.computeIfAbsent(namespace, new CollidingKey("key2"), __ -> {
509+
key2ComputationStarted.countDown();
510+
return "value2";
511+
});
512+
key2Result.set(result);
513+
});
514+
515+
thread1.start();
516+
thread2.start();
517+
thread1.join(2000);
518+
thread2.join(2000);
519+
520+
assertThat(key1Result.get()).as("key1 result").isEqualTo("value1");
521+
assertThat(key2Result.get()).as("key2 result").isEqualTo("value2");
522+
assertThat(key2WasBlocked).as(
523+
"computeIfAbsent for key2 should not be blocked by key1's defaultCreator").isFalse();
524+
}
525+
}
526+
423527
@SuppressWarnings("deprecation")
424528
@Test
425529
void getOrComputeIfAbsentDoesNotDeadlockWithCollidingKeys() throws Exception {
@@ -467,7 +571,7 @@ void getOrComputeIfAbsentDoesNotDeadlockWithCollidingKeys() throws Exception {
467571
}
468572

469573
@Test
470-
void computeIfAbsentCanDeadlockWithCollidingKeys() throws Exception {
574+
void computeIfAbsentDoesNotDeadlockWithCollidingKeys() throws Exception {
471575
try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
472576
var firstComputationStarted = new CountDownLatch(1);
473577
var secondComputationAllowedToFinish = new CountDownLatch(1);
@@ -510,6 +614,119 @@ void computeIfAbsentCanDeadlockWithCollidingKeys() throws Exception {
510614
}
511615
}
512616

617+
@Test
618+
void getDoesNotSeeTransientExceptionFromComputeIfAbsent() throws Exception {
619+
try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
620+
var computeStarted = new CountDownLatch(1);
621+
var getCanProceed = new CountDownLatch(1);
622+
var computeCanThrow = new CountDownLatch(1);
623+
var exceptionSeenByGet = new AtomicBoolean(false);
624+
var getReturnedNull = new AtomicBoolean(false);
625+
626+
Thread computeThread = new Thread(() -> {
627+
try {
628+
localStore.computeIfAbsent(namespace, key, __ -> {
629+
computeStarted.countDown();
630+
try {
631+
// Wait for the get thread to be ready
632+
computeCanThrow.await(1, TimeUnit.SECONDS);
633+
}
634+
catch (InterruptedException e) {
635+
Thread.currentThread().interrupt();
636+
}
637+
throw new RuntimeException("boom");
638+
});
639+
}
640+
catch (RuntimeException expected) {
641+
// Expected - the exception should propagate back to this thread
642+
}
643+
finally {
644+
getCanProceed.countDown();
645+
}
646+
});
647+
648+
Thread getThread = new Thread(() -> {
649+
try {
650+
computeStarted.await(1, TimeUnit.SECONDS);
651+
// Signal compute thread to throw
652+
computeCanThrow.countDown();
653+
// Wait a brief moment for compute to throw and remove the entry
654+
getCanProceed.await(1, TimeUnit.SECONDS);
655+
// Now try to get the value
656+
Object result = localStore.get(namespace, key);
657+
if (result == null) {
658+
getReturnedNull.set(true);
659+
}
660+
}
661+
catch (RuntimeException e) {
662+
// If we see the exception, that's the bug we're testing for
663+
exceptionSeenByGet.set(true);
664+
}
665+
catch (InterruptedException e) {
666+
Thread.currentThread().interrupt();
667+
}
668+
});
669+
670+
computeThread.start();
671+
getThread.start();
672+
673+
computeThread.join(2000);
674+
getThread.join(2000);
675+
676+
assertThat(exceptionSeenByGet).as(
677+
"get() should not see transient exception from failed computeIfAbsent").isFalse();
678+
assertThat(getReturnedNull).as(
679+
"get() should return null after computeIfAbsent fails and removes entry").isTrue();
680+
}
681+
}
682+
683+
@Test
684+
void getConcurrentWithFailingComputeIfAbsentDoesNotSeeException() throws Exception {
685+
int iterations = 100;
686+
for (int i = 0; i < iterations; i++) {
687+
try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
688+
var computeStarted = new CountDownLatch(1);
689+
var exceptionSeenByGet = new AtomicBoolean(false);
690+
691+
Thread computeThread = new Thread(() -> {
692+
try {
693+
localStore.computeIfAbsent(namespace, key, __ -> {
694+
computeStarted.countDown();
695+
throw new RuntimeException("boom");
696+
});
697+
}
698+
catch (RuntimeException expected) {
699+
// Expected
700+
}
701+
});
702+
703+
Thread getThread = new Thread(() -> {
704+
try {
705+
computeStarted.await(100, TimeUnit.MILLISECONDS);
706+
// Try to observe the transient state
707+
localStore.get(namespace, key);
708+
}
709+
catch (RuntimeException e) {
710+
exceptionSeenByGet.set(true);
711+
}
712+
catch (InterruptedException e) {
713+
Thread.currentThread().interrupt();
714+
}
715+
});
716+
717+
computeThread.start();
718+
getThread.start();
719+
720+
computeThread.join(500);
721+
getThread.join(500);
722+
723+
assertThat(exceptionSeenByGet).as(
724+
"get() should not see transient exception from failed computeIfAbsent (iteration %d)",
725+
i).isFalse();
726+
}
727+
}
728+
}
729+
513730
@Test
514731
void computeIfAbsentOverridesParentNullValue() {
515732
// computeIfAbsent must treat a null value from the parent store as logically absent,

0 commit comments

Comments
 (0)