package com.carrotsearch.ant.tasks.junit4;

import static com.carrotsearch.randomizedtesting.SysGlobals.CURRENT_PREFIX;
import static com.carrotsearch.randomizedtesting.SysGlobals.SYSPROP_PREFIX;
import static com.carrotsearch.randomizedtesting.SysGlobals.SYSPROP_RANDOM_SEED;
import static com.carrotsearch.randomizedtesting.SysGlobals.SYSPROP_TESTCLASS;
import static com.carrotsearch.randomizedtesting.SysGlobals.SYSPROP_TESTMETHOD;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.RandomAccessFile;
import java.io.Serializable;
import java.nio.charset.Charset;
import java.text.SimpleDateFormat;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.Deque;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.Vector;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.output.TeeOutputStream;
import org.apache.tools.ant.AntClassLoader;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.ProjectComponent;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.taskdefs.Execute;
import org.apache.tools.ant.types.Assertions;
import org.apache.tools.ant.types.Commandline;
import org.apache.tools.ant.types.CommandlineJava;
import org.apache.tools.ant.types.Environment;
import org.apache.tools.ant.types.Environment.Variable;
import org.apache.tools.ant.types.FileSet;
import org.apache.tools.ant.types.Path;
import org.apache.tools.ant.types.PropertySet;
import org.apache.tools.ant.types.Resource;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.types.resources.Resources;
import org.apache.tools.ant.util.LoaderUtils;
import org.junit.runner.Description;
import org.objectweb.asm.AnnotationVisitor;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;

import com.carrotsearch.ant.tasks.junit4.SuiteBalancer.Assignment;
import com.carrotsearch.ant.tasks.junit4.balancers.RoundRobinBalancer;
import com.carrotsearch.ant.tasks.junit4.balancers.SuiteHint;
import com.carrotsearch.ant.tasks.junit4.events.BootstrapEvent;
import com.carrotsearch.ant.tasks.junit4.events.QuitEvent;
import com.carrotsearch.ant.tasks.junit4.events.aggregated.AggregatedQuitEvent;
import com.carrotsearch.ant.tasks.junit4.events.aggregated.AggregatedStartEvent;
import com.carrotsearch.ant.tasks.junit4.events.aggregated.AggregatingListener;
import com.carrotsearch.ant.tasks.junit4.events.aggregated.ChildBootstrap;
import com.carrotsearch.ant.tasks.junit4.listeners.AggregatedEventListener;
import com.carrotsearch.ant.tasks.junit4.listeners.TextReport;
import com.carrotsearch.ant.tasks.junit4.slave.SlaveMain;
import com.carrotsearch.ant.tasks.junit4.slave.SlaveMainSafe;
import com.carrotsearch.randomizedtesting.ClassGlobFilter;
import com.carrotsearch.randomizedtesting.FilterExpressionParser;
import com.carrotsearch.randomizedtesting.MethodGlobFilter;
import com.carrotsearch.randomizedtesting.RandomizedRunner;
import com.carrotsearch.randomizedtesting.SeedUtils;
import com.carrotsearch.randomizedtesting.SysGlobals;
import com.carrotsearch.randomizedtesting.FilterExpressionParser.Node;
import com.carrotsearch.randomizedtesting.generators.RandomPicks;
import com.google.common.base.Charsets;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import com.google.common.collect.Collections2;
import com.google.common.collect.HashMultiset;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Multiset;
import com.google.common.collect.Ordering;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.CharStreams;
import com.google.common.io.Closeables;
import com.google.common.io.Closer;
import com.google.common.io.FileWriteMode;
import com.google.common.io.Files;
import com.google.gson.Gson;

/**
 * An ANT task to run JUnit4 tests. Differences (benefits?) compared to ANT's default JUnit task:
 * <ul>
 *  <li>Built-in parallel test execution support (spawns multiple JVMs to avoid 
 *  test interactions).</li>
 *  <li>Randomization of the order of test suites within a single JVM.</li>
 *  <li>Aggregates and synchronizes test events from executors. All reports run on
 *  the task's JVM (not on the test JVM).</li>
 *  <li>Fully configurable reporting via listeners (console, ANT-compliant XML, JSON).
 *  Report listeners use Google Guava's {@link EventBus} and receive full information
 *  about tests' execution (including skipped, assumption-skipped tests, streamlined
 *  output and error stream chunks, etc.).</li>
 *  <li>JUnit 4.10+ is required both for the task and for the tests classpath. 
 *  Older versions will cause build failure.</li>
 *  <li>Integration with {@link RandomizedRunner} (randomization seed is passed to
 *  children JVMs).</li>
 * </ul>
 */
public class JUnit4 extends Task {
  /**
   * Welcome messages.
   */
  private static String [] WELCOME_MESSAGES = {
    "hello!",               // en
    "hi!",                  // en
    "g'day!",               // en, australia
    "¡Hola!",               // es
    "jolly good day!",      // monty python
    "aloha!",               // en, hawaii
    "cześć!",               // pl
    "مرحبا!",               // arabic (modern)
    "kaixo!",               // basque
    "Привет!",              // bulgarian, russian
    "你好!",                 // cn, traditional
    "ahoj!",                // czech
    "salut!",               // french
    "hallo!",               // german
    "שלום!",                // hebrew
    "नमस्ते!",                // hindi
    "ᐊᐃ!",                  // inuktitut
    "ciao!",                // italian
    "今日は!",               // japanese
    "olá!",                 // portuguese
    // add more if your country/ place is not on the list ;)
  };

  /** Name of the antlib resource inside JUnit4 JAR. */
  public static final String ANTLIB_RESOURCE_NAME = "com/carrotsearch/junit4/antlib.xml";

  /** @see #setParallelism(String) */
  public static final Object PARALLELISM_AUTO = "auto";

  /** @see #setParallelism(String) */
  public static final String PARALLELISM_MAX = "max";

  /** Default value of {@link #setShuffleOnSlave}. */
  public static final boolean DEFAULT_SHUFFLE_ON_SLAVE = true;

  /** Default value of {@link #setParallelism}. */
  public static final String  DEFAULT_PARALLELISM = "1";

  /** Default value of {@link #setPrintSummary}. */
  public static final boolean DEFAULT_PRINT_SUMMARY = true;

  /** Default value of {@link #setHaltOnFailure}. */
  public static final boolean DEFAULT_HALT_ON_FAILURE = true;
  
  /** Default value of {@link #setIsolateWorkingDirectories(boolean)}. */
  public static final boolean DEFAULT_ISOLATE_WORKING_DIRECTORIES = true;

  /** Default value of {@link #setDynamicAssignmentRatio(float)} */
  public static final float DEFAULT_DYNAMIC_ASSIGNMENT_RATIO = .25f;

  /** Default value of {@link #setSysouts}. */
  public static final boolean DEFAULT_SYSOUTS = false;
  
  /** Default value of {@link #setDebugStream}. */
  public static final boolean DEFAULT_DEBUGSTREAM = false;

  /** Default value of {@link #setUniqueSuiteNames(boolean)} */
  public static final boolean DEFAULT_UNIQUE_SUITE_NAME = true;

  /** System property passed to forked VMs: current working directory (absolute). */
  private static final String CHILDVM_SYSPROP_CWD = "junit4.childvm.cwd";

  /** What to do on JVM output? */
  public static enum JvmOutputAction {
    PIPE,
    IGNORE,
    FAIL,
    WARN
  }
  
  /** What to do when there were no executed tests (all ignored or none at all?). */
  public static enum NoTestsAction {
    IGNORE,
    FAIL,
    WARN
  }

