Open source DynamicExecutionModule

RELNOTES: Dynamic execution is now available with --experimental_spawn_strategy. Dynamic execution allows a build action to run locally and remotely simultaneously, and Bazel picks the fastest action. This provides the best of both worlds: faster clean builds than pure local builds, and faster incremental builds than pure remote builds.
PiperOrigin-RevId: 222446721
diff --git a/src/main/java/com/google/devtools/build/lib/BUILD b/src/main/java/com/google/devtools/build/lib/BUILD
index 0d05429..af2a42b 100644
--- a/src/main/java/com/google/devtools/build/lib/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/BUILD
@@ -30,6 +30,7 @@
         "//src/main/java/com/google/devtools/build/lib/collect/nestedset:srcs",
         "//src/main/java/com/google/devtools/build/lib/collect:srcs",
         "//src/main/java/com/google/devtools/build/lib/concurrent:srcs",
+        "//src/main/java/com/google/devtools/build/lib/dynamic:srcs",
         "//src/main/java/com/google/devtools/build/lib/exec/apple:srcs",
         "//src/main/java/com/google/devtools/build/lib/exec/local:srcs",
         "//src/main/java/com/google/devtools/build/lib/graph:srcs",
@@ -766,6 +767,7 @@
         ":build-base",
         "//src/main/java/com/google/devtools/build/lib/bazel/debug:workspace-rule-module",
         "//src/main/java/com/google/devtools/build/lib/buildeventservice",
+        "//src/main/java/com/google/devtools/build/lib/dynamic",
         "//src/main/java/com/google/devtools/build/lib/metrics:metrics_module",
         "//src/main/java/com/google/devtools/build/lib/profiler/callcounts:callcounts_module",
         "//src/main/java/com/google/devtools/build/lib/profiler/memory:allocationtracker_module",
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java b/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
index 029c1ac..ddcc464 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
@@ -55,6 +55,7 @@
           com.google.devtools.build.lib.standalone.StandaloneModule.class,
           com.google.devtools.build.lib.sandbox.SandboxModule.class,
           com.google.devtools.build.lib.runtime.BuildSummaryStatsModule.class,
+          com.google.devtools.build.lib.dynamic.DynamicExecutionModule.class,
           com.google.devtools.build.lib.bazel.rules.BazelRulesModule.class,
           com.google.devtools.build.lib.bazel.rules.BazelStrategyModule.class,
           com.google.devtools.build.lib.buildeventservice.BazelBuildEventServiceModule.class,
diff --git a/src/main/java/com/google/devtools/build/lib/dynamic/BUILD b/src/main/java/com/google/devtools/build/lib/dynamic/BUILD
new file mode 100644
index 0000000..57e542d
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/dynamic/BUILD
@@ -0,0 +1,25 @@
+package(default_visibility = ["//src:__subpackages__"])
+
+filegroup(
+    name = "srcs",
+    srcs = glob(["**"]),
+    visibility = ["//src/main/java/com/google/devtools/build/lib:__pkg__"],
+)
+
+java_library(
+    name = "dynamic",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//src/main/java/com/google/devtools/build/lib:build-base",
+        "//src/main/java/com/google/devtools/build/lib:events",
+        "//src/main/java/com/google/devtools/build/lib:io",
+        "//src/main/java/com/google/devtools/build/lib:runtime",
+        "//src/main/java/com/google/devtools/build/lib/actions",
+        "//src/main/java/com/google/devtools/build/lib/concurrent",
+        "//src/main/java/com/google/devtools/build/lib/vfs",
+        "//src/main/java/com/google/devtools/common/options",
+        "//third_party:auto_value",
+        "//third_party:guava",
+        "//third_party:jsr305",
+    ],
+)
diff --git a/src/main/java/com/google/devtools/build/lib/dynamic/DynamicExecutionModule.java b/src/main/java/com/google/devtools/build/lib/dynamic/DynamicExecutionModule.java
new file mode 100644
index 0000000..750e2b4
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/dynamic/DynamicExecutionModule.java
@@ -0,0 +1,112 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.build.lib.dynamic;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.devtools.build.lib.actions.ExecutionStrategy;
+import com.google.devtools.build.lib.actions.ExecutorInitException;
+import com.google.devtools.build.lib.actions.Spawn;
+import com.google.devtools.build.lib.actions.SpawnActionContext;
+import com.google.devtools.build.lib.actions.Spawns;
+import com.google.devtools.build.lib.buildtool.BuildRequest;
+import com.google.devtools.build.lib.concurrent.ExecutorUtil;
+import com.google.devtools.build.lib.exec.ExecutionPolicy;
+import com.google.devtools.build.lib.exec.ExecutorBuilder;
+import com.google.devtools.build.lib.runtime.BlazeModule;
+import com.google.devtools.build.lib.runtime.Command;
+import com.google.devtools.build.lib.runtime.CommandEnvironment;
+import com.google.devtools.common.options.OptionsBase;
+import java.util.Arrays;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * {@link BlazeModule} providing support for dynamic spawn execution and scheduling.
+ */
+public class DynamicExecutionModule extends BlazeModule {
+  private ExecutorService executorService;
+
+  @Override
+  public Iterable<Class<? extends OptionsBase>> getCommandOptions(Command command) {
+    return "build".equals(command.name())
+        ? ImmutableList.<Class<? extends OptionsBase>>of(DynamicExecutionOptions.class)
+        : ImmutableList.<Class<? extends OptionsBase>>of();
+  }
+
+  @Override
+  public void beforeCommand(CommandEnvironment env) {
+    executorService =
+        Executors.newCachedThreadPool(
+            new ThreadFactoryBuilder().setNameFormat("dynamic-execution-thread-%d").build());
+    env.getEventBus().register(this);
+  }
+
+  /**
+   * Adds a strategy that backs the dynamic scheduler to the executor builder.
+   *
+   * @param builder the executor builder to modify
+   * @param name the name of the strategy
+   * @param flagName name of the flag the strategy came from; used for error reporting
+   *     purposes only
+   * @throws ExecutorInitException if the provided strategy would cause a scheduling cycle
+   */
+  private static void addBackingStrategy(ExecutorBuilder builder, String name, String flagName)
+      throws ExecutorInitException {
+    ExecutionStrategy strategy = DynamicSpawnStrategy.class.getAnnotation(ExecutionStrategy.class);
+    checkNotNull(strategy, "DynamicSpawnStrategy lacks expected ExecutionStrategy annotation");
+
+    if (Arrays.asList(strategy.name()).contains(name)) {
+      throw new ExecutorInitException("Cannot use strategy " + name + " in flag " + flagName
+          + " as it would create a cycle during execution");
+    }
+
+    builder.addStrategyByContext(SpawnActionContext.class, name);
+  }
+
+  @Override
+  public void executorInit(CommandEnvironment env, BuildRequest request, ExecutorBuilder builder)
+      throws ExecutorInitException {
+    DynamicExecutionOptions options = env.getOptions().getOptions(DynamicExecutionOptions.class);
+    if (options.internalSpawnScheduler) {
+      builder.addActionContext(
+          new DynamicSpawnStrategy(executorService, options, this::getExecutionPolicy));
+      builder.addStrategyByContext(SpawnActionContext.class, "dynamic");
+      addBackingStrategy(builder, options.dynamicLocalStrategy, "--dynamic_local_strategy");
+      addBackingStrategy(builder, options.dynamicRemoteStrategy, "--dynamic_remote_strategy");
+      addBackingStrategy(builder, options.dynamicWorkerStrategy, "--dynamic_worker_strategy");
+    }
+  }
+
+  /**
+   * Use the {@link Spawn} metadata to determine if it can be executed locally, remotely, or both.
+   * @param spawn the {@link Spawn} action
+   * @return the {@link ExecutionPolicy} containing local/remote execution policies
+   */
+  protected ExecutionPolicy getExecutionPolicy(Spawn spawn) {
+    if (!Spawns.mayBeExecutedRemotely(spawn)) {
+      return ExecutionPolicy.LOCAL_EXECUTION_ONLY;
+    }
+
+    return ExecutionPolicy.ANYWHERE;
+  }
+
+  @Override
+  public void afterCommand() {
+    ExecutorUtil.interruptibleShutdown(executorService);
+    executorService = null;
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/dynamic/DynamicExecutionOptions.java b/src/main/java/com/google/devtools/build/lib/dynamic/DynamicExecutionOptions.java
new file mode 100644
index 0000000..8b13c0c
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/dynamic/DynamicExecutionOptions.java
@@ -0,0 +1,94 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.build.lib.dynamic;
+
+import com.google.devtools.common.options.Option;
+import com.google.devtools.common.options.OptionDocumentationCategory;
+import com.google.devtools.common.options.OptionEffectTag;
+import com.google.devtools.common.options.OptionsBase;
+
+/**
+ * Options related to dynamic spawn execution.
+ */
+public class DynamicExecutionOptions extends OptionsBase {
+
+  @Option(
+      name = "experimental_spawn_scheduler",
+      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      defaultValue = "null",
+      help =
+          "Run actions locally instead of remotely for incremental builds as long as enough "
+              + "resources are available to execute all runnable actions in parallel.",
+      expansion = {"--internal_spawn_scheduler", "--spawn_strategy=dynamic"})
+  public Void experimentalSpawnScheduler;
+
+  @Option(
+    name = "internal_spawn_scheduler",
+    documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+    effectTags = {OptionEffectTag.UNKNOWN},
+    defaultValue = "false",
+    help =
+        "Placeholder option so that we can tell in Blaze whether the spawn scheduler was "
+            + "enabled."
+  )
+  public boolean internalSpawnScheduler;
+
+  @Option(
+    name = "dynamic_local_strategy",
+    documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+    effectTags = {OptionEffectTag.UNKNOWN},
+    defaultValue = "sandboxed",
+    help = "Strategy to use when the dynamic spawn scheduler decides to run an action locally."
+  )
+  public String dynamicLocalStrategy;
+
+  @Option(
+      name = "dynamic_remote_strategy",
+      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      defaultValue = "remote",
+      help = "Strategy to use when the dynamic spawn scheduler decides to run an action remotely."
+  )
+  public String dynamicRemoteStrategy;
+
+  @Option(
+      name = "dynamic_worker_strategy",
+      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      defaultValue = "worker",
+      help = "Strategy to use when the dynamic spawn scheduler decides to run an action in a"
+          + " worker."
+  )
+  public String dynamicWorkerStrategy;
+
+  @Option(
+    name = "experimental_local_execution_delay",
+    documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+    effectTags = {OptionEffectTag.UNKNOWN},
+    defaultValue = "1000",
+    help =
+        "How many milliseconds should local execution be delayed, if remote execution was faster"
+            + " during a build at least once?"
+  )
+  public int localExecutionDelay;
+
+  @Option(
+    name = "experimental_debug_spawn_scheduler",
+    documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+    effectTags = {OptionEffectTag.UNKNOWN},
+    defaultValue = "false"
+  )
+  public boolean debugSpawnScheduler;
+}
diff --git a/src/main/java/com/google/devtools/build/lib/dynamic/DynamicSpawnStrategy.java b/src/main/java/com/google/devtools/build/lib/dynamic/DynamicSpawnStrategy.java
new file mode 100644
index 0000000..d37d16c
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/dynamic/DynamicSpawnStrategy.java
@@ -0,0 +1,414 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.build.lib.dynamic;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.io.Files;
+import com.google.devtools.build.lib.actions.ActionContext;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ExecException;
+import com.google.devtools.build.lib.actions.ExecutionRequirements;
+import com.google.devtools.build.lib.actions.ExecutionStrategy;
+import com.google.devtools.build.lib.actions.ExecutorInitException;
+import com.google.devtools.build.lib.actions.SandboxedSpawnActionContext;
+import com.google.devtools.build.lib.actions.Spawn;
+import com.google.devtools.build.lib.actions.SpawnActionContext;
+import com.google.devtools.build.lib.actions.SpawnResult;
+import com.google.devtools.build.lib.actions.UserExecException;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.exec.ExecutionPolicy;
+import com.google.devtools.build.lib.util.io.FileOutErr;
+import com.google.devtools.build.lib.vfs.Path;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Phaser;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.logging.Logger;
+import javax.annotation.Nullable;
+
+/**
+ * A spawn strategy that speeds up incremental builds while not slowing down full builds.
+ *
+ * <p>It tries to run spawn actions on the local machine, which is faster than running them
+ * remotely, unless there are not enough resources available to run all of them in parallel, in
+ * which case the increased parallelism of remote execution is likely to provide higher overall
+ * throughput.
+ *
+ * <p>The strategy will delegate execution to either a remote spawn strategy for remote execution or
+ * the sandboxed strategy for local execution. The general idea is to run spawns on the local 
+ * machine, as long as it can keep up with the required parallelism. If it fails to do so, we assume
+ * that the build would benefit from the increased parallelism available when using remote 
+ * execution, so we switch to using remote execution for all following spawns.
+ *
+ * <p>One might ask, why we don't run spawns on the workstation all the time and just "spill over"
+ * actions to remote execution when there are no local resources available. This would work, except 
+ * that the cost of transferring action inputs and outputs from the local machine to and from remote
+ * executors over the network is way too high - there is no point in executing an action locally and
+ * save 0.5s of time, when it then takes us 5 seconds to upload the results to remote executors for 
+ * another action that's scheduled to run there.
+ */
+@ExecutionStrategy(
+  name = {"dynamic", "dynamic_worker"},
+  contextType = SpawnActionContext.class
+)
+public class DynamicSpawnStrategy implements SpawnActionContext {
+  private static final Logger logger = Logger.getLogger(DynamicSpawnStrategy.class.getName());
+
+  enum StrategyIdentifier {
+    NONE("unknown"),
+    LOCAL("locally"),
+    REMOTE("remotely");
+
+    private final String prettyName;
+
+    StrategyIdentifier(String prettyName) {
+      this.prettyName = prettyName;
+    }
+
+    String prettyName() {
+      return prettyName;
+    }
+  }
+
+  @AutoValue
+  abstract static class DynamicExecutionResult {
+    static DynamicExecutionResult create(
+        StrategyIdentifier strategyIdentifier,
+        @Nullable FileOutErr fileOutErr,
+        @Nullable ExecException execException,
+        List<SpawnResult> spawnResults) {
+      return new AutoValue_DynamicSpawnStrategy_DynamicExecutionResult(
+          strategyIdentifier, fileOutErr, execException, spawnResults);
+    }
+
+    abstract StrategyIdentifier strategyIdentifier();
+
+    @Nullable
+    abstract FileOutErr fileOutErr();
+
+    @Nullable
+    abstract ExecException execException();
+
+    /**
+     * Returns a list of SpawnResults associated with executing a Spawn.
+     *
+     * <p>The list will typically contain one element, but could contain zero elements if spawn
+     * execution did not complete, or multiple elements if multiple sub-spawns were executed.
+     */
+    abstract List<SpawnResult> spawnResults();
+  }
+
+  private static final ImmutableSet<String> WORKER_BLACKLISTED_MNEMONICS =
+      ImmutableSet.of("JavaDeployJar");
+
+  private final ExecutorService executorService;
+  private final DynamicExecutionOptions options;
+  private final Function<Spawn, ExecutionPolicy> getExecutionPolicy;
+  private final AtomicBoolean delayLocalExecution = new AtomicBoolean(false);
+
+  private @Nullable SandboxedSpawnActionContext remoteStrategy;
+  private @Nullable SandboxedSpawnActionContext localStrategy;
+  private @Nullable SandboxedSpawnActionContext workerStrategy;
+
+  /**
+   * Constructs a {@code DynamicSpawnStrategy}.
+   *
+   * @param executorService an {@link ExecutorService} that will be used to run Spawn actions.
+   */
+  public DynamicSpawnStrategy(
+      ExecutorService executorService,
+      DynamicExecutionOptions options,
+      Function<Spawn, ExecutionPolicy> getExecutionPolicy) {
+    this.executorService = executorService;
+    this.options = options;
+    this.getExecutionPolicy = getExecutionPolicy;
+  }
+
+  /**
+   * Searches for a sandboxed spawn strategy with the given name.
+   *
+   * @param usedContexts the action contexts used during the build
+   * @param name the name of the spawn strategy we are interested in
+   * @return a sandboxed spawn strategy
+   * @throws ExecutorInitException if the spawn strategy does not exist, or if it exists but is not
+   *     sandboxed
+   */
+  private SandboxedSpawnActionContext findStrategy(
+      Iterable<ActionContext> usedContexts, String name) throws ExecutorInitException {
+    for (ActionContext context : usedContexts) {
+      ExecutionStrategy strategy = context.getClass().getAnnotation(ExecutionStrategy.class);
+      if (strategy != null && Arrays.asList(strategy.name()).contains(name)) {
+        if (!(context instanceof SandboxedSpawnActionContext)) {
+          throw new ExecutorInitException("Requested strategy " + name + " exists but does not "
+              + "support sandboxing");
+        }
+        return (SandboxedSpawnActionContext) context;
+      }
+    }
+    throw new ExecutorInitException("Requested strategy " + name + " does not exist");
+  }
+
+  @Override
+  public void executorCreated(Iterable<ActionContext> usedContexts) throws ExecutorInitException {
+    localStrategy = findStrategy(usedContexts, options.dynamicLocalStrategy);
+    remoteStrategy = findStrategy(usedContexts, options.dynamicRemoteStrategy);
+    workerStrategy = findStrategy(usedContexts, options.dynamicWorkerStrategy);
+  }
+
+  @Override
+  public List<SpawnResult> exec(
+      final Spawn spawn, final ActionExecutionContext actionExecutionContext)
+      throws ExecException, InterruptedException {
+
+    ExecutionPolicy executionPolicy = getExecutionPolicy.apply(spawn);
+
+    // If a Spawn cannot run remotely, we must always execute it locally. Resources will already
+    // have been acquired by Skyframe for us.
+    if (executionPolicy.canRunLocallyOnly()) {
+      return runLocally(spawn, actionExecutionContext, null);
+    }
+
+    // If a Spawn cannot run locally, we must always execute it remotely. For remote execution,
+    // local resources should not be acquired.
+    if (executionPolicy.canRunRemotelyOnly()) {
+      return runRemotely(spawn, actionExecutionContext, null);
+    }
+
+    // At this point we have a Spawn that can run locally and can run remotely. Run it in parallel
+    // using both the remote and the local strategy.
+    ExecException exceptionDuringExecution = null;
+    DynamicExecutionResult dynamicExecutionResult =
+        DynamicExecutionResult.create(
+            StrategyIdentifier.NONE, null, null, /*spawnResults=*/ ImmutableList.of());
+
+    // As an invariant in Bazel, all actions must terminate before the build ends. We use a
+    // synchronizer here, in the main thread, to wait for the termination of both local and remote
+    // spawns. Termination implies successful completion, failure, or, if one spawn wins,
+    // cancellation by the executor.
+    //
+    // In the case where one task completes successfully before the other starts, Bazel must
+    // proceed and return, skipping the other spawn. To achieve this, we use Phaser for its ability
+    // to register a variable number of tasks.
+    //
+    // TODO(b/118451841): Note that this may incur a performance issue where a remote spawn is
+    // faster than a worker spawn, because the worker spawn cannot be cancelled once it starts. This
+    // nullifies the gains from the faster spawn.
+    Phaser bothTasksFinished = new Phaser(/*parties=*/ 1);
+
+    try {
+      final AtomicReference<Class<? extends SpawnActionContext>> outputsHaveBeenWritten =
+          new AtomicReference<>(null);
+      dynamicExecutionResult =
+          executorService.invokeAny(
+              ImmutableList.of(
+                  new DynamicExecutionCallable(
+                      bothTasksFinished,
+                      StrategyIdentifier.LOCAL,
+                      actionExecutionContext.getFileOutErr()) {
+                    @Override
+                    List<SpawnResult> callImpl() throws InterruptedException, ExecException {
+                      // This is a rather simple approach to make it possible to score a cache hit
+                      // on remote execution before even trying to start the action locally. This
+                      // saves resources that would otherwise be wasted by continuously starting and
+                      // immediately killing local processes. One possibility for improvement would
+                      // be to establish a reporting mechanism from strategies back to here, where
+                      // we delay starting locally until the remote strategy tells us that the
+                      // action isn't a cache hit.
+                      if (delayLocalExecution.get()) {
+                        Thread.sleep(options.localExecutionDelay);
+                      }
+                      return runLocally(
+                          spawn,
+                          actionExecutionContext.withFileOutErr(fileOutErr),
+                          outputsHaveBeenWritten);
+                    }
+                  },
+                  new DynamicExecutionCallable(
+                      bothTasksFinished,
+                      StrategyIdentifier.REMOTE,
+                      actionExecutionContext.getFileOutErr()) {
+                    @Override
+                    public List<SpawnResult> callImpl() throws InterruptedException, ExecException {
+                      List<SpawnResult> spawnResults =
+                          runRemotely(
+                              spawn,
+                              actionExecutionContext.withFileOutErr(fileOutErr),
+                              outputsHaveBeenWritten);
+                      delayLocalExecution.set(true);
+                      return spawnResults;
+                    }
+                  }));
+    } catch (ExecutionException e) {
+      Throwables.propagateIfPossible(e.getCause(), InterruptedException.class);
+      // DynamicExecutionCallable.callImpl only declares InterruptedException, so this should never
+      // happen.
+      exceptionDuringExecution = new UserExecException(e.getCause());
+    } finally {
+      bothTasksFinished.arriveAndAwaitAdvance();
+      if (dynamicExecutionResult.execException() != null) {
+        exceptionDuringExecution = dynamicExecutionResult.execException();
+      }
+      if (Thread.currentThread().isInterrupted()) {
+        // Warn but don't throw, in case we're crashing.
+        logger.warning("Interrupted waiting for dynamic execution tasks to finish");
+      }
+    }
+    // Check for interruption outside of finally block, so we don't mask any other exceptions.
+    // Clear the interrupt bit if it's set.
+    if (exceptionDuringExecution == null && Thread.interrupted()) {
+      throw new InterruptedException("Interrupted waiting for dynamic execution tasks to finish");
+    }
+    StrategyIdentifier winningStrategy = dynamicExecutionResult.strategyIdentifier();
+    FileOutErr fileOutErr = dynamicExecutionResult.fileOutErr();
+    if (StrategyIdentifier.NONE.equals(winningStrategy) || fileOutErr == null) {
+      throw new IllegalStateException("Neither local or remote execution has started.");
+    }
+
+    try {
+      moveFileOutErr(actionExecutionContext, fileOutErr);
+    } catch (IOException e) {
+      String strategyName = winningStrategy.name().toLowerCase();
+      if (exceptionDuringExecution == null) {
+        throw new UserExecException(
+            String.format("Could not move action logs from %s execution", strategyName), e);
+      } else {
+        actionExecutionContext
+            .getEventHandler()
+            .handle(
+                Event.warn(
+                    String.format(
+                        "Could not move action logs from %s execution: %s",
+                        strategyName, e.toString())));
+      }
+    }
+
+    if (exceptionDuringExecution != null) {
+      throw exceptionDuringExecution;
+    }
+
+    if (options.debugSpawnScheduler) {
+      actionExecutionContext
+          .getEventHandler()
+          .handle(
+              Event.info(
+                  String.format(
+                      "%s action %s %s",
+                      spawn.getMnemonic(),
+                      dynamicExecutionResult.execException() == null ? "finished" : "failed",
+                      winningStrategy.prettyName())));
+    }
+
+    // TODO(b/62588075) If a second list of spawnResults was generated (before execution was
+    // cancelled), then we might want to save it as well (e.g. for metrics purposes).
+    return dynamicExecutionResult.spawnResults();
+  }
+
+  private void moveFileOutErr(ActionExecutionContext actionExecutionContext, FileOutErr outErr)
+      throws IOException {
+    if (outErr.getOutputPath().exists()) {
+      Files.move(
+          outErr.getOutputPath().getPathFile(),
+          actionExecutionContext.getFileOutErr().getOutputPath().getPathFile());
+    }
+    if (outErr.getErrorPath().exists()) {
+      Files.move(
+          outErr.getErrorPath().getPathFile(),
+          actionExecutionContext.getFileOutErr().getErrorPath().getPathFile());
+    }
+  }
+
+  private static FileOutErr getSuffixedFileOutErr(FileOutErr fileOutErr, String suffix) {
+    Path outDir = Preconditions.checkNotNull(fileOutErr.getOutputPath().getParentDirectory());
+    String outBaseName = fileOutErr.getOutputPath().getBaseName();
+    Path errDir = Preconditions.checkNotNull(fileOutErr.getErrorPath().getParentDirectory());
+    String errBaseName = fileOutErr.getErrorPath().getBaseName();
+    return new FileOutErr(
+        outDir.getChild(outBaseName + suffix), errDir.getChild(errBaseName + suffix));
+  }
+
+  private List<SpawnResult> runLocally(
+      Spawn spawn,
+      ActionExecutionContext actionExecutionContext,
+      AtomicReference<Class<? extends SpawnActionContext>> outputWriteBarrier)
+      throws ExecException, InterruptedException {
+    SandboxedSpawnActionContext strategy;
+    if (!WORKER_BLACKLISTED_MNEMONICS.contains(spawn.getMnemonic())
+        && "1".equals(spawn.getExecutionInfo().get(ExecutionRequirements.SUPPORTS_WORKERS))) {
+      strategy = Preconditions.checkNotNull(workerStrategy, "executorCreated not yet called");
+    } else {
+      strategy = Preconditions.checkNotNull(localStrategy, "executorCreated not yet called");
+    }
+    return strategy.exec(spawn, actionExecutionContext, outputWriteBarrier);
+  }
+
+  private List<SpawnResult> runRemotely(
+      Spawn spawn,
+      ActionExecutionContext actionExecutionContext,
+      AtomicReference<Class<? extends SpawnActionContext>> outputWriteBarrier)
+      throws ExecException, InterruptedException {
+    SandboxedSpawnActionContext strategy =
+        Preconditions.checkNotNull(remoteStrategy, "executorCreated not yet called");
+    return strategy.exec(spawn, actionExecutionContext, outputWriteBarrier);
+  }
+
+  private abstract static class DynamicExecutionCallable
+      implements Callable<DynamicExecutionResult> {
+    private final Phaser taskFinished;
+    private final StrategyIdentifier strategyIdentifier;
+    protected final FileOutErr fileOutErr;
+
+    DynamicExecutionCallable(
+        Phaser taskFinished,
+        StrategyIdentifier strategyIdentifier,
+        FileOutErr fileOutErr) {
+      this.taskFinished = taskFinished;
+      this.strategyIdentifier = strategyIdentifier;
+      this.fileOutErr = getSuffixedFileOutErr(fileOutErr, "." + strategyIdentifier.name());
+    }
+
+    abstract List<SpawnResult> callImpl() throws InterruptedException, ExecException;
+
+    @Override
+    public final DynamicExecutionResult call() throws InterruptedException {
+      taskFinished.register();
+      try {
+        List<SpawnResult> spawnResults = callImpl();
+        return DynamicExecutionResult.create(strategyIdentifier, fileOutErr, null, spawnResults);
+      } catch (Exception e) {
+        Throwables.throwIfInstanceOf(e, InterruptedException.class);
+        return DynamicExecutionResult.create(
+            strategyIdentifier,
+            fileOutErr, e instanceof ExecException ? (ExecException) e : new UserExecException(e),
+            /*spawnResults=*/ ImmutableList.of());
+      } finally {
+        try {
+          fileOutErr.close();
+        } catch (IOException ignored) {
+          // Nothing we can do here.
+        }
+        taskFinished.arriveAndDeregister();
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/exec/ExecutionPolicy.java b/src/main/java/com/google/devtools/build/lib/exec/ExecutionPolicy.java
index 40bbf38..b7265f8 100644
--- a/src/main/java/com/google/devtools/build/lib/exec/ExecutionPolicy.java
+++ b/src/main/java/com/google/devtools/build/lib/exec/ExecutionPolicy.java
@@ -37,10 +37,18 @@
     this.locality = locality;
   }
 
+  public boolean canRunRemotelyOnly() {
+    return locality == Locality.REMOTE_ONLY;
+  }
+
   public boolean canRunRemotely() {
     return locality != Locality.LOCAL_ONLY;
   }
 
+  public boolean canRunLocallyOnly() {
+    return locality == Locality.LOCAL_ONLY;
+  }
+
   public boolean canRunLocally() {
     return locality != Locality.REMOTE_ONLY;
   }
diff --git a/src/test/java/com/google/devtools/build/lib/BUILD b/src/test/java/com/google/devtools/build/lib/BUILD
index c2532ce..d4e6665 100644
--- a/src/test/java/com/google/devtools/build/lib/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/BUILD
@@ -242,6 +242,29 @@
 )
 
 java_test(
+    name = "dynamic_test",
+    size = "small",
+    srcs = glob(["dynamic/*.java"]),
+    tags = [
+        "no_windows",
+    ],
+    test_class = "com.google.devtools.build.lib.AllTests",
+    deps = [
+        ":actions_testutil",
+        ":foundations_testutil",
+        ":guava_junit_truth",
+        ":testutil",
+        "//src/main/java/com/google/devtools/build/lib:build-base",
+        "//src/main/java/com/google/devtools/build/lib:io",
+        "//src/main/java/com/google/devtools/build/lib/actions",
+        "//src/main/java/com/google/devtools/build/lib/actions:localhost_capacity",
+        "//src/main/java/com/google/devtools/build/lib/dynamic",
+        "//src/main/java/com/google/devtools/build/lib/vfs",
+        "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment",
+    ],
+)
+
+java_test(
     name = "events_test",
     size = "small",
     srcs = glob(["events/*.java"]),
diff --git a/src/test/java/com/google/devtools/build/lib/dynamic/DynamicSpawnStrategyTest.java b/src/test/java/com/google/devtools/build/lib/dynamic/DynamicSpawnStrategyTest.java
new file mode 100644
index 0000000..1f3454b
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/dynamic/DynamicSpawnStrategyTest.java
@@ -0,0 +1,612 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.build.lib.dynamic;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionExecutionMetadata;
+import com.google.devtools.build.lib.actions.ActionInput;
+import com.google.devtools.build.lib.actions.ActionKeyContext;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.ArtifactRoot;
+import com.google.devtools.build.lib.actions.BaseSpawn;
+import com.google.devtools.build.lib.actions.ExecException;
+import com.google.devtools.build.lib.actions.ExecutionStrategy;
+import com.google.devtools.build.lib.actions.ExecutorInitException;
+import com.google.devtools.build.lib.actions.LocalHostCapacity;
+import com.google.devtools.build.lib.actions.ResourceManager;
+import com.google.devtools.build.lib.actions.ResourceSet;
+import com.google.devtools.build.lib.actions.SandboxedSpawnActionContext;
+import com.google.devtools.build.lib.actions.Spawn;
+import com.google.devtools.build.lib.actions.SpawnActionContext;
+import com.google.devtools.build.lib.actions.SpawnResult;
+import com.google.devtools.build.lib.actions.UserExecException;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil.NullAction;
+import com.google.devtools.build.lib.exec.ExecutionPolicy;
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.util.io.FileOutErr;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.Root;
+import com.google.devtools.build.lib.vfs.util.FileSystems;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link DynamicSpawnStrategy}. */
+@RunWith(JUnit4.class)
+public class DynamicSpawnStrategyTest {
+  protected FileSystem fileSystem;
+  protected Path testRoot;
+  private ExecutorService executorService;
+  private MockLocalSpawnStrategy localStrategy;
+  private MockRemoteSpawnStrategy remoteStrategy;
+  private SpawnActionContext dynamicSpawnStrategy;
+  private Artifact inputArtifact;
+  private Artifact outputArtifact;
+  private FileOutErr outErr;
+  private ActionExecutionContext actionExecutionContext;
+  private DynamicExecutionOptions options;
+  private final ActionKeyContext actionKeyContext = new ActionKeyContext();
+
+  abstract static class MockSpawnStrategy implements SandboxedSpawnActionContext {
+    private final Path testRoot;
+    private final int delayMs;
+    private volatile Spawn executedSpawn;
+    private CountDownLatch succeeded = new CountDownLatch(1);
+    private boolean failsDuringExecution;
+    private CountDownLatch beforeExecutionWaitFor;
+    private Callable<List<SpawnResult>> execute;
+
+    public MockSpawnStrategy(Path testRoot, int delayMs) {
+      this.testRoot = testRoot;
+      this.delayMs = delayMs;
+    }
+
+    @Override
+    public List<SpawnResult> exec(Spawn spawn, ActionExecutionContext actionExecutionContext)
+        throws ExecException, InterruptedException {
+      return exec(spawn, actionExecutionContext, null);
+    }
+
+    @Override
+    public List<SpawnResult> exec(
+        Spawn spawn,
+        ActionExecutionContext actionExecutionContext,
+        AtomicReference<Class<? extends SpawnActionContext>> writeOutputFiles)
+        throws ExecException, InterruptedException {
+      executedSpawn = spawn;
+
+      if (beforeExecutionWaitFor != null) {
+        beforeExecutionWaitFor.countDown();
+        beforeExecutionWaitFor.await();
+      }
+
+      if (delayMs > 0) {
+        Thread.sleep(delayMs);
+      }
+
+      List<SpawnResult> spawnResults = ImmutableList.of();
+      if (execute != null) {
+        try {
+          spawnResults = execute.call();
+        } catch (ExecException | InterruptedException e) {
+          throw e;
+        } catch (Exception e) {
+          Throwables.throwIfUnchecked(e);
+          throw new IllegalStateException(e);
+        }
+      }
+      if (failsDuringExecution) {
+        try {
+          FileSystemUtils.appendIsoLatin1(
+              actionExecutionContext.getFileOutErr().getOutputPath(),
+              "action failed with " + getClass().getSimpleName());
+        } catch (IOException e) {
+          throw new IllegalStateException(e);
+        }
+        throw new UserExecException(getClass().getSimpleName() + " failed to execute the Spawn");
+      }
+
+      if (writeOutputFiles != null && !writeOutputFiles.compareAndSet(null, getClass())) {
+        throw new InterruptedException(getClass() + " could not acquire barrier");
+      } else {
+        for (ActionInput output : spawn.getOutputFiles()) {
+          try {
+            FileSystemUtils.writeIsoLatin1(
+                testRoot.getRelative(output.getExecPath()), getClass().getSimpleName());
+          } catch (IOException e) {
+            throw new IllegalStateException(e);
+          }
+        }
+      }
+
+      try {
+        FileSystemUtils.appendIsoLatin1(
+            actionExecutionContext.getFileOutErr().getOutputPath(),
+            "output files written with " + getClass().getSimpleName());
+      } catch (IOException e) {
+        throw new IllegalStateException(e);
+      }
+
+      succeeded.countDown();
+
+      return spawnResults;
+    }
+
+    public Spawn getExecutedSpawn() {
+      return executedSpawn;
+    }
+
+    boolean succeeded() {
+      return succeeded.getCount() == 0;
+    }
+
+    CountDownLatch getSucceededLatch() {
+      return succeeded;
+    }
+
+    public void failsDuringExecution() {
+      failsDuringExecution = true;
+    }
+
+    public void beforeExecutionWaitFor(CountDownLatch countDownLatch) {
+      beforeExecutionWaitFor = countDownLatch;
+    }
+
+    void setExecute(Callable<List<SpawnResult>> execute) {
+      this.execute = execute;
+    }
+  }
+
+  @ExecutionStrategy(
+    name = {"mock-remote"},
+    contextType = SpawnActionContext.class
+  )
+  private static class MockRemoteSpawnStrategy extends MockSpawnStrategy {
+    public MockRemoteSpawnStrategy(Path testRoot, int delayMs) {
+      super(testRoot, delayMs);
+    }
+  }
+
+  @ExecutionStrategy(
+    name = {"mock-local"},
+    contextType = SpawnActionContext.class
+  )
+  private static class MockLocalSpawnStrategy extends MockSpawnStrategy {
+    public MockLocalSpawnStrategy(Path testRoot, int delayMs) {
+      super(testRoot, delayMs);
+    }
+  }
+
+  private static class DynamicSpawnStrategyUnderTest extends DynamicSpawnStrategy {
+    public DynamicSpawnStrategyUnderTest(
+        ExecutorService executorService,
+        DynamicExecutionOptions options,
+        Function<Spawn, ExecutionPolicy> executionPolicy) {
+      super(executorService, options, executionPolicy);
+    }
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    ResourceManager.instance().setAvailableResources(LocalHostCapacity.getLocalHostCapacity());
+    ResourceManager.instance()
+        .setRamUtilizationPercentage(ResourceManager.DEFAULT_RAM_UTILIZATION_PERCENTAGE);
+    ResourceManager.instance().resetResourceUsage();
+
+    fileSystem = FileSystems.getNativeFileSystem();
+    testRoot = fileSystem.getPath(TestUtils.tmpDir());
+    FileSystemUtils.deleteTreesBelow(testRoot);
+    executorService = Executors.newCachedThreadPool();
+    inputArtifact =
+        new Artifact(
+            PathFragment.create("input.txt"), ArtifactRoot.asSourceRoot(Root.fromPath(testRoot)));
+    outputArtifact =
+        new Artifact(
+            PathFragment.create("output.txt"), ArtifactRoot.asSourceRoot(Root.fromPath(testRoot)));
+    outErr = new FileOutErr(testRoot.getRelative("stdout"), testRoot.getRelative("stderr"));
+    actionExecutionContext =
+        ActionsTestUtil.createContext(null, actionKeyContext, outErr, testRoot, null, null);
+  }
+
+  void createSpawnStrategy(int localDelay, int remoteDelay) throws ExecutorInitException {
+    localStrategy = new MockLocalSpawnStrategy(testRoot, localDelay);
+    remoteStrategy = new MockRemoteSpawnStrategy(testRoot, remoteDelay);
+    options = new DynamicExecutionOptions();
+    options.dynamicLocalStrategy = "mock-local";
+    options.dynamicRemoteStrategy = "mock-remote";
+    options.dynamicWorkerStrategy = "mock-local";
+    options.internalSpawnScheduler = true;
+    options.localExecutionDelay = 0;
+    dynamicSpawnStrategy =
+        new DynamicSpawnStrategyUnderTest(executorService, options, this::getExecutionPolicy);
+    dynamicSpawnStrategy.executorCreated(ImmutableList.of(localStrategy, remoteStrategy));
+  }
+
+  ExecutionPolicy getExecutionPolicy(Spawn spawn) {
+    if (spawn.getExecutionInfo().containsKey("local")) {
+      return ExecutionPolicy.LOCAL_EXECUTION_ONLY;
+    } else if (spawn.getExecutionInfo().containsKey("remote")) {
+      return ExecutionPolicy.REMOTE_EXECUTION_ONLY;
+    } else {
+      return ExecutionPolicy.ANYWHERE;
+    }
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    executorService.shutdownNow();
+  }
+
+  Spawn getSpawnForTest(boolean forceLocal, boolean forceRemote) {
+    Preconditions.checkArgument(
+        !(forceLocal && forceRemote), "Cannot force local and remote at the same time");
+    ActionExecutionMetadata action =
+        new NullAction(ImmutableList.of(inputArtifact), outputArtifact);
+    return new BaseSpawn(
+        ImmutableList.<String>of(),
+        ImmutableMap.<String, String>of(),
+        forceLocal
+            ? ImmutableMap.of("local", "1")
+            : forceRemote ? ImmutableMap.of("remote", "1") : ImmutableMap.<String, String>of(),
+        action,
+        ResourceSet.create(1, 0, 0));
+  }
+
+  @Test
+  public void nonRemotableSpawnRunsLocally() throws Exception {
+    Spawn spawn = getSpawnForTest(true, false);
+    createSpawnStrategy(0, 0);
+
+    dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+
+    assertThat(localStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(localStrategy.succeeded()).isTrue();
+    assertThat(remoteStrategy.getExecutedSpawn()).isNull();
+    assertThat(remoteStrategy.succeeded()).isFalse();
+
+    assertThat(outErr.outAsLatin1()).contains("output files written with MockLocalSpawnStrategy");
+    assertThat(outErr.outAsLatin1()).doesNotContain("MockRemoteSpawnStrategy");
+  }
+
+  @Test
+  public void nonLocallyExecutableSpawnRunsRemotely() throws Exception {
+    Spawn spawn = getSpawnForTest(false, true);
+    createSpawnStrategy(0, 0);
+
+    dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+
+    assertThat(localStrategy.getExecutedSpawn()).isNull();
+    assertThat(localStrategy.succeeded()).isFalse();
+    assertThat(remoteStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(remoteStrategy.succeeded()).isTrue();
+
+    assertThat(outErr.outAsLatin1()).contains("output files written with MockRemoteSpawnStrategy");
+    assertThat(outErr.outAsLatin1()).doesNotContain("MockLocalSpawnStrategy");
+  }
+
+  @Test
+  public void actionSucceedsIfLocalExecutionSucceedsEvenIfRemoteFailsLater() throws Exception {
+    Spawn spawn = getSpawnForTest(false, false);
+    createSpawnStrategy(0, 2000);
+    CountDownLatch countDownLatch = new CountDownLatch(2);
+    localStrategy.beforeExecutionWaitFor(countDownLatch);
+    remoteStrategy.beforeExecutionWaitFor(countDownLatch);
+    remoteStrategy.failsDuringExecution();
+
+    dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+
+    assertThat(localStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(localStrategy.succeeded()).isTrue();
+    assertThat(remoteStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(remoteStrategy.succeeded()).isFalse();
+
+    assertThat(outErr.outAsLatin1()).contains("output files written with MockLocalSpawnStrategy");
+    assertThat(outErr.outAsLatin1()).doesNotContain("MockRemoteSpawnStrategy");
+  }
+
+  @Test
+  public void actionSucceedsIfRemoteExecutionSucceedsEvenIfLocalFailsLater() throws Exception {
+    Spawn spawn = getSpawnForTest(false, false);
+    createSpawnStrategy(2000, 0);
+    CountDownLatch countDownLatch = new CountDownLatch(2);
+    localStrategy.beforeExecutionWaitFor(countDownLatch);
+    localStrategy.failsDuringExecution();
+    remoteStrategy.beforeExecutionWaitFor(countDownLatch);
+
+    dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+
+    assertThat(localStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(localStrategy.succeeded()).isFalse();
+    assertThat(remoteStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(remoteStrategy.succeeded()).isTrue();
+
+    assertThat(outErr.outAsLatin1()).contains("output files written with MockRemoteSpawnStrategy");
+    assertThat(outErr.outAsLatin1()).doesNotContain("MockLocalSpawnStrategy");
+  }
+
+  @Test
+  public void actionFailsIfLocalFailsImmediatelyEvenIfRemoteSucceedsLater() throws Exception {
+    Spawn spawn = getSpawnForTest(false, false);
+    createSpawnStrategy(0, 2000);
+    CountDownLatch countDownLatch = new CountDownLatch(2);
+    localStrategy.beforeExecutionWaitFor(countDownLatch);
+    localStrategy.failsDuringExecution();
+    remoteStrategy.beforeExecutionWaitFor(countDownLatch);
+
+    try {
+      dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+      fail("Expected dynamicSpawnStrategy to throw an ExecException");
+    } catch (ExecException e) {
+      assertThat(e).hasMessageThat().matches("MockLocalSpawnStrategy failed to execute the Spawn");
+    }
+
+    assertThat(localStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(localStrategy.succeeded()).isFalse();
+    assertThat(remoteStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(remoteStrategy.succeeded()).isFalse();
+
+    assertThat(outErr.outAsLatin1()).contains("action failed with MockLocalSpawnStrategy");
+    assertThat(outErr.outAsLatin1()).doesNotContain("MockRemoteSpawnStrategy");
+  }
+
+  @Test
+  public void actionFailsIfRemoteFailsImmediatelyEvenIfLocalSucceedsLater() throws Exception {
+    Spawn spawn = getSpawnForTest(false, false);
+    createSpawnStrategy(2000, 0);
+    CountDownLatch countDownLatch = new CountDownLatch(2);
+    localStrategy.beforeExecutionWaitFor(countDownLatch);
+    remoteStrategy.beforeExecutionWaitFor(countDownLatch);
+    remoteStrategy.failsDuringExecution();
+
+    try {
+      dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+      fail("Expected dynamicSpawnStrategy to throw an ExecException");
+    } catch (ExecException e) {
+      assertThat(e).hasMessageThat().matches("MockRemoteSpawnStrategy failed to execute the Spawn");
+    }
+
+    assertThat(localStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(localStrategy.succeeded()).isFalse();
+    assertThat(remoteStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(remoteStrategy.succeeded()).isFalse();
+
+    assertThat(outErr.outAsLatin1()).contains("action failed with MockRemoteSpawnStrategy");
+    assertThat(outErr.outAsLatin1()).doesNotContain("MockLocalSpawnStrategy");
+  }
+
+  @Test
+  public void actionFailsIfLocalAndRemoteFail() throws Exception {
+    Spawn spawn = getSpawnForTest(false, false);
+    createSpawnStrategy(0, 0);
+    CountDownLatch countDownLatch = new CountDownLatch(2);
+    localStrategy.beforeExecutionWaitFor(countDownLatch);
+    remoteStrategy.beforeExecutionWaitFor(countDownLatch);
+    localStrategy.failsDuringExecution();
+    remoteStrategy.failsDuringExecution();
+
+    try {
+      dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+      fail("Expected dynamicSpawnStrategy to throw an ExecException");
+    } catch (ExecException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .matches("Mock(Local|Remote)SpawnStrategy failed to execute the Spawn");
+    }
+
+    assertThat(localStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(localStrategy.succeeded()).isFalse();
+    assertThat(remoteStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(remoteStrategy.succeeded()).isFalse();
+  }
+
+  @Test
+  public void noDeadlockWithSingleThreadedExecutor() throws Exception {
+    final Spawn spawn = getSpawnForTest(/*forceLocal=*/ false, /*forceRemote=*/ false);
+
+    // Replace the executorService with a single threaded one.
+    executorService = Executors.newSingleThreadExecutor();
+    createSpawnStrategy(/*localDelay=*/ 0, /*remoteDelay=*/ 0);
+
+    dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+
+    assertThat(localStrategy.getExecutedSpawn()).isEqualTo(spawn);
+    assertThat(localStrategy.succeeded()).isTrue();
+
+    /**
+     * The single-threaded executorService#invokeAny does not comply to the contract where
+     * the callables are *always* called sequentially. In this case, both spawns will start
+     * executing, but the local one will always succeed as it's the first to be called. The remote
+     * one will then be cancelled, or is null if the local one completes before the remote one
+     * starts.
+     *
+     * See the documentation of {@link BoundedExectorService#invokeAny(Collection)}, specifically:
+     * "The following is less efficient (it goes on submitting tasks even if there is some task
+     * already finished), but quite straight-forward.".
+     */
+    assertThat(remoteStrategy.getExecutedSpawn()).isAnyOf(spawn, null);
+    assertThat(remoteStrategy.succeeded()).isFalse();
+  }
+
+  @Test
+  public void interruptDuringExecutionDoesActuallyInterruptTheExecution() throws Exception {
+    final Spawn spawn = getSpawnForTest(false, false);
+    createSpawnStrategy(60000, 60000);
+    CountDownLatch countDownLatch = new CountDownLatch(2);
+    localStrategy.beforeExecutionWaitFor(countDownLatch);
+    remoteStrategy.beforeExecutionWaitFor(countDownLatch);
+
+    TestThread testThread =
+        new TestThread() {
+          @Override
+          public void runTest() throws Exception {
+            try {
+              dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+            } catch (InterruptedException e) {
+              // This is expected.
+            }
+          }
+        };
+    testThread.start();
+    countDownLatch.await(5, TimeUnit.SECONDS);
+    testThread.interrupt();
+    testThread.joinAndAssertState(5000);
+
+    assertThat(outErr.getOutputPath().exists()).isFalse();
+    assertThat(outErr.getErrorPath().exists()).isFalse();
+  }
+
+  private void strategyWaitsForBothSpawnsToFinish(boolean interruptThread, boolean executionFails)
+      throws Exception {
+    final Spawn spawn = getSpawnForTest(false, false);
+    createSpawnStrategy(0, 0);
+    CountDownLatch waitToFinish = new CountDownLatch(1);
+    CountDownLatch wasInterrupted = new CountDownLatch(1);
+    CountDownLatch executionCanProceed = new CountDownLatch(2);
+    localStrategy.setExecute(
+        () -> {
+          executionCanProceed.countDown();
+          try {
+            Thread.sleep(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
+            throw new IllegalStateException("Should have been interrupted");
+          } catch (InterruptedException e) {
+            // Expected.
+          }
+          wasInterrupted.countDown();
+          try {
+            Preconditions.checkState(
+                waitToFinish.await(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+          } catch (InterruptedException e) {
+            throw new IllegalStateException(e);
+          }
+          return ImmutableList.of();
+        });
+    if (executionFails) {
+      remoteStrategy.failsDuringExecution();
+    }
+    remoteStrategy.beforeExecutionWaitFor(executionCanProceed);
+
+    TestThread testThread =
+        new TestThread() {
+          @Override
+          public void runTest() {
+            try {
+              dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+              Preconditions.checkState(!interruptThread && !executionFails);
+            } catch (InterruptedException e) {
+              Preconditions.checkState(interruptThread && !executionFails);
+              Preconditions.checkState(!Thread.currentThread().isInterrupted());
+            } catch (ExecException e) {
+              Preconditions.checkState(executionFails);
+              Preconditions.checkState(Thread.currentThread().isInterrupted() == interruptThread);
+            }
+          }
+        };
+    testThread.start();
+    if (!executionFails) {
+      assertThat(
+              remoteStrategy
+                  .getSucceededLatch()
+                  .await(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS))
+          .isTrue();
+    }
+    assertThat(wasInterrupted.await(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue();
+    assertThat(testThread.isAlive()).isTrue();
+    if (interruptThread) {
+      testThread.interrupt();
+    }
+    // Wait up to 5 seconds for this thread to finish. It should not have finished.
+    testThread.join(5000);
+    assertThat(testThread.isAlive()).isTrue();
+    waitToFinish.countDown();
+    testThread.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
+  }
+
+  @Test
+  public void strategyWaitsForBothSpawnsToFinish() throws Exception {
+    strategyWaitsForBothSpawnsToFinish(false, false);
+  }
+
+  @Test
+  public void strategyWaitsForBothSpawnsToFinishEvenIfInterrupted() throws Exception {
+    strategyWaitsForBothSpawnsToFinish(true, false);
+  }
+
+  @Test
+  public void strategyWaitsForBothSpawnsToFinishOnFailure() throws Exception {
+    strategyWaitsForBothSpawnsToFinish(false, true);
+  }
+
+  @Test
+  public void strategyWaitsForBothSpawnsToFinishOnFailureEvenIfInterrupted() throws Exception {
+    strategyWaitsForBothSpawnsToFinish(true, true);
+  }
+
+  @Test
+  public void strategyPropagatesFasterLocalException() throws Exception {
+    strategyPropagatesException(true);
+  }
+
+  @Test
+  public void strategyPropagatesFasterRemoteException() throws Exception {
+    strategyPropagatesException(false);
+  }
+
+  private void strategyPropagatesException(boolean preferLocal) throws Exception {
+    final Spawn spawn = getSpawnForTest(false, false);
+    createSpawnStrategy(!preferLocal ? 60000 : 0, preferLocal ? 60000 : 0);
+
+    String message = "Mock spawn execution exception";
+    Callable<List<SpawnResult>> execute = () -> {
+      throw new IllegalStateException(message);
+    };
+    localStrategy.setExecute(execute);
+    remoteStrategy.setExecute(execute);
+
+    try {
+      dynamicSpawnStrategy.exec(spawn, actionExecutionContext);
+      fail("Expected dynamicSpawnStrategy to throw an ExecException");
+    } catch (ExecException e) {
+      assertThat(e).hasMessageThat().matches("java.lang.IllegalStateException: " + message);
+    }
+
+    Spawn executedSpawn = localStrategy.getExecutedSpawn();
+    executedSpawn = executedSpawn == null ? remoteStrategy.getExecutedSpawn() : executedSpawn;
+    assertThat(executedSpawn).isEqualTo(spawn);
+    assertThat(localStrategy.succeeded()).isFalse();
+    assertThat(remoteStrategy.succeeded()).isFalse();
+  }
+}