Add process running interface layer.

Change-Id: I4a791df1663916b4a5c0071318913a73ede51a13
diff --git a/pom.xml b/pom.xml
index 86b79fc..fe6fc0d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -23,6 +23,16 @@
       <artifactId>org.eclipse.jgit</artifactId>
       <version>3.3.2.201404171909-r</version>
     </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-io</artifactId>
+      <version>1.3.2</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-exec</artifactId>
+      <version>1.2</version>
+    </dependency>
   </dependencies>
   <build>
     <pluginManagement>
diff --git a/src/main/java/com/google/gwt/benchmark/runner/ProcessRunner.java b/src/main/java/com/google/gwt/benchmark/runner/ProcessRunner.java
new file mode 100644
index 0000000..026717f
--- /dev/null
+++ b/src/main/java/com/google/gwt/benchmark/runner/ProcessRunner.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.benchmark.runner;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Charsets;
+import com.google.common.base.Joiner;
+import com.google.common.collect.Maps;
+
+import org.apache.commons.exec.CommandLine;
+import org.apache.commons.exec.DefaultExecutor;
+import org.apache.commons.exec.ExecuteWatchdog;
+import org.apache.commons.exec.Executor;
+import org.apache.commons.exec.PumpStreamHandler;
+import org.apache.commons.io.output.TeeOutputStream;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A thin layer to execute commands as separate processes and capture their output.
+ */
+public class ProcessRunner {
+
+  /**
+   * How long to wait after the process has been stopped until the handler is forcefully stopped.
+   */
+  private static final int STREAM_HANDLER_STOP_TIMEOUT = 2000;
+  /**
+   * Maximum time allowed for running a command.
+   */
+  private static final int ONE_HOUR_IN_MILLISECONDS = 1*60*60*1000;
+
+  /**
+   * Tester main method.
+   */
+  public static void main(String[] args) {
+    File currentDir = new File(System.getProperty("user.dir"));
+    try {
+      File runnerOut = File.createTempFile("runner", ".out");
+      File runnerErr = File.createTempFile("runner", ".err");
+      System.out.println("Output will be stored at " + runnerOut.getPath() + " and "
+          + runnerErr.getPath());
+      ProcessStats processStats =
+          runAndTimeCommand(currentDir, runnerOut, runnerErr, Maps.<String, String>newHashMap(),
+              args);
+      System.out.println(processStats);
+    } catch (RunnerException | IOException e) {
+      e.printStackTrace();
+    }
+  }
+
+  private static ProcessStats parseProcessStats(boolean error, File timeStatsFile)
+      throws RunnerException {
+    List<String> lines;
+    try {
+      lines = Files.readAllLines(timeStatsFile.toPath(), Charsets.UTF_8);
+    } catch (IOException e) {
+      throw new RunnerException("Could not get time stats", e);
+    }
+    ProcessStats processStats = extractProcessStats(error, lines);
+    return processStats;
+  }
+
+  @VisibleForTesting
+  static ProcessStats extractProcessStats(boolean error, List<String> lines)
+      throws RunnerException, NumberFormatException {
+    int firstLine = error ? 1 : 0;
+    // If the command exits with non zero error code /usr/time will print an additional line
+    // "Command exited with non-zero status"
+    if (lines.size() != 5 + firstLine) {
+      throw new RunnerException("Could not get time stats", null);
+    }
+
+    ProcessStats processStats = new ProcessStats();
+    processStats.setElapsedSeconds(Double.parseDouble(lines.get(firstLine + 0)));
+    processStats.setSystemSeconds(Double.parseDouble(lines.get(firstLine + 1)));
+    processStats.setUserSeconds(Double.parseDouble(lines.get(firstLine + 2)));
+    processStats.setMemused(Long.parseLong(lines.get(firstLine + 3)));
+    processStats.setExitCode(Integer.parseInt(lines.get(firstLine + 4)));
+
+    assert error || processStats.getExitCode() == 0;
+    return processStats;
+  }
+
+  /**
+   * Executes the command and args as a separate process and records timing info.
+   *
+   * @param workdir the working directory to run the command on.
+   * @param newEnviromentVars enviroment variables and values to add the to environment on which to
+   *        the command.
+   * @param commandPlusArgs the command to be run and its arguments.
+   */
+  public static ProcessStats runAndTimeCommand(File workdir, File stdOutput, File stdError,
+      Map<String, String> newEnviromentVars, String... commandPlusArgs) throws RunnerException {
+    System.out.println("Running command " + Joiner.on(" ").join(commandPlusArgs));
+
+    File timerOutputFile = null;
+
+    try (
+        FileOutputStream outputFileStream = new FileOutputStream(stdOutput);
+        FileOutputStream errorFileStream = new FileOutputStream(stdError)) {
+
+      timerOutputFile = File.createTempFile("gwt-compile-bench", "timestats");
+
+      // Set the parameters to /usr/bin/time
+      CommandLine commandLine = new CommandLine("/usr/bin/time");
+      commandLine.addArgument("--format");
+      commandLine.addArgument("%e\n%S\n%U\n%M\n%x");
+      commandLine.addArgument("--output");
+      commandLine.addArgument(timerOutputFile.getAbsolutePath());
+
+      // Add the original command line to run
+      commandLine.addArguments(commandPlusArgs);
+
+      Executor executor = new DefaultExecutor();
+
+      // Set up the stopping condition, and stream handling.
+      executor.setWatchdog(new ExecuteWatchdog(ONE_HOUR_IN_MILLISECONDS));
+      PumpStreamHandler pumpStreamHandler = new PumpStreamHandler(
+          new TeeOutputStream(System.out, outputFileStream),
+          new TeeOutputStream(System.err, errorFileStream));
+      pumpStreamHandler.setStopTimeout(STREAM_HANDLER_STOP_TIMEOUT);
+      executor.setStreamHandler(pumpStreamHandler);
+
+      System.out.println("Setting workdir to " + workdir);
+      executor.setWorkingDirectory(workdir);
+
+      Map<String, String> environment = Maps.newHashMap(System.getenv());
+      environment.putAll(newEnviromentVars);
+
+      // Actually execute the command.
+      int exitValue = executor.execute(commandLine, environment);
+
+      ProcessStats processStats = parseProcessStats(exitValue != 0, timerOutputFile);
+
+      return processStats;
+    } catch (IOException e) {
+      throw new RunnerException("Could not run command " + Joiner.on(" ").join(commandPlusArgs), e);
+    } finally {
+      if (timerOutputFile != null) {
+        timerOutputFile.delete();
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/google/gwt/benchmark/runner/ProcessStats.java b/src/main/java/com/google/gwt/benchmark/runner/ProcessStats.java
new file mode 100644
index 0000000..785a138
--- /dev/null
+++ b/src/main/java/com/google/gwt/benchmark/runner/ProcessStats.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.benchmark.runner;
+
+/**
+ * Stats collected as a result of running a process.
+ */
+class ProcessStats {
+  private double elapsedSeconds;
+  private int exitCode;
+  private long memused;
+  private double systemSeconds;
+  private double userSeconds;
+
+  /**
+   * Returns the process' elapsed time in seconds.
+   */
+  public final double getElapsedSeconds() {
+    return elapsedSeconds;
+  }
+
+  /**
+   * Returns the process' exit code.
+   */
+  public final int getExitCode() {
+    return exitCode;
+  }
+
+  /**
+   * Returns the process' used memory in bytes.
+   */
+  public final long getMemoryUsed() {
+    return memused;
+  }
+
+  /**
+   * Returns the process' system time in seconds.
+   */
+  public final double getSystemSeconds() {
+    return systemSeconds;
+  }
+
+  /**
+   * Returns the process' user time in seconds.
+   */
+  public final double getUserSeconds() {
+    return userSeconds;
+  }
+
+  /**
+   * Sets the process' elapsed time in seconds.
+   */
+  final void setElapsedSeconds(double elapsedSeconds) {
+    this.elapsedSeconds = elapsedSeconds;
+  }
+
+  /**
+   * Sets the process' exit code.
+   */
+  final void setExitCode(int exitCode) {
+    this.exitCode = exitCode;
+  }
+
+  /**
+   * Sets the process' used memory in bytes.
+   */
+  final void setMemused(long memused) {
+    this.memused = memused;
+  }
+
+  /**
+   * Sets the process' system time in seconds.
+   */
+  final void setSystemSeconds(double systemSeconds) {
+    this.systemSeconds = systemSeconds;
+  }
+
+  /**
+   * Sets the process' user time in seconds.
+   */
+  final void setUserSeconds(double userSeconds) {
+    this.userSeconds = userSeconds;
+  }
+
+  /**
+   * Returns a string representation of process' stats.
+   */
+  @Override
+  public String toString() {
+    return "ProcessStats [elapsedSeconds=" + elapsedSeconds + ", userSeconds=" + userSeconds
+        + ", systemSeconds=" + systemSeconds + ", memused=" + memused + ", exitCode=" + exitCode
+        + "]";
+  }
+}
\ No newline at end of file
diff --git a/src/main/java/com/google/gwt/benchmark/runner/RunnerException.java b/src/main/java/com/google/gwt/benchmark/runner/RunnerException.java
new file mode 100644
index 0000000..ff8558f
--- /dev/null
+++ b/src/main/java/com/google/gwt/benchmark/runner/RunnerException.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.benchmark.runner;
+
+/**
+ * An exception thrown by the process runner.
+ */
+public class RunnerException extends Exception {
+
+  /**
+   * Constructor for RunnerException.
+   */
+  public RunnerException(String message, Exception e) {
+    super(message, e);
+  }
+
+}
diff --git a/src/test/java/com/google/gwt/benchmark/runner/ProcessRunnerTest.java b/src/test/java/com/google/gwt/benchmark/runner/ProcessRunnerTest.java
new file mode 100644
index 0000000..1b69d0e
--- /dev/null
+++ b/src/test/java/com/google/gwt/benchmark/runner/ProcessRunnerTest.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.benchmark.runner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Charsets;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Tests for {@link ProcessRunner}.
+ */
+public class ProcessRunnerTest {
+
+  /**
+   * Test method for {@link ProcessRunner#extractProcessStats}.
+   */
+  @Test
+  public void testExtractProcessStats_normalExit() throws NumberFormatException, RunnerException {
+    String[] timeCommandOutput = new String[] {"2.35", // elapsed time
+        "0.16", // system time
+        "3.4", // user time
+        "234567", // Memory used
+        "0" // Exit code
+    };
+
+    ProcessStats processStats =
+        ProcessRunner.extractProcessStats(false, Arrays.asList(timeCommandOutput));
+
+    assertEquals(2.35, processStats.getElapsedSeconds(), 0.01);
+    assertEquals(0.16, processStats.getSystemSeconds(), 0.01);
+    assertEquals(3.40, processStats.getUserSeconds(), 0.01);
+    assertEquals(234567L, processStats.getMemoryUsed());
+    assertEquals(0, processStats.getExitCode());
+  }
+
+  /**
+   * Test method for {@link ProcessRunner#extractProcessStats}.
+   */
+  @Test
+  public void testExtractProcessStats_errorExit() throws NumberFormatException, RunnerException {
+    String[] timeCommandOutput = new String[] {"Command exited with non-zero status 127", // elapsed
+                                                                                          // time
+        "3.35", // elapsed time
+        "2.16", // system time
+        "1.4", // user time
+        "34567", // Memory used
+        "127" // Exit code
+    };
+
+    ProcessStats processStats =
+        ProcessRunner.extractProcessStats(true, Arrays.asList(timeCommandOutput));
+
+    assertEquals(3.35, processStats.getElapsedSeconds(), 0.01);
+    assertEquals(2.16, processStats.getSystemSeconds(), 0.01);
+    assertEquals(1.40, processStats.getUserSeconds(), 0.01);
+    assertEquals(34567L, processStats.getMemoryUsed());
+    assertEquals(127, processStats.getExitCode());
+  }
+
+  /**
+   * Test method for {@link ProcessRunner#extractProcessStats}.
+   *
+   * @throws RunnerException
+   */
+  @Test
+  public void testExtractProcessStats_badInput() throws RunnerException {
+    String[] timeCommandOutput = new String[] {"h3.35", // elapsed time
+        "2.16", // system time
+        "1.4", // user time
+        "34567", // Memory used
+        "127" // Exit code
+    };
+
+    try {
+      ProcessRunner.extractProcessStats(false, Arrays.asList(timeCommandOutput));
+      fail("Should have thrown NumberFormatException");
+    } catch (NumberFormatException e) {
+      // Expected.
+    }
+
+    timeCommandOutput = new String[] {"2.16", // system time
+        "1.4", // user time
+        "34567", // Memory used
+        "127" // Exit code
+    };
+
+    try {
+      ProcessRunner.extractProcessStats(false, Arrays.asList(timeCommandOutput));
+      fail("Should have thrown RunnerException");
+    } catch (RunnerException e) {
+      // Expected.
+    }
+  }
+
+
+  /**
+   * Test method for {@link ProcessRunner#runAndTimeCommand}.
+   */
+  @Test
+  public void testRunAndTimeCommand() throws IOException, RunnerException {
+    File outFile = File.createTempFile("test", ".out");
+    File errFile = File.createTempFile("test", ".err");
+    File workdir = new File(System.getProperty("user.dir"));
+    ProcessStats processStats =
+        ProcessRunner.runAndTimeCommand(workdir, outFile, errFile,
+            ImmutableMap.<String, String>of(), "ls", "-1a");
+
+    assertNotNull(processStats);
+    assertEquals(0, processStats.getExitCode());
+    List<String> lines = Files.readAllLines(outFile.toPath(), Charsets.UTF_8);
+    assertEquals(".", lines.get(0));
+    assertEquals("..", lines.get(1));
+  }
+}