  /**
   * @see #setJvmOutputAction(String)
   */
  public EnumSet<JvmOutputAction> jvmOutputAction = EnumSet.of(
      JvmOutputAction.PIPE,
      JvmOutputAction.WARN);

  /**
   * @see #setSysouts
   */
  private boolean sysouts = DEFAULT_SYSOUTS; 

  /**
   * @see #setDebugStream
   */
  private boolean debugStream = DEFAULT_DEBUGSTREAM; 

  /**
   * Slave VM command line.
   */
  private CommandlineJava slaveCommand = new CommandlineJava();

  /**
   * Set new environment for the forked process?
   */
  private boolean newEnvironment;

  /**
   * @see #setUniqueSuiteNames
   */
  private boolean uniqueSuiteNames = DEFAULT_UNIQUE_SUITE_NAME;
  
  /**
   * Environment variables to use in the forked JVM.
   */
  private Environment env = new Environment();
  
  /**
   * Directory to invoke slave VM in.
   */
  private File dir;

  /**
   * Test names.
   */
  private final Resources resources;

  /**
   * Stop the build process if there were errors?
   */
  private boolean haltOnFailure = DEFAULT_HALT_ON_FAILURE;

  /**
   * Print summary of all tests at the end.
   */
  private boolean printSummary = DEFAULT_PRINT_SUMMARY;

  /**
   * Property to set if there were test failures or errors.
   */
  private String failureProperty;
  
  /**
   * A folder to store temporary files in. Defaults to {@link #dir} or  
   * the project's basedir.
   */
  private File tempDir;

  /**
   * Listeners listening on the event bus.
   */
  private List<Object> listeners = Lists.newArrayList();

  /**
   * Balancers scheduling tests for individual JVMs in parallel mode.
   */
  private List<SuiteBalancer> balancers = Lists.newArrayList();

  /**
   * Class loader used to resolve annotations and classes referenced from annotations
   * when {@link Description}s containing them are passed from slaves.
   */
  private AntClassLoader testsClassLoader;

  /**
   * @see #setParallelism(String)
   */
  private String parallelism = DEFAULT_PARALLELISM;

  /**
   * Set to true to leave temporary files (for diagnostics).
   */
  private boolean leaveTemporary;

  /**
   * A list of temporary files to leave or remove if build passes.
   */
  private List<File> temporaryFiles = Collections.synchronizedList(Lists.<File>newArrayList());

  /**
   * @see #setSeed(String)
   */
  private String random;

  /**
   * @see #setIsolateWorkingDirectories(boolean)
   */
  private boolean isolateWorkingDirectories = DEFAULT_ISOLATE_WORKING_DIRECTORIES;

  /**
   * Multiple path resolution in {@link CommandlineJava#getCommandline()} is very slow
   * so we construct and canonicalize paths.
   */
  private Path classpath;
  private Path bootclasspath;

  /**
   * @see #setDynamicAssignmentRatio(float)
   */
  private float dynamicAssignmentRatio = DEFAULT_DYNAMIC_ASSIGNMENT_RATIO;

  /**
   * @see #setShuffleOnSlave(boolean)
   */
  private boolean shuffleOnSlave = DEFAULT_SHUFFLE_ON_SLAVE;

  /**
   * @see #setHeartbeat
   */
  private long heartbeat; 
  
  /**
   * @see #setIfNoTests
   */
  private NoTestsAction ifNoTests = NoTestsAction.IGNORE;
  
  /**
   * @see #setStatsPropertyPrefix
   */
  private String statsPropertyPrefix;
  
  /**
   * 
   */
  public JUnit4() {
    resources = new Resources();
  }

  /**
   * What should be done on unexpected JVM output? JVM may write directly to the 
   * original descriptors, bypassing redirections of System.out and System.err. Typically,
   * these messages will be important and should fail the build (permgen space exceeded,
   * compiler errors, crash dumps). However, certain legitimate logs (gc activity, class loading
   * logs) are also printed to these streams so sometimes the output can be ignored.
   * 
   * <p>Allowed values (any comma-delimited combination of): {@link JvmOutputAction} 
   * constants.
   */
  public void setJvmOutputAction(String jvmOutputActions) {
    EnumSet<JvmOutputAction> actions = EnumSet.noneOf(JvmOutputAction.class); 
    for (String s : jvmOutputActions.split("[\\,\\ ]+")) {
      s = s.trim().toUpperCase(Locale.ENGLISH);
      actions.add(JvmOutputAction.valueOf(s));
    }
    this.jvmOutputAction = actions;
  }

  /**
   * If set to true, any sysout and syserr calls will be written to original
   * output and error streams (and in effect will appear as "jvm output". By default
   * sysout and syserrs are captured and proxied to the event stream to be synchronized
   * with other test events but occasionally one may want to synchronize them with direct 
   * JVM output (to synchronize with compiler output or GC output for example). 
   */
  public void setSysouts(boolean sysouts) {
    this.sysouts = sysouts;
  }
  
  /**
   * Enables a debug stream from each forked JVM. This will create an additional file
   * next to each events file. For debugging the framework only, not a general-purpose setting.
   */
  public void setDebugStream(boolean debugStream) {
    this.debugStream = debugStream;
  }
  
  /**
   * Allow or disallow duplicate suite names in resource collections. By default this option
   * is <code>true</code> because certain ANT-compatible report types (like XML reports)
   * will have a problem with duplicate suite names (will overwrite files).
   */
  public void setUniqueSuiteNames(boolean uniqueSuiteNames) {
    this.uniqueSuiteNames = uniqueSuiteNames;
  }
  
  /**
   * @see #setUniqueSuiteNames(boolean)
   */
  public boolean isUniqueSuiteNames() {
    return uniqueSuiteNames;
  }

  /**
   * Specifies the ratio of suites moved to dynamic assignment list. A dynamic
   * assignment list dispatches suites to the first idle slave JVM. Theoretically
   * this is an optimal strategy, but it is usually better to have some static assignments
   * to avoid communication costs.
   * 
   * <p>A ratio of 0 means only static assignments are used. A ratio of 1 means
   * only dynamic assignments are used.
   * 
   * <p>The list of dynamic assignments is sorted by decreasing cost (always) and
   * is inherently prone to race conditions in distributing suites. Should there
   * be an error based on suite-dependency it will not be directly repeatable. In such
   * case use the per-slave-jvm list of suites file dumped to disk for each slave JVM.
   * (see {@link #setLeaveTemporary(boolean)}).
   */
  public void setDynamicAssignmentRatio(float ratio) {
    if (ratio < 0 || ratio > 1) {
      throw new IllegalArgumentException("Dynamic assignment ratio must be " +
      		"between 0 (only static assignments) to 1 (fully dynamic assignments).");
    }
    this.dynamicAssignmentRatio = ratio;
  }

  /**
   * The number of parallel slaves. Can be set to a constant "max" for the
   * number of cores returned from {@link Runtime#availableProcessors()} or 
   * "auto" for sensible defaults depending on the number of cores.
   * The default is a single subprocess.
   * 
   * <p>Note that this setting forks physical JVM processes so it multiplies the 
   * requirements for heap memory, IO, etc. 
   */
  public void setParallelism(String parallelism) {
    this.parallelism = parallelism;
  }

  /**
   * Property to set to "true" if there is a failure in a test.
   */
  public void setFailureProperty(String failureProperty) {
    this.failureProperty = failureProperty;
  }
  
  /**
   * Do not propagate the old environment when new environment variables are specified.
   */
  public void setNewEnvironment(boolean v) {
    this.newEnvironment = v;
  }
  
