All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.kiwiproject.beta.base.process.ProcessHelpers Maven / Gradle / Ivy

package org.kiwiproject.beta.base.process;

import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotBlank;
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotEmpty;
import static org.kiwiproject.collect.KiwiLists.last;

import com.google.common.annotations.Beta;
import com.google.common.primitives.Ints;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.kiwiproject.base.KiwiStrings;
import org.kiwiproject.base.process.ProcessHelper;
import org.kiwiproject.base.process.Processes;
import org.kiwiproject.collect.KiwiLists;
import org.kiwiproject.io.KiwiIO;

import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

/**
 * Utilities to execute a command using a {@link ProcessHelper}.
 * 

* These static methods could be considered for addition to kiwi's {@link ProcessHelper}. * They would be instance methods inside ProcessHelper. Static versions of the methods could also be * added to kiwi's Processes class (which contains only static utilities), and then the instance * methods in ProcessHelper would delegate, thereby providing two ways to use this. Using ProcessHelper * is more friendly to testing since it can easily be mocked. */ @Beta @UtilityClass @Slf4j public class ProcessHelpers { private static final int DEFAULT_TIMEOUT_MILLIS = 5_000; // TODO consider overloads or variants that allow callers to provide a working directory. // Maybe that should be a method that takes a "parameter object" containing the command, // timeout (with a default), working directory, whether to collect standard out/err, and // possibly even a ProcessBuilder for complete customization, etc. If a ProcessBuilder is // specified, then that would be used to launch a Process, otherwise a ProcessHelper would // be required. // TODO consider adding methods to kiwi's Processes and ProcessHelper to supply a ProcessBuilder // to the launch method, which would allow complete customization of the process that we don't // currently support (b/c we have never needed them). Then we can add more 'execute' methods here // that accept ProcessBuilder instead of ProcessHelper. /** * Execute command with timeout of 5 seconds. * * @implNote See the implementation note in {@link #execute(ProcessHelper, List, long, TimeUnit)}. */ public static ProcessResult execute(ProcessHelper processHelper, List command) { return execute(processHelper, command, DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); } /** * Execute command with the given timeout. * * @see #execute(ProcessHelper, List, long, TimeUnit) */ public static ProcessResult execute(ProcessHelper processHelper, List command, Duration timeout) { var timeoutNanos = timeout.toNanos(); return execute(processHelper, command, timeoutNanos, TimeUnit.NANOSECONDS); } /** * Execute command with the given timeout. * * @implNote This uses {@link CompletableFuture} to ensure we time out even if the stdout * or stderr blocks, which according to the {@link Process} docs, can at least theoretically * happen. For example, if someone gives the command {@code ls -laR /} to list all files in * the filesystem, it will probably take quite a long time. */ public static ProcessResult execute(ProcessHelper processHelper, List command, long timeout, TimeUnit timeoutUnit) { var timeoutMillis = Ints.checkedCast(timeoutUnit.toMillis(timeout)); return tryExecute(processHelper, command, timeoutMillis); } private static ProcessResult tryExecute(ProcessHelper processHelper, List command, int timeoutMillis) { try { LOG.trace("Executing command with timeout of {} millis: {}", timeoutMillis, command); return CompletableFuture .supplyAsync(() -> executeSync(processHelper, command, timeoutMillis)) .get(timeoutMillis, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); LOG.error("Interrupted while executing command with timeout {} millis: {}", timeoutMillis, command, e); return processResultFromException(e, timeoutMillis); } catch (Exception e) { LOG.error("{} while executing command with timeout {} millis: {}", e.getClass().getSimpleName(), timeoutMillis, command, e); return processResultFromException(e, timeoutMillis); } } private static ProcessResult executeSync(ProcessHelper processHelper, List command, int timeoutMillis) { var process = processHelper.launch(command); var stdOut = KiwiIO.readLinesFromInputStreamOf(process); var stdErr = KiwiIO.readLinesFromErrorStreamOf(process); var exitCodeOptional = processHelper.waitForExit(process, timeoutMillis, TimeUnit.MILLISECONDS); return ProcessResult.builder() .exitCode(exitCodeOptional.orElse(null)) .timedOut(exitCodeOptional.isEmpty()) .timeoutThresholdMillis(timeoutMillis) .stdOutLines(stdOut) .stdErrLines(stdErr) .build(); } private static ProcessResult processResultFromException(Exception ex, int timeoutMillis) { var timedOut = ex instanceof TimeoutException; // If ExecutionException, unwrap it to get the actual cause of the problem var error = (ex instanceof ExecutionException) ? ex.getCause() : ex; return ProcessResult.builder() .error(error) .timedOut(timedOut) .timeoutThresholdMillis(timeoutMillis) .stdOutLines(List.of()) .stdErrLines(List.of()) .build(); } /** * Convenience method that splits the given {@code command} on spaces before passing * it to {@link Processes#launch(List)}. *

* Warning: * If a command argument contains spaces and needs to be quoted, you cannot * use this method. Instead, use {@link Processes#launch(List)} or {@link Processes#launch(String...)}. * The reason is that this method just splits on all spaces, so arguments with spaces * would be broken up, and the quotes would become part of the two separate arguments. * In other words, this method is not a command parser. * * @param command the command to execute * @return the new {@link Process} * @throws UncheckedIOException if anything goes wrong, for example if the working directory does not exist */ public static Process launchCommand(String command) { return launchCommand(null, command); } /** * Convenience method that splits the given {@code command} on spaces before passing * it to {@link Processes#launch(List)}. The command uses the given working directory. *

* Warning: See the warning in {@link #launchCommand(String)}. * * @param workingDirectory the working directory for the command * @param command the command to execute * @return the new {@link Process} * @throws UncheckedIOException if anything goes wrong, for example if the working directory does not exist */ public static Process launchCommand(@Nullable File workingDirectory, String command) { checkArgumentNotBlank(command, "command must not be blank"); var commandList = KiwiStrings.splitToList(command); return Processes.launch(workingDirectory, commandList); } /** * Convenience method that splits a pipeline using "|" and then splits each * individual command on spaces. *

* Warning: The same caveats on command splitting on spaces * apply to this method, as described in {@link #launchCommand(String)}. For * similar reasons, nested pipelines won't work either. * * @param pipeline the pipeline command * @return the last {@link Process} in the pipeline * @throws UncheckedIOException if anything goes wrong, for example if the working directory does not exist * @see ProcessBuilder#startPipeline */ public static Process launchPipelineCommand(String pipeline) { return launchPipelineCommand(null, pipeline); } /** * Convenience method that splits a pipeline using "|" and then splits each * individual command on spaces. Each command in the pipeline uses the given * working directory. *

* Warning: The same caveats on command splitting on spaces * apply to this method, as described in {@link #launchCommand(String)}. For * similar reasons, nested pipelines won't work either. * * @param workingDirectory the working directory for each command in the pipeline * @param pipeline the pipeline command * @return the last {@link Process} in the pipeline * @throws UncheckedIOException if anything goes wrong executing the command * @see ProcessBuilder#startPipeline */ public static Process launchPipelineCommand(@Nullable File workingDirectory, String pipeline) { checkArgumentNotBlank(pipeline, "pipeline must not be blank"); List> pipelineCommands = KiwiStrings.splitToList(pipeline, '|') .stream() .map(KiwiStrings::splitToList) .toList(); return launchPipeline(workingDirectory, pipelineCommands); } /** * Executes a pipeline of the given commands. * * @param commands the commands in the pipeline * @return the last {@link Process} in the pipeline * @throws UncheckedIOException if anything goes wrong executing the command * @see ProcessBuilder#startPipeline */ public static Process launchPipeline(List> commands) { return launchPipeline(null, commands); } /** * Executes a pipeline of the given commands. *

* Each command in the pipeline uses the given working directory. * * @param workingDirectory the working directory for each command in the pipeline * @param commands the commands in the pipeline * @return the last {@link Process} in the pipeline * @throws UncheckedIOException if anything goes wrong, for example if the working directory does not exist * @see ProcessBuilder#startPipeline */ public static Process launchPipeline(@Nullable File workingDirectory, List> commands) { checkArgumentNotEmpty(commands, "commands must not be empty"); var procBuilders = commands.stream() .filter(KiwiLists::isNotNullOrEmpty) .map(command -> new ProcessBuilder(command).directory(workingDirectory)) .toList(); try { var procs = ProcessBuilder.startPipeline(procBuilders); return last(procs); } catch (IOException e) { throw new UncheckedIOException(e); } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy