From 2c1f4b6a526ef445046be420adff7713201013c9 Mon Sep 17 00:00:00 2001 From: Semyon Levin Date: Mon, 13 Apr 2026 13:45:40 +0400 Subject: [PATCH] Make GenericContainer.start() and stop() thread-safe --- .../containers/GenericContainer.java | 5 +- .../ClickHouseR2DBCDatabaseContainer.java | 3 + .../junit/jupiter/ParallelDependsOnTest.java | 66 +++++++++++++++++++ .../MariaDBR2DBCDatabaseContainer.java | 3 + .../MSSQLR2DBCDatabaseContainer.java | 3 + .../mysql/MySQLR2DBCDatabaseContainer.java | 3 + .../oracle/OracleR2DBCDatabaseContainer.java | 3 + .../PostgreSQLR2DBCDatabaseContainer.java | 3 + .../containers/BrowserWebDriverContainer.java | 2 + .../selenium/BrowserWebDriverContainer.java | 2 + 10 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ParallelDependsOnTest.java diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 0fe944433ae..225b63c0545 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -28,6 +28,7 @@ import lombok.NonNull; import lombok.Setter; import lombok.SneakyThrows; +import lombok.Synchronized; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.SystemUtils; import org.jetbrains.annotations.NotNull; @@ -169,7 +170,7 @@ public class GenericContainer> */ @Setter(AccessLevel.NONE) @VisibleForTesting - String containerId; + volatile String containerId; @Setter(AccessLevel.NONE) private InspectContainerResponse containerInfo; @@ -306,6 +307,7 @@ public String getContainerId() { * Starts the container using docker, pulling an image if necessary. */ @Override + @Synchronized @SneakyThrows({ InterruptedException.class, ExecutionException.class }) public void start() { if (containerId != null) { @@ -634,6 +636,7 @@ private void connectToPortForwardingNetwork(String networkMode) { * Kill and remove the container. */ @Override + @Synchronized public void stop() { if (containerId == null) { return; diff --git a/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainer.java b/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainer.java index be6d8af67c2..21e3d58cb33 100644 --- a/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainer.java +++ b/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainer.java @@ -1,6 +1,7 @@ package org.testcontainers.clickhouse; import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.Synchronized; import org.testcontainers.r2dbc.R2DBCDatabaseContainer; /** @@ -24,11 +25,13 @@ public static ConnectionFactoryOptions getOptions(ClickHouseContainer container) } @Override + @Synchronized public void start() { this.container.start(); } @Override + @Synchronized public void stop() { this.container.stop(); } diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ParallelDependsOnTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ParallelDependsOnTest.java new file mode 100644 index 00000000000..5c1d7f0b778 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ParallelDependsOnTest.java @@ -0,0 +1,66 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.lifecycle.Startables; +import org.testcontainers.utility.DockerImageName; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that concurrent {@link GenericContainer#start()} calls on the same + * container do not result in {@link GenericContainer#doStart()} being + * called more than once. + * + *

A background thread calls {@code container.start()} during class initialization, + * racing with the extension's {@link Startables#deepStart(Stream)} which also starts + * the container as part of the {@code @Container} lifecycle.

+ */ +@Testcontainers(parallel = true) +class ParallelDependsOnTest { + static { + // Pre-initialize the Docker client to increase the chance of a race + // and to emulate a test suite where an earlier test class already + // triggered the initialization. + DockerClientFactory.instance().client(); + } + + private static final AtomicInteger doStartCount = new AtomicInteger(); + + @Container + private static final StartCountingContainer container = new StartCountingContainer( + JUnitJupiterTestImages.HTTPD_IMAGE, + doStartCount + ); + + static { + // Race with the extension's Startables.deepStart. + new Thread(() -> container.start()).start(); + } + + @Test + void containerShouldBeStartedOnlyOnce() { + assertThat(container.isRunning()).isTrue(); + assertThat(doStartCount).as("doStart() invocations").hasValue(1); + } + + private static class StartCountingContainer extends GenericContainer { + + private final AtomicInteger doStartCount; + + StartCountingContainer(DockerImageName image, AtomicInteger doStartCount) { + super(image); + this.doStartCount = doStartCount; + } + + @Override + protected void doStart() { + doStartCount.incrementAndGet(); + super.doStart(); + } + } +} diff --git a/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainer.java b/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainer.java index 4c9e6350d23..5a0977c21a5 100644 --- a/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainer.java +++ b/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainer.java @@ -1,6 +1,7 @@ package org.testcontainers.mariadb; import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.Synchronized; import org.mariadb.r2dbc.MariadbConnectionFactoryProvider; import org.testcontainers.lifecycle.Startable; import org.testcontainers.r2dbc.R2DBCDatabaseContainer; @@ -42,11 +43,13 @@ public Set getDependencies() { } @Override + @Synchronized public void start() { this.container.start(); } @Override + @Synchronized public void stop() { this.container.stop(); } diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainer.java b/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainer.java index d8d7740e01d..3324cdf38c0 100644 --- a/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainer.java +++ b/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainer.java @@ -2,6 +2,7 @@ import io.r2dbc.mssql.MssqlConnectionFactoryProvider; import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.Synchronized; import org.testcontainers.lifecycle.Startable; import org.testcontainers.r2dbc.R2DBCDatabaseContainer; @@ -43,11 +44,13 @@ public Set getDependencies() { } @Override + @Synchronized public void start() { this.container.start(); } @Override + @Synchronized public void stop() { this.container.stop(); } diff --git a/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainer.java b/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainer.java index d2a272559d9..95e26c3803d 100644 --- a/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainer.java +++ b/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainer.java @@ -2,6 +2,7 @@ import io.asyncer.r2dbc.mysql.MySqlConnectionFactoryProvider; import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.Synchronized; import org.testcontainers.lifecycle.Startable; import org.testcontainers.r2dbc.R2DBCDatabaseContainer; @@ -42,11 +43,13 @@ public Set getDependencies() { } @Override + @Synchronized public void start() { this.container.start(); } @Override + @Synchronized public void stop() { this.container.stop(); } diff --git a/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainer.java b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainer.java index ae480027f25..0d7e6e58572 100644 --- a/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainer.java +++ b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainer.java @@ -1,6 +1,7 @@ package org.testcontainers.oracle; import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.Synchronized; import org.testcontainers.lifecycle.Startable; import org.testcontainers.r2dbc.R2DBCDatabaseContainer; @@ -41,11 +42,13 @@ public Set getDependencies() { } @Override + @Synchronized public void start() { this.container.start(); } @Override + @Synchronized public void stop() { this.container.stop(); } diff --git a/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainer.java b/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainer.java index d99d638b100..4b20c91200d 100644 --- a/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainer.java +++ b/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainer.java @@ -2,6 +2,7 @@ import io.r2dbc.postgresql.PostgresqlConnectionFactoryProvider; import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.Synchronized; import org.testcontainers.lifecycle.Startable; import org.testcontainers.r2dbc.R2DBCDatabaseContainer; @@ -42,11 +43,13 @@ public Set getDependencies() { } @Override + @Synchronized public void start() { this.container.start(); } @Override + @Synchronized public void stop() { this.container.stop(); } diff --git a/modules/selenium/src/main/java/org/testcontainers/containers/BrowserWebDriverContainer.java b/modules/selenium/src/main/java/org/testcontainers/containers/BrowserWebDriverContainer.java index 53ee8ba5577..90b1f472997 100644 --- a/modules/selenium/src/main/java/org/testcontainers/containers/BrowserWebDriverContainer.java +++ b/modules/selenium/src/main/java/org/testcontainers/containers/BrowserWebDriverContainer.java @@ -5,6 +5,7 @@ import com.github.dockerjava.api.model.Bind; import com.github.dockerjava.api.model.Volume; import com.google.common.collect.ImmutableSet; +import lombok.Synchronized; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.SystemUtils; import org.jetbrains.annotations.NotNull; @@ -351,6 +352,7 @@ public void afterTest(TestDescription description, Optional throwable } @Override + @Synchronized public void stop() { if (driver != null) { try { diff --git a/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java b/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java index 97ac23f5d55..1de43ef783b 100644 --- a/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java +++ b/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java @@ -5,6 +5,7 @@ import com.github.dockerjava.api.model.Bind; import com.github.dockerjava.api.model.Volume; import com.google.common.collect.ImmutableSet; +import lombok.Synchronized; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.SystemUtils; import org.jetbrains.annotations.NotNull; @@ -202,6 +203,7 @@ public void afterTest(TestDescription description, Optional throwable } @Override + @Synchronized public void stop() { if (vncRecordingContainer != null) { try {