  /**
   * Initial random seed used for shuffling test suites and other sources
   * of pseudo-randomness. If not set, any random value is set. 
   * 
   * <p>The seed's format is compatible with {@link RandomizedRunner} so that
   * seed can be fixed for suites and methods alike.
   */
  public void setSeed(String randomSeed) {
    if (!Strings.isNullOrEmpty(getProject().getUserProperty(SYSPROP_RANDOM_SEED()))) {
      String userProperty = getProject().getUserProperty(SYSPROP_RANDOM_SEED());
      if (!userProperty.equals(randomSeed)) {
        log("Ignoring seed attribute because it is overridden by user properties.", Project.MSG_WARN);
      }
    } else if (!Strings.isNullOrEmpty(randomSeed)) {
      this.random = randomSeed;
    }
  }

  /**
   * Initializes custom prefix for all junit4 properties. This must be consistent
   * across all junit4 invocations if done from the same classpath. Use only when REALLY needed.
   */
  public void setPrefix(String prefix) {
    if (!Strings.isNullOrEmpty(getProject().getUserProperty(SYSPROP_PREFIX()))) {
      log("Ignoring prefix attribute because it is overridden by user properties.", Project.MSG_WARN);
    } else {
      SysGlobals.initializeWith(prefix);
    }
  }

  /**
   * @see #setSeed(String) 
   */
  public String getSeed() {
    return random;
  }  

  /**
   * Predictably shuffle tests order after balancing. This will help in spreading
   * lighter and heavier tests over a single slave's execution timeline while
   * still keeping the same tests order depending on the seed. 
   */ 
  public void setShuffleOnSlave(boolean shuffle) {
    this.shuffleOnSlave = shuffle;
  }

  /*
   * 
   */
  @Override
  public void setProject(Project project) {
    super.setProject(project);

    this.resources.setProject(project);
    this.classpath = new Path(getProject());
    this.bootclasspath = new Path(getProject());
  }
  
  /**
   * Prints the summary of all executed, ignored etc. tests at the end. 
   */
  public void setPrintSummary(boolean printSummary) {
    this.printSummary = printSummary;
  }

  /**
   * Stop the build process if there were failures or errors during test execution.
   */
  public void setHaltOnFailure(boolean haltOnFailure) {
    this.haltOnFailure = haltOnFailure;
  }

  /**
   * Set the maximum memory to be used by all forked JVMs.
   * 
   * @param max
   *          the value as defined by <tt>-mx</tt> or <tt>-Xmx</tt> in the java
   *          command line options.
   */
  public void setMaxmemory(String max) {
    if (!Strings.isNullOrEmpty(max)) {
      getCommandline().setMaxmemory(max);
    }
  }

  /**
   * Set to true to leave temporary files for diagnostics.
   */
  public void setLeaveTemporary(boolean leaveTemporary) {
    this.leaveTemporary = leaveTemporary;
  }
  
  /**
   * Add an additional argument to any forked JVM.
   */
  public Commandline.Argument createJvmarg() {
    return getCommandline().createVmArgument();
  }

  /**
   * The directory to invoke forked VMs in.
   */
  public void setDir(File dir) {
    this.dir = dir;
  }

  /**
   * The directory to store temporary files in.
   */
  public void setTempDir(File tempDir) {
    this.tempDir = tempDir;
  }
  
  /**
   * What to do when no tests were executed (all tests were ignored)?
   * @see NoTestsAction
   */
  public void setIfNoTests(String value) {
    try {
      ifNoTests = NoTestsAction.valueOf(value.toUpperCase(Locale.ROOT));
    } catch (IllegalArgumentException e) {
      throw new BuildException("Invalid value (one of "
          + Arrays.toString(NoTestsAction.values()) + " accepted): " + value);
    }
  }

  /**
   * A {@link org.apache.tools.ant.types.Environment.Variable} with an additional
   * attribute specifying whether or not empty values should be propagated or ignored.  
   */
  public static class ExtendedVariable extends Environment.Variable {
    private boolean ignoreEmptyValue = false;

    public void setIgnoreEmpty(boolean ignoreEmptyValue) {
      this.ignoreEmptyValue = ignoreEmptyValue;
    }

    public boolean shouldIgnore() {
      return ignoreEmptyValue && Strings.isNullOrEmpty(getValue());
    }

    @Override
    public String toString() {
      return getContent() + " (ignoreEmpty=" + ignoreEmptyValue + ")";
    }
  }
  
  /**
   * Adds a system property to any forked JVM.
   */
  public void addConfiguredSysproperty(ExtendedVariable sysp) {
    if (!sysp.shouldIgnore()) {
      getCommandline().addSysproperty(sysp);
    }
  }
  
  /**
   * A {@link PropertySet} with an additional
   * attribute specifying whether or not empty values should be propagated or ignored.
   */
  public static class ExtendedPropertySet extends PropertySet {
    private boolean ignoreEmptyValue = false;

    public void setIgnoreEmpty(boolean ignoreEmptyValue) {
      this.ignoreEmptyValue = ignoreEmptyValue;
    }

    @Override
    public Properties getProperties() {
      Properties properties = super.getProperties();
      Properties clone = new Properties();
      for (String s : properties.stringPropertyNames()) {
        String value = (String) properties.get(s);
        if (ignoreEmptyValue && Strings.isNullOrEmpty(value)) {
          continue;
        } else {
          clone.setProperty(s, value);
        }
      }
      return clone;
    }
  }

  /**
   * Adds a set of properties that will be used as system properties that tests
   * can access.
   * 
   * This might be useful to transfer Ant properties to the testcases.
   */
  public void addConfiguredSyspropertyset(ExtendedPropertySet sysp) {
    getCommandline().addSyspropertyset(sysp);
  }
  
  /**
   * The command used to invoke the Java Virtual Machine, default is 'java'. The
   * command is resolved by java.lang.Runtime.exec().
   */
  public void setJvm(String jvm) {
    if (!Strings.isNullOrEmpty(jvm)) {
      getCommandline().setVm(jvm);
    }
  }

  /**
   * If set to <code>true</code> each slave JVM gets a separate working directory
   * under whatever is set in {@link #setDir(File)}. The directory naming for each slave
   * follows: "S<i>num</i>", where <i>num</i> is slave's number. Directories are created
   * automatically and removed unless {@link #setLeaveTemporary(boolean)} is set to
   * <code>true</code>.
   */
  public void setIsolateWorkingDirectories(boolean isolateWorkingDirectories) {
    this.isolateWorkingDirectories = isolateWorkingDirectories;
  }

  /**
   * Adds an environment variable; used when forking.
   */
  public void addEnv(ExtendedVariable var) {
    env.addVariable(var);
  }

  /**
   * Adds a set of tests based on pattern matching.
   */
  public void addFileSet(FileSet fs) {
    add(fs);
    if (fs.getProject() == null) {
      fs.setProject(getProject());
    }
  }

  /**
   * Adds a set of tests based on pattern matching.
   */
  public void add(ResourceCollection rc) {
    resources.add(rc);
  }  

  /**
   * Creates a new list of listeners.
   */
  public ListenersList createListeners() {
    return new ListenersList(listeners);
  }

  /**
   * Add assertions to tests execution.
   */
  public void addAssertions(Assertions asserts) {
    if (getCommandline().getAssertions() != null) {
        throw new BuildException("Only one assertion declaration is allowed");
    }
    getCommandline().setAssertions(asserts);
  }

  /**
   * Creates a new list of balancers.
   */
  public BalancersList createBalancers() {
    return new BalancersList(balancers);
  }

