Skip to content

Commit 7ef262c

Browse files
committed
Make arquillian-core thread-safe for parallel JUnit 5/6 class execution
Signed-off-by: Olivier Lamy <olamy@apache.org>
1 parent 2fd36cb commit 7ef262c

4 files changed

Lines changed: 57 additions & 60 deletions

File tree

container/impl-base/src/main/java/org/jboss/arquillian/container/impl/ContainerImpl.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ public class ContainerImpl<T extends ContainerConfiguration> implements Containe
6060

6161
private DeployableContainer<T> deployableContainer;
6262
private String name;
63-
private State state = State.STOPPED;
64-
private Throwable failureCause;
63+
private volatile State state = State.STOPPED;
64+
private volatile Throwable failureCause;
6565

6666
private ContainerDef containerConfiguration;
6767

core/impl-base/src/main/java/org/jboss/arquillian/core/impl/ManagerImpl.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.List;
2525
import java.util.Set;
2626
import java.util.concurrent.Callable;
27+
import java.util.concurrent.CopyOnWriteArrayList;
2728
import org.jboss.arquillian.core.api.Injector;
2829
import org.jboss.arquillian.core.api.annotation.ApplicationScoped;
2930
import org.jboss.arquillian.core.api.event.ManagerStarted;
@@ -75,8 +76,8 @@ protected Set<Class<? extends Throwable>> initialValue() {
7576
};
7677

