From 663de31e1c92b6f5fdf1cdf94f18c063c4d4604c Mon Sep 17 00:00:00 2001 From: Nikita Kibitkin Date: Sat, 4 Jul 2026 17:18:22 +0200 Subject: [PATCH] Fix NullPointerException on concurrent GenericContainer.stop() calls stop() re-read the mutable containerId field after the null guard, so a concurrent stop() that completes in between, or a containerIsStopping() hook that reenters stop(), made the second read pass null to ResourceReaper.stopAndRemoveContainer, crashing with NullPointerException in ConcurrentHashMap.remove. Read the container state into locals once instead; the duplicate removal attempt is already tolerated by ResourceReaper (NotFoundException is swallowed). Fixes #11492 --- .../containers/GenericContainer.java | 9 +- .../containers/GenericContainerTest.java | 88 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index fa5711807e7..67bef97cb2f 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -635,6 +635,11 @@ private void connectToPortForwardingNetwork(String networkMode) { */ @Override public void stop() { + // Read the mutable state once: a concurrent or reentrant stop() call nulls the + // fields in its finally block, and a second read would pass a null containerId + // to the ResourceReaper (see GH-11492). + String containerId = this.containerId; + InspectContainerResponse containerInfo = this.containerInfo; if (containerId == null) { return; } @@ -652,8 +657,8 @@ public void stop() { ResourceReaper.instance().stopAndRemoveContainer(containerId, imageName); containerIsStopped(containerInfo); } finally { - containerId = null; - containerInfo = null; + this.containerId = null; + this.containerInfo = null; } } diff --git a/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java b/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java index 37aac1b0af9..d58afafe043 100644 --- a/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java +++ b/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java @@ -37,7 +37,12 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -295,6 +300,50 @@ void testStartupAttemptsDoesNotLeaveContainersRunningWhenWrongWaitStrategyIsUsed assertThat(reportLeakedContainers()).isEmpty(); } + /** + * Test case for: https://github.com/testcontainers/testcontainers-java/issues/11492 + */ + @Test + void concurrentStopShouldNotPassNullContainerIdToResourceReaper() throws Exception { + try (LatchedStopContainer container = new LatchedStopContainer(TestImages.TINY_IMAGE)) { + container.withCommand("tail", "-f", "/dev/null"); + container.start(); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future blockedStop = executor.submit(container::stop); + assertThat(container.stopping.await(10, TimeUnit.SECONDS)).isTrue(); + + // completes fully and nulls the container state while the first call is + // still inside containerIsStopping + container.stop(); + container.proceed.countDown(); + + // throws NullPointerException without the containerId snapshot in stop() + blockedStop.get(30, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + assertThat(container.isRunning()).isFalse(); + } + } + + /** + * Test case for: https://github.com/testcontainers/testcontainers-java/issues/11492 + */ + @Test + void reentrantStopFromContainerIsStoppingHookShouldNotThrow() { + try (ReentrantStopContainer container = new ReentrantStopContainer(TestImages.TINY_IMAGE)) { + container.withCommand("tail", "-f", "/dev/null"); + container.start(); + + // throws NullPointerException without the containerId snapshot in stop() + container.stop(); + + assertThat(container.isRunning()).isFalse(); + } + } + private static Optional reportLeakedContainers() { @SuppressWarnings("resource") // Throws when close is attempted, as this is a global instance. DockerClient dockerClient = DockerClientFactory.lazyClient(); @@ -336,6 +385,45 @@ private static Optional reportLeakedContainers() { ); } + static class LatchedStopContainer extends GenericContainer { + + final CountDownLatch stopping = new CountDownLatch(1); + + final CountDownLatch proceed = new CountDownLatch(1); + + private final AtomicBoolean firstStop = new AtomicBoolean(true); + + LatchedStopContainer(DockerImageName image) { + super(image); + } + + @Override + @SneakyThrows + protected void containerIsStopping(InspectContainerResponse containerInfo) { + if (this.firstStop.getAndSet(false)) { + this.stopping.countDown(); + this.proceed.await(10, TimeUnit.SECONDS); + } + } + } + + static class ReentrantStopContainer extends GenericContainer { + + private final AtomicBoolean reentered = new AtomicBoolean(false); + + ReentrantStopContainer(DockerImageName image) { + super(image); + } + + @Override + protected void containerIsStopping(InspectContainerResponse containerInfo) { + if (this.reentered.compareAndSet(false, true)) { + // simulates a hook implementation that triggers another stop of the same container + stop(); + } + } + } + static class NoopStartupCheckStrategy extends StartupCheckStrategy { @Override