From d506b0c1672a61e2d4448ee0e79ed10dc847cf7e Mon Sep 17 00:00:00 2001 From: Richard North Date: Fri, 6 Mar 2026 09:31:42 +0000 Subject: [PATCH 1/2] Add POC test exposing bug in Timeouts.shutdown() implementation Demonstrates that calling Timeouts.shutdown() (as GenericContainer.stop() does in this PR) permanently kills the static shared ExecutorService, causing all subsequent Timeouts usage to fail with RejectedExecutionException. This breaks any test suite that stops one container and then starts another. Co-Authored-By: Claude Opus 4.6 --- .../utility/TimeoutsShutdownTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 core/src/test/java/org/testcontainers/utility/TimeoutsShutdownTest.java diff --git a/core/src/test/java/org/testcontainers/utility/TimeoutsShutdownTest.java b/core/src/test/java/org/testcontainers/utility/TimeoutsShutdownTest.java new file mode 100644 index 00000000000..3ac7a28f6d5 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/TimeoutsShutdownTest.java @@ -0,0 +1,35 @@ +package org.testcontainers.utility; + +import org.junit.Test; +import org.testcontainers.utility.ducttape.Timeouts; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link Timeouts} works correctly across shutdown/reuse cycles. + * After {@code shutdown()} the executor is re-created on next use. + */ +public class TimeoutsShutdownTest { + + @Test + public void timeoutsWorkAfterShutdown() { + // First use + String result1 = Timeouts.getWithTimeout(5, TimeUnit.SECONDS, () -> "container-1-ready"); + assertThat(result1).isEqualTo("container-1-ready"); + + // Shutdown (as GenericContainer.stop() does) + Timeouts.shutdown(); + + // Second use — should transparently create a fresh executor + String result2 = Timeouts.getWithTimeout(5, TimeUnit.SECONDS, () -> "container-2-ready"); + assertThat(result2).isEqualTo("container-2-ready"); + + // Shutdown and use again to confirm repeatable + Timeouts.shutdown(); + + String result3 = Timeouts.getWithTimeout(5, TimeUnit.SECONDS, () -> "container-3-ready"); + assertThat(result3).isEqualTo("container-3-ready"); + } +} From d17370b1f20296bc3c2eb18d6161d1e9040d4404 Mon Sep 17 00:00:00 2001 From: Richard North Date: Fri, 6 Mar 2026 09:45:18 +0000 Subject: [PATCH 2/2] Make Timeouts executor lazily initialized and re-creatable Replace the static final ExecutorService with a lazily-created one so that shutdown() doesn't permanently break subsequent container operations. The executor is re-created transparently on next use. Co-Authored-By: Claude Opus 4.6 --- .../utility/ducttape/Timeouts.java | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/org/testcontainers/utility/ducttape/Timeouts.java b/core/src/main/java/org/testcontainers/utility/ducttape/Timeouts.java index 4433886b4f9..4a70340be2f 100644 --- a/core/src/main/java/org/testcontainers/utility/ducttape/Timeouts.java +++ b/core/src/main/java/org/testcontainers/utility/ducttape/Timeouts.java @@ -9,20 +9,28 @@ */ public class Timeouts { - private static final ExecutorService EXECUTOR_SERVICE = Executors.newCachedThreadPool(new ThreadFactory() { + private static final AtomicInteger THREAD_COUNTER = new AtomicInteger(0); - final AtomicInteger threadCounter = new AtomicInteger(0); + private static final ThreadFactory THREAD_FACTORY = r -> { + Thread thread = new Thread(r, "ducttape-" + THREAD_COUNTER.getAndIncrement()); + thread.setDaemon(true); + return thread; + }; - @Override - public Thread newThread(Runnable r) { - Thread thread = new Thread(r, "ducttape-" + threadCounter.getAndIncrement()); - thread.setDaemon(true); - return thread; + private static volatile ExecutorService executorService; + + private static synchronized ExecutorService getExecutorService() { + if (executorService == null || executorService.isShutdown()) { + executorService = Executors.newCachedThreadPool(THREAD_FACTORY); } - }); + return executorService; + } - public static void shutdown() { - EXECUTOR_SERVICE.shutdown(); + public static synchronized void shutdown() { + if (executorService != null) { + executorService.shutdown(); + executorService = null; + } } /** @@ -40,7 +48,7 @@ public static T getWithTimeout(final int timeout, final TimeUnit timeUnit, f check("timeout must be greater than zero", timeout > 0); - Future future = EXECUTOR_SERVICE.submit(lambda); + Future future = getExecutorService().submit(lambda); return callFuture(timeout, timeUnit, future); } @@ -57,7 +65,7 @@ public static void doWithTimeout(final int timeout, final TimeUnit timeUnit, fin check("timeout must be greater than zero", timeout > 0); - Future future = EXECUTOR_SERVICE.submit(lambda); + Future future = getExecutorService().submit(lambda); callFuture(timeout, timeUnit, future); }