From f8481d0bd7229c28b885aca5d63e9bc48555d07b Mon Sep 17 00:00:00 2001 From: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:29:02 +0900 Subject: [PATCH] Don't hijack the global uncaught exception handler during Docker probing DockerClientProviderStrategy.test() probes the Docker socket through an executor-backed Awaitility await. With Awaitility's default settings, uncaught-exception catching is enabled, which installs Awaitility's own Thread.UncaughtExceptionHandler as the JVM-global default handler for the duration of the await. This silently replaces the application's handler while Testcontainers initializes (see the stack trace in #11483). The probed condition runs on a single dedicated thread and never relies on exceptions propagating from other threads, so catching uncaught exceptions provides no value here. Opt out with dontCatchUncaughtExceptions() so the application's global handler is left untouched. Fixes #11483 --- .../DockerClientProviderStrategy.java | 1 + ...waitilityUncaughtExceptionHandlerTest.java | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 core/src/test/java/org/testcontainers/dockerclient/AwaitilityUncaughtExceptionHandlerTest.java 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(); + } +}