Skip to content

Commit 84ac590

Browse files
committed
Invoke @⁠AutoClose methods via interfaces whenever possible
Prior to this commit, the @⁠AutoClose extension attempted to invoke a "close" method via the most specific "close" method found in the type hierarchy. However, this resulted in an InaccessibleObjectException if the "close" method was implemented in a non-public type from a different module (such as from the java.base module). In order to avoid issues with the enforcement of strict encapsulation within the JVM, this commit attempts to always invoke a "close" method via the declaring interface. For a "close" method in a type that implements AutoCloseable, we now simply cast the object to AutoCloseable and invoke the "close()" method directly, without using reflection. For any other method, we use the new getInterfaceMethodIfPossible() utility in ReflectionUtils to locate an interface method (if such a method exists) and invoke that. The latter was graciously donated by @jhoeller from the Spring Framework. Closes #3684
1 parent abb5ed1 commit 84ac590

File tree

4 files changed

+112
-1
lines changed

4 files changed

+112
-1
lines changed

junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/AutoCloseExtension.java

+9-1
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,20 @@ private static void closeField(Field field, Object testInstance) throws Exceptio
8080
}
8181
}
8282

83-
private static void invokeCloseMethod(Field field, Object target, String methodName) {
83+
private static void invokeCloseMethod(Field field, Object target, String methodName) throws Exception {
84+
// Avoid reflection if we can directly invoke close() via AutoCloseable.
85+
if (target instanceof AutoCloseable && "close".equals(methodName)) {
86+
((AutoCloseable) target).close();
87+
return;
88+
}
89+
8490
Class<?> targetType = target.getClass();
8591
Method closeMethod = ReflectionUtils.findMethod(targetType, methodName).orElseThrow(
8692
() -> new ExtensionConfigurationException(
8793
String.format("Cannot @AutoClose field %s because %s does not define method %s().",
8894
getQualifiedName(field), targetType.getName(), methodName)));
95+
96+
closeMethod = ReflectionUtils.getInterfaceMethodIfPossible(closeMethod, targetType);
8997
ReflectionUtils.invokeMethod(closeMethod, target);
9098
}
9199

junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/AutoCloseTests.java

+24
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818
import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
1919
import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message;
2020

21+
import java.io.InputStream;
2122
import java.lang.annotation.Retention;
2223
import java.lang.annotation.RetentionPolicy;
2324
import java.util.ArrayList;
2425
import java.util.List;
26+
import java.util.concurrent.ExecutorService;
27+
import java.util.concurrent.Executors;
2528
import java.util.logging.Level;
2629
import java.util.logging.LogRecord;
2730

@@ -115,6 +118,18 @@ void spyPermitsOnlyASingleAction() {
115118
assertThat(recorder).containsExactly("AutoCloseTests.preconditions.close()");
116119
}
117120

121+
/**
122+
* @see <a href="https://github.com/junit-team/junit5/issues/3684">#3684</a>
123+
*/
124+
@Test
125+
void fieldsAreProperlyClosedViaInterfaceMethods() {
126+
// If the test method succeeds, that means there was no issue invoking
127+
// the @AutoClose fields. No need to assert anything else for this use case.
128+
executeTestsForClass(CloseMethodMustBeInvokedViaInterfaceTestCase.class)//
129+
.testEvents()//
130+
.assertStatistics(stats -> stats.succeeded(1));
131+
}
132+
118133
@Test
119134
void fieldsAreProperlyClosedWithInstancePerMethodTestClass() {
120135
Events tests = executeTestsForClass(InstancePerMethodTestCase.class).testEvents();
@@ -432,6 +447,15 @@ static class NoShutdownMethodTestCase implements TestInterface {
432447
private final String field = "";
433448
}
434449

450+
static class CloseMethodMustBeInvokedViaInterfaceTestCase implements TestInterface {
451+
452+
@AutoClose
453+
final InputStream inputStream = InputStream.nullInputStream();
454+
455+
@AutoClose("shutdown")
456+
final ExecutorService service = Executors.newSingleThreadExecutor();
457+
}
458+
435459
@TestInstance(PER_METHOD)
436460
static class InstancePerMethodTestCase {
437461

junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java

+56
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package org.junit.platform.commons.util;
1212

1313
import static java.lang.String.format;
14+
import static java.util.Collections.synchronizedMap;
1415
import static java.util.stream.Collectors.toCollection;
1516
import static java.util.stream.Collectors.toList;
1617
import static java.util.stream.Collectors.toSet;
@@ -125,6 +126,13 @@ public enum HierarchyTraversalMode {
125126
private static final ClasspathScanner classpathScanner = new ClasspathScanner(
126127
ClassLoaderUtils::getDefaultClassLoader, ReflectionUtils::tryToLoadClass);
127128

129+
/**
130+
* Cache for equivalent methods on an interface implemented by the declaring class.
131+
* @since 1.11
132+
* @see #getInterfaceMethodIfPossible(Method, Class)
133+
*/
134+
private static final Map<Method, Method> interfaceMethodCache = synchronizedMap(new LruCache<>(255));
135+
128136
/**
129137
* Set of fully qualified class names for which no cycles have been detected
130138
* in inner class hierarchies.
@@ -1312,6 +1320,54 @@ public static Try<Method> tryToGetMethod(Class<?> clazz, String methodName, Clas
13121320
return Try.call(() -> clazz.getMethod(methodName, parameterTypes));
13131321
}
13141322

1323+
/**
1324+
* Determine a corresponding interface method for the given method handle, if possible.
1325+
* <p>This is particularly useful for arriving at a public exported type on the Java
1326+
* Module System which can be reflectively invoked without an illegal access warning.
1327+
* @param method the method to be invoked, potentially from an implementation class
1328+
* @param targetClass the target class to check for declared interfaces
1329+
* @return the corresponding interface method, or the original method if none found
1330+
* @since 1.11
1331+
*/
1332+
@API(status = INTERNAL, since = "1.11")
1333+
public static Method getInterfaceMethodIfPossible(Method method, Class<?> targetClass) {
1334+
if (!isPublic(method) || method.getDeclaringClass().isInterface()) {
1335+
return method;
1336+
}
1337+
// Try cached version of method in its declaring class
1338+
Method result = interfaceMethodCache.computeIfAbsent(method,
1339+
m -> findInterfaceMethodIfPossible(m, m.getDeclaringClass(), Object.class));
1340+
if (result == method && targetClass != null) {
1341+
// No interface method found yet -> try given target class (possibly a subclass of the
1342+
// declaring class, late-binding a base class method to a subclass-declared interface:
1343+
// see e.g. HashMap.HashIterator.hasNext)
1344+
result = findInterfaceMethodIfPossible(method, targetClass, method.getDeclaringClass());
1345+
}
1346+
return result;
1347+
}
1348+
1349+
private static Method findInterfaceMethodIfPossible(Method method, Class<?> startClass, Class<?> endClass) {
1350+
Class<?>[] parameterTypes = null;
1351+
Class<?> current = startClass;
1352+
while (current != null && current != endClass) {
1353+
if (parameterTypes == null) {
1354+
// Since Method#getParameterTypes() clones the array, we lazily retrieve
1355+
// and cache parameter types to avoid cloning the array multiple times.
1356+
parameterTypes = method.getParameterTypes();
1357+
}
1358+
for (Class<?> ifc : current.getInterfaces()) {
1359+
try {
1360+
return ifc.getMethod(method.getName(), parameterTypes);
1361+
}
1362+
catch (NoSuchMethodException ex) {
1363+
// ignore
1364+
}
1365+
}
1366+
current = current.getSuperclass();
1367+
}
1368+
return method;
1369+
}
1370+
13151371
/**
13161372
* @see org.junit.platform.commons.support.ReflectionSupport#findMethod(Class, String, String)
13171373
*/

platform-tests/src/test/java/org/junit/platform/commons/util/ReflectionUtilsTests.java

+23
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@
3434
import static org.junit.platform.commons.util.ReflectionUtils.readFieldValues;
3535
import static org.junit.platform.commons.util.ReflectionUtils.tryToReadFieldValue;
3636

37+
import java.io.Closeable;
3738
import java.io.File;
3839
import java.io.IOException;
40+
import java.io.InputStream;
3941
import java.io.OutputStream;
4042
import java.lang.reflect.Constructor;
4143
import java.lang.reflect.Field;
@@ -258,6 +260,27 @@ void isGeneric() {
258260
}
259261
}
260262

263+
/**
264+
* @see <a href="https://github.com/junit-team/junit5/issues/3684">#3684</a>
265+
*/
266+
@Test
267+
void getInterfaceMethodIfPossible() throws Exception {
268+
// "anonymous" because it's implemented as an anonymous class.
269+
InputStream anonymousInputStream = InputStream.nullInputStream();
270+
Class<?> targetType = anonymousInputStream.getClass();
271+
assertThat(targetType.isAnonymousClass()).isTrue();
272+
273+
Method method = targetType.getMethod("close");
274+
assertThat(method).isNotNull();
275+
assertThat(method.getDeclaringClass()).isEqualTo(targetType);
276+
277+
Method interfaceMethod = ReflectionUtils.getInterfaceMethodIfPossible(method, targetType);
278+
assertThat(interfaceMethod).isNotNull().isNotEqualTo(method);
279+
// InputStream implements Closeable directly, so we find the `close` method
280+
// in Closeable instead of AutoCloseable.
281+
assertThat(interfaceMethod.getDeclaringClass()).isEqualTo(Closeable.class);
282+
}
283+
261284
static class ClassWithVoidAndNonVoidMethods {
262285

263286
void voidMethod() {

0 commit comments

Comments
 (0)