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.** +[![Java CI](https://github.com/itsallcode/simple-process/actions/workflows/build.yml/badge.svg)](https://github.com/itsallcode/simple-process/actions/workflows/build.yml) +[![CodeQL](https://github.com/itsallcode/simple-process/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/itsallcode/simple-process/actions/workflows/codeql-analysis.yml) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=org.itsallcode%3Asimple-process&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=org.itsallcode%3Asimple-process&metric=coverage)](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=org.itsallcode%3Asimple-process&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=org.itsallcode%3Asimple-process&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=org.itsallcode%3Asimple-process&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=org.itsallcode%3Asimple-process) +[![Maven Central](https://img.shields.io/maven-central/v/org.itsallcode/simple-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