001package org.itsallcode.process; 002 003import static java.util.stream.Collectors.joining; 004 005import java.io.IOException; 006import java.io.UncheckedIOException; 007import java.lang.Thread.UncaughtExceptionHandler; 008import java.nio.file.Path; 009import java.time.Duration; 010import java.util.List; 011import java.util.concurrent.Executor; 012import java.util.logging.Level; 013import java.util.logging.Logger; 014 015/** 016 * Builder for {@link SimpleProcess}. Create a new instance with 017 * {@link #create()}. 018 */ 019public final class SimpleProcessBuilder { 020 private final ProcessBuilder processBuilder; 021 private Duration streamCloseTimeout = Duration.ofSeconds(1); 022 private Executor executor; 023 private Level streamLogLevel = Level.FINE; 024 025 private SimpleProcessBuilder() { 026 this.processBuilder = new ProcessBuilder(); 027 } 028 029 /** 030 * Create a new instance. 031 * 032 * @return new {@link SimpleProcessBuilder} 033 */ 034 public static SimpleProcessBuilder create() { 035 return new SimpleProcessBuilder(); 036 } 037 038 /** 039 * Set program and arguments. 040 * 041 * @param command program and arguments 042 * @return {@code this} for fluent programming 043 * @see ProcessBuilder#command(String...) 044 */ 045 @SuppressWarnings("java:S923") // Using varargs by intention 046 public SimpleProcessBuilder command(final String... command) { 047 this.processBuilder.command(command); 048 return this; 049 } 050 051 /** 052 * Set program and arguments. 053 * 054 * @param command program and arguments 055 * @return {@code this} for fluent programming 056 * @see ProcessBuilder#command(List) 057 */ 058 public SimpleProcessBuilder command(final List<String> command) { 059 this.processBuilder.command(command); 060 return this; 061 } 062 063 /** 064 * Set working directory to the current process's working directory. 065 * 066 * @return {@code this} for fluent programming 067 * @see ProcessBuilder#directory(java.io.File) 068 */ 069 public SimpleProcessBuilder currentProcessWorkingDir() { 070 this.processBuilder.directory(null); 071 return this; 072 } 073 074 /** 075 * Set working directory. 076 * 077 * @param workingDir working directory 078 * @return {@code this} for fluent programming 079 * @see ProcessBuilder#directory(java.io.File) 080 */ 081 public SimpleProcessBuilder workingDir(final Path workingDir) { 082 this.processBuilder.directory(workingDir.toFile()); 083 return this; 084 } 085 086 /** 087 * Redirect the error stream to the output stream if this is {@code true}. 088 * 089 * @param redirectErrorStream the new property value, default: {@code false} 090 * @return {@code this} for fluent programming 091 * @see ProcessBuilder#redirectErrorStream(boolean) 092 */ 093 public SimpleProcessBuilder redirectErrorStream(final boolean redirectErrorStream) { 094 this.processBuilder.redirectErrorStream(redirectErrorStream); 095 return this; 096 } 097 098 /** 099 * Set the timeout for closing the asynchronous stream readers. 100 * 101 * @param streamCloseTimeout timeout 102 * @return {@code this} for fluent programming 103 */ 104 public SimpleProcessBuilder setStreamCloseTimeout(final Duration streamCloseTimeout) { 105 this.streamCloseTimeout = streamCloseTimeout; 106 return this; 107 } 108 109 /** 110 * Set a custom executor for asynchronous stream readers. 111 * <p> 112 * Default: Create new threads on demand. 113 * 114 * @param executor executor 115 * @return {@code this} for fluent programming 116 */ 117 public SimpleProcessBuilder streamConsumerExecutor(final Executor executor) { 118 this.executor = executor; 119 return this; 120 } 121 122 /** 123 * Log level for the process's stdout and stderr. 124 * <p> 125 * Default: {@link Level#FINE} 126 * 127 * @param streamLogLevel log level 128 * @return {@code this} for fluent programming 129 */ 130 public SimpleProcessBuilder streamLogLevel(Level streamLogLevel) { 131 this.streamLogLevel = streamLogLevel; 132 return this; 133 } 134 135 /** 136 * Start the new process. 137 * 138 * @return a new process 139 * @see ProcessBuilder#start() 140 */ 141 public SimpleProcess<String> start() { 142 final Process process = startProcess(); 143 final ProcessOutputConsumer<String> consumer = ProcessOutputConsumer.create(getExecutor(process), process, 144 streamCloseTimeout, streamLogLevel, new StringCollector(), new StringCollector()); 145 consumer.start(); 146 return new SimpleProcess<>(process, consumer, getCommand()); 147 } 148 149 private Process startProcess() { 150 try { 151 return processBuilder.start(); 152 } catch (final IOException exception) { 153 throw new UncheckedIOException( 154 "Failed to start process %s in working dir %s: %s".formatted(processBuilder.command(), 155 processBuilder.directory(), exception.getMessage()), 156 exception); 157 } 158 } 159 160 private Executor getExecutor(final Process process) { 161 if (this.executor != null) { 162 return executor; 163 } 164 return createThreadExecutor(process.pid()); 165 } 166 167 private static Executor createThreadExecutor(final long pid) { 168 return runnable -> { 169 final Thread thread = new Thread(runnable); 170 thread.setName("SimpleProcess-" + pid); 171 thread.setUncaughtExceptionHandler(new LoggingExceptionHandler()); 172 thread.start(); 173 }; 174 } 175 176 private String getCommand() { 177 return processBuilder.command().stream().collect(joining(" ")); 178 } 179 180 private static class LoggingExceptionHandler implements UncaughtExceptionHandler { 181 private static final Logger LOG = Logger.getLogger(LoggingExceptionHandler.class.getName()); 182 183 @Override 184 public void uncaughtException(final Thread thread, final Throwable exception) { 185 LOG.log(Level.WARNING, 186 "Exception occurred in thread '%s': %s".formatted(thread.getName(), exception.toString()), 187 exception); 188 } 189 } 190}