From 7a6c15f0405c1c0e7cd38e5788f45db00d726f7f Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Fri, 10 Jul 2026 01:22:44 +0900 Subject: [PATCH] Don't replace the global uncaught exception handler during container startup Testcontainers uses Awaitility in a few core code paths (Docker client discovery, image pulling and the host-port wait strategy). By default Awaitility installs a global default uncaught exception handler through Thread.setDefaultUncaughtExceptionHandler for the duration of each await and restores it afterwards. As a result, simply starting a container temporarily replaces the caller's default handler and can intercept uncaught exceptions thrown by unrelated threads during that window. Opt out of that behaviour with dontCatchUncaughtExceptions() so Testcontainers no longer mutates this JVM-global state. See #11483 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wait/strategy/HostPortWaitStrategy.java | 1 + .../DockerClientProviderStrategy.java | 1 + .../images/RemoteDockerImage.java | 1 + ...rStrategyUncaughtExceptionHandlerTest.java | 72 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 core/src/test/java/org/testcontainers/dockerclient/DockerClientProviderStrategyUncaughtExceptionHandlerTest.java diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java index 5f7eb92818c..f4204db973e 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java @@ -82,6 +82,7 @@ protected void waitUntilReady() { Instant now = Instant.now(); Awaitility .await() + .dontCatchUncaughtExceptions() // don't replace the global uncaught exception handler, see #11483 .pollInSameThread() .pollInterval(Duration.ofMillis(100)) .pollDelay(Duration.ZERO) diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java index 7b0aaafc169..1189501060e 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() // don't replace 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/main/java/org/testcontainers/images/RemoteDockerImage.java b/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java index 9d669e46b07..d25ee9861a5 100644 --- a/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java +++ b/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java @@ -102,6 +102,7 @@ protected final String resolve() { Awaitility .await() + .dontCatchUncaughtExceptions() // don't replace the global uncaught exception handler, see #11483 .pollInSameThread() .pollDelay(Duration.ZERO) // start checking immediately .atMost(PULL_RETRY_TIME_LIMIT) diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerClientProviderStrategyUncaughtExceptionHandlerTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerClientProviderStrategyUncaughtExceptionHandlerTest.java new file mode 100644 index 00000000000..f4a137e5399 --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerClientProviderStrategyUncaughtExceptionHandlerTest.java @@ -0,0 +1,72 @@ +package org.testcontainers.dockerclient; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.testcontainers.utility.MockTestcontainersConfigurationExtension; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.net.ServerSocket; +import java.net.URI; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class DockerClientProviderStrategyUncaughtExceptionHandlerTest { + + @Test + void doesNotReplaceGlobalUncaughtExceptionHandler() throws Exception { + // Keep the strategy test window short, but long enough to observe. + Mockito.doReturn(1).when(TestcontainersConfiguration.getInstance()).getClientPingTimeout(); + + // A port where nothing is listening, so the strategy keeps polling until it times out. + int closedPort; + try (ServerSocket serverSocket = new ServerSocket(0)) { + closedPort = serverSocket.getLocalPort(); + } + + DockerClientProviderStrategy strategy = new DockerClientProviderStrategy() { + @Override + public TransportConfig getTransportConfig() { + return TransportConfig.builder().dockerHost(URI.create("tcp://localhost:" + closedPort)).build(); + } + + @Override + public String getDescription() { + return "strategy pointing at a closed port"; + } + }; + + Thread.UncaughtExceptionHandler original = Thread.getDefaultUncaughtExceptionHandler(); + Thread.UncaughtExceptionHandler sentinel = (thread, throwable) -> {}; + Thread.setDefaultUncaughtExceptionHandler(sentinel); + + // Awaitility installs its own default uncaught exception handler for the duration of the await and + // restores the previous one afterwards, so the replacement is only observable while the await runs. + AtomicBoolean handlerReplaced = new AtomicBoolean(false); + AtomicBoolean stopWatching = new AtomicBoolean(false); + Thread watcher = new Thread(() -> { + while (!stopWatching.get()) { + if (Thread.getDefaultUncaughtExceptionHandler() != sentinel) { + handlerReplaced.set(true); + return; + } + } + }); + watcher.setDaemon(true); + + try { + watcher.start(); + strategy.test(); + } finally { + stopWatching.set(true); + watcher.join(); + Thread.setDefaultUncaughtExceptionHandler(original); + } + + assertThat(handlerReplaced) + .as("Testcontainers must not replace the global uncaught exception handler") + .isFalse(); + } +}