  /**
   * Adds path to classpath used for tests.
   * 
   * @return reference to the classpath in the embedded java command line
   */
  public Path createClasspath() {
    return classpath.createPath();
  }

  /**
   * Adds a path to the bootclasspath.
   * 
   * @return reference to the bootclasspath in the embedded java command line
   */
  public Path createBootclasspath() {
    return bootclasspath.createPath();
  }
  
  /* ANT-junit compat only. */
  public void setFork(boolean fork) {
    warnUnsupported("fork");
  }

  public void setForkmode(String forkMode) {
    warnUnsupported("forkmode");
  }
  
  public void setHaltOnError(boolean haltOnError) {
    warnUnsupported("haltonerror");
  }

  public void setFiltertrace(boolean filterTrace) {
    warnUnsupported("filtertrace");
    log("Hint: report listeners have stack filtering options.", Project.MSG_WARN);
  }

  public void setTimeout(String v) {
    warnUnsupported("timeout");
  }

  public void setIncludeantruntime(String v) {
    warnUnsupported("includeantruntime");
  }

  public void setShowoutput(String v) {
    warnUnsupported("showoutput");
  }

  public void setOutputtoformatters(String v) {
    warnUnsupported("outputtoformatters");
  }

  public void setReloading(String v) {
    warnUnsupported("reloading");
  }
  
  public void setClonevm(String v) {
    warnUnsupported("clonevm");
  }

  public void setErrorproperty(String v) {
      warnUnsupported("errorproperty");
  }

  public void setLogfailedtests(String v) {
    warnUnsupported("logfailedtests");
  }
  
  public void setEnableTestListenerEvents(String v) {
    warnUnsupported("enableTestListenerEvents");
  }

  public Object createFormatter() {
      throw new BuildException("<formatter> elements are not supported by <junit4>. " +
      		"Refer to the documentation about listeners and reports.");
  }

  public Object createTest() {
      throw new BuildException("<test> elements are not supported by <junit4>. " +
            "Use regular ANT resource collections to point at individual tests or their groups.");
  }

  public Object createBatchtest() {
    throw new BuildException("<batchtest> elements are not supported by <junit4>. " +
        "Use regular ANT resource collections to point at individual tests or their groups.");
  }

  private void warnUnsupported(String attName) {
    log("The '" + attName + "' attribute is not supported by <junit4>.", Project.MSG_WARN);
  }

  /**
   * Sets the heartbeat used to detect inactive/ hung forked tests (JVMs) to the given
   * number of seconds. The heartbeat detects
   * no-event intervals and will report them to listeners. Notably, {@link TextReport} report will
   * emit heartbeat information (to a file or console).
   * 
   * <p>Setting the heartbeat to zero means no detection.
   */
  public void setHeartbeat(long heartbeat) {
    this.heartbeat = heartbeat;
  }

  /**
   * Sets the property prefix to which test statistics are saved.
   */
  public void setStatsPropertyPrefix(String statsPropertyPrefix) {
    this.statsPropertyPrefix = statsPropertyPrefix;
  }
  
