Repository: junit-team/junit5 (language: java)

Issue:
Shutdown hook fails with ClassNotFoundException in junit-platform-console-standalone
As it stated in the title, when using `junit-platform-console-standalone` uses [CustomContextClassLoaderExecutor.java](https://github.com/junit-team/junit5/blob/ee52c64336ad3a079d598bd1c3884bafbc285047/junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutor.java#L47) which closes classloader after tests are passed, and then JVM terminates which triggers shutdown hook, but classloader already had been closed

For instance, `testcontainers` installs a shutdown hook.

Console launcher standalone version is 1.11.4, but, I've also built from sources and problem exists

## Steps to reproduce

Minimal example, requires `org.apache.commons:commons-math3:3.6.1`

```
class MathTest {

    @Test
    void testExample() {
        Runtime.getRuntime()
                .addShutdownHook(new Thread(() -> new BigReal(BigDecimal.ZERO)));
    }
}
```



```
Exception in thread "Thread-0" java.lang.NoClassDefFoundError: org/apache/commons/math3/util/BigReal
        at MathTest.lambda$testExample$0(MathTest.java:11)
        at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.math3.util.BigReal
        at java.base/java.net.URLClassLoader.findClass(URLClassLoader.java:445)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:593)
        at java.base/java.net.FactoryURLClassLoader.loadClass(URLClassLoader.java:872)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
        ... 2 more
```



Discussion / hints:
Could you please try out this workaround? Put all user JAR files on the outer class/module path, including the console launcher (standalone or not), in order to let application class loader keep a reference to them till the very end.

For example: `java -cp classes:lib/* org.junit.platform.console.ConsoleLauncher <ARGS>`
Here's a minimal reproducer without an external dependency, and without the standalone artifact. All required JAR files are stored in the `lib` subdirectory.
```java
class ShutdownTest {
  @org.junit.jupiter.api.Test
  void noop() {
    record Local() {}
    Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println(Local.class)));
  }
}
```

```shell
javac -cp lib\* ShutdownTest.java
```

Works as expected, no error is thrown: 
```shell
java -cp .;lib\* org.junit.platform.console.ConsoleLauncher execute --scan-class-path
```

Fails as reported, `NoClassDefFoundError` is thrown:
```shell
java -cp lib\* org.junit.platform.console.ConsoleLauncher execute -cp . --scan-class-path
[...]
Exception in thread "Thread-1" java.lang.NoClassDefFoundError: ShutdownTest$1Local
        at ShutdownTest.lambda$noop$0(ShutdownTest.java:8)
        at java.base/java.lang.Thread.run(Thread.java:1570)
Caused by: java.lang.ClassNotFoundException: ShutdownTest$1Local
        at java.base/java.net.URLClassLoader.findClass(URLClassLoader.java:445)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:592)
        at java.base/java.net.FactoryURLClassLoader.loadClass(URLClassLoader.java:870)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:525)
        ... 2 more
```

Note the difference in the commads where the `.` (current working directory) is added to the class path.

At the moment, I don't see solution to this issue, though. The convenient options offered by the JUnit Platform launcher ...
```
RUNTIME CONFIGURATION
      -cp, --classpath, --class-path=PATH
                             Provide additional classpath entries -- for example, for adding
                               engines and their dependencies. This option can be repeated.
```
... may only operate in the boundaries of the class loader world. It's unfortunate that the TestContainers extension installs a shutdown hook.

As there's already

- https://github.com/testcontainers/testcontainers-java/issues/1454

I tend to close this report as a 3rd-party issue.
Well, one option we have would be to _not_ close the `URLClassLoader` since the JVM is going to be shut down anyway and release its file handles. 🤔 
Which is what we did before:

- https://github.com/junit-team/junit5/issues/1896

➡ Team discussion.
**Team decision:** Document that `java -cp` should be used instead of `java -jar ... execute -cp` when JVM shutdown hooks are involved.
> Well, one option we have would be to not close the URLClassLoader since the JVM is going to be shut down anyway and release its file handles

This would make sense.

> Which is what we did before

Why is it a problem _not_ to close the Classloader? It seems that file handles would be released anyway since the JVM is shutting down. Was there more to it than just "by the way we forgot `close()`"?
Here is a wild idea: how about "mocking" or capturing `addShutdownHook` in some way, and running all these hooks at the end of the test execution (to simulate the JVM shutdown from the test's perspective) but _before_ closing the classloader?
> Why is it a problem _not_ to close the Classloader?

Using the launcher multiple times in a single VM session led to memory and file handle leaks. The latter is a problme on Windows, as those non-closed JAR files couldn't be deleted between runs. Note that the launcher implements `ToolProvider` interface in order to allow multiple usages without spawning a new VM.

In addition, it's a resource the launcher creates/opens and the `URLClassLoader` implements `AutoCloseable`. It is meant to used in try-with-resources block - closing the resource gracefully when it's not longer needed.

> Here is a wild idea: how about "mocking" or capturing addShutdownHook in some way, and running all these hooks at the end of the test execution (to simulate the JVM shutdown from the test's perspective) but before closing the classloader?

No.
> Here is a wild idea: how about "mocking" or capturing `addShutdownHook` in some way, and running all these hooks at the end of the test execution (to simulate the JVM shutdown from the test's perspective) but _before_ closing the classloader?

I hope that resolving #1880 will remove the desire to register shutdown hooks in the future.
> Note that the launcher implements ToolProvider interface in order to allow usages without spawning a new VM.

Oh, I didn't know about this, so didn't realize several sessions could be run in a single JVM. But why would people do this instead of using the `junit-plaform-launcher` library? Is it to reuse listeners and code like this that is in the console artifact but not published as separate modules?

Then I would say it's even more important to properly run the application shutdown hooks between test sessions.

> > Here is a wild idea: how about "mocking" or capturing addShutdownHook in some way, and running all these hooks at the end of the test execution (to simulate the JVM shutdown from the test's perspective) but before closing the classloader?
> 
> No.

Well, that was harsh 😆 Could you please elaborate on why you think this wouldn't be a good thing?
> Team decision: Document that java -cp should be used instead of java -jar ... execute -cp when JVM shutdown hooks are involved.

@marcphilipp I'm writing a build tool, so I don't know if shutdown hooks will be involved or not in the user's test code. Should I then use `java -cp` directly all the time for the user's classes?
If this is viable, then what is the purpose of the separate `-cp` argument of the console launcher?
> Note that the launcher implements ToolProvider interface in order to allow usages without spawning a new VM.

@sormuras This is quite interesting, by the way. Now assuming my build tool relies on the `ToolProvider` interface to launch JUnit in my own VM, how am I expected to deal with the user's shutdown hooks? I can no longer use the workaround to pass the user's classpath in `-cp`, because that's my own classpath, so what's the suggested approach?
> [@marcphilipp](https://github.com/marcphilipp) I'm writing a build tool, so I don't know if shutdown hooks will be involved or not in the user's test code. Should I then use `java -cp` directly all the time for the user's classes?

Yes, that would be safer.

> If this is viable, then what is the purpose of the separate `-cp` argument of the console launcher?

It was only meant as convenience option because the `java` command does not let you use both `-jar` _and_ `-cp` to extend the classpath.

Without adding to the classpath you can run the following:
```
java -jar junit-platform-console-standalone.jar execute ...
```

However, since JUnit tests are Java classes, you usually have to add them to the classpath. But since the `-jar` option of `java` ignores the `-cp` option, you have to rewrite the entire call to:
```
java -cp junit-platform-console-standalone.jar:test-classes-dir \
     org.junit.platform.console.ConsoleLauncher execute ...
```

The `-cp` option of `ConsoleLauncher` makes that easier:
```
java -jar junit-platform-console-standalone.jar execute -cp test-classes-dir ...
```
Hmm, looking at this again there is a separate entry point for the `ToolProvider`:

https://github.com/junit-team/junit5/blob/ee52c64336ad3a079d598bd1c3884bafbc285047/junit-platform-console/src/main/java9/org/junit/platform/console/ConsoleLauncherToolProvider.java#L26-L37

So, I think we could pass in a parameter telling us whether to close the `ClassLoader`. Thus, I'm putting this back up for discussion.
> Now assuming my build tool relies on the `ToolProvider` interface to launch JUnit in my own VM, how am I expected to deal with the user's shutdown hooks? I can no longer use the workaround to pass the user's classpath in `-cp`, because that's my own classpath, so what's the suggested approach?

The shutdown hooks from Testcontainers I'm aware of are used _in addition_ to normally closing resources using regular JUnit lifecycle hooks. This makes them effectively no-ops if test execution finishes without the test JVM being killed.

Introducing an API to gracefully stop a test execution (in #1880) will hopefully render them obsolete because build tools and IDEs should then no longer kill the test JVM but rather use the new API to stop the current execution.
@joffrey-bion Let's continue the `junit` as a `ToolProvider` discussion here: https://github.com/junit-team/junit5/discussions/4473
> It was only meant as convenience option because the java command does not let you use both -jar and -cp to extend the classpath.

Ah, thank you! I didn't think of the approach where people used the `-jar` argument. Makes total sense.

Beyond making things more convenient with `-jar`, there is also some sort of isolation going on thanks to the child classloader. I wonder what the adverse effect of using the same classloader for the test code as for the JUnit launcher itself could be.

Maybe people writing JUnit libraries would have trouble testing them because the classes they use could override the classes the launcher uses 🤔 

For instance, in our project (the Amper build tool itself), we wrote custom JUnit listeners, which directly depend on some JUnit APIs. This means we will bring some JUnit jars (our dependencies) to the same classloader as the JUnit launcher itself. They will contain the same classes, but maybe in different versions, and that might break things in very strange ways, if I'm not mistaken.
The versions of all JUnit JARs should definitely be aligned to avoid running into strange issues. Therefore, your custom listeners should probably only have `compileOnly` dependencies on the JUnit Platform and then use the version on the test runtime classpath when tests are being executed. For that reason, Gradle has deprecated not declaring a `testRuntimeOnly` dependency on `junit-platform-launcher` in recent versions.

I haven't used Amper so far. Does it have support for BOMs or Gradle Module Metadata for dependency management?
> For instance, in our project (the Amper build tool itself), we wrote custom JUnit listeners, which directly depend on some JUnit APIs. This means we will bring some JUnit jars (our dependencies) to the same classloader as the JUnit launcher itself. They will contain the same classes, but maybe in different versions, and that might break things in very strange ways, if I'm not mistaken.

Yes. And when putting JAR files on the module-path, you'll find out early and also find the `ModuleLayer` API helpful to isolate stuff.
> I haven't used Amper so far. Does it have support for BOMs or Gradle Module Metadata for dependency management?

Yes for Gradle Module Metadata. For BOMs, it's only true in our current dev versions, not in the latest released version, but we can say yes too for the sake of the discussion.

> Therefore, your custom listeners should probably only have compileOnly dependencies on the JUnit Platform and then use the version on the test runtime classpath when tests are being executed. For that reason, Gradle has deprecated not declaring a testRuntimeOnly dependency on junit-platform-launcher in recent versions.

For production use, I agree. My project declaring the listeners would use `compileOnly` for the JUnit platform dependencies (in the version it wants to be compatible with), and the consumers of this project would get my listeners in their test runtime classpath, but not any transitive dependency on the JUnit platform, which will be brought by the build tool they use (or manually if they need to). In this case my listeners will just be subject to the usual binary compatibility implications.

But I'm talking about the tests of this test listeners project. If I have a `compileOnly("org.junit.platform:junit-platform-launcher:1.12.1")` dependency, then I generally want to bring it to my test runtime classpath with the corresponding `testRuntimeOnly`. But that would clash with whatever version of `junit-platform-launcher` is provided by the build tool itself for the tests (say, maybe an older 1.11.4, or whatever the console launcher 1.11.4 brings in my case).
So I would need a way to specify this dependency without version to align with whatever is provided, but then I wouldn't know whic version I'm testing against (and could still have issues). I don't even know exactly which JUnit modules are provided by the build tool.

> And when putting JAR files on the module-path, you'll find out early and also find the ModuleLayer API helpful to isolate stuff

I'm not familiar with the `ModuleLayer` API, does the JUnit console launcher use it? AFAICT it is just using a plain classloader to load the user classes.

Fix patch:
diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc
index 02e6d302fb22..545ef90d6875 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc
@@ -29,7 +29,10 @@ repository on GitHub.
 [[release-notes-5.13.0-M3-junit-platform-bug-fixes]]
 ==== Bug Fixes
 
-* ❓
+* Reintroduce support for JVM shutdown hooks when using the `-cp`/`--classpath` option of
+  the `ConsoleLauncher`. Prior to this release, the created class loader was closed prior
+  to JVM shutdown hooks being invoked, which caused hooks to fail with a
+  `ClassNotFoundException` when loading classes during shutdown.
 
 [[release-notes-5.13.0-M3-junit-platform-deprecations-and-breaking-changes]]
 ==== Deprecations and Breaking Changes
diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/ConsoleLauncher.java b/junit-platform-console/src/main/java/org/junit/platform/console/ConsoleLauncher.java
index 240d6216f61e..929ec8c035fe 100644
--- a/junit-platform-console/src/main/java/org/junit/platform/console/ConsoleLauncher.java
+++ b/junit-platform-console/src/main/java/org/junit/platform/console/ConsoleLauncher.java
@@ -19,6 +19,7 @@
 import org.junit.platform.console.options.CommandFacade;
 import org.junit.platform.console.options.CommandResult;
 import org.junit.platform.console.tasks.ConsoleTestExecutor;
+import org.junit.platform.console.tasks.CustomClassLoaderCloseStrategy;
 
 /**
  * The {@code ConsoleLauncher} is a stand-alone application for launching the
@@ -30,17 +31,20 @@
 public class ConsoleLauncher {
 
 	public static void main(String... args) {
-		CommandResult<?> result = newCommandFacade().run(args);
+		CommandFacade facade = newCommandFacade(CustomClassLoaderCloseStrategy.KEEP_OPEN);
+		CommandResult<?> result = facade.run(args);
 		System.exit(result.getExitCode());
 	}
 
 	@API(status = INTERNAL, since = "1.0")
 	public static CommandResult<?> run(PrintWriter out, PrintWriter err, String... args) {
-		return newCommandFacade().run(args, out, err);
+		CommandFacade facade = newCommandFacade(CustomClassLoaderCloseStrategy.CLOSE_AFTER_CALLING_LAUNCHER);
+		return facade.run(args, out, err);
 	}
 
-	private static CommandFacade newCommandFacade() {
-		return new CommandFacade(ConsoleTestExecutor::new);
+	private static CommandFacade newCommandFacade(CustomClassLoaderCloseStrategy classLoaderCleanupStrategy) {
+		return new CommandFacade((discoveryOptions, outputOptions) -> new ConsoleTestExecutor(discoveryOptions,
+			outputOptions, classLoaderCleanupStrategy));
 	}
 
 }
diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java
index ed08f30b5adc..eb0f3874a8e3 100644
--- a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java
+++ b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java
@@ -50,31 +50,48 @@ public class ConsoleTestExecutor {
 	private final TestDiscoveryOptions discoveryOptions;
 	private final TestConsoleOutputOptions outputOptions;
 	private final Supplier<Launcher> launcherSupplier;
+	private final CustomClassLoaderCloseStrategy classLoaderCloseStrategy;
 
 	public ConsoleTestExecutor(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions) {
-		this(discoveryOptions, outputOptions, LauncherFactory::create);
+		this(discoveryOptions, outputOptions, CustomClassLoaderCloseStrategy.CLOSE_AFTER_CALLING_LAUNCHER);
+	}
+
+	public ConsoleTestExecutor(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions,
+			CustomClassLoaderCloseStrategy classLoaderCloseStrategy) {
+		this(discoveryOptions, outputOptions, classLoaderCloseStrategy, LauncherFactory::create);
 	}
 
 	// for tests only
 	ConsoleTestExecutor(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions,
 			Supplier<Launcher> launcherSupplier) {
+		this(discoveryOptions, outputOptions, CustomClassLoaderCloseStrategy.CLOSE_AFTER_CALLING_LAUNCHER,
+			launcherSupplier);
+	}
+
+	private ConsoleTestExecutor(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions,
+			CustomClassLoaderCloseStrategy classLoaderCloseStrategy, Supplier<Launcher> launcherSupplier) {
 		this.discoveryOptions = discoveryOptions;
 		this.outputOptions = outputOptions;
 		this.launcherSupplier = launcherSupplier;
+		this.classLoaderCloseStrategy = classLoaderCloseStrategy;
 	}
 
 	public void discover(PrintWriter out) {
-		new CustomContextClassLoaderExecutor(createCustomClassLoader()).invoke(() -> {
+		createCustomContextClassLoaderExecutor().invoke(() -> {
 			discoverTests(out);
 			return null;
 		});
 	}
 
 	public TestExecutionSummary execute(PrintWriter out, Optional<Path> reportsDir) {
-		return new CustomContextClassLoaderExecutor(createCustomClassLoader()) //
+		return createCustomContextClassLoaderExecutor() //
 				.invoke(() -> executeTests(out, reportsDir));
 	}
 
+	private CustomContextClassLoaderExecutor createCustomContextClassLoaderExecutor() {
+		return new CustomContextClassLoaderExecutor(createCustomClassLoader(), classLoaderCloseStrategy);
+	}
+
 	private void discoverTests(PrintWriter out) {
 		Launcher launcher = launcherSupplier.get();
 		Optional<DetailsPrintingListener> commandLineTestPrinter = createDetailsPrintingListener(out);
diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomClassLoaderCloseStrategy.java b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomClassLoaderCloseStrategy.java
new file mode 100644
index 000000000000..8a4694ba2156
--- /dev/null
+++ b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomClassLoaderCloseStrategy.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.console.tasks;
+
+import static org.apiguardian.api.API.Status.INTERNAL;
+
+import org.apiguardian.api.API;
+import org.junit.platform.commons.JUnitException;
+
+/**
+ * Defines the strategy for closing custom class loaders created for test
+ * discovery and execution.
+ */
+@API(status = INTERNAL, since = "1.13")
+public enum CustomClassLoaderCloseStrategy {
+
+	/**
+	 * Close the custom class loader after calling the
+	 * {@link org.junit.platform.launcher.Launcher} for test discovery or
+	 * execution.
+	 */
+	CLOSE_AFTER_CALLING_LAUNCHER {
+
+		@Override
+		public void handle(ClassLoader customClassLoader) {
+			if (customClassLoader instanceof AutoCloseable) {
+				close((AutoCloseable) customClassLoader);
+			}
+		}
+
+		private void close(AutoCloseable customClassLoader) {
+			try {
+				customClassLoader.close();
+			}
+			catch (Exception e) {
+				throw new JUnitException("Failed to close custom class loader", e);
+			}
+		}
+	},
+
+	/**
+	 * Rely on the JVM to release resources held by the custom class loader when
+	 * it terminates.
+	 *
+	 * <p>This mode is only safe to use when calling {@link System#exit(int)}
+	 * afterward.
+	 */
+	KEEP_OPEN {
+		@Override
+		public void handle(ClassLoader customClassLoader) {
+			// do nothing
+		}
+	};
+
+	/**
+	 * Handle the class loader according to the strategy.
+	 */
+	public abstract void handle(ClassLoader classLoader);
+
+}
diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutor.java b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutor.java
index 0313fe16ca5c..4e1a4e0f3dda 100644
--- a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutor.java
+++ b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutor.java
@@ -13,17 +13,22 @@
 import java.util.Optional;
 import java.util.function.Supplier;
 
-import org.junit.platform.commons.JUnitException;
-
 /**
  * @since 1.0
  */
 class CustomContextClassLoaderExecutor {
 
 	private final Optional<ClassLoader> customClassLoader;
+	private final CustomClassLoaderCloseStrategy closeStrategy;
 
 	CustomContextClassLoaderExecutor(Optional<ClassLoader> customClassLoader) {
+		this(customClassLoader, CustomClassLoaderCloseStrategy.CLOSE_AFTER_CALLING_LAUNCHER);
+	}
+
+	CustomContextClassLoaderExecutor(Optional<ClassLoader> customClassLoader,
+			CustomClassLoaderCloseStrategy closeStrategy) {
 		this.customClassLoader = customClassLoader;
+		this.closeStrategy = closeStrategy;
 	}
 
 	<T> T invoke(Supplier<T> supplier) {
@@ -43,18 +48,7 @@ private <T> T replaceThreadContextClassLoaderAndInvoke(ClassLoader customClassLo
 		}
 		finally {
 			Thread.currentThread().setContextClassLoader(originalClassLoader);
-			if (customClassLoader instanceof AutoCloseable) {
-				close((AutoCloseable) customClassLoader);
-			}
-		}
-	}
-
-	private static void close(AutoCloseable customClassLoader) {
-		try {
-			customClassLoader.close();
-		}
-		catch (Exception e) {
-			throw new JUnitException("Failed to close custom class loader", e);
+			closeStrategy.handle(customClassLoader);
 		}
 	}
 


Test patch:
diff --git a/platform-tests/src/test/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutorTests.java b/platform-tests/src/test/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutorTests.java
index 0c0d6e9ec935..9307d4a26754 100644
--- a/platform-tests/src/test/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutorTests.java
+++ b/platform-tests/src/test/java/org/junit/platform/console/tasks/CustomContextClassLoaderExecutorTests.java
@@ -11,6 +11,7 @@
 package org.junit.platform.console.tasks;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -28,7 +29,7 @@
 class CustomContextClassLoaderExecutorTests {
 
 	@Test
-	void invokeWithoutCustomClassLoaderDoesNotSetClassLoader() throws Exception {
+	void invokeWithoutCustomClassLoaderDoesNotSetClassLoader() {
 		var originalClassLoader = Thread.currentThread().getContextClassLoader();
 		var executor = new CustomContextClassLoaderExecutor(Optional.empty());
 
@@ -42,7 +43,7 @@ void invokeWithoutCustomClassLoaderDoesNotSetClassLoader() throws Exception {
 	}
 
 	@Test
-	void invokeWithCustomClassLoaderSetsCustomAndResetsToOriginal() throws Exception {
+	void invokeWithCustomClassLoaderSetsCustomAndResetsToOriginal() {
 		var originalClassLoader = Thread.currentThread().getContextClassLoader();
 		ClassLoader customClassLoader = URLClassLoader.newInstance(new URL[0]);
 		var executor = new CustomContextClassLoaderExecutor(Optional.of(customClassLoader));
@@ -57,7 +58,7 @@ void invokeWithCustomClassLoaderSetsCustomAndResetsToOriginal() throws Exception
 	}
 
 	@Test
-	void invokeWithCustomClassLoaderAndEnsureItIsClosedAfterUsage() throws Exception {
+	void invokeWithCustomClassLoaderAndEnsureItIsClosedAfterUsage() {
 		var closed = new AtomicBoolean(false);
 		ClassLoader localClassLoader = new URLClassLoader(new URL[0]) {
 			@Override
@@ -73,4 +74,23 @@ public void close() throws IOException {
 		assertEquals(4711, result);
 		assertTrue(closed.get());
 	}
+
+	@Test
+	void invokeWithCustomClassLoaderAndKeepItOpenAfterUsage() {
+		var closed = new AtomicBoolean(false);
+		ClassLoader localClassLoader = new URLClassLoader(new URL[0]) {
+			@Override
+			public void close() throws IOException {
+				closed.set(true);
+				super.close();
+			}
+		};
+		var executor = new CustomContextClassLoaderExecutor(Optional.of(localClassLoader),
+			CustomClassLoaderCloseStrategy.KEEP_OPEN);
+
+		int result = executor.invoke(() -> 4711);
+
+		assertEquals(4711, result);
+		assertFalse(closed.get());
+	}
 }
diff --git a/platform-tooling-support-tests/projects/standalone/src/other/OtherwiseNotReferencedClass.java b/platform-tooling-support-tests/projects/standalone/src/other/OtherwiseNotReferencedClass.java
new file mode 100644
index 000000000000..81be14e5346c
--- /dev/null
+++ b/platform-tooling-support-tests/projects/standalone/src/other/OtherwiseNotReferencedClass.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package other;
+
+public class OtherwiseNotReferencedClass {
+}
diff --git a/platform-tooling-support-tests/projects/standalone/src/standalone/JupiterIntegration.java b/platform-tooling-support-tests/projects/standalone/src/standalone/JupiterIntegration.java
index e8bbce2489cb..a2a1267e3f44 100644
--- a/platform-tooling-support-tests/projects/standalone/src/standalone/JupiterIntegration.java
+++ b/platform-tooling-support-tests/projects/standalone/src/standalone/JupiterIntegration.java
@@ -19,6 +19,9 @@ class JupiterIntegration {
 
 	@Test
 	void successful() {
+		Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+			new other.OtherwiseNotReferencedClass();
+		}));
 	}
 
 	@Test
diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/StandaloneTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/StandaloneTests.java
index 031fd0a039f6..a86b65a8aa51 100644
--- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/StandaloneTests.java
+++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/StandaloneTests.java
@@ -30,7 +30,6 @@
 import java.util.stream.Stream;
 
 import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.MethodOrderer;
 import org.junit.jupiter.api.Order;
 import org.junit.jupiter.api.Test;
@@ -155,8 +154,7 @@ void printVersionViaModule(@FilePrefix("java") OutputFiles outputFiles) throws E
 	@Test
 	@Order(1)
 	@Execution(SAME_THREAD)
-	void compile(@FilePrefix("javac") OutputFiles javacOutputFiles, @FilePrefix("jar") OutputFiles jarOutputFiles)
-			throws Exception {
+	void compile(@FilePrefix("javac") OutputFiles javacOutputFiles) throws Exception {
 		var result = ProcessStarters.javaCommand("javac") //
 				.workingDir(workspace) //
 				.addArguments("-Xlint:-options") //
@@ -164,6 +162,7 @@ void compile(@FilePrefix("javac") OutputFiles javacOutputFiles, @FilePrefix("jar
 				.addArguments("-proc:none") //
 				.addArguments("-d", workspace.resolve("bin").toString()) //
 				.addArguments("--class-path", MavenRepo.jar("junit-platform-console-standalone").toString()) //
+				.addArguments(workspace.resolve("src/other/OtherwiseNotReferencedClass.java").toString()) //
 				.addArguments(workspace.resolve("src/standalone/JupiterIntegration.java").toString()) //
 				.addArguments(workspace.resolve("src/standalone/JupiterParamsIntegration.java").toString()) //
 				.addArguments(workspace.resolve("src/standalone/SuiteIntegration.java").toString()) //
@@ -174,17 +173,6 @@ void compile(@FilePrefix("javac") OutputFiles javacOutputFiles, @FilePrefix("jar
 		assertEquals(0, result.exitCode());
 		assertTrue(result.stdOut().isEmpty());
 		assertTrue(result.stdErr().isEmpty());
-
-		// create "tests.jar" that'll be picked-up by "testWithJarredTestClasses()" later
-		var jarFolder = Files.createDirectories(workspace.resolve("jar"));
-		var jarResult = ProcessStarters.javaCommand("jar") //
-				.workingDir(workspace) //
-				.addArguments("--create") //
-				.addArguments("--file", jarFolder.resolve("tests.jar").toString()) //
-				.addArguments("-C", workspace.resolve("bin").toString(), ".") //
-				.redirectOutput(jarOutputFiles) //
-				.startAndWait();
-		assertEquals(0, jarResult.exitCode());
 	}
 
 	@Test
@@ -431,23 +419,7 @@ void execute(@FilePrefix("console-launcher") OutputFiles outputFiles) throws Exc
 
 		assertEquals(1, result.exitCode());
 
-		var expectedOutLines = Files.readAllLines(workspace.resolve("expected-out.txt"));
-		var expectedErrLines = Files.readAllLines(workspace.resolve("expected-err.txt"));
-		assertLinesMatch(expectedOutLines, result.stdOutLines());
-		var actualErrLines = result.stdErrLines();
-		if (actualErrLines.getFirst().contains("stty: /dev/tty: No such device or address")) {
-			// Happens intermittently on GitHub Actions on Windows
-			actualErrLines = new ArrayList<>(actualErrLines);
-			actualErrLines.removeFirst();
-		}
-		assertLinesMatch(expectedErrLines, actualErrLines);
-
-		var jupiterVersion = Helper.version("junit-jupiter-engine");
-		var vintageVersion = Helper.version("junit-vintage-engine");
-		assertTrue(result.stdErr().contains("junit-jupiter"
-				+ " (group ID: org.junit.jupiter, artifact ID: junit-jupiter-engine, version: " + jupiterVersion));
-		assertTrue(result.stdErr().contains("junit-vintage"
-				+ " (group ID: org.junit.vintage, artifact ID: junit-vintage-engine, version: " + vintageVersion));
+		assertOutputOnCurrentJvm(result);
 	}
 
 	@Test
@@ -530,27 +502,57 @@ private static List<String> getExpectedErrLinesOnJava8(Path workspace) throws IO
 	@Test
 	@Order(6)
 	@Execution(SAME_THREAD)
-	@Disabled("https://github.com/junit-team/junit5/issues/1724")
-	void executeWithJarredTestClasses(@FilePrefix("console-launcher") OutputFiles outputFiles) throws Exception {
-		var jar = MavenRepo.jar("junit-platform-console-standalone");
-		var path = new ArrayList<String>();
-		// path.add("bin"); // "exploded" test classes are found, see also test() above
-		path.add(workspace.resolve("standalone/jar/tests.jar").toAbsolutePath().toString());
-		path.add(jar.toString());
+	void executeWithJarredTestClasses(@FilePrefix("jar") OutputFiles jarOutputFiles,
+			@FilePrefix("console-launcher") OutputFiles outputFiles) throws Exception {
+		var jar = workspace.resolve("tests.jar");
+		var jarResult = ProcessStarters.javaCommand("jar") //
+				.workingDir(workspace) //
+				.addArguments("--create") //
+				.addArguments("--file", jar.toAbsolutePath().toString()) //
+				.addArguments("-C", workspace.resolve("bin").toString(), ".") //
+				.redirectOutput(jarOutputFiles) //
+				.startAndWait();
+
+		assertEquals(0, jarResult.exitCode());
+
 		var result = ProcessStarters.java() //
+				.workingDir(workspace) //
+				.putEnvironment("NO_COLOR", "1") // --disable-ansi-colors
 				.addArguments("--show-version") //
 				.addArguments("-enableassertions") //
 				.addArguments("-Djava.util.logging.config.file=logging.properties") //
-				.addArguments("--class-path", String.join(File.pathSeparator, path)) //
-				.addArguments("org.junit.platform.console.ConsoleLauncher") //
+				.addArguments("-Djunit.platform.launcher.interceptors.enabled=true") //
+				.addArguments("-jar", MavenRepo.jar("junit-platform-console-standalone").toString()) //
 				.addArguments("execute") //
 				.addArguments("--scan-class-path") //
 				.addArguments("--disable-banner") //
 				.addArguments("--include-classname", "standalone.*") //
-				.addArguments("--fail-if-no-tests") //
+				.addArguments("--classpath", jar.toAbsolutePath().toString()) //
 				.redirectOutput(outputFiles) //
 				.startAndWait();
 
 		assertEquals(1, result.exitCode());
+
+		assertOutputOnCurrentJvm(result);
+	}
+
+	private static void assertOutputOnCurrentJvm(ProcessResult result) throws IOException {
+		var expectedOutLines = Files.readAllLines(workspace.resolve("expected-out.txt"));
+		var expectedErrLines = Files.readAllLines(workspace.resolve("expected-err.txt"));
+		assertLinesMatch(expectedOutLines, result.stdOutLines());
+		var actualErrLines = result.stdErrLines();
+		if (actualErrLines.getFirst().contains("stty: /dev/tty: No such device or address")) {
+			// Happens intermittently on GitHub Actions on Windows
+			actualErrLines = new ArrayList<>(actualErrLines);
+			actualErrLines.removeFirst();
+		}
+		assertLinesMatch(expectedErrLines, actualErrLines);
+
+		var jupiterVersion = Helper.version("junit-jupiter-engine");
+		var vintageVersion = Helper.version("junit-vintage-engine");
+		assertTrue(result.stdErr().contains("junit-jupiter"
+				+ " (group ID: org.junit.jupiter, artifact ID: junit-jupiter-engine, version: " + jupiterVersion));
+		assertTrue(result.stdErr().contains("junit-vintage"
+				+ " (group ID: org.junit.vintage, artifact ID: junit-vintage-engine, version: " + vintageVersion));
 	}
 }