diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java index 7b0aaafc169..68783e7c494 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java @@ -208,6 +208,7 @@ protected boolean test() { try (Socket socket = socketProvider.call()) { Awaitility .await() + .dontCatchUncaughtExceptions() // avoid mutating the global uncaught exception handler, see #11483 .atMost(TestcontainersConfiguration.getInstance().getClientPingTimeout(), TimeUnit.SECONDS) // timeout after configured duration .pollInterval(Duration.ofMillis(200)) // check state every 200ms .pollDelay(Duration.ofSeconds(0)) // start checking immediately diff --git a/core/src/test/java/org/testcontainers/dockerclient/AwaitilityUncaughtExceptionHandlerTest.java b/core/src/test/java/org/testcontainers/dockerclient/AwaitilityUncaughtExceptionHandlerTest.java new file mode 100644 index 00000000000..9b30f785e6b --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/AwaitilityUncaughtExceptionHandlerTest.java @@ -0,0 +1,58 @@ +package org.testcontainers.dockerclient; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; + +import java.lang.Thread.UncaughtExceptionHandler; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression test for #11483. + * + *
{@code DockerClientProviderStrategy.test()} pings the Docker socket through an executor-backed
+ * {@code Awaitility.await()...untilAsserted(...)}. With Awaitility's default (uncaught-exception catching enabled),
+ * Awaitility installs its own {@link UncaughtExceptionHandler} as the JVM-global default handler for the duration of
+ * that await, hijacking the application's handler (see the stack trace in the issue). The probed condition runs on a
+ * single dedicated thread and never relies on exceptions surfacing from other threads, so the catching provides no
+ * value here and the strategy opts out via {@code dontCatchUncaughtExceptions()}.
+ */
+class AwaitilityUncaughtExceptionHandlerTest {
+
+ private static final UncaughtExceptionHandler SENTINEL = (thread, throwable) -> {};
+
+ @Test
+ void defaultExecutorPollingHijacksTheGlobalHandler() {
+ // Documents the broken behaviour: while the await runs, the global handler is no longer the application's one.
+ assertThat(handlerSeenWhilePolling(Awaitility.await())).isNotSameAs(SENTINEL);
+ }
+
+ @Test
+ void dontCatchUncaughtExceptionsKeepsTheGlobalHandler() {
+ // The fix applied in DockerClientProviderStrategy#test(): the application's handler stays in place throughout.
+ assertThat(handlerSeenWhilePolling(Awaitility.await().dontCatchUncaughtExceptions())).isSameAs(SENTINEL);
+ }
+
+ /**
+ * Installs {@link #SENTINEL} as the global default handler, then drives an executor-backed await exactly as
+ * {@code DockerClientProviderStrategy#test()} does, and returns the handler that was active while polling.
+ */
+ private static UncaughtExceptionHandler handlerSeenWhilePolling(org.awaitility.core.ConditionFactory factory) {
+ UncaughtExceptionHandler original = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler(SENTINEL);
+ AtomicReference