SimpleProcessBuilder.java
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 Level streamLogLevel = Level.FINE;
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<String> command) {
this.processBuilder.command(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.
*
* @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.
* <p>
* Default: Create new threads on demand.
*
* @param executor executor
* @return {@code this} for fluent programming
*/
public SimpleProcessBuilder streamConsumerExecutor(final Executor executor) {
this.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.
*
* @return a new process
* @see ProcessBuilder#start()
*/
public SimpleProcess<String> start() {
final Process process = startProcess();
final ProcessOutputConsumer<String> consumer = ProcessOutputConsumer.create(getExecutor(process), process,
streamCloseTimeout, streamLogLevel, 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);
}
}
}