Skip to content

Commit 9ea0697

Browse files
authored
Fix support for JVM shutdown hooks (junit-team#4474)
When the `ConsoleLauncher` is executed via its `main` method while passing arguments via its `-cp` option, it no longer closes the custom class loader it creates eagerly after test discovery or execution. Instead, it relies on the JVM shutdown to free up any resource held by the class loader which is initiated by calling `System.exit` directly afterward. Fixes junit-team#4469.
1 parent c60aaa9 commit 9ea0697

File tree

9 files changed

+191
-66
lines changed

9 files changed

+191
-66
lines changed

documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc

+4-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ repository on GitHub.
2929
[[release-notes-5.13.0-M3-junit-platform-bug-fixes]]
3030
==== Bug Fixes
3131

32-
* ❓
32+
* Reintroduce support for JVM shutdown hooks when using the `-cp`/`--classpath` option of
33+
the `ConsoleLauncher`. Prior to this release, the created class loader was closed prior
34+
to JVM shutdown hooks being invoked, which caused hooks to fail with a
35+
`ClassNotFoundException` when loading classes during shutdown.
3336

3437
[[release-notes-5.13.0-M3-junit-platform-deprecations-and-breaking-changes]]
3538
==== Deprecations and Breaking Changes

junit-platform-console/src/main/java/org/junit/platform/console/ConsoleLauncher.java

+8-4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.junit.platform.console.options.CommandFacade;
2020
import org.junit.platform.console.options.CommandResult;
2121
import org.junit.platform.console.tasks.ConsoleTestExecutor;
22+
import org.junit.platform.console.tasks.CustomClassLoaderCloseStrategy;
2223

2324
/**
2425
* The {@code ConsoleLauncher} is a stand-alone application for launching the
@@ -30,17 +31,20 @@
3031
public class ConsoleLauncher {
3132

3233
public static void main(String... args) {
33-
CommandResult<?> result = newCommandFacade().run(args);
34+
CommandFacade facade = newCommandFacade(CustomClassLoaderCloseStrategy.KEEP_OPEN);
35+
CommandResult<?> result = facade.run(args);
3436
System.exit(result.getExitCode());
3537
}
3638

3739
@API(status = INTERNAL, since = "1.0")
3840
public static CommandResult<?> run(PrintWriter out, PrintWriter err, String... args) {
39-
return newCommandFacade().run(args, out, err);
41+
CommandFacade facade = newCommandFacade(CustomClassLoaderCloseStrategy.CLOSE_AFTER_CALLING_LAUNCHER);
42+
return facade.run(args, out, err);
4043
}
4144

42-
private static CommandFacade newCommandFacade() {
43-
return new CommandFacade(ConsoleTestExecutor::new);
45+
private static CommandFacade newCommandFacade(CustomClassLoaderCloseStrategy classLoaderCleanupStrategy) {
46+
return new CommandFacade((discoveryOptions, outputOptions) -> new ConsoleTestExecutor(discoveryOptions,
47+
outputOptions, classLoaderCleanupStrategy));
4448
}
4549

4650
}

junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java

+20-3
Original file line numberDiff line numberDiff line change
@@ -50,31 +50,48 @@ public class ConsoleTestExecutor {
5050
private final TestDiscoveryOptions discoveryOptions;
5151
private final TestConsoleOutputOptions outputOptions;
5252
private final Supplier<Launcher> launcherSupplier;
53+
private final CustomClassLoaderCloseStrategy classLoaderCloseStrategy;
5354

5455
public ConsoleTestExecutor(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions) {
55-
this(discoveryOptions, outputOptions, LauncherFactory::create);
56+
this(discoveryOptions, outputOptions, CustomClassLoaderCloseStrategy.CLOSE_AFTER_CALLING_LAUNCHER);
57+
}
58+
59+
public ConsoleTestExecutor(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions,
60+
CustomClassLoaderCloseStrategy classLoaderCloseStrategy) {
61+
this(discoveryOptions, outputOptions, classLoaderCloseStrategy, LauncherFactory::create);
5662
}
5763

5864
// for tests only
5965
ConsoleTestExecutor(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions,
6066
Supplier<Launcher> launcherSupplier) {
67+
this(discoveryOptions, outputOptions, CustomClassLoaderCloseStrategy.CLOSE_AFTER_CALLING_LAUNCHER,
68+
launcherSupplier);
69+
}
70+
71+
private ConsoleTestExecutor(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions,
72+
CustomClassLoaderCloseStrategy classLoaderCloseStrategy, Supplier<Launcher> launcherSupplier) {
6173
this.discoveryOptions = discoveryOptions;
6274
this.outputOptions = outputOptions;
6375
this.launcherSupplier = launcherSupplier;
76+
this.classLoaderCloseStrategy = classLoaderCloseStrategy;
6477
}
6578

6679
public void discover(PrintWriter out) {
67-
new CustomContextClassLoaderExecutor(createCustomClassLoader()).invoke(() -> {
80+
createCustomContextClassLoaderExecutor().invoke(() -> {
6881
discoverTests(out);
6982
return null;
7083
});
7184
}
7285

7386
public TestExecutionSummary execute(PrintWriter out, Optional<Path> reportsDir) {
74-
return new CustomContextClassLoaderExecutor(createCustomClassLoader()) //
87+
return createCustomContextClassLoaderExecutor() //
7588
.invoke(() -> executeTests(out, reportsDir));
7689
}
7790

91+
private CustomContextClassLoaderExecutor createCustomContextClassLoaderExecutor() {
92+
return new CustomContextClassLoaderExecutor(createCustomClassLoader(), classLoaderCloseStrategy);
93+
}
94+
7895
private void discoverTests(PrintWriter out) {
7996
Launcher launcher = launcherSupplier.get();
8097
Optional<DetailsPrintingListener> commandLineTestPrinter = createDetailsPrintingListener(out);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.platform.console.tasks;
12+
13+
import static org.apiguardian.api.API.Status.INTERNAL;
14+
15+
import org.apiguardian.api.API;
16+
import org.junit.platform.commons.JUnitException;
17+
18+
/**
19+
* Defines the strategy for closing custom class loaders created for test
20+
* discovery and execution.
21+
*/
22+
@API(status = INTERNAL, since = "1.13")
23+
public enum CustomClassLoaderCloseStrategy {
24+
25+
/**
26+
* Close the custom class loader after calling the
27+
* {@link org.junit.platform.launcher.Launcher} for test discovery or
28+
* execution.
29+
*/
30+
CLOSE_AFTER_CALLING_LAUNCHER {
31+
32+
@Override
33+
public void handle(ClassLoader customClassLoader) {
34+
if (customClassLoader instanceof AutoCloseable) {
35+
close((AutoCloseable) customClassLoader);
36+
}
37+
}
38+
39+
private void close(AutoCloseable customClassLoader) {
40+
try {
41+
customClassLoader.close();
42+
}
43+
catch (Exception e) {
44+
throw new JUnitException("Failed to close custom class loader", e);
45+
}
46+
}
47+
},
48+
49+
/**
50+
* Rely on the JVM to release resources held by the custom class loader when
51+
* it terminates.
52+
*
53+
* <p>This mode is only safe to use when calling {@link System#exit(int)}
54+
* afterward.
55+
*/
56+
KEEP_OPEN {
57+
@Override
58+
public void handle(ClassLoader customClassLoader) {
59+
// do nothing
60+
}
61+
};
62+
63+
/**
64+
* Handle the class loader according to the strategy.
65+
*/
66+
public abstract void handle(ClassLoader classLoader);
67+
68+
}

junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutor.java

+8-14
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,22 @@
1313
import java.util.Optional;
1414
import java.util.function.Supplier;
1515

16-
import org.junit.platform.commons.JUnitException;
17-
1816
/**
1917
* @since 1.0
2018
*/
2119
class CustomContextClassLoaderExecutor {
2220

2321
private final Optional<ClassLoader> customClassLoader;
22+
private final CustomClassLoaderCloseStrategy closeStrategy;
2423

2524
CustomContextClassLoaderExecutor(Optional<ClassLoader> customClassLoader) {
25+
this(customClassLoader, CustomClassLoaderCloseStrategy.CLOSE_AFTER_CALLING_LAUNCHER);
26+
}
27+
28+
CustomContextClassLoaderExecutor(Optional<ClassLoader> customClassLoader,
29+
CustomClassLoaderCloseStrategy closeStrategy) {
2630
this.customClassLoader = customClassLoader;
31+
this.closeStrategy = closeStrategy;
2732
}
2833

2934
<T> T invoke(Supplier<T> supplier) {
@@ -43,18 +48,7 @@ private <T> T replaceThreadContextClassLoaderAndInvoke(ClassLoader customClassLo
4348
}
4449
finally {
4550
Thread.currentThread().setContextClassLoader(originalClassLoader);
46-
if (customClassLoader instanceof AutoCloseable) {
47-
close((AutoCloseable) customClassLoader);
48-
}
49-
}
50-
}
51-
52-
private static void close(AutoCloseable customClassLoader) {
53-
try {
54-
customClassLoader.close();
55-
}
56-
catch (Exception e) {
57-
throw new JUnitException("Failed to close custom class loader", e);
51+
closeStrategy.handle(customClassLoader);
5852
}
5953
}
6054

platform-tests/src/test/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutorTests.java

+23-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package org.junit.platform.console.tasks;
1212

1313
import static org.junit.jupiter.api.Assertions.assertEquals;
14+
import static org.junit.jupiter.api.Assertions.assertFalse;
1415
import static org.junit.jupiter.api.Assertions.assertSame;
1516
import static org.junit.jupiter.api.Assertions.assertTrue;
1617

@@ -28,7 +29,7 @@
2829
class CustomContextClassLoaderExecutorTests {
2930

3031
@Test
31-
void invokeWithoutCustomClassLoaderDoesNotSetClassLoader() throws Exception {
32+
void invokeWithoutCustomClassLoaderDoesNotSetClassLoader() {
3233
var originalClassLoader = Thread.currentThread().getContextClassLoader();
3334
var executor = new CustomContextClassLoaderExecutor(Optional.empty());
3435

@@ -42,7 +43,7 @@ void invokeWithoutCustomClassLoaderDoesNotSetClassLoader() throws Exception {
4243
}
4344

4445
@Test
45-
void invokeWithCustomClassLoaderSetsCustomAndResetsToOriginal() throws Exception {
46+
void invokeWithCustomClassLoaderSetsCustomAndResetsToOriginal() {
4647
var originalClassLoader = Thread.currentThread().getContextClassLoader();
4748
ClassLoader customClassLoader = URLClassLoader.newInstance(new URL[0]);
4849
var executor = new CustomContextClassLoaderExecutor(Optional.of(customClassLoader));
@@ -57,7 +58,7 @@ void invokeWithCustomClassLoaderSetsCustomAndResetsToOriginal() throws Exception
5758
}
5859

5960
@Test
60-
void invokeWithCustomClassLoaderAndEnsureItIsClosedAfterUsage() throws Exception {
61+
void invokeWithCustomClassLoaderAndEnsureItIsClosedAfterUsage() {
6162
var closed = new AtomicBoolean(false);
6263
ClassLoader localClassLoader = new URLClassLoader(new URL[0]) {
6364
@Override
@@ -73,4 +74,23 @@ public void close() throws IOException {
7374
assertEquals(4711, result);
7475
assertTrue(closed.get());
7576
}
77+
78+
@Test
79+
void invokeWithCustomClassLoaderAndKeepItOpenAfterUsage() {
80+
var closed = new AtomicBoolean(false);
81+
ClassLoader localClassLoader = new URLClassLoader(new URL[0]) {
82+
@Override
83+
public void close() throws IOException {
84+
closed.set(true);
85+
super.close();
86+
}
87+
};
88+
var executor = new CustomContextClassLoaderExecutor(Optional.of(localClassLoader),
89+
CustomClassLoaderCloseStrategy.KEEP_OPEN);
90+
91+
int result = executor.invoke(() -> 4711);
92+
93+
assertEquals(4711, result);
94+
assertFalse(closed.get());
95+
}
7696
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package other;
12+
13+
public class OtherwiseNotReferencedClass {
14+
}

platform-tooling-support-tests/projects/standalone/src/standalone/JupiterIntegration.java

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class JupiterIntegration {
1919

2020
@Test
2121
void successful() {
22+
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
23+
new other.OtherwiseNotReferencedClass();
24+
}));
2225
}
2326

2427
@Test

0 commit comments

Comments
 (0)