Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ plugins {
}

group = 'org.itsallcode'
version = '0.1.0'
version = '0.2.0'

repositories {
mavenCentral()
Expand Down Expand Up @@ -110,8 +110,8 @@ publishing {
}
}


signing {
required = { gradle.taskGraph.hasTask("publish") }
def signingKey = findProperty("signingKey")
def signingPassword = findProperty("signingPassword")
useInMemoryPgpKeys(signingKey, signingPassword)
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
23 changes: 14 additions & 9 deletions src/main/java/org/itsallcode/process/ProcessOutputConsumer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
final class ProcessOutputConsumer<T> {
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;
Expand All @@ -18,7 +21,7 @@ class ProcessOutputConsumer<T> {
private final StreamCollector<T> stdOutCollector;
private final StreamCollector<T> stdErrCollector;

ProcessOutputConsumer(final Executor executor, final Process process,
private ProcessOutputConsumer(final Executor executor, final Process process,
final List<Runnable> consumers, final List<StreamCloseWaiter> streamCloseWaiter,
final StreamCollector<T> stdOutCollector, final StreamCollector<T> stdErrCollector) {
this.executor = executor;
Expand All @@ -30,15 +33,17 @@ class ProcessOutputConsumer<T> {
}

static <T> ProcessOutputConsumer<T> create(final Executor executor, final Process process,
final Duration streamCloseTimeout, final StreamCollector<T> stdOutCollector,
final Duration streamCloseTimeout, Level logLevel, final StreamCollector<T> stdOutCollector,
final StreamCollector<T> 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);
}
Expand Down
36 changes: 23 additions & 13 deletions src/main/java/org/itsallcode/process/SimpleProcess.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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));
}
}

Expand All @@ -84,25 +84,25 @@ 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();
}

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);
}
}
Expand All @@ -112,7 +112,7 @@ private void waitForProcess(final Duration timeout) {
*
* @return standard output
*/
T getStdOut() {
public T getStdOut() {
return consumer.getStdOut();
}

Expand All @@ -121,7 +121,7 @@ T getStdOut() {
*
* @return standard error
*/
T getStdErr() {
public T getStdErr() {
return consumer.getStdErr();
}

Expand All @@ -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();
}

Expand All @@ -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();
}

Expand Down
29 changes: 28 additions & 1 deletion src/main/java/org/itsallcode/process/SimpleProcessBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -59,6 +60,17 @@ public SimpleProcessBuilder command(final List<String> 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.
*
Expand Down Expand Up @@ -96,6 +108,8 @@ public SimpleProcessBuilder setStreamCloseTimeout(final Duration streamCloseTime

/**
* Set a custom executor for asynchronous stream readers.
* <p>
* Default: Create new threads on demand.
*
* @param executor executor
* @return {@code this} for fluent programming
Expand All @@ -105,6 +119,19 @@ public SimpleProcessBuilder streamConsumerExecutor(final Executor executor) {
return this;
}

/**
* Log level for the process's stdout and stderr.
* <p>
* 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.
*
Expand All @@ -114,7 +141,7 @@ public SimpleProcessBuilder streamConsumerExecutor(final Executor executor) {
public SimpleProcess<String> start() {
final Process process = startProcess();
final ProcessOutputConsumer<String> 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());
}
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/org/itsallcode/process/StreamLogger.java
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading