diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java index 7a095d888f2..80616805f13 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java @@ -2,12 +2,19 @@ import com.github.dockerjava.api.command.LogContainerCmd; import lombok.SneakyThrows; +import org.awaitility.Awaitility; +import org.awaitility.core.TerminalFailureException; import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.output.FrameConsumerResultCallback; import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.containers.output.WaitingConsumer; import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Predicate; @@ -19,7 +26,7 @@ public class LogMessageWaitStrategy extends AbstractWaitStrategy { private int times = 1; @Override - @SneakyThrows(IOException.class) + @SneakyThrows({ IOException.class, InterruptedException.class }) protected void waitUntilReady() { WaitingConsumer waitingConsumer = new WaitingConsumer(); @@ -41,9 +48,43 @@ protected void waitUntilReady() { // (?s) enables line terminator matching (equivalent to Pattern.DOTALL) return outputFrame.getUtf8String().matches("(?s)" + regEx); }; - try { + + // Run the log wait in the background, so that this thread can abort as soon as the + // container exits, instead of waiting for the whole startup timeout. + final Future logWaitFuture = EXECUTOR.submit(() -> { waitingConsumer.waitUntil(waitPredicate, startupTimeout.getSeconds(), TimeUnit.SECONDS, times); - } catch (TimeoutException e) { + return true; + }); + + final Callable logWaitComplete = logWaitFuture::isDone; + try { + Awaitility + .await() + .pollInSameThread() + .pollInterval(Duration.ofMillis(100)) + .pollDelay(Duration.ZERO) + .failFast("container is no longer running", () -> !waitStrategyTarget.isRunning()) + .forever() + .until(logWaitComplete); + } catch (TerminalFailureException e) { + // The container exited while we were still waiting. Give the log wait a brief grace + // period to match any output that was buffered right before the container exited, + // before failing fast. + try { + logWaitFuture.get(1, TimeUnit.SECONDS); + return; + } catch (TimeoutException | ExecutionException | CancellationException graceException) { + throw new IllegalStateException( + "Container stopped before the expected log output matching '" + regEx + "' was produced" + ); + } finally { + logWaitFuture.cancel(true); + } + } + + try { + logWaitFuture.get(0, TimeUnit.SECONDS); + } catch (ExecutionException | TimeoutException | CancellationException e) { throw new ContainerLaunchException("Timed out waiting for log output matching '" + regEx + "'"); } } diff --git a/core/src/test/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategyTest.java b/core/src/test/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategyTest.java new file mode 100644 index 00000000000..ac352a744bb --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategyTest.java @@ -0,0 +1,58 @@ +package org.testcontainers.containers.wait.strategy; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class LogMessageWaitStrategyTest { + + @Test + void shouldFailFastWhenContainerExitsBeforeExpectedLogOutput() { + GenericContainer container = new GenericContainer<>("alpine:3.20") + .withCommand("sh", "-c", "echo started; sleep 2; exit 1") + .waitingFor(Wait.forLogMessage(".*never.*", 1).withStartupTimeout(Duration.ofSeconds(60))); + + Instant start = Instant.now(); + Throwable thrown = catchThrowable(container::start); + Duration elapsed = Duration.between(start, Instant.now()); + + assertThat(thrown) + .as("Container launch fails when the container exits before producing the expected log output") + .isInstanceOf(ContainerLaunchException.class) + .hasRootCauseMessage("Container stopped before the expected log output matching '.*never.*' was produced"); + assertThat(elapsed) + .as("Fails fast instead of waiting for the whole startup timeout") + .isLessThan(Duration.ofSeconds(20)); + } + + @Test + void shouldStillTimeOutWhenContainerKeepsRunningWithoutExpectedLogOutput() { + GenericContainer container = new GenericContainer<>("alpine:3.20") + .withCommand("sh", "-c", "sleep 30") + .waitingFor(Wait.forLogMessage(".*never.*", 1).withStartupTimeout(Duration.ofSeconds(3))); + + Throwable thrown = catchThrowable(container::start); + + assertThat(thrown) + .as("Timeout semantics for a still running container are unchanged") + .isInstanceOf(ContainerLaunchException.class) + .hasRootCauseMessage("Timed out waiting for log output matching '.*never.*'"); + } + + @Test + void shouldSucceedWhenExpectedLogOutputAppearsWhileContainerIsRunning() { + GenericContainer container = new GenericContainer<>("alpine:3.20") + .withCommand("sh", "-c", "echo ready; sleep 5") + .waitingFor(Wait.forLogMessage(".*ready.*", 1).withStartupTimeout(Duration.ofSeconds(30))); + + container.start(); + + assertThat(container.isRunning()).isTrue(); + } +}