Skip to content

Commit bcf955a

Browse files
committed
Refactor CompilationExtension to support all usage scenarios.
- Refactor so in absence of (before/after)All lifecycle events, it falls back on the (before/after)Each events. This supports @RegisterExtension and per-method @ExtendWith usage scenarios. - Slim down EvaluatingProcessor so it can either fail to the CompletableFuture or block on the Phaser, simplifying the exception path. - Extract all state in a dedicated object which can do checks on the state of the phaser before actions are performed. - Add tests for the compilation state and the various operations that synchronize it with the tests. - Test each of the different extension usage scenarios. - @ExtendWith on class - @ExtendWith on method - @RegisterExtension with Lifecycle.PER_CLASS - @RegisterExtension with Lifecycle.PER_METHOD
1 parent 033de57 commit bcf955a

File tree

2 files changed

+334
-125
lines changed

2 files changed

+334
-125
lines changed

src/main/java/com/google/testing/compile/CompilationExtension.java

Lines changed: 188 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
import com.google.common.collect.ImmutableMap;
2323
import com.google.common.collect.ImmutableSet;
2424
import com.google.common.util.concurrent.ThreadFactoryBuilder;
25+
import org.junit.jupiter.api.TestInstance;
2526
import org.junit.jupiter.api.extension.AfterAllCallback;
27+
import org.junit.jupiter.api.extension.AfterEachCallback;
2628
import org.junit.jupiter.api.extension.BeforeAllCallback;
2729
import org.junit.jupiter.api.extension.BeforeEachCallback;
2830
import org.junit.jupiter.api.extension.ExtendWith;
@@ -34,13 +36,15 @@
3436
import java.util.Map;
3537
import java.util.Set;
3638
import java.util.concurrent.CompletableFuture;
37-
import java.util.concurrent.CompletionStage;
38-
import java.util.concurrent.ExecutorService;
39+
import java.util.concurrent.ExecutionException;
40+
import java.util.concurrent.Executor;
3941
import java.util.concurrent.Executors;
4042
import java.util.concurrent.Phaser;
4143
import java.util.concurrent.TimeUnit;
44+
import java.util.concurrent.TimeoutException;
4245
import java.util.concurrent.atomic.AtomicReference;
4346
import java.util.function.Function;
47+
import java.util.function.Supplier;
4448
import javax.annotation.processing.AbstractProcessor;
4549
import javax.annotation.processing.ProcessingEnvironment;
4650
import javax.annotation.processing.RoundEnvironment;
@@ -67,20 +71,14 @@
6771
*
6872
* @author David van Leusen
6973
*/
70-
public class CompilationExtension
71-
implements BeforeAllCallback, BeforeEachCallback, AfterAllCallback, ParameterResolver {
74+
public class CompilationExtension implements BeforeAllCallback, BeforeEachCallback,
75+
AfterAllCallback, AfterEachCallback, ParameterResolver {
7276
private static final JavaFileObject DUMMY =
7377
JavaFileObjects.forSourceLines("Dummy", "final class Dummy {}");
7478
private static final ExtensionContext.Namespace NAMESPACE =
7579
ExtensionContext.Namespace.create(CompilationExtension.class);
7680

77-
private static final StoreAccessor<Phaser> PHASER_KEY = new StoreAccessor<>(Phaser.class);
78-
private static final StoreAccessor<AtomicReference<ProcessingEnvironment>> PROCESSINGENV_KEY =
79-
new StoreAccessor<>(ProcessingEnvironment.class);
80-
private static final StoreAccessor<CompletionStage<Compilation>> RESULT_KEY =
81-
new StoreAccessor<>(Compilation.class);
82-
83-
private static final ExecutorService COMPILER_EXECUTOR = Executors.newCachedThreadPool(
81+
private static final Executor DEFAULT_COMPILER_EXECUTOR = Executors.newCachedThreadPool(
8482
new ThreadFactoryBuilder().setDaemon(true).setNameFormat("async-compiler-%d").build()
8583
);
8684

@@ -93,68 +91,63 @@ public class CompilationExtension
9391
.build();
9492
}
9593

94+
private final Executor compilerExecutor;
95+
96+
public CompilationExtension(Executor compilerExecutor) {
97+
this.compilerExecutor = compilerExecutor;
98+
}
99+
100+
public CompilationExtension() {
101+
this(DEFAULT_COMPILER_EXECUTOR);
102+
}
103+
96104
@Override
97-
public void beforeAll(ExtensionContext context) throws Exception {
98-
final Phaser sharedBarrier = new Phaser(2) {
99-
@Override
100-
protected boolean onAdvance(int phase, int parties) {
101-
// Terminate the phaser once all parties have deregistered
102-
return parties == 0;
103-
}
104-
};
105-
106-
final AtomicReference<ProcessingEnvironment> sharedState
107-
= new AtomicReference<>(null);
108-
109-
final CompletionStage<Compilation> futureResult = CompletableFuture.supplyAsync(
110-
() -> {
111-
try {
112-
return Compiler.javac()
113-
.withProcessors(new EvaluatingProcessor(sharedBarrier, sharedState))
114-
.compile(DUMMY);
115-
} finally {
116-
sharedBarrier.forceTermination();
117-
}
118-
}, COMPILER_EXECUTOR);
119-
120-
final ExtensionContext.Store store = context.getStore(NAMESPACE);
121-
PHASER_KEY.put(store, sharedBarrier);
122-
PROCESSINGENV_KEY.put(store, sharedState);
123-
RESULT_KEY.put(store, futureResult);
124-
125-
// Wait until the processor is ready for testing, handle termination on error
126-
if (sharedBarrier.arriveAndAwaitAdvance() < 0) {
127-
// Rethrow the exception thrown by the compiler, otherwise throw based on the result.
128-
final Compilation result = futureResult.toCompletableFuture()
129-
.get(5, TimeUnit.SECONDS);
130-
throw new IllegalStateException(result.toString());
131-
}
105+
public void beforeAll(ExtensionContext context) throws InterruptedException {
106+
final CompilerState state = context.getStore(NAMESPACE).getOrComputeIfAbsent(
107+
CompilerState.class,
108+
ignored -> new CompilerState(this.compilerExecutor, TestInstance.Lifecycle.PER_CLASS),
109+
CompilerState.class
110+
);
111+
112+
checkState(state.prepareForTests(), state);
132113
}
133114

134115
@Override
135-
public void beforeEach(ExtensionContext extensionContext) {
136-
checkState(
137-
PHASER_KEY.get(extensionContext.getStore(NAMESPACE)) != null,
138-
"CompilationExtension is only available as a class-level extension. " +
139-
"Using it as an instance-level extension through @RegisterExtension is not supported"
116+
public void beforeEach(ExtensionContext context) throws InterruptedException {
117+
final CompilerState state = context.getStore(NAMESPACE).getOrComputeIfAbsent(
118+
CompilerState.class,
119+
ignored -> new CompilerState(this.compilerExecutor, TestInstance.Lifecycle.PER_METHOD),
120+
CompilerState.class
140121
);
122+
123+
checkState(state.prepareForTests(), state);
141124
}
142125

143126
@Override
144-
public void afterAll(ExtensionContext context) throws Exception {
145-
final ExtensionContext.Store store = context.getStore(NAMESPACE);
146-
final Phaser sharedPhaser = PHASER_KEY.get(store);
127+
public void afterEach(ExtensionContext context) throws Exception {
128+
final CompilerState state = checkNotNull(context.getStore(NAMESPACE).get(
129+
CompilerState.class,
130+
CompilerState.class
131+
));
147132

148-
// Allow the processor to finish
149-
sharedPhaser.arriveAndDeregister();
133+
if (state.getLifecycle() == TestInstance.Lifecycle.PER_METHOD) {
134+
// Created on a per-method basis, must clean up as a mirror action
135+
final Compilation compilation = state.allowTermination();
136+
checkState(compilation.status().equals(SUCCESS), compilation);
137+
}
138+
}
150139

151-
// Perform status checks, since processing is 'over' almost instantly
152-
final Compilation compilation = RESULT_KEY.get(store)
153-
.toCompletableFuture().get(1, TimeUnit.SECONDS);
154-
checkState(compilation.status().equals(SUCCESS), compilation);
140+
@Override
141+
public void afterAll(ExtensionContext context) throws ExecutionException, InterruptedException {
142+
final CompilerState state = checkNotNull(context.getStore(NAMESPACE).get(
143+
CompilerState.class,
144+
CompilerState.class
145+
));
146+
147+
checkState(state.getLifecycle() == TestInstance.Lifecycle.PER_CLASS);
155148

156-
// Check postcondition
157-
checkState(sharedPhaser.isTerminated(), "Phaser not terminated");
149+
final Compilation compilation = state.allowTermination();
150+
checkState(compilation.status().equals(SUCCESS), compilation);
158151
}
159152

160153
@Override
@@ -171,52 +164,144 @@ public Object resolveParameter(
171164
ParameterContext parameterContext,
172165
ExtensionContext extensionContext
173166
) throws ParameterResolutionException {
174-
final ExtensionContext.Store store = extensionContext.getStore(NAMESPACE);
175-
final AtomicReference<ProcessingEnvironment> processingEnvironment
176-
= PROCESSINGENV_KEY.get(store);
167+
final CompilerState state = extensionContext.getStore(NAMESPACE).get(
168+
CompilerState.class,
169+
CompilerState.class
170+
);
171+
172+
checkState(state != null, "CompilerState not initialized");
177173

178174
return SUPPORTED_PARAMETERS.getOrDefault(
179175
parameterContext.getParameter().getType(),
180176
ignored -> {
181177
throw new ParameterResolutionException("Unknown parameter type");
182178
}
183-
).apply(checkNotNull(
184-
processingEnvironment.get(),
185-
"ProcessingEnvironment not available: %s",
186-
RESULT_KEY.get(store)
187-
));
179+
).apply(state.getProcessingEnvironment());
188180
}
189181

190-
/**
191-
* Utility class to safely access {@link ExtensionContext.Store} when dealing with
192-
* parameterized types.
193-
*/
194-
static final class StoreAccessor<R> {
195-
private final Object key;
182+
static final class CompilerState implements ExtensionContext.Store.CloseableResource {
183+
private final AtomicReference<ProcessingEnvironment> sharedState;
184+
private final Phaser syncBarrier;
185+
private final CompletableFuture<Compilation> result;
186+
private final TestInstance.Lifecycle lifecycle;
187+
188+
CompilerState(Executor compilerExecutor, TestInstance.Lifecycle lifecycle) {
189+
this.lifecycle = lifecycle;
190+
this.sharedState = new AtomicReference<>(null);
191+
this.syncBarrier = new Phaser(2) {
192+
@Override
193+
protected boolean onAdvance(int phase, int parties) {
194+
// Terminate the phaser once all parties have deregistered
195+
return parties == 0;
196+
}
197+
};
198+
this.result = CompletableFuture.supplyAsync(
199+
new EvaluatingProcessor(syncBarrier, sharedState),
200+
compilerExecutor
201+
);
202+
}
203+
204+
ProcessingEnvironment getProcessingEnvironment() throws ParameterResolutionException {
205+
// Only while the phaser is in phase 1 should the ProcessingEnvironment be valid.
206+
if (this.syncBarrier.getPhase() != 1) {
207+
throw new ParameterResolutionException(this.toString());
208+
}
196209

197-
StoreAccessor(Object key) {
198-
this.key = key;
210+
final ProcessingEnvironment processingEnvironment = this.sharedState.get();
211+
if (processingEnvironment != null) {
212+
return processingEnvironment;
213+
} else {
214+
throw new ParameterResolutionException(
215+
String.format("ProcessingEnvironment was not initialized: %s", this)
216+
);
217+
}
199218
}
200219

201-
@SuppressWarnings("unchecked")
202-
R get(ExtensionContext.Store store) {
203-
return (R) store.get(key);
220+
TestInstance.Lifecycle getLifecycle() {
221+
return this.lifecycle;
204222
}
205223

206-
void put(ExtensionContext.Store store, R value) {
207-
store.put(key, value);
224+
boolean prepareForTests() throws InterruptedException {
225+
switch (this.syncBarrier.getPhase()) {
226+
case 0: // Compiler has been started, but might not yet be initialized
227+
return checkNotTerminated(this.syncBarrier.arriveAndAwaitAdvance());
228+
case 1: // Compiler has been initialized, ready for tests
229+
return true;
230+
default:
231+
throw new IllegalStateException(this.toString());
232+
}
233+
}
234+
235+
Compilation allowTermination() throws InterruptedException, ExecutionException {
236+
if (this.syncBarrier.getPhase() == 1) {
237+
checkState(this.syncBarrier.arriveAndDeregister() == 1, this);
238+
} else if (!this.syncBarrier.isTerminated()) {
239+
throw new IllegalStateException(this.toString());
240+
}
241+
242+
try {
243+
final Compilation result = this.result.get(1, TimeUnit.SECONDS);
244+
checkState(this.syncBarrier.isTerminated(), this);
245+
return result;
246+
} catch (TimeoutException e) {
247+
// This really should never happen, since the 'syncBarrier' is the only thing the
248+
// processor blocks on, deregistering at this point should allow the processor
249+
// to run until it finishes.
250+
throw new AssertionError("Timed out waiting for the compiler to finish");
251+
}
252+
}
253+
254+
private boolean checkNotTerminated(int phaseNumber) throws InterruptedException {
255+
if (phaseNumber < 0) {
256+
// Phaser has terminated unexpectedly, throw exception based on result.
257+
258+
try {
259+
// 'Successful' result
260+
final Compilation result = this.result.get(5, TimeUnit.SECONDS);
261+
throw new IllegalStateException(
262+
String.format("Anomalous compilation result: %s", result)
263+
);
264+
} catch (ExecutionException e) {
265+
// Exception in the compiler
266+
throw new IllegalStateException("Exception during annotation processing", e.getCause());
267+
} catch (TimeoutException e) {
268+
// This really should never happen, since the 'syncBarrier' is the only thing the
269+
// processor blocks on, termination should mean it runs until it finished,
270+
// resolving 'result'
271+
throw new AssertionError("Timed out waiting for the cause of termination");
272+
}
273+
}
274+
275+
return true;
276+
}
277+
278+
@Override
279+
public void close() {
280+
// If the owning ExtensionContext.Store is closed, ensure the compilation terminates as well
281+
this.syncBarrier.forceTermination();
282+
}
283+
284+
@Override
285+
public String toString() {
286+
return "CompilerState{" +
287+
"sharedState=" + sharedState +
288+
", syncBarrier=" + syncBarrier +
289+
", result=" + result +
290+
", lifecycle=" + lifecycle +
291+
'}';
208292
}
209293
}
210294

211-
static final class EvaluatingProcessor extends AbstractProcessor {
212-
private final Phaser barrier;
295+
static final class EvaluatingProcessor extends AbstractProcessor
296+
implements Supplier<Compilation> {
297+
private final Phaser syncBarrier;
213298
private final AtomicReference<ProcessingEnvironment> sharedState;
214299

215300
EvaluatingProcessor(
216-
Phaser barrier,
301+
Phaser syncBarrier,
217302
AtomicReference<ProcessingEnvironment> sharedState
218303
) {
219-
this.barrier = barrier;
304+
this.syncBarrier = syncBarrier;
220305
this.sharedState = sharedState;
221306
}
222307

@@ -235,26 +320,34 @@ public synchronized void init(ProcessingEnvironment processingEnvironment) {
235320
super.init(processingEnvironment);
236321

237322
// Share the processing environment
238-
if (!sharedState.compareAndSet(null, processingEnvironment)) {
239-
// Invalid state, init() run twice
240-
barrier.forceTermination();
241-
throw new IllegalStateException("Processor initialized twice");
242-
}
323+
checkState(
324+
sharedState.compareAndSet(null, processingEnvironment),
325+
"Shared ProcessingEnvironment was already initialized"
326+
);
243327
}
244328

245329
@Override
246330
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
247331
if (roundEnv.processingOver()) {
248332
// Synchronize on the beginning of the test run
249-
barrier.arriveAndAwaitAdvance();
333+
syncBarrier.arriveAndAwaitAdvance();
250334

251335
// Now wait until testing is over
252-
barrier.awaitAdvance(barrier.arriveAndDeregister());
336+
syncBarrier.awaitAdvance(syncBarrier.arriveAndDeregister());
253337

254338
// Clean up the shared state
255-
sharedState.getAndSet(null);
339+
sharedState.lazySet(null);
256340
}
257341
return false;
258342
}
343+
344+
@Override
345+
public Compilation get() {
346+
try {
347+
return Compiler.javac().withProcessors(this).compile(DUMMY);
348+
} finally {
349+
syncBarrier.forceTermination();
350+
}
351+
}
259352
}
260353
}

0 commit comments

Comments
 (0)