  @Override
  public void execute() throws BuildException {
    validateJUnit4();
    validateArguments();

    // Initialize random if not already provided.
    if (random == null) {
      this.random = com.google.common.base.Objects.firstNonNull( 
          Strings.emptyToNull(getProject().getProperty(SYSPROP_RANDOM_SEED())),
          SeedUtils.formatSeed(new Random().nextLong()));
    }
    masterSeed();

    // Say hello and continue.
    log("<JUnit4> says " +
        RandomPicks.randomFrom(new Random(masterSeed()), WELCOME_MESSAGES) +
        " Master seed: " + getSeed(), Project.MSG_INFO);

    // Pass the random seed property.
    createJvmarg().setValue("-D" + SYSPROP_PREFIX() + "=" + CURRENT_PREFIX());
    createJvmarg().setValue("-D" + SYSPROP_RANDOM_SEED() + "=" + random);

    // Resolve paths first.
    this.classpath = resolveFiles(classpath);
    this.bootclasspath = resolveFiles(bootclasspath);
    getCommandline().createClasspath(getProject()).add(classpath);
    getCommandline().createBootclasspath(getProject()).add(bootclasspath);

    // Setup a class loader over test classes. This will be used for loading annotations
    // and referenced classes. This is kind of ugly, but mirroring annotation content will
    // be even worse and Description carries these.
    testsClassLoader = new AntClassLoader(
        this.getClass().getClassLoader(),
        getProject(),
        getCommandline().getClasspath(),
        true);

    // Pass method filter if any.
    String testMethodFilter = Strings.emptyToNull(getProject().getProperty(SYSPROP_TESTMETHOD()));
    if (testMethodFilter != null) {
      Environment.Variable v = new Environment.Variable();
      v.setKey(SYSPROP_TESTMETHOD());
      v.setValue(testMethodFilter);
      getCommandline().addSysproperty(v);
    }

    // Process test classes and resources.
    long start = System.currentTimeMillis();    
    final TestsCollection testCollection = processTestResources();

    final EventBus aggregatedBus = new EventBus("aggregated");
    final TestsSummaryEventListener summaryListener = new TestsSummaryEventListener();
    aggregatedBus.register(summaryListener);
    
    for (Object o : listeners) {
      if (o instanceof ProjectComponent) {
        ((ProjectComponent) o).setProject(getProject());
      }
      if (o instanceof AggregatedEventListener) {
        ((AggregatedEventListener) o).setOuter(this);
      }
      aggregatedBus.register(o);
    }

    if (testCollection.testClasses.isEmpty()) {
      aggregatedBus.post(new AggregatedQuitEvent());
    } else {
      start = System.currentTimeMillis();

      // Check if we allow duplicate suite names. Some reports (ANT compatible XML
      // reports) will have a problem with duplicate suite names, for example.
      if (uniqueSuiteNames) {
        testCollection.onlyUniqueSuiteNames();
      }

      final int jvmCount = determineForkedJvmCount(testCollection);
      final List<ForkedJvmInfo> slaveInfos = Lists.newArrayList();
      for (int jvmid = 0; jvmid < jvmCount; jvmid++) {
        final ForkedJvmInfo slaveInfo = new ForkedJvmInfo(jvmid, jvmCount);
        slaveInfos.add(slaveInfo);
      }

      
      if (jvmCount > 1 && uniqueSuiteNames && testCollection.hasReplicatedSuites()) {
        throw new BuildException(String.format(Locale.ENGLISH,
            "There are test suites that request JVM replication and the number of forked JVMs %d is larger than 1. Run on a single JVM.",
            jvmCount));
      }

      // Prepare a pool of suites dynamically dispatched to slaves as they become idle.
      final Deque<String> stealingQueue = 
          new ArrayDeque<String>(loadBalanceSuites(slaveInfos, testCollection, balancers));
      aggregatedBus.register(new Object() {
        @Subscribe
        public void onSlaveIdle(SlaveIdle slave) {
          if (stealingQueue.isEmpty()) {
            slave.finished();
          } else {
            String suiteName = stealingQueue.pop();
            slave.newSuite(suiteName);
          }
        }
      });
      
      // Check for filtering expressions.
      @SuppressWarnings("unchecked")
      Vector<Variable> vv = getCommandline().getSystemProperties().getVariablesVector();
      for (Variable v : vv) {
        if (SysGlobals.SYSPROP_TESTFILTER().equals(v.getKey())) {
          try {
            Node root = new FilterExpressionParser().parse(v.getValue());
            log("Parsed test filtering expression: " + root.toExpression(), Project.MSG_INFO);
          } catch (Exception e) {
            log("Could not parse filtering expression: " + v.getValue(), Project.MSG_WARN);
          }
        }
      }

      // Create callables for the executor.
      final List<Callable<Void>> slaves = Lists.newArrayList();
      for (int slave = 0; slave < jvmCount; slave++) {
        final ForkedJvmInfo slaveInfo = slaveInfos.get(slave);
        slaves.add(new Callable<Void>() {
          @Override
          public Void call() throws Exception {
            executeSlave(slaveInfo, aggregatedBus);
            return null;
          }
        });
      }

      ExecutorService executor = Executors.newCachedThreadPool();
      aggregatedBus.post(new AggregatedStartEvent(slaves.size(),
          // TODO: this doesn't account for replicated suites.
          testCollection.testClasses.size()));

      try {
        List<Future<Void>> all = executor.invokeAll(slaves);
        executor.shutdown();

        for (int i = 0; i < slaves.size(); i++) {
          Future<Void> f = all.get(i);
          try {
            f.get();
          } catch (ExecutionException e) {
            slaveInfos.get(i).executionError = e.getCause();
          }
        }
      } catch (InterruptedException e) {
        log("Master interrupted? Weird.", Project.MSG_ERR);
      }
      aggregatedBus.post(new AggregatedQuitEvent());

      for (ForkedJvmInfo si : slaveInfos) {
        if (si.start > 0 && si.end > 0) {
          log(String.format(Locale.ENGLISH, "JVM J%d: %8.2f .. %8.2f = %8.2fs",
              si.id,
              (si.start - start) / 1000.0f,
              (si.end - start) / 1000.0f,
              (si.getExecutionTime() / 1000.0f)), 
              Project.MSG_INFO);
        }
      }
      log("Execution time total: " + Duration.toHumanDuration(
          (System.currentTimeMillis() - start)));

      ForkedJvmInfo slaveInError = null;
      for (ForkedJvmInfo i : slaveInfos) {
        if (i.executionError != null) {
          log("ERROR: JVM J" + i.id + " ended with an exception, command line: " + i.getCommandLine());
          log("ERROR: JVM J" + i.id + " ended with an exception: " + 
              Throwables.getStackTraceAsString(i.executionError), Project.MSG_ERR);
          if (slaveInError == null) {
            slaveInError = i;
          }
        }
      }

      if (slaveInError != null) {
        throw new BuildException("At least one slave process threw an exception, first: "
            + slaveInError.executionError.getMessage(), slaveInError.executionError);
      }
    }

    final TestsSummary testsSummary = summaryListener.getResult();
    if (printSummary) {
      log("Tests summary: " + testsSummary, Project.MSG_INFO);
    }

    if (!testsSummary.isSuccessful()) {
      if (!Strings.isNullOrEmpty(failureProperty)) {
        getProject().setNewProperty(failureProperty, "true");        
      }
      if (haltOnFailure) {
        throw new BuildException("There were test failures: " + testsSummary);
      }
    }

    if (!leaveTemporary) {
      for (File f : temporaryFiles) {
        try {
          if (f != null) {
            if (!FileUtils.deleteQuietly(f)) throw new IOException();
          }
        } catch (IOException e) {
          log("Could not remove temporary path: " + f.getAbsolutePath(), Project.MSG_WARN);
        }
      }
    }

    if (statsPropertyPrefix != null) {
      Project p = getProject();
      p.setNewProperty(statsPropertyPrefix + ".tests", Integer.toString(testsSummary.tests));
      p.setNewProperty(statsPropertyPrefix + ".errors", Integer.toString(testsSummary.errors));
      p.setNewProperty(statsPropertyPrefix + ".failures", Integer.toString(testsSummary.failures));
      p.setNewProperty(statsPropertyPrefix + ".ignores", Integer.toString(testsSummary.ignores));
      p.setNewProperty(statsPropertyPrefix + ".suites", Integer.toString(testsSummary.suites));
      p.setNewProperty(statsPropertyPrefix + ".assumptions", Integer.toString(testsSummary.assumptions));
      p.setNewProperty(statsPropertyPrefix + ".suiteErrors", Integer.toString(testsSummary.suiteErrors));

      p.setNewProperty(statsPropertyPrefix + ".nonIgnored", Integer.toString(testsSummary.getNonIgnoredTestsCount()));
      p.setNewProperty(statsPropertyPrefix + ".successful", Boolean.toString(testsSummary.isSuccessful()));
    }

    int executedTests = testsSummary.getNonIgnoredTestsCount();
    if (executedTests == 0) {
      String message = "There were no executed tests: " + testsSummary;
      switch (ifNoTests) {
        case FAIL:
          throw new BuildException(message);
        case WARN:
          log(message, Project.MSG_WARN);
          break;
        case IGNORE:
          break;
        default:
          throw new RuntimeException("Unreachable case clause: " + ifNoTests);
      }
    }
  }

  /**
   * Validate arguments.
   */
  private void validateArguments() {
    File tempDir = getTempDir();

    if (tempDir == null) {
      throw new BuildException("Temporary directory cannot be null.");
    }
    
    if (tempDir.exists()) {
      if (!tempDir.isDirectory()) {
        throw new BuildException("Temporary directory is not a folder: " + tempDir.getAbsolutePath());
      }
    } else {
      if (!tempDir.mkdirs()) {
        throw new BuildException("Failed to create temporary directory: " + tempDir.getAbsolutePath());
      }
    }
    
    // TODO: we should probably add validation for the entire set of attrs...
  }

  /**
   * Validate JUnit4 presence in a concrete version.
   */
  private void validateJUnit4() throws BuildException {
    try {
      Class<?> clazz = Class.forName("org.junit.runner.Description");
      if (!Serializable.class.isAssignableFrom(clazz)) {
        throw new BuildException("At least JUnit version 4.10 is required on junit4's taskdef classpath.");
      }
    } catch (ClassNotFoundException e) {
      throw new BuildException("JUnit JAR must be added to junit4 taskdef's classpath.");
    }
  }

