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 observed = new AtomicReference<>(); + try { + factory + .atMost(5, TimeUnit.SECONDS) + .pollInterval(Duration.ofMillis(10)) + .pollDelay(Duration.ZERO) + .untilAsserted(() -> observed.set(Thread.getDefaultUncaughtExceptionHandler())); + } finally { + Thread.setDefaultUncaughtExceptionHandler(original); + } + return observed.get(); + } +}