2121import rife .bld .BaseProject ;
2222import rife .bld .extension .tools .IOTools ;
2323import rife .bld .extension .tools .ObjectTools ;
24+ import rife .bld .extension .tools .ProcessExecutor ;
2425import rife .bld .extension .tools .SystemTools ;
2526import rife .bld .operations .AbstractOperation ;
2627import rife .bld .operations .exceptions .ExitStatusException ;
2728
28- import java .io .* ;
29- import java .nio . charset . StandardCharsets ;
29+ import java .io .File ;
30+ import java .io . IOException ;
3031import java .nio .file .Path ;
3132import java .util .*;
32- import java .util .concurrent . TimeUnit ;
33+ import java .util .function . Consumer ;
3334import java .util .logging .Level ;
3435import java .util .logging .Logger ;
3536
3940 * @author <a href="https://erik.thauvin.net/">Erik C. Thauvin</a>
4041 * @since 1.0
4142 */
42- @ SuppressWarnings ("PMD.DoNotUseThreads" )
4343@ SuppressFBWarnings (value = "EI_EXPOSE_REP" , justification = "intentional and documented" )
4444public class ExecOperation extends AbstractOperation <ExecOperation > {
4545
4646 private static final String COMMAND_NOT_VALID = "command values must not be null or empty" ;
4747 private static final Logger logger = Logger .getLogger (ExecOperation .class .getName ());
48+ private static final Consumer <String > DEFAULT_OUTPUT_CONSUMER = logger ::info ;
4849 private final List <String > args_ = new ArrayList <>();
4950 private final Map <String , String > env_ = new HashMap <>();
5051 private boolean failOnExit_ = true ;
5152 private boolean inheritIO_ = true ;
52- private int timeout_ = 30 ;
53+ @ NonNull
54+ private Consumer <String > outputConsumer_ = DEFAULT_OUTPUT_CONSUMER ;
55+ private int timeout_ = ProcessExecutor .DEFAULT_TIMEOUT_SECONDS ;
5356 private File workDir_ ;
5457
5558 @ Override
56- @ SuppressWarnings ({"PMD.CloseResource" , "PMD. PreserveStackTrace" })
59+ @ SuppressWarnings ({"PMD.PreserveStackTrace" })
5760 @ SuppressFBWarnings ("LEST_LOST_EXCEPTION_STACK_TRACE" )
5861 public void execute () throws Exception {
5962 validatePreconditions ();
6063 logExecutionStart ();
6164
62- var pb = createProcessBuilder ();
63- Process proc = null ;
64- Thread outputThread = null ;
65-
6665 try {
67- proc = pb .start ();
68- outputThread = startOutputReader (proc );
69- waitForCompletion (proc );
70- handleExitCode (proc );
71- } catch (IOException e ) {
66+ var executor = new ProcessExecutor ()
67+ .command (args_ )
68+ .workDir (workDir_ )
69+ .timeout (timeout_ )
70+ .inheritIO (inheritIO_ );
71+
72+ if (!env_ .isEmpty ()) {
73+ executor .env (env_ );
74+ }
75+
76+ if (!inheritIO_ ) {
77+ executor .outputConsumer (outputConsumer_ );
78+ }
79+
80+ var result = executor .execute ();
81+ handleExitCode (result .exitCode ());
82+
83+ if (result .timedOut () && logger .isLoggable (Level .SEVERE ) && !silent ()) {
84+ logger .severe ("The command timed out after " + timeout_ + " seconds." );
85+ throw new ExitStatusException (ExitStatusException .EXIT_FAILURE );
86+ }
87+ } catch (IOException | InterruptedException e ) {
7288 if (logger .isLoggable (Level .SEVERE ) && !silent ()) {
7389 logger .log (Level .SEVERE , "Failed to execute command." , e );
7490 }
7591 throw new ExitStatusException (ExitStatusException .EXIT_FAILURE );
76- } finally {
77- cleanup (proc , outputThread );
7892 }
7993 }
8094
@@ -457,8 +471,8 @@ public ExecOperation onUnix(@NonNull Collection<String> args) {
457471 * <pre>{@code
458472 * new ExecOperation()
459473 * .fromProject(this)
460- * .isWindows ("cmd", "/c", "build.bat")
461- * .isUnix ("./build.sh")
474+ * .onWindows ("cmd", "/c", "build.bat")
475+ * .onUnix ("./build.sh")
462476 * .execute();
463477 * }</pre>
464478 * <p>
@@ -498,6 +512,21 @@ public ExecOperation onWindows(@NonNull Collection<String> args) {
498512 return this ;
499513 }
500514
515+ /**
516+ * Sets a consumer to receive output lines when not inheriting I/O.
517+ * <p>
518+ * Only called when {@link #isInheritIO()} is {@code false}. Default logs at INFO level.
519+ *
520+ * @param outputConsumer the output consumer, must not be null
521+ * @return this operation instance
522+ * @throws NullPointerException if outputConsumer is null
523+ */
524+ public ExecOperation outputConsumer (@ NonNull Consumer <String > outputConsumer ) {
525+ Objects .requireNonNull (outputConsumer , "outputConsumer must not be null" );
526+ outputConsumer_ = outputConsumer ;
527+ return this ;
528+ }
529+
501530 /**
502531 * Configure the command timeout.
503532 *
@@ -570,54 +599,7 @@ public File workDir() {
570599 return workDir_ ;
571600 }
572601
573- private void cleanup (Process proc , Thread outputThread ) {
574- if (proc != null ) {
575- if (proc .isAlive ()) {
576- proc .destroyForcibly ();
577- }
578- closeQuietly (proc .getInputStream ());
579- closeQuietly (proc .getErrorStream ());
580- closeQuietly (proc .getOutputStream ());
581- }
582- if (outputThread != null ) {
583- outputThread .interrupt ();
584- try {
585- outputThread .join (1000 );
586- } catch (InterruptedException ignored ) {
587- Thread .currentThread ().interrupt ();
588- }
589- }
590- }
591-
592- private void closeQuietly (Closeable closeable ) {
593- try {
594- closeable .close ();
595- } catch (IOException ignored ) {
596- }
597- }
598-
599- @ SuppressFBWarnings ("COMMAND_INJECTION" )
600- private ProcessBuilder createProcessBuilder () {
601- var pb = new ProcessBuilder ();
602- pb .command (args_ );
603- pb .directory (workDir_ );
604-
605- if (!env_ .isEmpty ()) {
606- pb .environment ().putAll (env_ );
607- }
608-
609- if (inheritIO_ ) {
610- pb .inheritIO ();
611- } else {
612- pb .redirectErrorStream (true );
613- var devNull = SystemTools .isWindows () ? "NUL" : "/dev/null" ;
614- pb .redirectInput (ProcessBuilder .Redirect .from (new File (devNull )));
615- }
616- return pb ;
617- }
618-
619- private void handleExitCode (Process proc ) throws ExitStatusException {
620- int exitCode = proc .exitValue ();
602+ private void handleExitCode (int exitCode ) throws ExitStatusException {
621603 if (exitCode != 0 && failOnExit_ ) {
622604 if (logger .isLoggable (Level .SEVERE ) && !silent ()) {
623605 logger .log (Level .SEVERE , "The command exit value/status is: " + exitCode );
@@ -628,45 +610,14 @@ private void handleExitCode(Process proc) throws ExitStatusException {
628610
629611 private void logExecutionStart () {
630612 if (logger .isLoggable (Level .INFO ) && !silent ()) {
631- logger .log (Level .INFO , "Working directory: " + workDir_ .getAbsolutePath ());
613+ logger .log (Level .INFO , "Working directory: " + workDir_ .getAbsolutePath ());
632614 if (!env_ .isEmpty ()) {
633615 logger .log (Level .INFO , "Environment: " + env_ );
634616 }
635617 logger .info (String .join (" " , args_ ));
636618 }
637619 }
638620
639- @ SuppressFBWarnings ("CRLF_INJECTION_LOGS" )
640- private void readProcessOutput (Process proc ) {
641- final var logInfo = logger .isLoggable (Level .INFO ) && !silent ();
642- final var logSevere = logger .isLoggable (Level .SEVERE ) && !silent ();
643-
644- try (var reader = new BufferedReader (
645- new InputStreamReader (proc .getInputStream (), StandardCharsets .UTF_8 ))) {
646- String line ;
647- while (!Thread .currentThread ().isInterrupted () && (line = reader .readLine ()) != null ) {
648- if (logInfo ) {
649- logger .info (line );
650- }
651- }
652- } catch (IOException e ) {
653- if (logSevere && proc .isAlive () && !Thread .currentThread ().isInterrupted ()) {
654- logger .log (Level .SEVERE , "Failed to read command output." , e );
655- }
656- }
657- }
658-
659- private Thread startOutputReader (Process proc ) {
660- if (inheritIO_ ) {
661- return null ;
662- }
663-
664- var thread = new Thread (() -> readProcessOutput (proc ));
665- thread .setDaemon (true );
666- thread .start ();
667- return thread ;
668- }
669-
670621 private void validatePreconditions () throws ExitStatusException {
671622 final var logSevere = logger .isLoggable (Level .SEVERE ) && !silent ();
672623
@@ -682,29 +633,9 @@ private void validatePreconditions() throws ExitStatusException {
682633 }
683634 throw new ExitStatusException (ExitStatusException .EXIT_FAILURE );
684635 }
685- }
686-
687- @ SuppressWarnings ("PMD.PreserveStackTrace" )
688- @ SuppressFBWarnings ("LEST_LOST_EXCEPTION_STACK_TRACE" )
689- private void waitForCompletion (Process proc ) throws ExitStatusException {
690- final var logSevere = logger .isLoggable (Level .SEVERE ) && !silent ();
691-
692- try {
693- if (!proc .waitFor (timeout_ , TimeUnit .SECONDS )) {
694- proc .destroyForcibly ();
695- if (!proc .waitFor (5 , TimeUnit .SECONDS ) && proc .isAlive ()) {
696- if (logSevere ) {
697- logger .severe ("Process could not be killed after timeout." );
698- }
699- } else if (logSevere ) {
700- logger .severe ("The command timed out after " + timeout_ + " seconds." );
701- }
702- throw new ExitStatusException (ExitStatusException .EXIT_FAILURE );
703- }
704- } catch (InterruptedException e ) {
705- Thread .currentThread ().interrupt ();
636+ if (inheritIO_ && outputConsumer_ != DEFAULT_OUTPUT_CONSUMER ) {
706637 if (logSevere ) {
707- logger .log ( Level . SEVERE , "The command was interrupted." , e );
638+ logger .severe ( "Cannot use custom outputConsumer with inheritIO(true)." );
708639 }
709640 throw new ExitStatusException (ExitStatusException .EXIT_FAILURE );
710641 }
0 commit comments