diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index de9ccfb..ee2e1ea 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -13,7 +13,7 @@ jobs:
contents: read
strategy:
matrix:
- java: ['17', '21', '23']
+ java: ['17', '21', '24']
env:
DEFAULT_JAVA: '17'
runs-on: ubuntu-latest
@@ -47,7 +47,7 @@ jobs:
- name: Sonar analysis
if: ${{ env.DEFAULT_JAVA == matrix.java && env.SONAR_TOKEN != null }}
- run: ./gradlew sonar --info --exclude-task integrationTest -Dsonar.token=$SONAR_TOKEN
+ run: ./gradlew sonar --info -Dsonar.token=$SONAR_TOKEN
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml
index 97041ad..548c2a4 100644
--- a/.github/workflows/gh-pages.yml
+++ b/.github/workflows/gh-pages.yml
@@ -29,7 +29,7 @@ jobs:
- name: Build Javadoc
run: ./gradlew javadoc --info
- name: Build Reports
- run: ./gradlew check jacocoTestReport --exclude-task integrationTest --info
+ run: ./gradlew check jacocoTestReport --info
- name: Collect artifacts
run: cp -r build/reports/ build/docs/
- name: Upload artifact
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9d22e00..b862b1b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -46,8 +46,8 @@ jobs:
if: ${{ !inputs.skip-deploy-maven-central }}
run: ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository --warning-mode all
env:
- ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.OSSRH_USERNAME }}
- ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.OSSRH_PASSWORD }}
+ ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.MAVEN_CENTRAL_PORTAL_TOKEN }}
+ ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.MAVEN_CENTRAL_PORTAL_USERNAME }}
ORG_GRADLE_PROJECT_signingKey: ${{ secrets.OSSRH_GPG_SECRET_KEY }}
ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index b38ba1b..b210a23 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -16,4 +16,8 @@
"-Djava.util.logging.config.file=src/test/resources/logging.properties"
]
},
+ "sonarlint.connectedMode.project": {
+ "connectionId": "itsallcode",
+ "projectKey": "org.itsallcode:simple-process"
+ }
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e4d36dc
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog
+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.1.0] - 2025-02-??
+
+* [PR #1](https://github.com/itsallcode/simple-process/pull/1): Initial release
diff --git a/README.md b/README.md
index d9f0f52..a3dc786 100644
--- a/README.md
+++ b/README.md
@@ -4,3 +4,94 @@ Wrapper to simplify working with external processes.
**This project is at an early development stage and the API will change without backwards compatibility.**
+[](https://github.com/itsallcode/simple-process/actions/workflows/build.yml)
+[](https://github.com/itsallcode/simple-process/actions/workflows/codeql-analysis.yml)
+[](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process)
+[](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process)
+[](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process)
+[](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process)
+[](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process)
+[](https://search.maven.org/artifact/org.itsallcode/simple-process)
+
+* [Changelog](CHANGELOG.md)
+* [API JavaDoc](https://blog.itsallcode.org/simple-process/javadoc/org.itsallcode.process/module-summary.html)
+* [Test report](https://blog.itsallcode.org/simple-process/reports/tests/test/index.html)
+* [Coverage report](https://blog.itsallcode.org/simple-process/reports/jacoco/test/html/index.html)
+
+## Usage
+
+This project requires Java 17 or later.
+
+### Add Dependency
+
+Add dependency to your Gradle project:
+
+```groovy
+dependencies {
+ implementation 'org.itsallcode:simple-process:0.1.0'
+}
+```
+
+Add dependency to your Maven project:
+
+```xml
+
+ org.itsallcode
+ simple-process
+ 0.1.0
+
+```
+
+### Features
+
+Simplified API for starting external processes and executable JARs. Allows easy capturing stdout and stderr and forwarding to log output.
+
+## Development
+
+### Check if dependencies are up-to-date
+
+```sh
+./gradlew dependencyUpdates
+```
+
+### Building
+
+Install to local maven repository:
+
+```sh
+./gradlew publishToMavenLocal
+```
+
+### Test Coverage
+
+To calculate and view test coverage:
+
+```sh
+./gradlew check jacocoTestReport
+open build/reports/jacoco/test/html/index.html
+```
+
+### View Generated Javadoc
+
+```sh
+./gradlew javadoc
+open build/docs/javadoc/index.html
+```
+
+### Publish to Maven Central
+
+#### Preparations
+
+1. Checkout the `main` branch, create a new branch.
+2. Update version number in `build.gradle` and `README.md`.
+3. Add changes in new version to `CHANGELOG.md`.
+4. Commit and push changes.
+5. Create a new pull request, have it reviewed and merged to `main`.
+
+#### Perform the Release
+
+1. Start the release workflow
+ * Run command `gh workflow run release.yml --repo itsallcode/simple-process --ref main`
+ * or go to [GitHub Actions](https://github.com/itsallcode/simple-process/actions/workflows/release.yml) and start the `release.yml` workflow on branch `main`.
+2. Update title and description of the newly created [GitHub release](https://github.com/itsallcode/simple-process/releases).
+3. After some time the release will be available at [Maven Central](https://repo1.maven.org/maven2/org/itsallcode/simple-process/).
diff --git a/build.gradle b/build.gradle
index 032b194..69d477c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -5,7 +5,7 @@ plugins {
id 'jacoco-report-aggregation'
id 'signing'
id 'maven-publish'
- id 'org.sonarqube' version '6.0.1.5171'
+ id 'org.sonarqube' version '6.2.0.5505'
id "io.github.gradle-nexus.publish-plugin" version "2.0.0"
id 'com.github.ben-manes.versions' version '0.52.0'
}
@@ -18,17 +18,21 @@ repositories {
}
dependencies {
- // This dependency is exported to consumers, that is to say found on their compile classpath.
- api libs.commons.math3
-
- // This dependency is used internally, and not exposed to consumers on their own compile classpath.
- implementation libs.guava
}
testing {
suites {
test {
useJUnitJupiter(libs.versions.junitJupiter.get())
+ dependencies {
+ implementation libs.assertj
+ implementation libs.mockitoJunit
+ }
+ targets.all {
+ testTask.configure {
+ systemProperty 'java.util.logging.config.file', file('src/test/resources/logging.properties')
+ }
+ }
}
}
}
@@ -119,6 +123,8 @@ nexusPublishing {
repositories {
sonatype {
stagingProfileId = "546ea6ce74787e"
+ nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/"))
+ snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/"))
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 75919d3..75e0866 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,11 +2,14 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions]
-commons-math3 = "3.6.1"
-guava = "33.3.1-jre"
junitJupiter = "5.11.1"
+mockito = "5.18.0"
+assertj = "3.27.3"
+equalsverifier = "3.18.1"
+tostringverifier = "1.4.8"
[libraries]
-commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" }
-guava = { module = "com.google.guava:guava", version.ref = "guava" }
-
+mockitoJunit = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" }
+equalsverifier = { module = "nl.jqno.equalsverifier:equalsverifier", version.ref = "equalsverifier" }
+tostringverifier = { module = "com.jparams:to-string-verifier", version.ref = "tostringverifier" }
+assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index a4b76b9..1b33c55 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index e18bc25..ff23a68 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index f3b75f3..23d15a9 100755
--- a/gradlew
+++ b/gradlew
@@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -205,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
diff --git a/gradlew.bat b/gradlew.bat
index 9d21a21..db3a6ac 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+set CLASSPATH=
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
new file mode 100644
index 0000000..9fca419
--- /dev/null
+++ b/src/main/java/module-info.java
@@ -0,0 +1,15 @@
+
+/**
+ * Simplified usage of Java's {@link java.lang.Process} API.
+ *
+ * Create a new process builder using
+ * {@link org.itsallcode.process.SimpleProcessBuilder#create()} and start the
+ * process with
+ * {@link org.itsallcode.process.SimpleProcessBuilder#start()}.
+ */
+
+module simple.process {
+ exports org.itsallcode.process;
+
+ requires java.logging;
+}
diff --git a/src/main/java/org/example/Library.java b/src/main/java/org/example/Library.java
deleted file mode 100644
index 0bc27a0..0000000
--- a/src/main/java/org/example/Library.java
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * This source file was generated by the Gradle 'init' task
- */
-package org.example;
-
-/**
- * Dummy code
- */
-public class Library {
- /**
- * Dummy method
- *
- * @return value
- */
- public boolean someLibraryMethod() {
- return true;
- }
-}
diff --git a/src/main/java/org/itsallcode/process/AsyncStreamConsumer.java b/src/main/java/org/itsallcode/process/AsyncStreamConsumer.java
new file mode 100644
index 0000000..ed8b15c
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/AsyncStreamConsumer.java
@@ -0,0 +1,42 @@
+package org.itsallcode.process;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+class AsyncStreamConsumer implements Runnable {
+ private static final Logger LOG = Logger.getLogger(AsyncStreamConsumer.class.getName());
+ private final String name;
+ private final long pid;
+ private final ProcessStreamConsumer consumer;
+ private final InputStream stream;
+
+ AsyncStreamConsumer(final String name, final long pid, final InputStream stream,
+ final ProcessStreamConsumer consumer) {
+ this.name = name;
+ this.pid = pid;
+ this.stream = stream;
+ this.consumer = consumer;
+ }
+
+ @Override
+ public void run() {
+ LOG.finest(() -> "Start reading from '%s' stream of process %d...".formatted(name, pid));
+ try (final BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
+ String line = null;
+ while ((line = reader.readLine()) != null) {
+ consumer.accept(line);
+ }
+ LOG.finest(() -> "Stream '%s' of process %d finished".formatted(name, pid));
+ consumer.streamFinished();
+ } catch (final IOException exception) {
+ final Level logLevel = "Stream closed".equals(exception.getMessage()) ? Level.FINEST : Level.WARNING;
+ LOG.log(logLevel,
+ "Reading stream '%s' of process %d failed: %s".formatted(name, pid,
+ exception.getMessage()),
+ exception);
+ consumer.streamReadingFailed(exception);
+ }
+ }
+}
diff --git a/src/main/java/org/itsallcode/process/DelegatingConsumer.java b/src/main/java/org/itsallcode/process/DelegatingConsumer.java
new file mode 100644
index 0000000..4532c84
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/DelegatingConsumer.java
@@ -0,0 +1,29 @@
+package org.itsallcode.process;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+
+class DelegatingConsumer implements ProcessStreamConsumer {
+
+ private final List delegates;
+
+ DelegatingConsumer(final List delegates) {
+ this.delegates = Collections.unmodifiableList(delegates);
+ }
+
+ @Override
+ public void accept(final String line) {
+ delegates.forEach(delegate -> delegate.accept(line));
+ }
+
+ @Override
+ public void streamFinished() {
+ delegates.forEach(ProcessStreamConsumer::streamFinished);
+ }
+
+ @Override
+ public void streamReadingFailed(final IOException exception) {
+ delegates.forEach(delegate -> delegate.streamReadingFailed(exception));
+ }
+}
diff --git a/src/main/java/org/itsallcode/process/ProcessOutputConsumer.java b/src/main/java/org/itsallcode/process/ProcessOutputConsumer.java
new file mode 100644
index 0000000..aa62c77
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/ProcessOutputConsumer.java
@@ -0,0 +1,63 @@
+package org.itsallcode.process;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+/**
+ * Consumes stdout and stderr of a process asynchronously.
+ */
+class ProcessOutputConsumer {
+ private static final Logger LOG = Logger.getLogger(ProcessOutputConsumer.class.getName());
+ private final Executor executor;
+ private final Process process;
+ private final List consumers;
+ private final List streamCloseWaiter;
+ private final StreamCollector stdOutCollector;
+ private final StreamCollector stdErrCollector;
+
+ ProcessOutputConsumer(final Executor executor, final Process process,
+ final List consumers, final List streamCloseWaiter,
+ final StreamCollector stdOutCollector, final StreamCollector stdErrCollector) {
+ this.executor = executor;
+ this.process = process;
+ this.consumers = Collections.unmodifiableList(consumers);
+ this.streamCloseWaiter = Collections.unmodifiableList(streamCloseWaiter);
+ this.stdOutCollector = stdOutCollector;
+ this.stdErrCollector = stdErrCollector;
+ }
+
+ static ProcessOutputConsumer create(final Executor executor, final Process process,
+ final Duration streamCloseTimeout, 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)));
+ return new ProcessOutputConsumer<>(executor, process, List.of(stdOutConsumer, stdErrConsumer),
+ List.of(stdOutCloseWaiter, stdErrCloseWaiter), stdOutCollector, stdErrCollector);
+ }
+
+ void start() {
+ LOG.finest(() -> "Start reading stdout and stderr streams of process %d in background..."
+ .formatted(process.pid()));
+ consumers.forEach(executor::execute);
+ }
+
+ T getStdOut() {
+ return stdOutCollector.getResult();
+ }
+
+ T getStdErr() {
+ return stdErrCollector.getResult();
+ }
+
+ void waitForStreamsClosed() {
+ streamCloseWaiter.forEach(StreamCloseWaiter::waitUntilStreamClosed);
+ }
+}
diff --git a/src/main/java/org/itsallcode/process/ProcessStreamConsumer.java b/src/main/java/org/itsallcode/process/ProcessStreamConsumer.java
new file mode 100644
index 0000000..3414755
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/ProcessStreamConsumer.java
@@ -0,0 +1,12 @@
+package org.itsallcode.process;
+
+import java.io.IOException;
+
+interface ProcessStreamConsumer {
+
+ void accept(String line);
+
+ void streamFinished();
+
+ void streamReadingFailed(IOException exception);
+}
diff --git a/src/main/java/org/itsallcode/process/SimpleProcess.java b/src/main/java/org/itsallcode/process/SimpleProcess.java
new file mode 100644
index 0000000..2d1c87d
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/SimpleProcess.java
@@ -0,0 +1,165 @@
+package org.itsallcode.process;
+
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+/**
+ * Provides control over native processes.
+ *
+ * @param type of stdout and stderr, e.g. {@link String}.
+ */
+public class SimpleProcess {
+ private static final Logger LOG = Logger.getLogger(SimpleProcess.class.getName());
+ private final Process process;
+ private final String command;
+ private final ProcessOutputConsumer consumer;
+
+ SimpleProcess(final Process process, final ProcessOutputConsumer consumer, final String command) {
+ this.process = process;
+ this.consumer = consumer;
+ this.command = command;
+ }
+
+ /**
+ * Wait until the process has terminated.
+ *
+ * @return exit code
+ * @see Process#waitFor()
+ */
+ public int waitForTermination() {
+ final int exitCode = waitForProcess();
+ consumer.waitForStreamsClosed();
+ return exitCode;
+ }
+
+ private int waitForProcess() {
+ try {
+ LOG.finest(() -> "Waiting for process %d (command '%s') to terminate...".formatted(
+ process.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(),
+ command),
+ exception);
+ }
+ }
+
+ /**
+ * Wait until the process terminates successfully with exit code {@code 0}.
+ *
+ * @throws IllegalStateException if exit code is not equal to {@code 0}.
+ * @see #waitForTermination()
+ */
+ public void waitForSuccessfulTermination() {
+ waitForTermination(0);
+ }
+
+ /**
+ * Wait until the process terminates successfully with the given exit code.
+ *
+ * @param expectedExitCode expected exit code
+ * @throws IllegalStateException if exit code is not equal to the given expected
+ * exit code.
+ * @see #waitForTermination(int)
+ */
+ public void waitForTermination(final int expectedExitCode) {
+ final int exitCode = waitForTermination();
+ 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));
+ }
+ }
+
+ /**
+ * Wait until the process terminates with the given timeout.
+ *
+ * @param timeout maximum time to wait for the termination
+ * @throws IllegalStateException if process does not exit within the given
+ * timeout.
+ * @see Process#waitFor(long, TimeUnit)
+ */
+ public void waitForTermination(final Duration timeout) {
+ waitForProcess(timeout);
+ LOG.finest(() -> "Process %d (command '%s') terminated with exit code %d".formatted(process.pid(), command,
+ exitValue()));
+ consumer.waitForStreamsClosed();
+ }
+
+ private void waitForProcess(final Duration timeout) {
+ try {
+ LOG.finest(() -> "Waiting %s for process %d (command '%s') to terminate...".formatted(timeout,
+ process.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(),
+ 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),
+ exception);
+ }
+ }
+
+ /**
+ * Get the standard output of the process.
+ *
+ * @return standard output
+ */
+ T getStdOut() {
+ return consumer.getStdOut();
+ }
+
+ /**
+ * Get the standard error of the process.
+ *
+ * @return standard error
+ */
+ T getStdErr() {
+ return consumer.getStdErr();
+ }
+
+ /**
+ * Check wether the process is alive.
+ *
+ * @return {@code true} if the process has not yet terminated
+ * @see Process#isAlive()
+ */
+ boolean isAlive() {
+ return process.isAlive();
+ }
+
+ /**
+ * Get the exit value of the process.
+ *
+ * @return exit value
+ * @see Process#exitValue()
+ */
+ int exitValue() {
+ return process.exitValue();
+ }
+
+ /**
+ * Kill the process.
+ *
+ * @See Process#destroy()
+ */
+ void destroy() {
+ process.destroy();
+ }
+
+ /**
+ * Kill the process forcibly.
+ *
+ * @see Process#destroyForcibly()
+ */
+ public void destroyForcibly() {
+ process.destroyForcibly();
+ }
+}
diff --git a/src/main/java/org/itsallcode/process/SimpleProcessBuilder.java b/src/main/java/org/itsallcode/process/SimpleProcessBuilder.java
new file mode 100644
index 0000000..11d5063
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/SimpleProcessBuilder.java
@@ -0,0 +1,163 @@
+package org.itsallcode.process;
+
+import static java.util.stream.Collectors.joining;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.Thread.UncaughtExceptionHandler;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Builder for {@link SimpleProcess}. Create a new instance with
+ * {@link #create()}.
+ */
+public final class SimpleProcessBuilder {
+ private final ProcessBuilder processBuilder;
+ private Duration streamCloseTimeout = Duration.ofSeconds(1);
+ private Executor executor;
+
+ private SimpleProcessBuilder() {
+ this.processBuilder = new ProcessBuilder();
+ }
+
+ /**
+ * Create a new instance.
+ *
+ * @return new {@link SimpleProcessBuilder}
+ */
+ public static SimpleProcessBuilder create() {
+ return new SimpleProcessBuilder();
+ }
+
+ /**
+ * Set program and arguments.
+ *
+ * @param command program and arguments
+ * @return {@code this} for fluent programming
+ * @see ProcessBuilder#command(String...)
+ */
+ @SuppressWarnings("java:S923") // Using varargs by intention
+ public SimpleProcessBuilder command(final String... command) {
+ this.processBuilder.command(command);
+ return this;
+ }
+
+ /**
+ * Set program and arguments.
+ *
+ * @param command program and arguments
+ * @return {@code this} for fluent programming
+ * @see ProcessBuilder#command(List)
+ */
+ public SimpleProcessBuilder command(final List command) {
+ this.processBuilder.command(command);
+ return this;
+ }
+
+ /**
+ * Set working directory.
+ *
+ * @param workingDir working directory
+ * @return {@code this} for fluent programming
+ * @see ProcessBuilder#directory(java.io.File)
+ */
+ public SimpleProcessBuilder workingDir(final Path workingDir) {
+ this.processBuilder.directory(workingDir.toFile());
+ return this;
+ }
+
+ /**
+ * Redirect the error stream to the output stream if this is {@code true}.
+ *
+ * @param redirectErrorStream the new property value, default: {@code false}
+ * @return {@code this} for fluent programming
+ * @see ProcessBuilder#redirectErrorStream(boolean)
+ */
+ public SimpleProcessBuilder redirectErrorStream(final boolean redirectErrorStream) {
+ this.processBuilder.redirectErrorStream(redirectErrorStream);
+ return this;
+ }
+
+ /**
+ * Set the timeout for closing the asynchronous stream readers.
+ *
+ * @param streamCloseTimeout timeout
+ * @return {@code this} for fluent programming
+ */
+ public SimpleProcessBuilder setStreamCloseTimeout(final Duration streamCloseTimeout) {
+ this.streamCloseTimeout = streamCloseTimeout;
+ return this;
+ }
+
+ /**
+ * Set a custom executor for asynchronous stream readers.
+ *
+ * @param executor executor
+ * @return {@code this} for fluent programming
+ */
+ public SimpleProcessBuilder streamConsumerExecutor(final Executor executor) {
+ this.executor = executor;
+ return this;
+ }
+
+ /**
+ * Start the new process.
+ *
+ * @return a new process
+ * @see ProcessBuilder#start()
+ */
+ public SimpleProcess start() {
+ final Process process = startProcess();
+ final ProcessOutputConsumer consumer = ProcessOutputConsumer.create(getExecutor(process), process,
+ streamCloseTimeout, new StringCollector(), new StringCollector());
+ consumer.start();
+ return new SimpleProcess<>(process, consumer, getCommand());
+ }
+
+ private Process startProcess() {
+ try {
+ return processBuilder.start();
+ } catch (final IOException exception) {
+ throw new UncheckedIOException(
+ "Failed to start process %s in working dir %s: %s".formatted(processBuilder.command(),
+ processBuilder.directory(), exception.getMessage()),
+ exception);
+ }
+ }
+
+ private Executor getExecutor(final Process process) {
+ if (this.executor != null) {
+ return executor;
+ }
+ return createThreadExecutor(process.pid());
+ }
+
+ private static Executor createThreadExecutor(final long pid) {
+ return runnable -> {
+ final Thread thread = new Thread(runnable);
+ thread.setName("SimpleProcess-" + pid);
+ thread.setUncaughtExceptionHandler(new LoggingExceptionHandler());
+ thread.start();
+ };
+ }
+
+ private String getCommand() {
+ return processBuilder.command().stream().collect(joining(" "));
+ }
+
+ private static class LoggingExceptionHandler implements UncaughtExceptionHandler {
+ private static final Logger LOG = Logger.getLogger(LoggingExceptionHandler.class.getName());
+
+ @Override
+ public void uncaughtException(final Thread thread, final Throwable exception) {
+ LOG.log(Level.WARNING,
+ "Exception occurred in thread '%s': %s".formatted(thread.getName(), exception.toString()),
+ exception);
+ }
+ }
+}
diff --git a/src/main/java/org/itsallcode/process/StreamCloseWaiter.java b/src/main/java/org/itsallcode/process/StreamCloseWaiter.java
new file mode 100644
index 0000000..5836ca6
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/StreamCloseWaiter.java
@@ -0,0 +1,60 @@
+package org.itsallcode.process;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+class StreamCloseWaiter implements ProcessStreamConsumer {
+ private static final Logger LOG = Logger.getLogger(StreamCloseWaiter.class.getName());
+ private final CountDownLatch streamFinished = new CountDownLatch(1);
+ private final Duration streamCloseTimeout;
+ private final String name;
+ private final long pid;
+
+ StreamCloseWaiter(final String name, final long pid, final Duration streamCloseTimeout) {
+ this.name = name;
+ this.pid = pid;
+ this.streamCloseTimeout = streamCloseTimeout;
+ }
+
+ void waitUntilStreamClosed() {
+ LOG.finest(
+ () -> "Waiting %s for stream '%s' of process %d to close".formatted(streamCloseTimeout, name, pid));
+ if (!await(streamCloseTimeout)) {
+ throw new IllegalStateException(
+ "Stream '%s' of process %d not closed within timeout of %s".formatted(name, pid,
+ streamCloseTimeout));
+ } else {
+ LOG.finest(() -> "Stream '%s' of process %d closed".formatted(name, pid));
+ }
+ }
+
+ private boolean await(final Duration timeout) {
+ try {
+ return streamFinished.await(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (final InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(
+ "Interrupted while waiting for stream '%s' of process %d to be closed: %s"
+ .formatted(name, pid, exception.getMessage()),
+ exception);
+ }
+ }
+
+ @Override
+ public void accept(final String line) {
+ // ignore
+ }
+
+ @Override
+ public void streamFinished() {
+ streamFinished.countDown();
+ }
+
+ @Override
+ public void streamReadingFailed(final IOException exception) {
+ streamFinished.countDown();
+ }
+}
diff --git a/src/main/java/org/itsallcode/process/StreamCollector.java b/src/main/java/org/itsallcode/process/StreamCollector.java
new file mode 100644
index 0000000..11cd6a8
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/StreamCollector.java
@@ -0,0 +1,5 @@
+package org.itsallcode.process;
+
+interface StreamCollector extends ProcessStreamConsumer {
+ T getResult();
+}
diff --git a/src/main/java/org/itsallcode/process/StringCollector.java b/src/main/java/org/itsallcode/process/StringCollector.java
new file mode 100644
index 0000000..346ba7d
--- /dev/null
+++ b/src/main/java/org/itsallcode/process/StringCollector.java
@@ -0,0 +1,28 @@
+package org.itsallcode.process;
+
+import java.io.IOException;
+
+class StringCollector implements StreamCollector {
+
+ private final StringBuilder builder = new StringBuilder();
+
+ @Override
+ public void accept(final String line) {
+ builder.append(line).append("\n");
+ }
+
+ @Override
+ public void streamFinished() {
+ // Nothing to do
+ }
+
+ @Override
+ public void streamReadingFailed(final IOException exception) {
+ // Nothing to do
+ }
+
+ @Override
+ public String getResult() {
+ return builder.toString();
+ }
+}
diff --git a/src/test/java/org/example/LibraryTest.java b/src/test/java/org/example/LibraryTest.java
deleted file mode 100644
index ef34950..0000000
--- a/src/test/java/org/example/LibraryTest.java
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * This source file was generated by the Gradle 'init' task
- */
-package org.example;
-
-import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
-
-class LibraryTest {
- @Test void someLibraryMethodReturnsTrue() {
- Library classUnderTest = new Library();
- assertTrue(classUnderTest.someLibraryMethod(), "someLibraryMethod should return 'true'");
- }
-}
diff --git a/src/test/java/org/itsallcode/process/SimpleProcessTest.java b/src/test/java/org/itsallcode/process/SimpleProcessTest.java
new file mode 100644
index 0000000..0b7c903
--- /dev/null
+++ b/src/test/java/org/itsallcode/process/SimpleProcessTest.java
@@ -0,0 +1,187 @@
+package org.itsallcode.process;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.UncheckedIOException;
+import java.nio.file.Path;
+import java.time.Duration;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class SimpleProcessTest {
+
+ private static final Duration MIN_TIMEOUT = Duration.ofMillis(3);
+
+ @Test
+ void startingFails() {
+ final SimpleProcessBuilder builder = builder().command("no-such-process");
+ assertThatThrownBy(builder::start).isInstanceOf(UncheckedIOException.class).hasMessage(
+ "Failed to start process [no-such-process] in working dir null: Cannot run program \"no-such-process\": error=2, No such file or directory");
+ }
+
+ @Test
+ void workingDirNull() {
+ final SimpleProcess process = builder().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();
+ process.waitForSuccessfulTermination();
+
+ assertThat(process.getStdOut()).isEqualTo("%s%n".formatted(tempDir));
+ }
+
+ @Test
+ void processWritesToStdOut() {
+ final SimpleProcess process = builder().command("echo", "hello world").start();
+ process.waitForSuccessfulTermination();
+
+ assertThat(process.getStdOut()).isEqualTo("hello world\n");
+ assertThat(process.getStdErr()).isEmpty();
+ }
+
+ @Test
+ void processWritesMultipleLinesToStdOut() {
+ final SimpleProcess process = builder()
+ .command("sh", "-c", "echo 'line 1' && echo 'line 2'")
+ .start();
+ process.waitForSuccessfulTermination();
+
+ assertThat(process.getStdOut()).isEqualTo("line 1\nline 2\n");
+ }
+
+ @Test
+ void processWritesToStdOutWithoutTrailingNewline() {
+ final SimpleProcess process = builder().command("echo", "-n", "hello world").start();
+ process.waitForSuccessfulTermination();
+
+ assertThat(process.getStdOut()).isEqualTo("hello world\n");
+ assertThat(process.getStdErr()).isEmpty();
+ }
+
+ @Test
+ void processWritesToStdErr() {
+ final SimpleProcess process = builder().command("sh", "-c", "echo 'hello world' >&2")
+ .start();
+ process.waitForTermination(MIN_TIMEOUT);
+
+ assertThat(process.getStdOut()).isEmpty();
+ assertThat(process.getStdErr()).isEqualTo("hello world\n");
+ }
+
+ @Test
+ void processWritesToStdOutAndStdErr() {
+ final SimpleProcess process = builder().redirectErrorStream(false)
+ .command("sh", "-c",
+ "echo '1: std err' >&2 && echo '2: std out' && echo '3: std err' >&2 && echo '4: std out'")
+ .start();
+ process.waitForTermination(MIN_TIMEOUT);
+
+ assertThat(process.getStdOut()).isEqualTo("2: std out\n4: std out\n");
+ assertThat(process.getStdErr()).isEqualTo("1: std err\n3: std err\n");
+ }
+
+ @Test
+ void redirectErrorStream() {
+ final SimpleProcess process = builder().redirectErrorStream(true)
+ .command("sh", "-c",
+ "echo '1: std err' >&2 && echo '2: std out' && echo '3: std err' >&2 && echo '4: std out'")
+ .start();
+ process.waitForTermination(MIN_TIMEOUT);
+
+ assertThat(process.getStdOut()).isEqualTo("1: std err\n2: std out\n3: std err\n4: std out\n");
+ assertThat(process.getStdErr()).isEmpty();
+ }
+
+ @Test
+ void processExitNonZero() {
+ final SimpleProcess process = builder().command("false").start();
+ process.waitForTermination(MIN_TIMEOUT);
+
+ assertThat(process.exitValue()).isOne();
+ assertThat(process.isAlive()).isFalse();
+ }
+
+ @Test
+ void processExitZero() {
+ final SimpleProcess process = builder().command("true").start();
+ process.waitForTermination(MIN_TIMEOUT);
+
+ assertThat(process.exitValue()).isZero();
+ assertThat(process.isAlive()).isFalse();
+ }
+
+ @Test
+ void waitForTerminationWithTimeout() {
+ final SimpleProcess process = builder().command("echo", "hello world").start();
+ process.waitForTermination(MIN_TIMEOUT);
+
+ assertThat(process.exitValue()).isZero();
+ assertThat(process.isAlive()).isFalse();
+ }
+
+ @Test
+ void waitForTerminationWithoutTimeout() {
+ final SimpleProcess process = builder().command("echo", "hello world").start();
+ assertThat(process.waitForTermination()).isZero();
+
+ assertThat(process.exitValue()).isZero();
+ assertThat(process.isAlive()).isFalse();
+ }
+
+ @Test
+ void waitForTerminationDoesNotDestroyProcess() {
+ final SimpleProcess process = builder().command("sleep", "1").start();
+ final Duration timeout = Duration.ofMillis(10);
+ assertThatThrownBy(() -> process.waitForTermination(timeout))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageStartingWith("Timeout while waiting PT0.01S for process");
+ assertThat(process.isAlive()).isTrue();
+ }
+
+ @Test
+ void destroyTerminatesProcess() {
+ final SimpleProcess process = builder().command("sleep", "1").start();
+ assertThat(process.isAlive()).isTrue();
+ process.destroy();
+ process.waitForTermination(Duration.ofMillis(10));
+ assertThat(process.isAlive()).isFalse();
+ assertThat(process.exitValue()).isEqualTo(143);
+ }
+
+ @Test
+ void destroyTerminatesProcessWaitForTermination() {
+ final SimpleProcess process = builder().command("sleep", "1").start();
+ assertThat(process.isAlive()).isTrue();
+ process.destroy();
+ assertThat(process.waitForTermination()).isEqualTo(143);
+ }
+
+ @Test
+ void destroyForcibly() {
+ final SimpleProcess process = builder().command("sleep", "1").start();
+ assertThat(process.isAlive()).isTrue();
+ process.destroyForcibly();
+ process.waitForTermination(Duration.ofMillis(10));
+ assertThat(process.isAlive()).isFalse();
+ assertThat(process.exitValue()).isEqualTo(137);
+ }
+
+ @Test
+ void destroyForciblyWaitForTermination() {
+ final SimpleProcess process = builder().command("sleep", "1").start();
+ assertThat(process.isAlive()).isTrue();
+ process.destroyForcibly();
+ assertThat(process.waitForTermination()).isEqualTo(137);
+ }
+
+ private static SimpleProcessBuilder builder() {
+ return SimpleProcessBuilder.create();
+ }
+}
diff --git a/src/test/resources/logging.properties b/src/test/resources/logging.properties
new file mode 100644
index 0000000..86f8e82
--- /dev/null
+++ b/src/test/resources/logging.properties
@@ -0,0 +1,6 @@
+handlers=java.util.logging.ConsoleHandler
+.level=INFO
+java.util.logging.ConsoleHandler.level=ALL
+java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter
+java.util.logging.SimpleFormatter.format=%1$tF %1$tT.%1$tL [%4$-7s] %2$s %3$s %5$s%6$s%n
+org.itsallcode.level=ALL