From b59e0c29bad8ba019c3c7fa83c3441389240b989 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:19:04 +0000 Subject: [PATCH 1/3] Prevent duplicate reusable containers on concurrent starts Reusable containers without an explicit name are now named testcontainers-reuse-. Docker's name uniqueness means only one of several concurrent starts can create the container; the others get a 409 Conflict and poll until the winner's container is running, then reuse it. A stopped leftover with the same name and hash is removed so a fresh container can be created. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FgKgGFfJ7u2w6BD4Zgxvd6 --- .../containers/GenericContainer.java | 71 ++++++++++- .../containers/ReusabilityUnitTests.java | 40 ++++++ .../ReusableContainerConcurrencyTest.java | 116 ++++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/org/testcontainers/containers/ReusableContainerConcurrencyTest.java diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 4d3778c63d1..0002177197a 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -6,6 +6,7 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerCmd; import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.exception.ConflictException; import com.github.dockerjava.api.exception.NotFoundException; import com.github.dockerjava.api.model.Bind; import com.github.dockerjava.api.model.ContainerNetwork; @@ -88,6 +89,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -378,6 +380,7 @@ private void tryStart() { createCommand.getLabels().putAll(DockerClientFactory.DEFAULT_LABELS); boolean reused = false; + String reuseHash = null; final boolean reusable; if (shouldBeReused) { if (!canBeReused()) { @@ -400,6 +403,11 @@ private void tryStart() { logger().debug("Can't find a reusable running container with hash: {}", hash); createCommand.getLabels().put(HASH_LABEL, hash); + if (createCommand.getName() == null) { + // Docker enforces unique names, so only one of several concurrent starts can create it + createCommand.withName("testcontainers-reuse-" + hash.substring(0, 12)); + } + reuseHash = hash; } reusable = true; } else { @@ -421,9 +429,13 @@ private void tryStart() { createCommand = ResourceReaper.instance().register(this, createCommand); } - if (!reused) { + if (reuseHash != null) { + reused = createOrAwaitReusableContainer(createCommand, reuseHash); + } else if (!reused) { containerId = createCommand.exec().getId(); + } + if (!reused) { // TODO use single "copy" invocation (and calculate an hash of the resulting tar archive) copyToFileContainerPathMap.forEach(this::copyFileToContainer); @@ -599,6 +611,63 @@ Optional findContainerForReuse(String hash) { .map(it -> it.getId()); } + /** + * Creates the reusable container, or waits for a concurrent start that already claimed its name to run it. + * + * @return {@code true} if an existing container is reused, {@code false} if this call created it + */ + private boolean createOrAwaitReusableContainer(CreateContainerCmd createCommand, String hash) { + AtomicBoolean created = new AtomicBoolean(false); + containerId = + await() + .atMost(1, TimeUnit.MINUTES) + .pollDelay(Duration.ZERO) + .pollInterval(DynamicPollInterval.ofMillis(100)) + .pollInSameThread() + .until( + () -> { + try { + String id = createCommand.exec().getId(); + created.set(true); + return id; + } catch (ConflictException e) { + logger() + .debug("Container {} already exists, waiting for it to run", createCommand.getName()); + } + String runningId = findContainerForReuse(hash).orElse(null); + if (runningId == null) { + removeStoppedContainer(createCommand.getName(), hash); + } + return runningId; + }, + Objects::nonNull + ); + return !created.get(); + } + + /** + * Removes a stopped leftover of this reusable container, so that the name can be claimed again. + */ + private void removeStoppedContainer(String name, String hash) { + try { + InspectContainerResponse existing = dockerClient.inspectContainerCmd(name).exec(); + Map labels = existing.getConfig().getLabels(); + if (labels == null || !hash.equals(labels.get(HASH_LABEL))) { + throw new IllegalStateException( + "Container name " + name + " is already used by a container with a different configuration" + ); + } + String status = existing.getState().getStatus(); + if ("exited".equals(status) || "dead".equals(status)) { + logger().info("Removing stopped reusable container {} ({})", name, existing.getId()); + // Remove by ID, so a container that was re-created in the meantime is not affected + dockerClient.removeContainerCmd(existing.getId()).withForce(true).exec(); + } + } catch (NotFoundException | ConflictException e) { + // Removed by someone else in the meantime + } + } + /** * Set any custom settings for the create command such as shared memory size. */ diff --git a/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java b/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java index 2c70a3f894f..df09ed7b91a 100644 --- a/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java +++ b/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java @@ -9,6 +9,7 @@ import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.command.ListContainersCmd; import com.github.dockerjava.api.command.StartContainerCmd; +import com.github.dockerjava.api.exception.ConflictException; import com.github.dockerjava.api.model.Container; import com.github.dockerjava.core.command.CreateContainerCmdImpl; import com.github.dockerjava.core.command.InspectContainerCmdImpl; @@ -252,6 +253,45 @@ void shouldSetCopiedFilesHashLabel() { assertThat(commandRef.get().getLabels()).containsKeys(GenericContainer.COPIED_FILES_HASH_LABEL); } + @Test + void shouldNameReusableContainerAfterHash() { + Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + AtomicReference commandRef = new AtomicReference<>(); + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId, commandRef::set)); + when(client.listContainersCmd()).then(listContainersAnswer()); + when(client.startContainerCmd(containerId)).then(startContainerAnswer()); + when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); + + container.start(); + + String hash = commandRef.get().getLabels().get(GenericContainer.HASH_LABEL); + assertThat(commandRef.get().getName()).isEqualTo("testcontainers-reuse-" + hash.substring(0, 12)); + } + + @Test + void shouldReuseContainerCreatedConcurrently() { + Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + when(client.createContainerCmd(any())) + .then(invocation -> { + CreateContainerCmd.Exec exec = command -> { + throw new ConflictException("name is already in use"); + }; + return new CreateContainerCmdImpl(exec, null, "image:latest"); + }); + String existingContainerId = randomContainerId(); + // Not running yet when first looked up, running once the concurrent start is done + when(client.listContainersCmd()) + .then(listContainersAnswer()) + .then(listContainersAnswer(existingContainerId)); + when(client.inspectContainerCmd(existingContainerId)).then(inspectContainerAnswer()); + + container.start(); + + assertThat(container.getContainerId()).isEqualTo(existingContainerId); + Mockito.verify(client, Mockito.never()).startContainerCmd(any()); + } + @Test void shouldHashCopiedFiles() { Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); diff --git a/core/src/test/java/org/testcontainers/containers/ReusableContainerConcurrencyTest.java b/core/src/test/java/org/testcontainers/containers/ReusableContainerConcurrencyTest.java new file mode 100644 index 00000000000..47a025e2876 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ReusableContainerConcurrencyTest.java @@ -0,0 +1,116 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.DockerClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.TestImages; +import org.testcontainers.utility.MockTestcontainersConfigurationExtension; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class ReusableContainerConcurrencyTest { + + private final DockerClient dockerClient = DockerClientFactory.instance().client(); + + // Makes the hash unique per test run, so containers from earlier runs are not reused + private final String runId = UUID.randomUUID().toString(); + + @BeforeEach + void enableReuse() { + Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + } + + @AfterEach + void removeContainers() { + dockerClient + .listContainersCmd() + .withShowAll(true) + .withLabelFilter(Collections.singletonMap("test-run-id", runId)) + .exec() + .forEach(it -> dockerClient.removeContainerCmd(it.getId()).withForce(true).exec()); + } + + @Test + void concurrentStartsShareOneContainer() throws Exception { + int starts = 8; + ExecutorService executor = Executors.newFixedThreadPool(starts); + CountDownLatch go = new CountDownLatch(1); + List> futures = new ArrayList<>(); + try { + for (int i = 0; i < starts; i++) { + futures.add( + CompletableFuture.supplyAsync( + () -> { + GenericContainer container = reusableContainer(); + try { + go.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + container.start(); + return container.getContainerId(); + }, + executor + ) + ); + } + go.countDown(); + + Set containerIds = futures.stream().map(CompletableFuture::join).collect(Collectors.toSet()); + + assertThat(containerIds).hasSize(1); + assertThat(containersOfThisRun()).hasSize(1); + } finally { + executor.shutdownNow(); + } + } + + @Test + void replacesStoppedContainer() { + GenericContainer first = reusableContainer(); + first.start(); + dockerClient.stopContainerCmd(first.getContainerId()).withTimeout(0).exec(); + + GenericContainer second = reusableContainer(); + second.start(); + + assertThat(second.getContainerId()).isNotEqualTo(first.getContainerId()); + assertThat(second.isRunning()).isTrue(); + assertThat(containersOfThisRun()).hasSize(1); + } + + private GenericContainer reusableContainer() { + return new GenericContainer<>(TestImages.TINY_IMAGE) + .withCommand("sleep", "infinity") + .withLabel("test-run-id", runId) + .withReuse(true); + } + + private List containersOfThisRun() { + return dockerClient + .listContainersCmd() + .withShowAll(true) + .withLabelFilter(Collections.singletonMap("test-run-id", runId)) + .exec() + .stream() + .map(it -> it.getId()) + .collect(Collectors.toList()); + } +} From 780994e6bc242a1fa5feb89a82539849cfab79d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:40:54 +0000 Subject: [PATCH 2/3] Reclaim reusable containers orphaned in "created" state A start that is killed between create and start leaves the named container in "created", which would block every later start of the same reusable container. Treat a "created" container older than a minute as orphaned and replace it, and fail fast on a paused one. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FgKgGFfJ7u2w6BD4Zgxvd6 --- .../containers/GenericContainer.java | 20 +++++-- .../containers/ReusabilityUnitTests.java | 53 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 0002177197a..2bb8b54e3f5 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -114,6 +114,11 @@ public class GenericContainer> static final String COPIED_FILES_HASH_LABEL = "org.testcontainers.copied_files.hash"; + /** + * How long a reusable container may stay in "created" before it counts as left behind by a killed start + */ + private static final Duration REUSE_ORPHAN_AGE = Duration.ofMinutes(1); + /* * Default settings */ @@ -620,7 +625,7 @@ private boolean createOrAwaitReusableContainer(CreateContainerCmd createCommand, AtomicBoolean created = new AtomicBoolean(false); containerId = await() - .atMost(1, TimeUnit.MINUTES) + .atMost(REUSE_ORPHAN_AGE.multipliedBy(2)) .pollDelay(Duration.ZERO) .pollInterval(DynamicPollInterval.ofMillis(100)) .pollInSameThread() @@ -646,7 +651,7 @@ private boolean createOrAwaitReusableContainer(CreateContainerCmd createCommand, } /** - * Removes a stopped leftover of this reusable container, so that the name can be claimed again. + * Removes a stopped or orphaned leftover of this reusable container, so that the name can be claimed again. */ private void removeStoppedContainer(String name, String hash) { try { @@ -658,8 +663,15 @@ private void removeStoppedContainer(String name, String hash) { ); } String status = existing.getState().getStatus(); - if ("exited".equals(status) || "dead".equals(status)) { - logger().info("Removing stopped reusable container {} ({})", name, existing.getId()); + if ("paused".equals(status)) { + throw new IllegalStateException("Reusable container " + name + " is paused, unpause or remove it"); + } + // A start that dies between create and start leaves the container in "created" for good + boolean orphaned = + "created".equals(status) && + Instant.parse(existing.getCreated()).isBefore(Instant.now().minus(REUSE_ORPHAN_AGE)); + if ("exited".equals(status) || "dead".equals(status) || orphaned) { + logger().info("Removing leftover reusable container {} ({}, {})", name, existing.getId(), status); // Remove by ID, so a container that was re-created in the meantime is not affected dockerClient.removeContainerCmd(existing.getId()).withForce(true).exec(); } diff --git a/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java b/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java index df09ed7b91a..f2cba2c21b5 100644 --- a/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java +++ b/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java @@ -8,6 +8,7 @@ import com.github.dockerjava.api.command.InspectContainerCmd; import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.command.ListContainersCmd; +import com.github.dockerjava.api.command.RemoveContainerCmd; import com.github.dockerjava.api.command.StartContainerCmd; import com.github.dockerjava.api.exception.ConflictException; import com.github.dockerjava.api.model.Container; @@ -22,6 +23,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Answers; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import org.mockito.stubbing.Answer; import org.testcontainers.DockerClientFactory; @@ -35,12 +37,15 @@ import java.io.File; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -292,6 +297,54 @@ void shouldReuseContainerCreatedConcurrently() { Mockito.verify(client, Mockito.never()).startContainerCmd(any()); } + @Test + void shouldReplaceContainerOrphanedInCreatedState() { + Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + AtomicReference commandRef = new AtomicReference<>(); + String containerId = randomContainerId(); + // The name is taken by a container whose start was killed before it ran + AtomicBoolean orphanRemoved = new AtomicBoolean(false); + when(client.createContainerCmd(any())) + .then(invocation -> { + CreateContainerCmd.Exec exec = command -> { + commandRef.set(command); + if (!orphanRemoved.get()) { + throw new ConflictException("name is already in use"); + } + CreateContainerResponse response = new CreateContainerResponse(); + response.setId(containerId); + return response; + }; + return new CreateContainerCmdImpl(exec, null, "image:latest"); + }); + when(client.listContainersCmd()).then(listContainersAnswer()); + String orphanId = randomContainerId(); + InspectContainerResponse orphan = Mockito.mock(InspectContainerResponse.class, Answers.RETURNS_DEEP_STUBS); + when(orphan.getId()).thenReturn(orphanId); + when(orphan.getState().getStatus()).thenReturn("created"); + when(orphan.getCreated()).thenReturn(Instant.now().minus(Duration.ofMinutes(5)).toString()); + when(client.inspectContainerCmd(ArgumentMatchers.startsWith("testcontainers-reuse-"))) + .then(invocation -> { + when(orphan.getConfig().getLabels()).thenReturn(commandRef.get().getLabels()); + return new InspectContainerCmdImpl(command -> orphan, invocation.getArgument(0)); + }); + RemoveContainerCmd removeCmd = Mockito.mock(RemoveContainerCmd.class, Answers.RETURNS_SELF); + when(removeCmd.exec()) + .then(invocation -> { + orphanRemoved.set(true); + return null; + }); + when(client.removeContainerCmd(orphanId)).thenReturn(removeCmd); + when(client.startContainerCmd(containerId)).then(startContainerAnswer()); + when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); + + container.start(); + + Mockito.verify(removeCmd).exec(); + assertThat(container.getContainerId()).isEqualTo(containerId); + Mockito.verify(client).startContainerCmd(containerId); + } + @Test void shouldHashCopiedFiles() { Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); From 0f1a9c01ad9a093a9224df826e7b656bec86c552 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:49:24 +0000 Subject: [PATCH 3/3] Treat an unparseable container creation time as unknown age Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FgKgGFfJ7u2w6BD4Zgxvd6 --- .../containers/GenericContainer.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 2bb8b54e3f5..cefdbfcd426 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -70,6 +70,8 @@ import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -667,9 +669,7 @@ private void removeStoppedContainer(String name, String hash) { throw new IllegalStateException("Reusable container " + name + " is paused, unpause or remove it"); } // A start that dies between create and start leaves the container in "created" for good - boolean orphaned = - "created".equals(status) && - Instant.parse(existing.getCreated()).isBefore(Instant.now().minus(REUSE_ORPHAN_AGE)); + boolean orphaned = "created".equals(status) && isOlderThan(existing.getCreated(), REUSE_ORPHAN_AGE); if ("exited".equals(status) || "dead".equals(status) || orphaned) { logger().info("Removing leftover reusable container {} ({}, {})", name, existing.getId(), status); // Remove by ID, so a container that was re-created in the meantime is not affected @@ -680,6 +680,15 @@ private void removeStoppedContainer(String name, String hash) { } } + private static boolean isOlderThan(String timestamp, Duration age) { + try { + return OffsetDateTime.parse(timestamp).toInstant().isBefore(Instant.now().minus(age)); + } catch (DateTimeParseException | NullPointerException e) { + // Unknown age, so never treat it as orphaned + return false; + } + } + /** * Set any custom settings for the create command such as shared memory size. */