  /**
   * Perform load balancing of the set of suites. Sets {@link ForkedJvmInfo#testSuites}
   * to suites preassigned to a given slave and returns a pool of suites
   * that should be load-balanced dynamically based on job stealing.
   */
  private List<String> loadBalanceSuites(List<ForkedJvmInfo> jvmInfo,
      TestsCollection testsCollection, List<SuiteBalancer> balancers) {
    
    // Order test suites identically for balancers.
    // and split into replicated and non-replicated suites.
    Multimap<Boolean,TestClass> partitioned = sortAndSplitReplicated(testsCollection.testClasses);
    Function<TestClass,String> extractClassName = new Function<TestClass,String>() {
      @Override
      public String apply(TestClass input) {
        return input.className;
      }
    };
    Collection<String> replicated = Collections2.transform(partitioned.get(true), extractClassName);
    Collection<String> suites     = Collections2.transform(partitioned.get(false), extractClassName);

    final List<SuiteBalancer> balancersWithFallback = Lists.newArrayList(balancers);
    balancersWithFallback.add(new RoundRobinBalancer());

    // Go through all the balancers, the first one to assign a suite wins.
    final Multiset<String> remaining = HashMultiset.create(suites);
    final Map<Integer,List<Assignment>> perJvmAssignments = Maps.newHashMap();
    for (ForkedJvmInfo si : jvmInfo) {
      perJvmAssignments.put(si.id, Lists.<Assignment> newArrayList());
    }
    final int jvmCount = jvmInfo.size();
    for (SuiteBalancer balancer : balancersWithFallback) {
      balancer.setOwner(this);
      final List<Assignment> assignments =
          balancer.assign(
              Collections.unmodifiableCollection(remaining), jvmCount, masterSeed());

      for (Assignment e : assignments) {
        if (e == null) {
          throw new RuntimeException("Balancer must return non-null assignments.");
        }
        if (!remaining.remove(e.suiteName)) {
          throw new RuntimeException("Balancer must return suite name as a key: " + e.suiteName);
        }

        log(String.format(Locale.ENGLISH,
            "Assignment hint: J%-2d (cost %5d) %s (by %s)",
            e.slaveId,
            e.estimatedCost,
            e.suiteName,
            balancer.getClass().getSimpleName()), Project.MSG_VERBOSE);

        perJvmAssignments.get(e.slaveId).add(e);
      }
    }

    if (remaining.size() != 0) {
      throw new RuntimeException("Not all suites assigned?: " + remaining);
    }

    if (shuffleOnSlave) {
      // Shuffle suites on slaves so that the result is always the same wrt master seed
      // (sort first, then shuffle with a constant seed).
      for (List<Assignment> assignments : perJvmAssignments.values()) {
        Collections.sort(assignments);
        Collections.shuffle(assignments, new Random(this.masterSeed()));
      }
    }

    // Take a fraction of suites scheduled as last on each slave and move them to a common
    // job-stealing queue.
    List<SuiteHint> stealingQueueWithHints = Lists.newArrayList();
    for (ForkedJvmInfo si : jvmInfo) {
      final List<Assignment> assignments = perJvmAssignments.get(si.id);
      int moveToCommon = (int) (assignments.size() * dynamicAssignmentRatio);

      if (moveToCommon > 0) {
        final List<Assignment> movedToCommon = 
            assignments.subList(assignments.size() - moveToCommon, assignments.size());
        for (Assignment a : movedToCommon) {
          stealingQueueWithHints.add(new SuiteHint(a.suiteName, a.estimatedCost));
        }
        movedToCommon.clear();
      }

      final ArrayList<String> slaveSuites = (si.testSuites = Lists.newArrayList());
      for (Assignment a : assignments) {
        slaveSuites.add(a.suiteName);
      }
    }

    // Sort stealing queue according to descending cost.
    Collections.sort(stealingQueueWithHints, SuiteHint.DESCENDING_BY_WEIGHT);

    // Append all replicated suites to each forked JVM, AFTER we process the stealing queue
    // to enforce all replicated suites run on each bound JVM.
    if (!replicated.isEmpty()) {
      for (ForkedJvmInfo si : jvmInfo) {
        for (String suite : replicated) {
            si.testSuites.add(suite);
        }
        if (shuffleOnSlave) {
          // Shuffle suites on slaves so that the result is always the same wrt master seed
          // (sort first, then shuffle with a constant seed).
          Collections.shuffle(si.testSuites, new Random(this.masterSeed()));
        }
      }
    }

    // Dump scheduling information.
    for (ForkedJvmInfo si : jvmInfo) {
      log("Forked JVM J" + si.id + " assignments (after shuffle):", Project.MSG_VERBOSE);
      for (String suiteName : si.testSuites) {
        log("  " + suiteName, Project.MSG_VERBOSE);
      }
    }

    log("Stealing queue:", Project.MSG_VERBOSE);
    for (SuiteHint suiteHint : stealingQueueWithHints) {
      log("  " + suiteHint.suiteName + " " + suiteHint.cost, Project.MSG_VERBOSE);
    }

    List<String> stealingQueue = Lists.newArrayListWithCapacity(stealingQueueWithHints.size());
    for (SuiteHint suiteHint : stealingQueueWithHints) {
      stealingQueue.add(suiteHint.suiteName);
    }
    return stealingQueue;
  }

  private Multimap<Boolean,TestClass> sortAndSplitReplicated(List<TestClass> testClasses) {
    List<TestClass> sorted = Ordering.natural()
      .onResultOf(new Function<TestClass,String>() {
        @Override
        public String apply(TestClass input) {
          return input.className + ";" + input.replicate;
        }
      })
      .sortedCopy(testClasses);

    return Multimaps.index(sorted, new Function<TestClass,Boolean>() {
      @Override
      public Boolean apply(TestClass t) {
        return t.replicate;
      }
    });
  }

  /**
   * Return the master seed of {@link #getSeed()}.
   */
  private long masterSeed() {
    long[] seeds = SeedUtils.parseSeedChain(getSeed());
    if (seeds.length < 1) {
      throw new BuildException("Random seed is required.");
    }
    return seeds[0];
  }

  /**
   * Resolve all files from a given path and simplify its definition.
   */
  private Path resolveFiles(Path path) {
    Path cloned = new Path(getProject());
    for (String location : path.list()) {
      cloned.createPathElement().setLocation(new File(location));
    }
    return cloned;
  }

  /**
   * Determine how many forked JVMs to use.
   */
  private int determineForkedJvmCount(TestsCollection testCollection) {
    int cores = Runtime.getRuntime().availableProcessors();
    int jvmCount;
    if (this.parallelism.equals(PARALLELISM_AUTO)) {
      if (cores >= 8) {
        // Maximum parallel jvms is 4, conserve some memory and memory bandwidth.
        jvmCount = 4;
      } else if (cores >= 4) {
        // Make some space for the aggregator.
        jvmCount = 3;
      } else {
        // even for dual cores it usually makes no sense to fork more than one
        // JVM.
        jvmCount = 1;
      }
    } else if (this.parallelism.equals(PARALLELISM_MAX)) {
      jvmCount = Runtime.getRuntime().availableProcessors();
    } else {
      try {
        jvmCount = Math.max(1, Integer.parseInt(parallelism));
      } catch (NumberFormatException e) {
        throw new BuildException("parallelism must be 'auto', 'max' or a valid integer: "
            + parallelism);
      }
    }

    if (!testCollection.hasReplicatedSuites()) {
      jvmCount = Math.min(testCollection.testClasses.size(), jvmCount);
    }
    return jvmCount;
  }

