From cb5229156966817679c8756948504e5a3b9757d8 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 3 Jul 2025 14:46:50 +0200 Subject: [PATCH 1/4] Allow configuring log level --- build.gradle | 4 +-- src/main/java/module-info.java | 4 +-- .../process/ProcessOutputConsumer.java | 23 +++++++----- .../org/itsallcode/process/SimpleProcess.java | 36 ++++++++++++------- .../process/SimpleProcessBuilder.java | 29 ++++++++++++++- .../org/itsallcode/process/StreamLogger.java | 34 ++++++++++++++++++ 6 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 src/main/java/org/itsallcode/process/StreamLogger.java diff --git a/build.gradle b/build.gradle index e5197a7..5047c4c 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ plugins { } group = 'org.itsallcode' -version = '0.1.0' +version = '0.2.0' repositories { mavenCentral() @@ -110,8 +110,8 @@ publishing { } } - signing { + required = { gradle.taskGraph.hasTask("publish") } def signingKey = findProperty("signingKey") def signingPassword = findProperty("signingPassword") useInMemoryPgpKeys(signingKey, signingPassword) diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 9fca419..6eab210 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -8,8 +8,8 @@ * {@link org.itsallcode.process.SimpleProcessBuilder#start()}. */ -module simple.process { +module org.itsallcode.process { exports org.itsallcode.process; - requires java.logging; + requires transitive java.logging; } diff --git a/src/main/java/org/itsallcode/process/ProcessOutputConsumer.java b/src/main/java/org/itsallcode/process/ProcessOutputConsumer.java index aa62c77..e9432b4 100644 --- a/src/main/java/org/itsallcode/process/ProcessOutputConsumer.java +++ b/src/main/java/org/itsallcode/process/ProcessOutputConsumer.java @@ -4,12 +4,15 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.Executor; +import java.util.logging.Level; import java.util.logging.Logger; /** * Consumes stdout and stderr of a process asynchronously. */ -class ProcessOutputConsumer { +final class ProcessOutputConsumer { + private static final String STD_ERR_NAME = "stdErr"; + private static final String STD_OUT_NAME = "stdOut"; private static final Logger LOG = Logger.getLogger(ProcessOutputConsumer.class.getName()); private final Executor executor; private final Process process; @@ -18,7 +21,7 @@ class ProcessOutputConsumer { private final StreamCollector stdOutCollector; private final StreamCollector stdErrCollector; - ProcessOutputConsumer(final Executor executor, final Process process, + private ProcessOutputConsumer(final Executor executor, final Process process, final List consumers, final List streamCloseWaiter, final StreamCollector stdOutCollector, final StreamCollector stdErrCollector) { this.executor = executor; @@ -30,15 +33,17 @@ class ProcessOutputConsumer { } static ProcessOutputConsumer create(final Executor executor, final Process process, - final Duration streamCloseTimeout, final StreamCollector stdOutCollector, + final Duration streamCloseTimeout, Level logLevel, final StreamCollector stdOutCollector, final StreamCollector stdErrCollector) { final long pid = process.pid(); - final StreamCloseWaiter stdOutCloseWaiter = new StreamCloseWaiter("stdOut", pid, streamCloseTimeout); - final StreamCloseWaiter stdErrCloseWaiter = new StreamCloseWaiter("stdErr", pid, streamCloseTimeout); - final AsyncStreamConsumer stdOutConsumer = new AsyncStreamConsumer("stdout", pid, process.getInputStream(), - new DelegatingConsumer(List.of(stdOutCloseWaiter, stdOutCollector))); - final AsyncStreamConsumer stdErrConsumer = new AsyncStreamConsumer("stderr", pid, process.getErrorStream(), - new DelegatingConsumer(List.of(stdErrCloseWaiter, stdErrCollector))); + final StreamCloseWaiter stdOutCloseWaiter = new StreamCloseWaiter(STD_OUT_NAME, pid, streamCloseTimeout); + final StreamCloseWaiter stdErrCloseWaiter = new StreamCloseWaiter(STD_ERR_NAME, pid, streamCloseTimeout); + final AsyncStreamConsumer stdOutConsumer = new AsyncStreamConsumer(STD_OUT_NAME, pid, process.getInputStream(), + new DelegatingConsumer( + List.of(stdOutCloseWaiter, stdOutCollector, new StreamLogger(pid, STD_OUT_NAME, logLevel)))); + final AsyncStreamConsumer stdErrConsumer = new AsyncStreamConsumer(STD_ERR_NAME, pid, process.getErrorStream(), + new DelegatingConsumer( + List.of(stdErrCloseWaiter, stdErrCollector, new StreamLogger(pid, STD_ERR_NAME, logLevel)))); return new ProcessOutputConsumer<>(executor, process, List.of(stdOutConsumer, stdErrConsumer), List.of(stdOutCloseWaiter, stdErrCloseWaiter), stdOutCollector, stdErrCollector); } diff --git a/src/main/java/org/itsallcode/process/SimpleProcess.java b/src/main/java/org/itsallcode/process/SimpleProcess.java index 2d1c87d..3b16f08 100644 --- a/src/main/java/org/itsallcode/process/SimpleProcess.java +++ b/src/main/java/org/itsallcode/process/SimpleProcess.java @@ -36,12 +36,12 @@ public int waitForTermination() { private int waitForProcess() { try { LOG.finest(() -> "Waiting for process %d (command '%s') to terminate...".formatted( - process.pid(), command)); + pid(), command)); return process.waitFor(); } catch (final InterruptedException exception) { Thread.currentThread().interrupt(); throw new IllegalStateException( - "Interrupted while waiting for process %d (command '%s') to finish".formatted(process.pid(), + "Interrupted while waiting for process %d (command '%s') to finish".formatted(pid(), command), exception); } @@ -70,7 +70,7 @@ public void waitForTermination(final int expectedExitCode) { if (exitCode != expectedExitCode) { throw new IllegalStateException( "Expected process %d (command '%s') to terminate with exit code %d but was %d" - .formatted(process.pid(), command, expectedExitCode, exitCode)); + .formatted(pid(), command, expectedExitCode, exitCode)); } } @@ -84,7 +84,7 @@ public void waitForTermination(final int expectedExitCode) { */ public void waitForTermination(final Duration timeout) { waitForProcess(timeout); - LOG.finest(() -> "Process %d (command '%s') terminated with exit code %d".formatted(process.pid(), command, + LOG.finest(() -> "Process %d (command '%s') terminated with exit code %d".formatted(pid(), command, exitValue())); consumer.waitForStreamsClosed(); } @@ -92,17 +92,17 @@ public void waitForTermination(final Duration timeout) { private void waitForProcess(final Duration timeout) { try { LOG.finest(() -> "Waiting %s for process %d (command '%s') to terminate...".formatted(timeout, - process.pid(), command)); + pid(), command)); if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) { throw new IllegalStateException( - "Timeout while waiting %s for process %d (command '%s')".formatted(timeout, process.pid(), + "Timeout while waiting %s for process %d (command '%s')".formatted(timeout, pid(), command)); } } catch (final InterruptedException exception) { Thread.currentThread().interrupt(); throw new IllegalStateException( "Interrupted while waiting %s for process %d (command '%s') to finish".formatted(timeout, - process.pid(), command), + pid(), command), exception); } } @@ -112,7 +112,7 @@ private void waitForProcess(final Duration timeout) { * * @return standard output */ - T getStdOut() { + public T getStdOut() { return consumer.getStdOut(); } @@ -121,7 +121,7 @@ T getStdOut() { * * @return standard error */ - T getStdErr() { + public T getStdErr() { return consumer.getStdErr(); } @@ -131,7 +131,7 @@ T getStdErr() { * @return {@code true} if the process has not yet terminated * @see Process#isAlive() */ - boolean isAlive() { + public boolean isAlive() { return process.isAlive(); } @@ -141,16 +141,26 @@ boolean isAlive() { * @return exit value * @see Process#exitValue() */ - int exitValue() { + public int exitValue() { return process.exitValue(); } + /** + * Get the process ID. + * + * @return process ID + * @see Process#pid() + */ + public long pid() { + return process.pid(); + } + /** * Kill the process. * - * @See Process#destroy() + * @see Process#destroy() */ - void destroy() { + public void destroy() { process.destroy(); } diff --git a/src/main/java/org/itsallcode/process/SimpleProcessBuilder.java b/src/main/java/org/itsallcode/process/SimpleProcessBuilder.java index 11d5063..fab3792 100644 --- a/src/main/java/org/itsallcode/process/SimpleProcessBuilder.java +++ b/src/main/java/org/itsallcode/process/SimpleProcessBuilder.java @@ -20,6 +20,7 @@ public final class SimpleProcessBuilder { private final ProcessBuilder processBuilder; private Duration streamCloseTimeout = Duration.ofSeconds(1); private Executor executor; + private Level streamLogLevel = Level.FINE; private SimpleProcessBuilder() { this.processBuilder = new ProcessBuilder(); @@ -59,6 +60,17 @@ public SimpleProcessBuilder command(final List command) { return this; } + /** + * Set working directory to the current process's working directory. + * + * @return {@code this} for fluent programming + * @see ProcessBuilder#directory(java.io.File) + */ + public SimpleProcessBuilder currentProcessWorkingDir() { + this.processBuilder.directory(null); + return this; + } + /** * Set working directory. * @@ -96,6 +108,8 @@ public SimpleProcessBuilder setStreamCloseTimeout(final Duration streamCloseTime /** * Set a custom executor for asynchronous stream readers. + *

+ * Default: Create new threads on demand. * * @param executor executor * @return {@code this} for fluent programming @@ -105,6 +119,19 @@ public SimpleProcessBuilder streamConsumerExecutor(final Executor executor) { return this; } + /** + * Log level for the process's stdout and stderr. + *

+ * Default: {@link Level#FINE} + * + * @param streamLogLevel log level + * @return {@code this} for fluent programming + */ + public SimpleProcessBuilder streamLogLevel(Level streamLogLevel) { + this.streamLogLevel = streamLogLevel; + return this; + } + /** * Start the new process. * @@ -114,7 +141,7 @@ public SimpleProcessBuilder streamConsumerExecutor(final Executor executor) { public SimpleProcess start() { final Process process = startProcess(); final ProcessOutputConsumer consumer = ProcessOutputConsumer.create(getExecutor(process), process, - streamCloseTimeout, new StringCollector(), new StringCollector()); + streamCloseTimeout, streamLogLevel, new StringCollector(), new StringCollector()); consumer.start(); return new SimpleProcess<>(process, consumer, getCommand()); } diff --git a/src/main/java/org/itsallcode/process/StreamLogger.java b/src/main/java/org/itsallcode/process/StreamLogger.java new file mode 100644 index 0000000..204d4dc --- /dev/null +++ b/src/main/java/org/itsallcode/process/StreamLogger.java @@ -0,0 +1,34 @@ +package org.itsallcode.process; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +class StreamLogger implements ProcessStreamConsumer { + + private static final Logger log = Logger.getLogger(StreamLogger.class.getName()); + private final Level logLevel; + private final long pid; + private final String streamName; + + StreamLogger(long pid, String streamName, Level logLevel) { + this.pid = pid; + this.streamName = streamName; + this.logLevel = logLevel; + } + + @Override + public void accept(String line) { + log.log(logLevel, () -> "%d:%s> %s".formatted(pid, streamName, line)); + } + + @Override + public void streamFinished() { + // Ignore + } + + @Override + public void streamReadingFailed(IOException exception) { + // Ignore + } +} From ef17ccd909b92f0bd22fa7eb875d37b2c1ee7b7b Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 5 Jul 2025 17:03:01 +0200 Subject: [PATCH 2/4] Improve test coverage --- .../itsallcode/process/SimpleProcessTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/test/java/org/itsallcode/process/SimpleProcessTest.java b/src/test/java/org/itsallcode/process/SimpleProcessTest.java index 0b7c903..c969ef7 100644 --- a/src/test/java/org/itsallcode/process/SimpleProcessTest.java +++ b/src/test/java/org/itsallcode/process/SimpleProcessTest.java @@ -2,10 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.io.UncheckedIOException; import java.nio.file.Path; import java.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.logging.Level; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -29,6 +33,14 @@ void workingDirNull() { assertThat(process.getStdOut()).isEqualTo("%s%n".formatted(Path.of(".").toAbsolutePath().normalize())); } + @Test + void currentProcessWorkingDir() { + final SimpleProcess process = builder().currentProcessWorkingDir().command("pwd").start(); + process.waitForSuccessfulTermination(); + + assertThat(process.getStdOut()).isEqualTo("%s%n".formatted(Path.of(".").toAbsolutePath().normalize())); + } + @Test void workingDirDefined(@TempDir final Path tempDir) { final SimpleProcess process = builder().command("pwd").workingDir(tempDir).start(); @@ -37,6 +49,28 @@ void workingDirDefined(@TempDir final Path tempDir) { assertThat(process.getStdOut()).isEqualTo("%s%n".formatted(tempDir)); } + @Test + void streamConsumerCloseTimeout() { + final SimpleProcess process = builder().setStreamCloseTimeout(Duration.ofSeconds(3)) + .command("echo", "hello world").start(); + assertDoesNotThrow(process::waitForSuccessfulTermination); + } + + @Test + void customExecutor() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final SimpleProcess process = builder().streamConsumerExecutor(executor) + .command("echo", "hello world").start(); + process.waitForSuccessfulTermination(); + + assertThat(process.getStdOut()).isEqualTo("hello world\n"); + assertThat(process.getStdErr()).isEmpty(); + } finally { + executor.shutdown(); + } + } + @Test void processWritesToStdOut() { final SimpleProcess process = builder().command("echo", "hello world").start(); @@ -46,6 +80,13 @@ void processWritesToStdOut() { assertThat(process.getStdErr()).isEmpty(); } + @Test + void customLogLevel() { + assertDoesNotThrow(() -> builder().streamLogLevel(Level.INFO) + .command("echo", "hello world") + .start().waitForTermination()); + } + @Test void processWritesMultipleLinesToStdOut() { final SimpleProcess process = builder() @@ -145,6 +186,21 @@ void waitForTerminationDoesNotDestroyProcess() { assertThat(process.isAlive()).isTrue(); } + @Test + void waitForTerminationWithWrongExpectedExitCode() { + final SimpleProcess process = builder().command("echo", "hello").start(); + assertThatThrownBy(() -> process.waitForTermination(1)) + .isInstanceOf(IllegalStateException.class) + .hasMessageMatching( + "Expected process \\d+ \\(command 'echo hello'\\) to terminate with exit code 1 but was 0"); + } + + @Test + void waitForTerminationWithCorrectExpectedExitCode() { + final SimpleProcess process = builder().command("echo", "hello").start(); + assertDoesNotThrow(() -> process.waitForTermination(0)); + } + @Test void destroyTerminatesProcess() { final SimpleProcess process = builder().command("sleep", "1").start(); From 8f056e49a854fd35cc96c333b9413e33ae56b05d Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 5 Jul 2025 17:05:01 +0200 Subject: [PATCH 3/4] Add changelog entry --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d36dc..f395a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.0] - unreleased +## [0.3.0] - unreleased -## [0.1.0] - 2025-02-?? +## [0.2.0] - 2025-07-05 + +* [PR #3](https://github.com/itsallcode/simple-process/pull/3): Allow configuring log level + +## [0.1.0] - 2025-06-028 * [PR #1](https://github.com/itsallcode/simple-process/pull/1): Initial release From 0f8fcddc887136f7d81ff55d65ca1dd448e0b04a Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 5 Jul 2025 17:05:07 +0200 Subject: [PATCH 4/4] Fix sonar warning --- src/main/java/org/itsallcode/process/StreamLogger.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/itsallcode/process/StreamLogger.java b/src/main/java/org/itsallcode/process/StreamLogger.java index 204d4dc..2092f27 100644 --- a/src/main/java/org/itsallcode/process/StreamLogger.java +++ b/src/main/java/org/itsallcode/process/StreamLogger.java @@ -6,7 +6,7 @@ class StreamLogger implements ProcessStreamConsumer { - private static final Logger log = Logger.getLogger(StreamLogger.class.getName()); + private static final Logger LOG = Logger.getLogger(StreamLogger.class.getName()); private final Level logLevel; private final long pid; private final String streamName; @@ -19,7 +19,7 @@ class StreamLogger implements ProcessStreamConsumer { @Override public void accept(String line) { - log.log(logLevel, () -> "%d:%s> %s".formatted(pid, streamName, line)); + LOG.log(logLevel, () -> "%d:%s> %s".formatted(pid, streamName, line)); } @Override