Split coverage post-processing into its own spawn

This change fixes b/79147227 by introducing a flag that makes coverage post-processing happen in its own spawn. The runfiles in the coverage spawn are those of the LCOV merger instead of the test runfiles.

The flag introduced is --experimental_split_coverage_postprocessing which depends on the flag --experimental_fetch_all_coverage_outputs being enabled too.

PiperOrigin-RevId: 339699090
diff --git a/src/main/java/com/google/devtools/build/lib/exec/StandaloneTestStrategy.java b/src/main/java/com/google/devtools/build/lib/exec/StandaloneTestStrategy.java
index f54d963..ccccfbc 100644
--- a/src/main/java/com/google/devtools/build/lib/exec/StandaloneTestStrategy.java
+++ b/src/main/java/com/google/devtools/build/lib/exec/StandaloneTestStrategy.java
@@ -16,13 +16,16 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import com.google.common.base.Verify;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.io.ByteStreams;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.ActionInputHelper;
+import com.google.devtools.build.lib.actions.Artifact.SpecialArtifact;
 import com.google.devtools.build.lib.actions.ArtifactPathResolver;
 import com.google.devtools.build.lib.actions.EnvironmentalExecException;
 import com.google.devtools.build.lib.actions.ExecException;
@@ -99,16 +102,9 @@
           "cannot run local tests with --nobuild_runfile_manifests",
           TestAction.Code.LOCAL_TEST_PREREQ_UNMET);
     }
-    Path execRoot = actionExecutionContext.getExecRoot();
-    ArtifactPathResolver pathResolver = actionExecutionContext.getPathResolver();
-    Path runfilesDir = pathResolver.convertPath(action.getExecutionSettings().getRunfilesDir());
-    Path tmpDir = pathResolver.convertPath(tmpDirRoot.getChild(TestStrategy.getTmpDirName(action)));
-    Map<String, String> env = setupEnvironment(
-        action, actionExecutionContext.getClientEnv(), execRoot, runfilesDir, tmpDir);
-    if (executionOptions.splitXmlGeneration) {
-      env.put("EXPERIMENTAL_SPLIT_XML_GENERATION", "1");
-    }
-    Path workingDirectory = runfilesDir.getRelative(action.getRunfilesPrefix());
+    Map<String, String> testEnvironment =
+        createEnvironment(
+            actionExecutionContext, action, tmpDirRoot, executionOptions.splitXmlGeneration);
 
     Map<String, String> executionInfo =
         new TreeMap<>(action.getTestProperties().getExecutionInfo());
@@ -130,7 +126,7 @@
         new SimpleSpawn(
             action,
             getArgs(action),
-            ImmutableMap.copyOf(env),
+            ImmutableMap.copyOf(testEnvironment),
             ImmutableMap.copyOf(executionInfo),
             action.getRunfilesSupplier(),
             ImmutableMap.of(),
@@ -140,6 +136,11 @@
                 : NestedSetBuilder.emptySet(Order.STABLE_ORDER),
             ImmutableSet.copyOf(action.getSpawnOutputs()),
             localResourceUsage);
+    Path execRoot = actionExecutionContext.getExecRoot();
+    ArtifactPathResolver pathResolver = actionExecutionContext.getPathResolver();
+    Path runfilesDir = pathResolver.convertPath(action.getExecutionSettings().getRunfilesDir());
+    Path tmpDir = pathResolver.convertPath(tmpDirRoot.getChild(TestStrategy.getTmpDirName(action)));
+    Path workingDirectory = runfilesDir.getRelative(action.getRunfilesPrefix());
     return new StandaloneTestRunnerSpawn(
         action, actionExecutionContext, spawn, tmpDir, workingDirectory, execRoot);
   }
@@ -279,8 +280,11 @@
     return new StandaloneFailedAttemptResult(data);
   }
 
-  private Map<String, String> setupEnvironment(
-      TestRunnerAction action, Map<String, String> clientEnv, Path execRoot, Path runfilesDir,
+  private static Map<String, String> setupEnvironment(
+      TestRunnerAction action,
+      Map<String, String> clientEnv,
+      Path execRoot,
+      Path runfilesDir,
       Path tmpDir) {
     PathFragment relativeTmpDir;
     if (tmpDir.startsWith(execRoot)) {
@@ -333,27 +337,32 @@
         testOutErr,
         streamed,
         startTimeMillis,
-        spawnContinuation);
+        spawnContinuation,
+        /* testResultDataBuilder= */ null,
+        /* spawnResults= */ null);
   }
 
-  /** In rare cases, we might write something to stderr. Append it to the real test.log. */
-  private static void appendStderr(FileOutErr outErr) throws IOException {
-    Path stdErr = outErr.getErrorPath();
-    FileStatus stat = stdErr.statNullable();
+  private static void appendCoverageLog(FileOutErr coverageOutErr, FileOutErr outErr)
+      throws IOException {
+    writeOutFile(coverageOutErr.getErrorPath(), outErr.getOutputPath());
+    writeOutFile(coverageOutErr.getOutputPath(), outErr.getOutputPath());
+  }
+
+  private static void writeOutFile(Path inFilePath, Path outFilePath) throws IOException {
+    FileStatus stat = inFilePath.statNullable();
     if (stat != null) {
       try {
         if (stat.getSize() > 0) {
-          Path stdOut = outErr.getOutputPath();
-          if (stdOut.exists()) {
-            stdOut.setWritable(true);
+          if (outFilePath.exists()) {
+            outFilePath.setWritable(true);
           }
-          try (OutputStream out = stdOut.getOutputStream(true);
-              InputStream in = stdErr.getInputStream()) {
+          try (OutputStream out = outFilePath.getOutputStream(true);
+              InputStream in = inFilePath.getInputStream()) {
             ByteStreams.copy(in, out);
           }
         }
       } finally {
-        stdErr.delete();
+        inFilePath.delete();
       }
     }
   }
@@ -413,6 +422,65 @@
         SpawnAction.DEFAULT_RESOURCE_SET);
   }
 
+  /**
+   * A spawn to generate a test.xml file from the test log. This is only used if the test does not
+   * generate a test.xml file itself.
+   */
+  private static Spawn createCoveragePostProcessingSpawn(
+      ActionExecutionContext actionExecutionContext,
+      TestRunnerAction action,
+      List<ActionInput> expandedCoverageDir,
+      Path tmpDirRoot,
+      boolean splitXmlGeneration) {
+    ImmutableList<String> args =
+        ImmutableList.of(action.getCollectCoverageScript().getExecPathString());
+
+    Map<String, String> testEnvironment =
+        createEnvironment(actionExecutionContext, action, tmpDirRoot, splitXmlGeneration);
+
+    testEnvironment.put("TEST_SHARD_INDEX", Integer.toString(action.getShardNum()));
+    testEnvironment.put(
+        "TEST_TOTAL_SHARDS", Integer.toString(action.getExecutionSettings().getTotalShards()));
+    testEnvironment.put("TEST_NAME", action.getTestName());
+    testEnvironment.put("IS_COVERAGE_SPAWN", "1");
+    return new SimpleSpawn(
+        action,
+        args,
+        ImmutableMap.copyOf(testEnvironment),
+        ImmutableMap.copyOf(action.getExecutionInfo()),
+        action.getLcovMergerRunfilesSupplier(),
+        /* filesetMappings= */ ImmutableMap.of(),
+        /* inputs= */ NestedSetBuilder.<ActionInput>compileOrder()
+            .addAll(expandedCoverageDir)
+            .add(action.getCollectCoverageScript())
+            .add(action.getCoverageDirectoryTreeArtifact())
+            .add(action.getCoverageManifest())
+            .addTransitive(action.getLcovMergerFilesToRun().build())
+            .build(),
+        /* tools= */ NestedSetBuilder.emptySet(Order.STABLE_ORDER),
+        /* outputs= */ ImmutableSet.of(
+            ActionInputHelper.fromPath(action.getCoverageData().getExecPathString())),
+        SpawnAction.DEFAULT_RESOURCE_SET);
+  }
+
+  private static Map<String, String> createEnvironment(
+      ActionExecutionContext actionExecutionContext,
+      TestRunnerAction action,
+      Path tmpDirRoot,
+      boolean splitXmlGeneration) {
+    Path execRoot = actionExecutionContext.getExecRoot();
+    ArtifactPathResolver pathResolver = actionExecutionContext.getPathResolver();
+    Path runfilesDir = pathResolver.convertPath(action.getExecutionSettings().getRunfilesDir());
+    Path tmpDir = pathResolver.convertPath(tmpDirRoot.getChild(TestStrategy.getTmpDirName(action)));
+    Map<String, String> testEnvironment =
+        setupEnvironment(
+            action, actionExecutionContext.getClientEnv(), execRoot, runfilesDir, tmpDir);
+    if (splitXmlGeneration) {
+      testEnvironment.put("EXPERIMENTAL_SPLIT_XML_GENERATION", "1");
+    }
+    return testEnvironment;
+  }
+
   @Override
   public TestResult newCachedTestResult(
       Path execRoot, TestRunnerAction action, TestResultData data) {
@@ -509,6 +577,8 @@
     private final Closeable streamed;
     private final long startTimeMillis;
     private final SpawnContinuation spawnContinuation;
+    private TestResultData.Builder testResultDataBuilder;
+    private ImmutableList<SpawnResult> spawnResults;
 
     BazelTestAttemptContinuation(
         TestRunnerAction testAction,
@@ -518,7 +588,9 @@
         FileOutErr fileOutErr,
         Closeable streamed,
         long startTimeMillis,
-        SpawnContinuation spawnContinuation) {
+        SpawnContinuation spawnContinuation,
+        TestResultData.Builder testResultDataBuilder,
+        ImmutableList<SpawnResult> spawnResults) {
       this.testAction = testAction;
       this.actionExecutionContext = actionExecutionContext;
       this.spawn = spawn;
@@ -527,6 +599,8 @@
       this.streamed = streamed;
       this.startTimeMillis = startTimeMillis;
       this.spawnContinuation = spawnContinuation;
+      this.testResultDataBuilder = testResultDataBuilder;
+      this.spawnResults = spawnResults;
     }
 
     @Nullable
@@ -536,58 +610,136 @@
     }
 
     @Override
-    public TestAttemptContinuation execute() throws InterruptedException, ExecException {
-      // We have two protos to represent test attempts:
-      // 1. com.google.devtools.build.lib.view.test.TestStatus.TestResultData represents both failed
-      //    attempts and finished tests. Bazel stores this to disk to persist cached test result
-      //    information across server restarts.
-      // 2. com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.TestResult
-      //    represents only individual attempts (failed or not). Bazel reports this as an event to
-      //    the Build Event Protocol, but never saves it to disk.
-      //
-      // The TestResult proto is always constructed from a TestResultData instance, either one that
-      // is created right here, or one that is read back from disk.
-      TestResultData.Builder builder;
-      ImmutableList<SpawnResult> spawnResults;
-      try {
-        SpawnContinuation nextContinuation = spawnContinuation.execute();
-        if (!nextContinuation.isDone()) {
-          return new BazelTestAttemptContinuation(
+    public TestAttemptContinuation execute()
+        throws InterruptedException, ExecException, IOException {
+
+      if (testResultDataBuilder == null) {
+        // We have two protos to represent test attempts:
+        // 1. com.google.devtools.build.lib.view.test.TestStatus.TestResultData represents both
+        //    failed attempts and finished tests. Bazel stores this to disk to persist cached test
+        //    result information across server restarts.
+        // 2. com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.TestResult
+        //    represents only individual attempts (failed or not). Bazel reports this as an event to
+        //    the Build Event Protocol, but never saves it to disk.
+        //
+        // The TestResult proto is always constructed from a TestResultData instance, either one
+        // that
+        // is created right here, or one that is read back from disk.
+        TestResultData.Builder builder = null;
+        ImmutableList<SpawnResult> spawnResults;
+        try {
+          SpawnContinuation nextContinuation = spawnContinuation.execute();
+          if (!nextContinuation.isDone()) {
+            return new BazelTestAttemptContinuation(
+                testAction,
+                actionExecutionContext,
+                spawn,
+                resolvedPaths,
+                fileOutErr,
+                streamed,
+                startTimeMillis,
+                nextContinuation,
+                builder,
+                /* spawnResults= */ null);
+          }
+          spawnResults = nextContinuation.get();
+          builder = TestResultData.newBuilder();
+          builder.setTestPassed(true).setStatus(BlazeTestStatus.PASSED);
+        } catch (SpawnExecException e) {
+          if (e.isCatastrophic()) {
+            closeSuppressed(e, streamed);
+            closeSuppressed(e, fileOutErr);
+            throw e;
+          }
+          if (!e.getSpawnResult().setupSuccess()) {
+            closeSuppressed(e, streamed);
+            closeSuppressed(e, fileOutErr);
+            // Rethrow as the test could not be run and thus there's no point in retrying.
+            throw e;
+          }
+          spawnResults = ImmutableList.of(e.getSpawnResult());
+          builder = TestResultData.newBuilder();
+          builder
+              .setTestPassed(false)
+              .setStatus(e.hasTimedOut() ? BlazeTestStatus.TIMEOUT : BlazeTestStatus.FAILED);
+        } catch (InterruptedException e) {
+          closeSuppressed(e, streamed);
+          closeSuppressed(e, fileOutErr);
+          throw e;
+        }
+        long endTimeMillis = actionExecutionContext.getClock().currentTimeMillis();
+
+        // SpawnActionContext guarantees the first entry to correspond to the spawn passed in (there
+        // may be additional entries due to tree artifact handling).
+        SpawnResult primaryResult = spawnResults.get(0);
+
+        // The SpawnResult of a remotely cached or remotely executed action may not have walltime
+        // set. We fall back to the time measured here for backwards compatibility.
+        long durationMillis = endTimeMillis - startTimeMillis;
+        durationMillis =
+            primaryResult.getWallTime().orElse(Duration.ofMillis(durationMillis)).toMillis();
+
+        builder
+            .setStartTimeMillisEpoch(startTimeMillis)
+            .addTestTimes(durationMillis)
+            .addTestProcessTimes(durationMillis)
+            .setRunDurationMillis(durationMillis)
+            .setHasCoverage(testAction.isCoverageMode());
+
+        if (testAction.isCoverageMode() && testAction.getSplitCoveragePostProcessing()) {
+          actionExecutionContext
+              .getMetadataHandler()
+              .getMetadata(testAction.getCoverageDirectoryTreeArtifact());
+          ImmutableSet<? extends ActionInput> expandedCoverageDir =
+              actionExecutionContext
+                  .getMetadataHandler()
+                  .getTreeArtifactChildren(
+                      (SpecialArtifact) testAction.getCoverageDirectoryTreeArtifact());
+          Spawn coveragePostProcessingSpawn =
+              createCoveragePostProcessingSpawn(
+                  actionExecutionContext,
+                  testAction,
+                  ImmutableList.copyOf(expandedCoverageDir),
+                  tmpDirRoot,
+                  executionOptions.splitXmlGeneration);
+          SpawnStrategyResolver spawnStrategyResolver =
+              actionExecutionContext.getContext(SpawnStrategyResolver.class);
+
+          Path testRoot =
+              actionExecutionContext.getInputPath(testAction.getTestLog()).getParentDirectory();
+
+          Path out = testRoot.getChild("coverage.log");
+          Path err = testRoot.getChild("coverage.err");
+          FileOutErr coverageOutErr = new FileOutErr(out, err);
+          ActionExecutionContext actionExecutionContextWithCoverageFileOutErr =
+              actionExecutionContext.withFileOutErr(coverageOutErr);
+
+          SpawnContinuation coveragePostProcessingContinuation =
+              spawnStrategyResolver.beginExecution(
+                  coveragePostProcessingSpawn, actionExecutionContextWithCoverageFileOutErr);
+          writeOutFile(coverageOutErr.getErrorPath(), coverageOutErr.getOutputPath());
+          appendCoverageLog(coverageOutErr, fileOutErr);
+          return new BazelCoveragePostProcessingContinuation(
               testAction,
               actionExecutionContext,
               spawn,
               resolvedPaths,
               fileOutErr,
               streamed,
-              startTimeMillis,
-              nextContinuation);
+              builder,
+              spawnResults,
+              coveragePostProcessingContinuation);
+        } else {
+          this.spawnResults = spawnResults;
+          this.testResultDataBuilder = builder;
         }
-        spawnResults = nextContinuation.get();
-        builder = TestResultData.newBuilder();
-        builder.setTestPassed(true).setStatus(BlazeTestStatus.PASSED);
-      } catch (SpawnExecException e) {
-        if (e.isCatastrophic()) {
-          closeSuppressed(e, streamed);
-          closeSuppressed(e, fileOutErr);
-          throw e;
-        }
-        if (!e.getSpawnResult().setupSuccess()) {
-          closeSuppressed(e, streamed);
-          closeSuppressed(e, fileOutErr);
-          // Rethrow as the test could not be run and thus there's no point in retrying.
-          throw e;
-        }
-        spawnResults = ImmutableList.of(e.getSpawnResult());
-        builder = TestResultData.newBuilder();
-        builder
-            .setTestPassed(false)
-            .setStatus(e.hasTimedOut() ? BlazeTestStatus.TIMEOUT : BlazeTestStatus.FAILED);
-      } catch (InterruptedException e) {
-        closeSuppressed(e, streamed);
-        closeSuppressed(e, fileOutErr);
-        throw e;
       }
-      long endTimeMillis = actionExecutionContext.getClock().currentTimeMillis();
+
+      Verify.verify(
+          !(testAction.isCoverageMode() && testAction.getSplitCoveragePostProcessing())
+              || testAction.getCoverageData().getPath().exists());
+      Verify.verifyNotNull(spawnResults);
+      Verify.verifyNotNull(testResultDataBuilder);
 
       try {
         if (!fileOutErr.hasRecordedOutput()) {
@@ -595,7 +747,7 @@
           FileSystemUtils.touchFile(fileOutErr.getOutputPath());
         }
         // Append any error output to the test.log. This is very rare.
-        appendStderr(fileOutErr);
+        writeOutFile(fileOutErr.getErrorPath(), fileOutErr.getOutputPath());
         fileOutErr.close();
         if (streamed != null) {
           streamed.close();
@@ -604,23 +756,6 @@
         throw new EnvironmentalExecException(e, Code.TEST_OUT_ERR_IO_EXCEPTION);
       }
 
-      // SpawnActionContext guarantees the first entry to correspond to the spawn passed in (there
-      // may be additional entries due to tree artifact handling).
-      SpawnResult primaryResult = spawnResults.get(0);
-
-      // The SpawnResult of a remotely cached or remotely executed action may not have walltime
-      // set. We fall back to the time measured here for backwards compatibility.
-      long durationMillis = endTimeMillis - startTimeMillis;
-      durationMillis =
-          primaryResult.getWallTime().orElse(Duration.ofMillis(durationMillis)).toMillis();
-
-      builder.setStartTimeMillisEpoch(startTimeMillis);
-      builder.addTestTimes(durationMillis);
-      builder.addTestProcessTimes(durationMillis);
-      builder.setRunDurationMillis(durationMillis);
-      if (testAction.isCoverageMode()) {
-        builder.setHasCoverage(true);
-      }
 
       // If the test did not create a test.xml, and --experimental_split_xml_generation is enabled,
       // then we run a separate action to create a test.xml from test.log. We do this as a spawn
@@ -630,7 +765,7 @@
       if (executionOptions.splitXmlGeneration
           && fileOutErr.getOutputPath().exists()
           && !xmlOutputPath.exists()) {
-        Spawn xmlGeneratingSpawn = createXmlGeneratingSpawn(testAction, primaryResult);
+        Spawn xmlGeneratingSpawn = createXmlGeneratingSpawn(testAction, spawnResults.get(0));
         SpawnStrategyResolver spawnStrategyResolver =
             actionExecutionContext.getContext(SpawnStrategyResolver.class);
         // We treat all failures to generate the test.xml here as catastrophic, and won't rerun
@@ -641,7 +776,7 @@
               spawnStrategyResolver.beginExecution(
                   xmlGeneratingSpawn, actionExecutionContext.withFileOutErr(xmlSpawnOutErr));
           return new BazelXmlCreationContinuation(
-              resolvedPaths, xmlSpawnOutErr, builder, spawnResults, xmlContinuation);
+              resolvedPaths, xmlSpawnOutErr, testResultDataBuilder, spawnResults, xmlContinuation);
         } catch (InterruptedException e) {
           closeSuppressed(e, xmlSpawnOutErr);
           throw e;
@@ -650,11 +785,11 @@
 
       TestCase details = parseTestResult(xmlOutputPath);
       if (details != null) {
-        builder.setTestCase(details);
+        testResultDataBuilder.setTestCase(details);
       }
 
       BuildEventStreamProtos.TestResult.ExecutionInfo executionInfo =
-          extractExecutionInfo(primaryResult, builder);
+          extractExecutionInfo(spawnResults.get(0), testResultDataBuilder);
       StandaloneTestResult standaloneTestResult =
           StandaloneTestResult.builder()
               .setSpawnResults(spawnResults)
@@ -662,7 +797,7 @@
               // instance, as we may have to rename the output files in case the test needs to be
               // rerun (if it failed here _and_ is marked flaky _and_ the number of flaky attempts
               // is larger than 1).
-              .setTestResultDataBuilder(builder)
+              .setTestResultDataBuilder(testResultDataBuilder)
               .setExecutionInfo(executionInfo)
               .build();
       return TestAttemptContinuation.of(standaloneTestResult);
@@ -734,4 +869,97 @@
       return TestAttemptContinuation.of(standaloneTestResult);
     }
   }
+
+  private final class BazelCoveragePostProcessingContinuation extends TestAttemptContinuation {
+    private final ResolvedPaths resolvedPaths;
+    private final FileOutErr fileOutErr;
+    private final Closeable streamed;
+    private final TestResultData.Builder testResultDataBuilder;
+    private final ImmutableList<SpawnResult> primarySpawnResults;
+    private final SpawnContinuation spawnContinuation;
+    private final TestRunnerAction testAction;
+    private final ActionExecutionContext actionExecutionContext;
+    private final Spawn spawn;
+
+    BazelCoveragePostProcessingContinuation(
+        TestRunnerAction testAction,
+        ActionExecutionContext actionExecutionContext,
+        Spawn spawn,
+        ResolvedPaths resolvedPaths,
+        FileOutErr fileOutErr,
+        Closeable streamed,
+        TestResultData.Builder testResultDataBuilder,
+        ImmutableList<SpawnResult> primarySpawnResults,
+        SpawnContinuation spawnContinuation) {
+      this.testAction = testAction;
+      this.actionExecutionContext = actionExecutionContext;
+      this.spawn = spawn;
+      this.resolvedPaths = resolvedPaths;
+      this.fileOutErr = fileOutErr;
+      this.streamed = streamed;
+      this.testResultDataBuilder = testResultDataBuilder;
+      this.primarySpawnResults = primarySpawnResults;
+      this.spawnContinuation = spawnContinuation;
+    }
+
+    @Nullable
+    @Override
+    public ListenableFuture<?> getFuture() {
+      return spawnContinuation.getFuture();
+    }
+
+    @Override
+    public TestAttemptContinuation execute() throws InterruptedException, ExecException {
+      SpawnContinuation nextContinuation = null;
+      try {
+        nextContinuation = spawnContinuation.execute();
+        if (!nextContinuation.isDone()) {
+          return new BazelCoveragePostProcessingContinuation(
+              testAction,
+              actionExecutionContext,
+              spawn,
+              resolvedPaths,
+              fileOutErr,
+              streamed,
+              testResultDataBuilder,
+              ImmutableList.<SpawnResult>builder()
+                  .addAll(primarySpawnResults)
+                  .addAll(nextContinuation.get())
+                  .build(),
+              nextContinuation);
+        }
+      } catch (SpawnExecException e) {
+        if (e.isCatastrophic()) {
+          closeSuppressed(e, streamed);
+          closeSuppressed(e, fileOutErr);
+          throw e;
+        }
+        if (!e.getSpawnResult().setupSuccess()) {
+          closeSuppressed(e, streamed);
+          closeSuppressed(e, fileOutErr);
+          // Rethrow as the test could not be run and thus there's no point in retrying.
+          throw e;
+        }
+        testResultDataBuilder
+            .setTestPassed(false)
+            .setStatus(e.hasTimedOut() ? BlazeTestStatus.TIMEOUT : BlazeTestStatus.FAILED);
+      } catch (ExecException | InterruptedException e) {
+        closeSuppressed(e, fileOutErr);
+        closeSuppressed(e, streamed);
+        throw e;
+      }
+
+      return new BazelTestAttemptContinuation(
+          testAction,
+          actionExecutionContext,
+          spawn,
+          resolvedPaths,
+          fileOutErr,
+          streamed,
+          /* startTimeMillis= */ 0,
+          nextContinuation,
+          testResultDataBuilder,
+          primarySpawnResults);
+    }
+  }
 }