  /**
   * Attach listeners and execute a slave process.
   */
  private void executeSlave(final ForkedJvmInfo slave, final EventBus aggregatedBus)
    throws Exception
  {
    final String uniqueSeed = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS").format(new Date());

    final File classNamesFile = tempFile(uniqueSeed,
        "junit4-J" + slave.id, ".suites", getTempDir());
    temporaryFiles.add(classNamesFile);

    final File classNamesDynamic = tempFile(uniqueSeed,
        "junit4-J" + slave.id, ".dynamic-suites", getTempDir());

    final File streamsBufferFile = tempFile(uniqueSeed,
        "junit4-J" + slave.id, ".spill", getTempDir());

    // Dump all test class names to a temporary file.
    String testClassPerLine = Joiner.on("\n").join(slave.testSuites);
    log("Test class names:\n" + testClassPerLine, Project.MSG_VERBOSE);

    Files.write(testClassPerLine, classNamesFile, Charsets.UTF_8);

    // Prepare command line for java execution.
    CommandlineJava commandline;
    commandline = (CommandlineJava) getCommandline().clone();
    commandline.createClasspath(getProject()).add(addSlaveClasspath());
    commandline.setClassname(SlaveMainSafe.class.getName());
    if (slave.slaves == 1) {
      commandline.createArgument().setValue(SlaveMain.OPTION_FREQUENT_FLUSH);
    }

    // Set up full output files.
    File sysoutFile = tempFile(uniqueSeed,
        "junit4-J" + slave.id, ".sysout", getTempDir());
    File syserrFile = tempFile(uniqueSeed,
        "junit4-J" + slave.id, ".syserr", getTempDir());

    // Set up communication channel.
    File eventFile = tempFile(uniqueSeed,
        "junit4-J" + slave.id, ".events", getTempDir());
    temporaryFiles.add(eventFile);
    commandline.createArgument().setValue(SlaveMain.OPTION_EVENTSFILE);
    commandline.createArgument().setFile(eventFile);
    
    if (sysouts) {
      commandline.createArgument().setValue(SlaveMain.OPTION_SYSOUTS);
    }
    
    if (debugStream) {
      commandline.createArgument().setValue(SlaveMain.OPTION_DEBUGSTREAM);
    }

    InputStream eventStream = new TailInputStream(eventFile);

    // Set up input suites file.
    commandline.createArgument().setValue("@" + classNamesFile.getAbsolutePath());

    // Emit command line before -stdin to avoid confusion.
    slave.slaveCommandLine = escapeAndJoin(commandline.getCommandline());
    log("Forked JVM process command line (may need escape sequences for your shell):\n" + 
        slave.slaveCommandLine, Project.MSG_VERBOSE);

    commandline.createArgument().setValue(SlaveMain.OPTION_STDIN);

    final EventBus eventBus = new EventBus("slave-" + slave.id);
    final DiagnosticsListener diagnosticsListener = new DiagnosticsListener(slave, this);
    eventBus.register(diagnosticsListener);
    eventBus.register(new AggregatingListener(aggregatedBus, slave));

    final AtomicReference<Charset> clientCharset = new AtomicReference<Charset>();
    final AtomicBoolean clientWithLimitedCharset = new AtomicBoolean(); 
    final PrintWriter w = new PrintWriter(Files.newWriter(classNamesDynamic, Charsets.UTF_8));
    eventBus.register(new Object() {
      @Subscribe
      public void onIdleSlave(final SlaveIdle idleSlave) {
        aggregatedBus.post(new SlaveIdle() {
          @Override
          public void finished() {
            idleSlave.finished();
          }

          @Override
          public void newSuite(String suiteName) {
            if (!clientCharset.get().newEncoder().canEncode(suiteName)) {
              clientWithLimitedCharset.set(true);
              log("Forked JVM J" + slave.id + " skipped suite (cannot encode suite name in charset " +
                  clientCharset.get() + "): " + suiteName, Project.MSG_WARN);
              return;
            }

            log("Forked JVM J" + slave.id + " stole suite: " + suiteName, Project.MSG_VERBOSE);
            w.println(suiteName);
            w.flush();
            idleSlave.newSuite(suiteName);
          }
        });
      }

      @Subscribe
      public void onBootstrap(final BootstrapEvent e) {
        Charset cs = Charset.forName(((BootstrapEvent) e).getDefaultCharsetName());
        clientCharset.set(cs);

        slave.start = System.currentTimeMillis();
        slave.setBootstrapEvent(e);
        aggregatedBus.post(new ChildBootstrap(slave));
      }

      @Subscribe
      public void receiveQuit(QuitEvent e) {
        slave.end = System.currentTimeMillis();
      }      
    });

    Closer closer = Closer.create();
    closer.register(eventStream);
    closer.register(w);
    try {
      OutputStream sysout = closer.register(new BufferedOutputStream(new FileOutputStream(sysoutFile)));
      OutputStream syserr = closer.register(new BufferedOutputStream(new FileOutputStream(syserrFile)));
      RandomAccessFile streamsBuffer = closer.register(new RandomAccessFile(streamsBufferFile, "rw"));

      Execute execute = forkProcess(slave, eventBus, commandline, eventStream, sysout, syserr, streamsBuffer);
      log("Forked JVM J" + slave.id + " finished with exit code: " + execute.getExitValue(), Project.MSG_DEBUG);

      if (execute.isFailure()) {
        final int exitStatus = execute.getExitValue();
        switch (exitStatus) {
          case SlaveMain.ERR_NO_JUNIT:
            throw new BuildException("Forked JVM's classpath must include a junit4 JAR.");
          case SlaveMain.ERR_OLD_JUNIT:
            throw new BuildException("Forked JVM's classpath must use JUnit 4.10 or newer.");
          default:
            Closeables.close(sysout, false);
            Closeables.close(syserr, false);

            StringBuilder message = new StringBuilder();
            if (exitStatus == SlaveMain.ERR_OOM) {
              message.append("Forked JVM ran out of memory.");
            } else {
              message.append("Forked process returned with error code: ").append(exitStatus).append(".");
            }

            if (sysoutFile.length() > 0 || syserrFile.length() > 0) {
              if (exitStatus != SlaveMain.ERR_OOM) {
                message.append(" Very likely a JVM crash. ");
              }

              if (jvmOutputAction.contains(JvmOutputAction.PIPE)) {
                message.append(" Process output piped in logs above.");
              } else if (!jvmOutputAction.contains(JvmOutputAction.IGNORE)) {
                if (sysoutFile.length() > 0) {
                  message.append(" See process stdout at: " + sysoutFile.getAbsolutePath());
                }
                if (syserrFile.length() > 0) {
                  message.append(" See process stderr at: " + syserrFile.getAbsolutePath());
                }
              }
            }
            throw new BuildException(message.toString());
        }
      }
    } catch (Throwable t) {
      throw closer.rethrow(t);
    } finally {
      try {
        closer.close();
      } finally {
        Files.asByteSource(classNamesDynamic).copyTo(Files.asByteSink(classNamesFile, FileWriteMode.APPEND));
        classNamesDynamic.delete();
        streamsBufferFile.delete();
        
        // Check sysout/syserr lengths.
        checkJvmOutput(sysoutFile, slave, "stdout");
        checkJvmOutput(syserrFile, slave, "stderr");        
      }
    }

    if (!diagnosticsListener.quitReceived()) {
      throw new BuildException("Quit event not received from the forked process? This may indicate JVM crash or runner bugs.");
    }

    if (clientWithLimitedCharset.get() && dynamicAssignmentRatio > 0) {
      throw new BuildException("Forked JVM J" + slave.id + " was not be able to decode class names when using" +
          " charset: " + clientCharset + ". Do not use " +
          "dynamic suite balancing to work around this problem (-DdynamicAssignmentRatio=0).");
    }
  }

  private void checkJvmOutput(File file, ForkedJvmInfo slave, String fileName) {
    if (file.length() > 0) {
      String message = "JVM J" + slave.id + ": " + fileName + " was not empty, see: " + file;
      if (jvmOutputAction.contains(JvmOutputAction.WARN)) {
        log(message, Project.MSG_WARN);
      }
      if (jvmOutputAction.contains(JvmOutputAction.PIPE)) {
        log(">>> JVM J" + slave.id + ": " + fileName + " (verbatim) ----", Project.MSG_INFO);
        try {
          // If file > 10 mb, stream directly. Otherwise use the logger.
          if (file.length() < 10 * (1024 * 1024)) {
            // Append to logger.
            log(Files.toString(file, slave.getCharset()), Project.MSG_INFO);
          } else {
            // Stream directly.
            CharStreams.copy(Files.newReader(file, slave.getCharset()), System.out);
          }
        } catch (IOException e) {
          log("Couldn't pipe file " + file + ": " + e.toString(), Project.MSG_INFO);
        }
        log("<<< JVM J" + slave.id + ": EOF ----", Project.MSG_INFO);
      }
      if (jvmOutputAction.contains(JvmOutputAction.IGNORE)) {
        file.delete();
      }
      if (jvmOutputAction.contains(JvmOutputAction.FAIL)) {
        throw new BuildException(message);
      }
      return;
    }
    file.delete();
  }