7778
ManagerImpl(final Collection<Class<? extends Context>> contextClasses, final Collection<Class<?>> extensionClasses) {
78-
this.contexts = new ArrayList<Context>();
79-
this.extensions = new ArrayList<Extension>();
79+
this.contexts = new CopyOnWriteArrayList<Context>();
80+
this.extensions = new CopyOnWriteArrayList<Extension>();
8081
this.runtimeLogger = new RuntimeLogger();
8182

8283
try {
Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
package org.jboss.arquillian.core.spi;
22

3-
import java.util.Hashtable;
4-
53
/**
6-
* Mapping for ThreadId to a value. Same as "ThreadLocal", but with simpler cleanup.
7-
*
4+
* Thread-local value holder backed by a real {@link ThreadLocal}.
5+
* A prior implementation used a map keyed by thread id, which leaked stale
6+
* values when a pool recycled a thread's id.
87
*/
98
public class ArquillianThreadLocal<T> {
10-
private Hashtable<Long, T> table = new Hashtable<Long, T>();
9+
10+
private volatile ThreadLocal<T> delegate = newDelegate();
11+
12+
private ThreadLocal<T> newDelegate() {
13+
return new ThreadLocal<T>() {
14+
@Override
15+
protected T initialValue() {
16+
return ArquillianThreadLocal.this.initialValue();
17+
}
18+
};
19+
}
1120

1221
protected T initialValue() {
1322
return null;
@@ -22,38 +31,21 @@ protected T initialValue() {
2231
* @return the current thread's value of this thread-local
2332
*/
2433
public T get() {
25-
Thread t = Thread.currentThread();
26-
long threadId = t.getId();
27-
28-
if (table.containsKey(threadId)) {
29-
return table.get(threadId);
30-
}
31-
else {
32-
T value = initialValue();
33-
table.put(threadId, value);
34-
return value;
35-
}
34+
return delegate.get();
3635
}
3736

3837
/**
39-
* Removes the current thread's value for this thread-local
40-
* variable.
41-
*
38+
* Removes the current thread's value for this thread-local variable.
4239
*/
43-
public void remove() {
44-
Thread t = Thread.currentThread();
45-
long threadId = t.getId();
46-
47-
if (table.containsKey(threadId)) {
48-
table.remove(threadId);
49-
}
50-
}
40+
public void remove() {
41+
delegate.remove();
42+
}
5143

52-
/**
53-
* Clears the cache
54-
*/
55-
public void clear() {
56-
table.clear();
57-
}
44+
/**
45+
* Drops every thread's value by swapping in a fresh backing {@link ThreadLocal};
46+
* subsequent {@link #get()} calls go back through {@link #initialValue()}.
47+
*/
48+
public void clear() {
49+
delegate = newDelegate();
50+
}
5851
}
59-

junit5/core/src/main/java/org/jboss/arquillian/junit5/JUnitJupiterTestClassLifecycleManager.java

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,18 @@
44
import org.jboss.arquillian.test.spi.TestRunnerAdaptorBuilder;
55
import org.junit.jupiter.api.extension.ExtensionContext;
66

7-
import static org.jboss.arquillian.junit5.ContextStore.getContextStore;
8-
97
/**
10-
* Manages the lifecycle of JUnit Jupiter test classes within the Arquillian framework.
11-
* This class implements both {@link AutoCloseable} and {@link ExtensionContext.Store.CloseableResource}
12-
* for junit5 prior to 5.13.0 to ensure proper resource cleanup.
8+
* Owns the {@link TestRunnerAdaptor} for a single JUnit Jupiter test class.
139
*
14-
* <p>The manager is responsible for:</p>
15-
* <ul>
16-
* <li>Initializing and managing the Arquillian{@link TestRunnerAdaptor}</li>
17-
* <li>Handling test suite lifecycle events (beforeSuite, afterSuite)</li>
18-
* <li>Providing access to the test runner adaptor for test execution</li>
19-
* <li>Managing initialization failures and error handling</li>
20-
* </ul>
10+
* <p>The manager is cached in the root store under a {@link ExtensionContext.Namespace}
11+
* keyed by the test class, so each class gets its own {@code TestRunnerAdaptor}
12+
* and underlying {@code Manager}. This matters under parallel class execution:
13+
* a shared {@code Manager} would leak thread-local context activations between
14+
* classes running on different threads.</p>
2115
*
22-
* <p>This class uses a singleton pattern per test context, ensuring that only one
23-
* manager instance exists per test suite execution.</p>
16+
* <p>Implementing {@link ExtensionContext.Store.CloseableResource} lets JUnit
17+
* call {@link #close()} (and therefore {@code afterSuite}) when it tears down
18+
* the root context at the end of the run.</p>
2419
*/
2520

2621
public class JUnitJupiterTestClassLifecycleManager implements AutoCloseable,
@@ -35,14 +30,23 @@ private JUnitJupiterTestClassLifecycleManager() {
3530
}
3631

3732
static JUnitJupiterTestClassLifecycleManager getManager(ExtensionContext context) throws Exception {
38-
ExtensionContext.Store store = getContextStore(context).getRootStore();
39-
JUnitJupiterTestClassLifecycleManager instance = store.get(MANAGER_KEY, JUnitJupiterTestClassLifecycleManager.class);
40-
if (instance == null) {
41-
instance = new JUnitJupiterTestClassLifecycleManager();
42-
store.put(MANAGER_KEY, instance);
43-
instance.initializeAdaptor();
44-
}
45-
// no, initialization has been attempted before and failed, refuse
33+
ExtensionContext.Store store = context.getRoot().getStore(
34+
ExtensionContext.Namespace.create(
35+
JUnitJupiterTestClassLifecycleManager.class,
36+
context.getRequiredTestClass()));
37+
JUnitJupiterTestClassLifecycleManager instance = store.getOrComputeIfAbsent(
38+
MANAGER_KEY,
39+
key -> {
40+
JUnitJupiterTestClassLifecycleManager mgr = new JUnitJupiterTestClassLifecycleManager();
41+
try {
42+
mgr.initializeAdaptor();
43+
} catch (Exception e) {
44+
mgr.caughtInitializationException = e;
45+
}
46+
return mgr;
47+
},
48+
JUnitJupiterTestClassLifecycleManager.class);
49+
// initialization has been attempted before and failed, refuse
4650
// to do anything else
4751
if (instance.hasInitializationException()) {
4852
instance.handleSuiteLevelFailure();

0 commit comments

Comments
 (0)