-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
fix: synchronize GenericContainer start()/stop() to prevent duplicate container creation (#11719) #12063
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix: synchronize GenericContainer start()/stop() to prevent duplicate container creation (#11719) #12063
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -157,6 +157,12 @@ public class GenericContainer<SELF extends GenericContainer<SELF>> | |
|
|
||
| protected final Set<Startable> dependencies = new HashSet<>(); | ||
|
|
||
| /** | ||
| * Guards {@link #start()} and {@link #stop()} so that concurrent invocations on the same instance | ||
| * do not create/start more than one underlying container (or race with a concurrent stop). | ||
| */ | ||
| private final Object startLock = new Object(); | ||
|
|
||
| /** | ||
| * Unique instance of DockerClient for use by this container object. | ||
| * We use {@link DockerClientFactory#lazyClient()} here to avoid eager client creation | ||
|
|
@@ -169,7 +175,7 @@ public class GenericContainer<SELF extends GenericContainer<SELF>> | |
| */ | ||
| @Setter(AccessLevel.NONE) | ||
| @VisibleForTesting | ||
| String containerId; | ||
| volatile String containerId; | ||
|
|
||
| @Setter(AccessLevel.NONE) | ||
| private InspectContainerResponse containerInfo; | ||
|
|
@@ -308,13 +314,15 @@ public String getContainerId() { | |
| @Override | ||
| @SneakyThrows({ InterruptedException.class, ExecutionException.class }) | ||
| public void start() { | ||
| if (containerId != null) { | ||
| return; | ||
| synchronized (startLock) { | ||
| if (containerId != null) { | ||
| return; | ||
| } | ||
| Startables.deepStart(dependencies).get(); | ||
| // trigger LazyDockerClient's resolve so that we fail fast here and not in getDockerImageName() | ||
| dockerClient.authConfig(); | ||
| doStart(); | ||
|
Comment on lines
+317
to
+324
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# Description: Locate concurrent stop()/close() call sites that could now block behind start().
rg -nP --type=java -C4 '\b(stop|close)\s*\(\s*\)' -g '!**/build/**' \
core/src/main/java modules 2>/dev/null | rg -n -B6 -A2 'Thread|Executor|shutdownHook|Timeout|submit' | head -100Repository: testcontainers/testcontainers-java Length of output: 3296 🤖 get_repo_knowledge executed:
Length of output: 1160 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- GenericContainer structure and lifecycle methods ---'
rg -n -C8 'startLock|void start\(|void stop\(|void close\(|tryStart\(|doStart\(|waitUntilContainerStarted|startupAttempts|deepStart' core/src/main/java/org/testcontainers/containers/GenericContainer.java
printf '%s\n' '--- changed-file diff summary ---'
git diff --stat -- core/src/main/java/org/testcontainers/containers/GenericContainer.java
printf '%s\n' '--- relevant tests and lifecycle call sites ---'
rg -n -C5 --type=java '(\.start\s*\(|\.stop\s*\(|\.close\s*\()' core/src/test modules/*/src/test 2>/dev/null | rg -n -C3 'Thread|Executor|Future|Timeout|shutdownHook|interrupt|start\s*\(' | head -160Repository: testcontainers/testcontainers-java Length of output: 26530 🏁 Script executed: #!/bin/bash
set -e
sed -n '145,180p;295,340p;480,575p' core/src/main/java/org/testcontainers/containers/GenericContainer.javaRepository: testcontainers/testcontainers-java Length of output: 7355 🌐 Web query:
💡 Result: In Testcontainers for Java, the Citations:
🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- GenericContainer declaration and close binding ---'
sed -n '80,115p;635,710p' core/src/main/java/org/testcontainers/containers/GenericContainer.java
printf '%s\n' '--- Startable close contract and GenericContainer close declarations ---'
rg -n -C6 'interface Startable|default void close|void close\(|implements .*Startable|extends .*Startable' core/src/main/java modules/*/src/main/java 2>/dev/null | head -160Repository: testcontainers/testcontainers-java Length of output: 19664 Prevent 🤖 Prompt for AI Agents |
||
| } | ||
| Startables.deepStart(dependencies).get(); | ||
| // trigger LazyDockerClient's resolve so that we fail fast here and not in getDockerImageName() | ||
| dockerClient.authConfig(); | ||
| doStart(); | ||
| } | ||
|
|
||
| protected void doStart() { | ||
|
|
@@ -635,25 +643,27 @@ private void connectToPortForwardingNetwork(String networkMode) { | |
| */ | ||
| @Override | ||
| public void stop() { | ||
| if (containerId == null) { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| String imageName; | ||
| synchronized (startLock) { | ||
| if (containerId == null) { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| imageName = getDockerImageName(); | ||
| } catch (Exception e) { | ||
| imageName = "<unknown>"; | ||
| } | ||
| String imageName; | ||
|
|
||
| containerIsStopping(containerInfo); | ||
| ResourceReaper.instance().stopAndRemoveContainer(containerId, imageName); | ||
| containerIsStopped(containerInfo); | ||
| } finally { | ||
| containerId = null; | ||
| containerInfo = null; | ||
| try { | ||
| imageName = getDockerImageName(); | ||
| } catch (Exception e) { | ||
| imageName = "<unknown>"; | ||
| } | ||
|
|
||
| containerIsStopping(containerInfo); | ||
| ResourceReaper.instance().stopAndRemoveContainer(containerId, imageName); | ||
| containerIsStopped(containerInfo); | ||
| } finally { | ||
| containerId = null; | ||
| containerInfo = null; | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| package org.testcontainers.containers; | ||
|
|
||
| import com.github.dockerjava.api.DockerClient; | ||
| import com.github.dockerjava.api.command.CreateContainerCmd; | ||
| import com.github.dockerjava.api.command.CreateContainerResponse; | ||
| 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.StartContainerCmd; | ||
| import com.github.dockerjava.core.command.CreateContainerCmdImpl; | ||
| import com.github.dockerjava.core.command.InspectContainerCmdImpl; | ||
| import com.github.dockerjava.core.command.ListContainersCmdImpl; | ||
| import com.github.dockerjava.core.command.StartContainerCmdImpl; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.extension.ExtendWith; | ||
| import org.mockito.Answers; | ||
| import org.mockito.Mockito; | ||
| import org.mockito.stubbing.Answer; | ||
| import org.testcontainers.TestImages; | ||
| import org.testcontainers.containers.startupcheck.StartupCheckStrategy; | ||
| import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; | ||
| import org.testcontainers.utility.MockTestcontainersConfigurationExtension; | ||
|
|
||
| import java.util.Collections; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
| import java.util.concurrent.CopyOnWriteArrayList; | ||
| import java.util.concurrent.CountDownLatch; | ||
| import java.util.concurrent.CyclicBarrier; | ||
| import java.util.concurrent.ExecutorService; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.atomic.AtomicInteger; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| /** | ||
| * Verifies that concurrent calls to {@link GenericContainer#start()} on a single container instance | ||
| * only ever create/start one underlying Docker container. | ||
| * | ||
| * <p>Uses a mocked {@link DockerClient} (like {@code ReusabilityUnitTests}) so the test is fully | ||
| * deterministic and does not require a Docker environment. A latch inside the {@code createContainerCmd} | ||
| * mock forces the racing threads to interleave inside the critical section, so the race is reproduced | ||
| * reliably rather than depending on thread scheduling. | ||
| */ | ||
| @ExtendWith(MockTestcontainersConfigurationExtension.class) | ||
| class GenericContainerConcurrentStartTest { | ||
|
|
||
| private static final int THREADS = 4; | ||
|
|
||
| private final DockerClient client = Mockito.mock(DockerClient.class); | ||
|
|
||
| private final AtomicInteger createInvocations = new AtomicInteger(0); | ||
|
|
||
| private final AtomicInteger startInvocations = new AtomicInteger(0); | ||
|
|
||
| // Latch that holds every thread inside the create command until all racing threads have arrived, | ||
| // guaranteeing they are all past the `containerId == null` guard at the same time. | ||
| private final CountDownLatch insideCreate = new CountDownLatch(THREADS); | ||
|
|
||
| @Test | ||
| void concurrentStartCreatesSingleContainer() throws Exception { | ||
| GenericContainer<?> container = makeTestable(new GenericContainer<>(TestImages.TINY_IMAGE)); | ||
|
|
||
| String containerId = UUID.randomUUID().toString(); | ||
| Mockito.when(client.createContainerCmd(Mockito.any())).then(createContainerAnswer(containerId)); | ||
| Mockito.when(client.listContainersCmd()).then(listContainersAnswer()); | ||
| Mockito.when(client.startContainerCmd(Mockito.anyString())).then(startContainerAnswer()); | ||
| Mockito.when(client.inspectContainerCmd(Mockito.anyString())).then(inspectContainerAnswer()); | ||
|
|
||
| CyclicBarrier barrier = new CyclicBarrier(THREADS); | ||
| ExecutorService executor = Executors.newFixedThreadPool(THREADS); | ||
| List<Throwable> failures = new CopyOnWriteArrayList<>(); | ||
| CountDownLatch done = new CountDownLatch(THREADS); | ||
|
|
||
| for (int i = 0; i < THREADS; i++) { | ||
| executor.submit(() -> { | ||
| try { | ||
| barrier.await(10, TimeUnit.SECONDS); | ||
| container.start(); | ||
| } catch (Throwable t) { | ||
| failures.add(t); | ||
| } finally { | ||
| done.countDown(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| assertThat(done.await(30, TimeUnit.SECONDS)).as("all start() calls completed").isTrue(); | ||
| executor.shutdownNow(); | ||
|
|
||
| assertThat(failures).as("no start() call threw").isEmpty(); | ||
| assertThat(createInvocations.get()).as("only one container was created").isEqualTo(1); | ||
| assertThat(startInvocations.get()).as("only one container was started").isEqualTo(1); | ||
| } | ||
|
|
||
| private <T extends GenericContainer<?>> T makeTestable(T container) { | ||
| container.dockerClient = client; | ||
| container.withNetworkMode("none"); // to disable the port forwarding | ||
| container.withStartupCheckStrategy( | ||
| new StartupCheckStrategy() { | ||
| @Override | ||
| public boolean waitUntilStartupSuccessful(DockerClient dockerClient, String containerId) { | ||
| return true; | ||
| } | ||
|
|
||
| @Override | ||
| public StartupStatus checkStartupState(DockerClient dockerClient, String containerId) { | ||
| return StartupStatus.SUCCESSFUL; | ||
| } | ||
| } | ||
| ); | ||
| container.waitingFor( | ||
| new AbstractWaitStrategy() { | ||
| @Override | ||
| protected void waitUntilReady() {} | ||
| } | ||
| ); | ||
| return container; | ||
| } | ||
|
|
||
| private Answer<CreateContainerCmd> createContainerAnswer(String containerId) { | ||
| return invocation -> { | ||
| CreateContainerCmd.Exec exec = command -> { | ||
| createInvocations.incrementAndGet(); | ||
| // Force any racing threads to be inside the critical section simultaneously, so that a | ||
| // broken (unsynchronized) start() reliably creates more than one container. When start() | ||
| // is correctly synchronized only one thread ever reaches this point; the bounded await | ||
| // then simply elapses without affecting the assertions. | ||
| insideCreate.countDown(); | ||
| try { | ||
| insideCreate.await(500, TimeUnit.MILLISECONDS); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| } | ||
| CreateContainerResponse response = new CreateContainerResponse(); | ||
| response.setId(containerId); | ||
| return response; | ||
| }; | ||
| return new CreateContainerCmdImpl(exec, null, "image:latest"); | ||
| }; | ||
| } | ||
|
|
||
| private Answer<StartContainerCmd> startContainerAnswer() { | ||
| return invocation -> { | ||
| StartContainerCmd.Exec exec = command -> { | ||
| startInvocations.incrementAndGet(); | ||
| return null; | ||
| }; | ||
| return new StartContainerCmdImpl(exec, invocation.getArgument(0)); | ||
| }; | ||
| } | ||
|
|
||
| private Answer<ListContainersCmd> listContainersAnswer() { | ||
| return invocation -> { | ||
| ListContainersCmd.Exec exec = command -> Collections.emptyList(); | ||
| return new ListContainersCmdImpl(exec); | ||
| }; | ||
| } | ||
|
|
||
| private Answer<InspectContainerCmd> inspectContainerAnswer() { | ||
| return invocation -> { | ||
| InspectContainerCmd.Exec exec = command -> { | ||
| InspectContainerResponse stubResponse = Mockito.mock( | ||
| InspectContainerResponse.class, | ||
| Answers.RETURNS_DEEP_STUBS | ||
| ); | ||
| Mockito | ||
| .when(stubResponse.getNetworkSettings().getPorts().getBindings()) | ||
| .thenReturn(Collections.emptyMap()); | ||
| return stubResponse; | ||
| }; | ||
| return new InspectContainerCmdImpl(exec, invocation.getArgument(0)); | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: testcontainers/testcontainers-java
Length of output: 1378
🤖 get_repo_knowledge executed:
get_repo_knowledge testcontainers/testcontainers-java /tmp/coderabbit-repo-knowledge/testcontainers-testcontainers-java-da9f00f5/architectureLength of output: 2381
🏁 Script executed:
Repository: testcontainers/testcontainers-java
Length of output: 10106
Suppress the generated accessor for
startLock.@Datagenerates a publicgetStartLock()method. This exposes the monitor used bystart()andstop(). In-process callers can synchronize on it and block container lifecycle operations. Add@Getter(AccessLevel.NONE).♻️ Proposed fix
+ `@Getter`(AccessLevel.NONE) private final Object startLock = new Object();📝 Committable suggestion
🤖 Prompt for AI Agents