From 30d42d7bbffab60ac2f882079f1231e94adfbae6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 16 Jul 2026 09:13:41 -0400 Subject: [PATCH] Docker Compose ProcessRunner can hang or leave orphan processes Release the output reader latch in a finally block so a failing stream read or output consumer cannot leave ProcessRunner.run blocked on CountDownLatch.await after the child process has exited. Destroy the child process when waitFor is interrupted so Docker Compose commands are not left running as orphans. Signed-off-by: Sebastien Tardif --- .../docker/compose/core/ProcessRunner.java | 5 +- .../compose/core/ProcessRunnerTests.java | 55 ++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/core/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ProcessRunner.java b/core/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ProcessRunner.java index 2aaac547822a..6fd72d1c83a9 100644 --- a/core/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ProcessRunner.java +++ b/core/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/ProcessRunner.java @@ -139,6 +139,7 @@ private int waitForProcess(Process process) { } catch (InterruptedException ex) { Thread.currentThread().interrupt(); + process.destroy(); throw new IllegalStateException("Interrupted waiting for %s".formatted(process), ex); } } @@ -177,11 +178,13 @@ public void run() { } line = reader.readLine(); } - this.latch.countDown(); } catch (IOException ex) { throw new UncheckedIOException("Failed to read process stream", ex); } + finally { + this.latch.countDown(); + } } @Override diff --git a/core/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ProcessRunnerTests.java b/core/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ProcessRunnerTests.java index 5fc96511bd39..cf501fecbf89 100644 --- a/core/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ProcessRunnerTests.java +++ b/core/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/ProcessRunnerTests.java @@ -16,7 +16,16 @@ package org.springframework.boot.docker.compose.core; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.Timeout.ThreadMode; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.springframework.boot.testsupport.process.DisabledIfProcessUnavailable; @@ -29,19 +38,21 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Sebastien Tardif */ -@DisabledIfProcessUnavailable("docker") class ProcessRunnerTests { - private ProcessRunner processRunner = new ProcessRunner(); + private final ProcessRunner processRunner = new ProcessRunner(); @Test + @DisabledIfProcessUnavailable("docker") void run() { String out = this.processRunner.run("docker", "--version"); assertThat(out).isNotEmpty(); } @Test + @DisabledIfProcessUnavailable("docker") void runWhenHasOutputConsumer() { StringBuilder output = new StringBuilder(); this.processRunner.run(output::append, "docker", "--version"); @@ -55,6 +66,7 @@ void runWhenProcessDoesNotStart() { } @Test + @DisabledIfProcessUnavailable("docker") void runWhenProcessReturnsNonZeroExitCode() { assertThatExceptionOfType(ProcessExitException.class) .isThrownBy(() -> this.processRunner.run("docker", "-thisdoesntwork")) @@ -65,4 +77,43 @@ void runWhenProcessReturnsNonZeroExitCode() { }); } + @Test + @DisabledOnOs(OS.WINDOWS) + @Timeout(value = 5, threadMode = ThreadMode.SEPARATE_THREAD) + void runWhenOutputConsumerThrowsDoesNotHang() { + this.processRunner.run((line) -> { + throw new IllegalStateException("boom"); + }, "echo", "hello"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + @Timeout(value = 10, threadMode = ThreadMode.SEPARATE_THREAD) + void runWhenInterruptedDestroysChildProcess() throws Exception { + Path pidFile = Files.createTempFile("process-runner-", ".pid"); + Files.delete(pidFile); + AtomicReference error = new AtomicReference<>(); + Thread runner = new Thread(() -> { + try { + this.processRunner.run("sh", "-c", "echo $$ > '" + pidFile + "'; exec sleep 60"); + } + catch (Throwable ex) { + error.set(ex); + } + }, "process-runner-interrupt-test"); + runner.start(); + long deadline = System.currentTimeMillis() + 5000; + while (!Files.exists(pidFile) && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + assertThat(pidFile).exists(); + long pid = Long.parseLong(Files.readString(pidFile).trim()); + assertThat(ProcessHandle.of(pid)).isPresent().get().matches(ProcessHandle::isAlive); + runner.interrupt(); + runner.join(Duration.ofSeconds(5).toMillis()); + assertThat(runner.isAlive()).isFalse(); + assertThat(error.get()).isInstanceOf(IllegalStateException.class); + assertThat(ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false)).isFalse(); + } + }