  private File tempFile(String uniqueSeed, String base, String suffix, File tempDir) throws IOException {
    int retry = 0;
    File finalName;
    do {
      if (retry > 0) {
        finalName = new File(tempDir, base + "-" + uniqueSeed + "_retry" + retry + suffix);
      } else {
        finalName = new File(tempDir, base + "-" + uniqueSeed + suffix);
      }
    } while (!finalName.createNewFile() && retry++ < 5);
    return finalName;
  }

  /**
   * Try to provide an escaped, ready-to-use shell line to repeat a given command line.
   */
  private String escapeAndJoin(String[] commandline) {
    // TODO: we should try to escape special characters here, depending on the OS.
    StringBuilder b = new StringBuilder();
    Pattern specials = Pattern.compile("[\\ ]");
    for (String arg : commandline) {
      if (b.length() > 0) {
        b.append(" ");
      }

      if (specials.matcher(arg).find()) {
        b.append('"').append(arg).append('"');
      } else {
        b.append(arg);
      }
    }
    return b.toString();
  }

  /**
   * Execute a slave process. Pump events to the given event bus.
   */
  private Execute forkProcess(ForkedJvmInfo slaveInfo, EventBus eventBus, 
      CommandlineJava commandline, 
      InputStream eventStream, OutputStream sysout, OutputStream syserr, RandomAccessFile streamsBuffer) {
    try {
      final LocalSlaveStreamHandler streamHandler = 
          new LocalSlaveStreamHandler(
              eventBus, testsClassLoader, System.err, eventStream, 
              sysout, syserr, heartbeat, streamsBuffer);

      // Add certain properties to allow identification of the forked JVM from within
      // the subprocess. This can be used for policy files etc.
      final File cwd = getWorkingDirectory(slaveInfo);

      Variable v = new Variable();
      v.setKey(CHILDVM_SYSPROP_CWD);
      v.setFile(cwd.getAbsoluteFile());
      commandline.addSysproperty(v);

      v = new Variable();
      v.setKey(SysGlobals.CHILDVM_SYSPROP_JVM_ID);
      v.setValue(Integer.toString(slaveInfo.id));
      commandline.addSysproperty(v);

      v = new Variable();
      v.setKey(SysGlobals.CHILDVM_SYSPROP_JVM_COUNT);
      v.setValue(Integer.toString(slaveInfo.slaves));
      commandline.addSysproperty(v);

      final Execute execute = new Execute();
      execute.setCommandline(commandline.getCommandline());
      execute.setVMLauncher(true);
      execute.setWorkingDirectory(cwd);
      execute.setStreamHandler(streamHandler);
      execute.setNewenvironment(newEnvironment);
      if (env.getVariables() != null)
        execute.setEnvironment(env.getVariables());
      log("Starting JVM J" + slaveInfo.id, Project.MSG_DEBUG);
      execute.execute();
      return execute;
    } catch (IOException e) {
      throw new BuildException("Could not execute slave process. Run ant with -verbose to get" +
      		" the execution details.", e);
    }
  }

  private File getWorkingDirectory(ForkedJvmInfo slaveInfo) {
    File baseDir = (dir == null ? getProject().getBaseDir() : dir);
    final File slaveDir;
    if (isolateWorkingDirectories) {
      slaveDir = new File(baseDir, "J" + slaveInfo.id);
      slaveDir.mkdirs();
      temporaryFiles.add(slaveDir);
    } else {
      slaveDir = baseDir;
    }
    return slaveDir;
  }

  /**
   * Resolve temporary folder.
   */
  private File getTempDir() {
    if (this.tempDir == null) {
      if (this.dir != null) {
        this.tempDir = dir;
      } else {
        this.tempDir = getProject().getBaseDir();
      }
    }
    return tempDir;
  }

  /**
   * Process test resources. If there are any test resources that are _not_ class files,
   * this will cause a build error.   
   */
  private TestsCollection processTestResources() {
    TestsCollection collection = new TestsCollection();
    resources.setProject(getProject());

    @SuppressWarnings("unchecked")
    Iterator<Resource> iter = (Iterator<Resource>) resources.iterator();
    boolean javaSourceWarn = false;
    while (iter.hasNext()) {
      final Resource r = iter.next();
      if (!r.isExists()) 
        throw new BuildException("Test class resource does not exist?: " + r.getName());

      try {
        if (r.getName().endsWith(".java")) {
          String pathname = r.getName();
          String className = pathname.substring(0, pathname.length() - ".java".length());
          className = className
            .replace(File.separatorChar, '.')
            .replace('/', '.')
            .replace('\\', '.');
          collection.add(new TestClass(className));
          
          if (!javaSourceWarn) {
            log("Source (.java) files used for naming source suites. This is discouraged, " +
            		"use a resource collection pointing to .class files instead.", Project.MSG_INFO);
            javaSourceWarn = true;
          }
        } else {
          // Assume .class file.
          InputStream is = r.getInputStream();
          if (!is.markSupported()) {
            is = new BufferedInputStream(is);          
          }
  
          try {
            is.mark(4);
            if (is.read() != 0xca ||
                is.read() != 0xfe ||
                is.read() != 0xba ||
                is.read() != 0xbe) {
              throw new BuildException("File does not start with a class magic 0xcafebabe: "
                  + r.getName() + ", " + r.getLocation());
            }
            is.reset();

            // Hardcoded intentionally.
            final String REPLICATE_CLASS = "com.carrotsearch.randomizedtesting.annotations.ReplicateOnEachVm";
            final TestClass testClass = new TestClass();
            ClassReader reader = new ClassReader(is);
            ClassVisitor annotationVisitor = new ClassVisitor(Opcodes.ASM5) {
              @Override
              public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
                String className = Type.getType(desc).getClassName();
                if (className.equals(REPLICATE_CLASS)) {
                  testClass.replicate = true;
                }
                return null;
              }
            };

            reader.accept(annotationVisitor, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
            testClass.className = reader.getClassName().replace('/', '.');
            log("Test class parsed: " + r.getName() + " as " + testClass.className, Project.MSG_DEBUG);
            
            collection.add(testClass);
          } finally {
            is.close();
          }
        }
      } catch (IOException e) {
        throw new BuildException("Could not read or parse as Java class: "
            + r.getName() + ", " + r.getLocation());
      }
    }

    String testClassFilter = Strings.emptyToNull(getProject().getProperty(SYSPROP_TESTCLASS()));
    if (testClassFilter != null) {
      ClassGlobFilter filter = new ClassGlobFilter(testClassFilter);
      for (Iterator<TestClass> i = collection.testClasses.iterator(); i.hasNext();) {
        if (!filter.shouldRun(Description.createSuiteDescription(i.next().className))) {
          i.remove();
        }
      }
    }

    return collection;
  }

  /**
   * Returns the slave VM command line.
   */
  private CommandlineJava getCommandline() {
    return slaveCommand;
  }

  /**
   * Adds a classpath source which contains the given resource.
   */
  private Path addSlaveClasspath() {
    Path path = new Path(getProject());

    String [] REQUIRED_SLAVE_CLASSES = {
        SlaveMain.class.getName(),
        Strings.class.getName(),
        MethodGlobFilter.class.getName(),
        Gson.class.getName(),
        TeeOutputStream.class.getName()
    };

    for (String clazz : Arrays.asList(REQUIRED_SLAVE_CLASSES)) {
      String resource = clazz.replace(".", "/") + ".class";
      File f = LoaderUtils.getResourceSource(getClass().getClassLoader(), resource);
      if (f != null) {
        path.createPath().setLocation(f);
      } else {
        throw new BuildException("Could not locate classpath for resource: "
            + resource);
      }
    }
    return path;
  }
}
