Remote: Async upload (Part 8)

Update UI to display background uploads that are still active after the build with message like:

```
INFO: Build completed successfully, 5001 total actions
Waiting for remote cache: 4911 uploads; 16s
```

Part of https://github.com/bazelbuild/bazel/pull/13655.

PiperOrigin-RevId: 395047830
diff --git a/src/main/java/com/google/devtools/build/lib/actions/ActionUploadFinishedEvent.java b/src/main/java/com/google/devtools/build/lib/actions/ActionUploadFinishedEvent.java
new file mode 100644
index 0000000..7d602cc
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/actions/ActionUploadFinishedEvent.java
@@ -0,0 +1,35 @@
+// Copyright 2021 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.actions;
+
+import com.google.auto.value.AutoValue;
+import com.google.devtools.build.lib.events.ExtendedEventHandler.ProgressLike;
+
+/** The event that is fired when the file being uploaded by the action is finished. */
+@AutoValue
+public abstract class ActionUploadFinishedEvent implements ProgressLike {
+
+  public static ActionUploadFinishedEvent create(
+      ActionExecutionMetadata action, String resourceId) {
+    return new AutoValue_ActionUploadFinishedEvent(action, resourceId);
+  }
+
+  /** Returns the associated action. */
+  public abstract ActionExecutionMetadata action();
+
+  /**
+   * Returns the id that uniquely determines the resource being uploaded among all upload events.
+   */
+  public abstract String resourceId();
+}
diff --git a/src/main/java/com/google/devtools/build/lib/actions/ActionUploadStartedEvent.java b/src/main/java/com/google/devtools/build/lib/actions/ActionUploadStartedEvent.java
new file mode 100644
index 0000000..ac34a24
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/actions/ActionUploadStartedEvent.java
@@ -0,0 +1,33 @@
+// Copyright 2021 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.actions;
+
+import com.google.auto.value.AutoValue;
+import com.google.devtools.build.lib.events.ExtendedEventHandler.ProgressLike;
+
+/** The event that is fired when the action is about to upload a file. */
+@AutoValue
+public abstract class ActionUploadStartedEvent implements ProgressLike {
+  public static ActionUploadStartedEvent create(ActionExecutionMetadata action, String resourceId) {
+    return new AutoValue_ActionUploadStartedEvent(action, resourceId);
+  }
+
+  /** Returns the associated action. */
+  public abstract ActionExecutionMetadata action();
+
+  /**
+   * Returns the id that uniquely determines the resource being uploaded among all upload events.
+   */
+  public abstract String resourceId();
+}
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteCache.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteCache.java
index fced8f1..af68ddf 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteCache.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteCache.java
@@ -30,7 +30,11 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.SettableFuture;
+import com.google.devtools.build.lib.actions.ActionExecutionMetadata;
+import com.google.devtools.build.lib.actions.ActionUploadFinishedEvent;
+import com.google.devtools.build.lib.actions.ActionUploadStartedEvent;
 import com.google.devtools.build.lib.concurrent.ThreadSafety;
+import com.google.devtools.build.lib.events.ExtendedEventHandler;
 import com.google.devtools.build.lib.exec.SpawnProgressEvent;
 import com.google.devtools.build.lib.remote.common.BulkTransferException;
 import com.google.devtools.build.lib.remote.common.LazyFileOutputStream;
@@ -62,6 +66,7 @@
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import javax.annotation.Nullable;
 
 /**
  * A cache for storing artifacts (input and output) as well as the output of running an action.
@@ -80,6 +85,7 @@
   private static final ListenableFuture<Void> COMPLETED_SUCCESS = immediateFuture(null);
   private static final ListenableFuture<byte[]> EMPTY_BYTES = immediateFuture(new byte[0]);
 
+  private final ExtendedEventHandler reporter;
   private final CountDownLatch closeCountDownLatch = new CountDownLatch(1);
   protected final AsyncTaskCache.NoResult<Digest> casUploadCache = AsyncTaskCache.NoResult.create();
 
@@ -88,7 +94,11 @@
   protected final DigestUtil digestUtil;
 
   public RemoteCache(
-      RemoteCacheClient cacheProtocol, RemoteOptions options, DigestUtil digestUtil) {
+      ExtendedEventHandler reporter,
+      RemoteCacheClient cacheProtocol,
+      RemoteOptions options,
+      DigestUtil digestUtil) {
+    this.reporter = reporter;
     this.cacheProtocol = cacheProtocol;
     this.options = options;
     this.digestUtil = digestUtil;
@@ -100,6 +110,23 @@
     return getFromFuture(cacheProtocol.downloadActionResult(context, actionKey, inlineOutErr));
   }
 
+  private void postUploadStartedEvent(@Nullable ActionExecutionMetadata action, String resourceId) {
+    if (action == null) {
+      return;
+    }
+
+    reporter.post(ActionUploadStartedEvent.create(action, resourceId));
+  }
+
+  private void postUploadFinishedEvent(
+      @Nullable ActionExecutionMetadata action, String resourceId) {
+    if (action == null) {
+      return;
+    }
+
+    reporter.post(ActionUploadFinishedEvent.create(action, resourceId));
+  }
+
   /**
    * Returns a set of digests that the remote cache does not know about. The returned set is
    * guaranteed to be a subset of {@code digests}.
@@ -115,7 +142,37 @@
   /** Upload the action result to the remote cache. */
   public ListenableFuture<Void> uploadActionResult(
       RemoteActionExecutionContext context, ActionKey actionKey, ActionResult actionResult) {
-    return cacheProtocol.uploadActionResult(context, actionKey, actionResult);
+
+    ActionExecutionMetadata action = context.getSpawnOwner();
+
+    Completable upload =
+        Completable.using(
+            () -> {
+              String resourceId = "ac/" + actionKey.getDigest().getHash();
+              postUploadStartedEvent(action, resourceId);
+              return resourceId;
+            },
+            resourceId ->
+                RxFutures.toCompletable(
+                    () -> cacheProtocol.uploadActionResult(context, actionKey, actionResult),
+                    directExecutor()),
+            resourceId -> postUploadFinishedEvent(action, resourceId));
+
+    return RxFutures.toListenableFuture(upload);
+  }
+
+  private Completable doUploadFile(RemoteActionExecutionContext context, Digest digest, Path file) {
+    ActionExecutionMetadata action = context.getSpawnOwner();
+    return Completable.using(
+        () -> {
+          String resourceId = "cas/" + digest.getHash();
+          postUploadStartedEvent(action, resourceId);
+          return resourceId;
+        },
+        resourceId ->
+            RxFutures.toCompletable(
+                () -> cacheProtocol.uploadFile(context, digest, file), directExecutor()),
+        resourceId -> postUploadFinishedEvent(action, resourceId));
   }
 
   /**
@@ -134,14 +191,26 @@
       return COMPLETED_SUCCESS;
     }
 
-    Completable upload =
-        casUploadCache.executeIfNot(
-            digest,
-            RxFutures.toCompletable(
-                () -> cacheProtocol.uploadFile(context, digest, file), directExecutor()));
+    Completable upload = casUploadCache.executeIfNot(digest, doUploadFile(context, digest, file));
+
     return RxFutures.toListenableFuture(upload);
   }
 
+  private Completable doUploadBlob(
+      RemoteActionExecutionContext context, Digest digest, ByteString data) {
+    ActionExecutionMetadata action = context.getSpawnOwner();
+    return Completable.using(
+        () -> {
+          String resourceId = "cas/" + digest.getHash();
+          postUploadStartedEvent(action, resourceId);
+          return resourceId;
+        },
+        resourceId ->
+            RxFutures.toCompletable(
+                () -> cacheProtocol.uploadBlob(context, digest, data), directExecutor()),
+        resourceId -> postUploadFinishedEvent(action, resourceId));
+  }
+
   /**
    * Upload sequence of bytes to the remote cache.
    *
@@ -158,11 +227,8 @@
       return COMPLETED_SUCCESS;
     }
 
-    Completable upload =
-        casUploadCache.executeIfNot(
-            digest,
-            RxFutures.toCompletable(
-                () -> cacheProtocol.uploadBlob(context, digest, data), directExecutor()));
+    Completable upload = casUploadCache.executeIfNot(digest, doUploadBlob(context, digest, data));
+
     return RxFutures.toListenableFuture(upload);
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionCache.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionCache.java
index 4a4135c..86ec811 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionCache.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionCache.java
@@ -22,6 +22,7 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.devtools.build.lib.events.ExtendedEventHandler;
 import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext;
 import com.google.devtools.build.lib.remote.common.RemoteCacheClient;
 import com.google.devtools.build.lib.remote.merkletree.MerkleTree;
@@ -42,8 +43,11 @@
 public class RemoteExecutionCache extends RemoteCache {
 
   public RemoteExecutionCache(
-      RemoteCacheClient protocolImpl, RemoteOptions options, DigestUtil digestUtil) {
-    super(protocolImpl, options, digestUtil);
+      ExtendedEventHandler reporter,
+      RemoteCacheClient protocolImpl,
+      RemoteOptions options,
+      DigestUtil digestUtil) {
+    super(reporter, protocolImpl, options, digestUtil);
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionService.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionService.java
index b5b0e7e..7b549dd 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionService.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionService.java
@@ -65,6 +65,7 @@
 import com.google.common.eventbus.Subscribe;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
+import com.google.devtools.build.lib.actions.ActionExecutionMetadata;
 import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.Artifact.SpecialArtifact;
@@ -262,6 +263,11 @@
       return remoteActionExecutionContext;
     }
 
+    /** Returns the {@link ActionExecutionMetadata} that owns this action. */
+    public ActionExecutionMetadata getOwner() {
+      return spawn.getResourceOwner();
+    }
+
     /**
      * Returns the sum of file sizes plus protobuf sizes used to represent the inputs of this
      * action.
@@ -381,7 +387,7 @@
         TracingMetadataUtils.buildMetadata(
             buildRequestId, commandId, actionKey.getDigest().getHash(), spawn.getResourceOwner());
     RemoteActionExecutionContext remoteActionExecutionContext =
-        RemoteActionExecutionContext.createForSpawn(spawn, metadata);
+        RemoteActionExecutionContext.create(spawn, metadata);
 
     return new RemoteAction(
         spawn,
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
index db52003..289d92c 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
@@ -220,7 +220,8 @@
       handleInitFailure(env, e, Code.CACHE_INIT_FAILURE);
       return;
     }
-    RemoteCache remoteCache = new RemoteCache(cacheClient, remoteOptions, digestUtil);
+    RemoteCache remoteCache =
+        new RemoteCache(env.getReporter(), cacheClient, remoteOptions, digestUtil);
     actionContextProvider =
         RemoteActionContextProvider.createForRemoteCaching(
             env, remoteCache, /* retryScheduler= */ null, digestUtil);
@@ -543,7 +544,7 @@
       }
       execChannel.release();
       RemoteExecutionCache remoteCache =
-          new RemoteExecutionCache(cacheClient, remoteOptions, digestUtil);
+          new RemoteExecutionCache(env.getReporter(), cacheClient, remoteOptions, digestUtil);
       actionContextProvider =
           RemoteActionContextProvider.createForRemoteExecution(
               env, remoteCache, remoteExecutor, retryScheduler, digestUtil, logDir);
@@ -573,7 +574,8 @@
         }
       }
 
-      RemoteCache remoteCache = new RemoteCache(cacheClient, remoteOptions, digestUtil);
+      RemoteCache remoteCache =
+          new RemoteCache(env.getReporter(), cacheClient, remoteOptions, digestUtil);
       actionContextProvider =
           RemoteActionContextProvider.createForRemoteCaching(
               env, remoteCache, retryScheduler, digestUtil);
diff --git a/src/main/java/com/google/devtools/build/lib/remote/common/RemoteActionExecutionContext.java b/src/main/java/com/google/devtools/build/lib/remote/common/RemoteActionExecutionContext.java
index e1616f3..52fafe3 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/common/RemoteActionExecutionContext.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/common/RemoteActionExecutionContext.java
@@ -14,6 +14,7 @@
 package com.google.devtools.build.lib.remote.common;
 
 import build.bazel.remote.execution.v2.RequestMetadata;
+import com.google.devtools.build.lib.actions.ActionExecutionMetadata;
 import com.google.devtools.build.lib.actions.Spawn;
 import javax.annotation.Nullable;
 
@@ -33,6 +34,16 @@
    */
   NetworkTime getNetworkTime();
 
+  @Nullable
+  default ActionExecutionMetadata getSpawnOwner() {
+    Spawn spawn = getSpawn();
+    if (spawn == null) {
+      return null;
+    }
+
+    return spawn.getResourceOwner();
+  }
+
   /** Creates a {@link SimpleRemoteActionExecutionContext} with given {@link RequestMetadata}. */
   static RemoteActionExecutionContext create(RequestMetadata metadata) {
     return new SimpleRemoteActionExecutionContext(/*spawn=*/ null, metadata, new NetworkTime());
@@ -42,7 +53,7 @@
    * Creates a {@link SimpleRemoteActionExecutionContext} with given {@link Spawn} and {@link
    * RequestMetadata}.
    */
-  static RemoteActionExecutionContext createForSpawn(Spawn spawn, RequestMetadata metadata) {
+  static RemoteActionExecutionContext create(@Nullable Spawn spawn, RequestMetadata metadata) {
     return new SimpleRemoteActionExecutionContext(spawn, metadata, new NetworkTime());
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/UiEventHandler.java b/src/main/java/com/google/devtools/build/lib/runtime/UiEventHandler.java
index ae84347..cbcb93e 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/UiEventHandler.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/UiEventHandler.java
@@ -23,6 +23,8 @@
 import com.google.devtools.build.lib.actions.ActionProgressEvent;
 import com.google.devtools.build.lib.actions.ActionScanningCompletedEvent;
 import com.google.devtools.build.lib.actions.ActionStartedEvent;
+import com.google.devtools.build.lib.actions.ActionUploadFinishedEvent;
+import com.google.devtools.build.lib.actions.ActionUploadStartedEvent;
 import com.google.devtools.build.lib.actions.CachingActionEvent;
 import com.google.devtools.build.lib.actions.RunningActionEvent;
 import com.google.devtools.build.lib.actions.ScanningActionEvent;
@@ -575,9 +577,8 @@
       ignoreRefreshLimitOnce();
       refresh();
 
-      // After a build has completed, only stop updating the UI if there is no more BEP
-      // upload happening.
-      if (stateTracker.pendingTransports() == 0) {
+      // After a build has completed, only stop updating the UI if there is no more activities.
+      if (!stateTracker.hasActivities()) {
         buildRunning = false;
         done = true;
       }
@@ -706,7 +707,7 @@
   @AllowConcurrentEvents
   public void actionProgress(ActionProgressEvent event) {
     stateTracker.actionProgress(event);
-    refresh();
+    refreshSoon();
   }
 
   @Subscribe
@@ -723,6 +724,30 @@
     refreshSoon();
   }
 
+  private void checkActivities() {
+    if (stateTracker.hasActivities()) {
+      refreshSoon();
+    } else {
+      stopUpdateThread();
+      flushStdOutStdErrBuffers();
+      ignoreRefreshLimitOnce();
+      refresh();
+    }
+  }
+
+  @Subscribe
+  @AllowConcurrentEvents
+  public void actionUploadStarted(ActionUploadStartedEvent event) {
+    stateTracker.actionUploadStarted(event);
+    refreshSoon();
+  }
+
+  @Subscribe
+  public void actionUploadFinished(ActionUploadFinishedEvent event) {
+    stateTracker.actionUploadFinished(event);
+    checkActivities();
+  }
+
   @Subscribe
   public void testFilteringComplete(TestFilteringCompleteEvent event) {
     stateTracker.testFilteringComplete(event);
@@ -797,12 +822,7 @@
       this.handle(Event.info(null, "Transport " + event.transport().name() + " closed"));
     }
 
-    if (stateTracker.pendingTransports() == 0) {
-      stopUpdateThread();
-      flushStdOutStdErrBuffers();
-      ignoreRefreshLimitOnce();
-    }
-    refresh();
+    checkActivities();
   }
 
   private void refresh() {
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/UiStateTracker.java b/src/main/java/com/google/devtools/build/lib/runtime/UiStateTracker.java
index eef7ea5..84ff8f1 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/UiStateTracker.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/UiStateTracker.java
@@ -14,7 +14,6 @@
 package com.google.devtools.build.lib.runtime;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Comparators;
@@ -28,6 +27,8 @@
 import com.google.devtools.build.lib.actions.ActionProgressEvent;
 import com.google.devtools.build.lib.actions.ActionScanningCompletedEvent;
 import com.google.devtools.build.lib.actions.ActionStartedEvent;
+import com.google.devtools.build.lib.actions.ActionUploadFinishedEvent;
+import com.google.devtools.build.lib.actions.ActionUploadStartedEvent;
 import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.CachingActionEvent;
 import com.google.devtools.build.lib.actions.RunningActionEvent;
@@ -55,6 +56,8 @@
 import com.google.devtools.build.lib.util.io.PositionAwareAnsiTerminalWriter;
 import com.google.devtools.build.lib.view.test.TestStatus.BlazeTestStatus;
 import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
 import java.util.ArrayDeque;
 import java.util.Comparator;
 import java.util.Deque;
@@ -360,6 +363,8 @@
   }
 
   private final Map<Artifact, ActionState> activeActions;
+  private final AtomicInteger activeActionUploads = new AtomicInteger(0);
+  private final AtomicInteger activeActionDownloads = new AtomicInteger(0);
 
   // running downloads are identified by the original URL they were trying to access.
   private final Deque<String> runningDownloads;
@@ -392,7 +397,7 @@
   // Set of build event protocol transports that need yet to be closed.
   private final Set<BuildEventTransport> bepOpenTransports = new HashSet<>();
   // The point in time when closing of BEP transports was started.
-  private long bepTransportClosingStartTimeMillis;
+  private Instant buildCompleteAt;
 
   UiStateTracker(Clock clock, int targetWidth) {
     this.activeActions = new ConcurrentHashMap<>();
@@ -472,8 +477,7 @@
 
   void buildComplete(BuildCompleteEvent event) {
     buildComplete = true;
-    // Build event protocol transports are closed right after the build complete event.
-    bepTransportClosingStartTimeMillis = clock.currentTimeMillis();
+    buildCompleteAt = Instant.ofEpochMilli(clock.currentTimeMillis());
 
     if (event.getResult().getSuccess()) {
       status = "INFO";
@@ -623,6 +627,14 @@
     }
   }
 
+  void actionUploadStarted(ActionUploadStartedEvent event) {
+    activeActionUploads.incrementAndGet();
+  }
+
+  void actionUploadFinished(ActionUploadFinishedEvent event) {
+    activeActionUploads.decrementAndGet();
+  }
+
   /** From a string, take a suffix of at most the given length. */
   static String suffix(String s, int len) {
     if (len <= 0) {
@@ -990,8 +1002,11 @@
     bepOpenTransports.remove(event.transport());
   }
 
-  synchronized int pendingTransports() {
-    return bepOpenTransports.size();
+  synchronized boolean hasActivities() {
+    return !(buildComplete
+        && bepOpenTransports.isEmpty()
+        && activeActionUploads.get() == 0
+        && activeActionDownloads.get() == 0);
   }
 
   /**
@@ -1006,7 +1021,7 @@
     if (runningDownloads.size() >= 1) {
       return true;
     }
-    if (buildComplete && !bepOpenTransports.isEmpty()) {
+    if (buildComplete && hasActivities()) {
       return true;
     }
     if (status != null) {
@@ -1129,6 +1144,55 @@
   }
 
   /**
+   * Display any action uploads/downloads that are still active after the build. Most likely,
+   * because upload/download takes longer than the build itself.
+   */
+  private void maybeReportActiveUploadsOrDownloads(PositionAwareAnsiTerminalWriter terminalWriter)
+      throws IOException {
+    int uploads = activeActionUploads.get();
+    int downloads = activeActionDownloads.get();
+
+    if (!buildComplete || (uploads == 0 && downloads == 0)) {
+      return;
+    }
+
+    Duration waitTime =
+        Duration.between(buildCompleteAt, Instant.ofEpochMilli(clock.currentTimeMillis()));
+    if (waitTime.getSeconds() == 0) {
+      // Special case for when bazel was interrupted, in which case we don't want to have a message.
+      return;
+    }
+
+    String suffix = "";
+    if (waitTime.compareTo(Duration.ofSeconds(SHOW_TIME_THRESHOLD_SECONDS)) > 0) {
+      suffix = "; " + waitTime.getSeconds() + "s";
+    }
+
+    String message = "Waiting for remote cache: ";
+    if (uploads != 0) {
+      if (uploads == 1) {
+        message += "1 upload";
+      } else {
+        message += uploads + " uploads";
+      }
+    }
+
+    if (downloads != 0) {
+      if (uploads != 0) {
+        message += ", ";
+      }
+
+      if (downloads == 1) {
+        message += "1 download";
+      } else {
+        message += downloads + " downloads";
+      }
+    }
+
+    terminalWriter.newline().append(message).append(suffix);
+  }
+
+  /**
    * Display any BEP transports that are still open after the build. Most likely, because uploading
    * build events takes longer than the build itself.
    */
@@ -1137,9 +1201,9 @@
     if (!buildComplete || bepOpenTransports.isEmpty()) {
       return;
     }
-    long sinceSeconds =
-        MILLISECONDS.toSeconds(clock.currentTimeMillis() - bepTransportClosingStartTimeMillis);
-    if (sinceSeconds == 0) {
+    Duration waitTime =
+        Duration.between(buildCompleteAt, Instant.ofEpochMilli(clock.currentTimeMillis()));
+    if (waitTime.getSeconds() == 0) {
       // Special case for when bazel was interrupted, in which case we don't want to have
       // a BEP upload message.
       return;
@@ -1150,17 +1214,17 @@
 
     String waitMessage = "Waiting for build events upload: ";
     String name = bepOpenTransports.iterator().next().name();
-    String line = waitMessage + name + " " + sinceSeconds + "s";
+    String line = waitMessage + name + " " + waitTime.getSeconds() + "s";
 
     if (count == 1 && line.length() <= maxWidth) {
       terminalWriter.newline().append(line);
     } else if (count == 1) {
       waitMessage = "Waiting for: ";
-      String waitSecs = " " + sinceSeconds + "s";
+      String waitSecs = " " + waitTime.getSeconds() + "s";
       int maxNameWidth = maxWidth - waitMessage.length() - waitSecs.length();
       terminalWriter.newline().append(waitMessage + shortenedString(name, maxNameWidth) + waitSecs);
     } else {
-      terminalWriter.newline().append(waitMessage + sinceSeconds + "s");
+      terminalWriter.newline().append(waitMessage + waitTime.getSeconds() + "s");
       for (BuildEventTransport transport : bepOpenTransports) {
         name = "  " + transport.name();
         terminalWriter.newline().append(shortenedString(name, maxWidth));
@@ -1197,6 +1261,7 @@
       }
       if (!shortVersion) {
         reportOnDownloads(terminalWriter);
+        maybeReportActiveUploadsOrDownloads(terminalWriter);
         maybeReportBepTransports(terminalWriter);
       }
       return;
@@ -1269,6 +1334,7 @@
     }
     if (!shortVersion) {
       reportOnDownloads(terminalWriter);
+      maybeReportActiveUploadsOrDownloads(terminalWriter);
       maybeReportBepTransports(terminalWriter);
     }
   }
diff --git a/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java b/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java
index b719889..06a35ad 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java
@@ -51,6 +51,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.Maps;
+import com.google.common.eventbus.EventBus;
 import com.google.common.util.concurrent.ListeningScheduledExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.devtools.build.lib.actions.ActionInputHelper;
@@ -60,6 +61,7 @@
 import com.google.devtools.build.lib.authandtls.CallCredentialsProvider;
 import com.google.devtools.build.lib.authandtls.GoogleAuthUtils;
 import com.google.devtools.build.lib.clock.JavaClock;
+import com.google.devtools.build.lib.events.Reporter;
 import com.google.devtools.build.lib.remote.RemoteRetrier.ExponentialBackoff;
 import com.google.devtools.build.lib.remote.Retrier.Backoff;
 import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext;
@@ -130,6 +132,7 @@
   private Path execRoot;
   private FileOutErr outErr;
   private FakeActionInputFileCache fakeFileCache;
+  private final Reporter reporter = new Reporter(new EventBus());
   private final MutableHandlerRegistry serviceRegistry = new MutableHandlerRegistry();
   private final String fakeServerName = "fake server for " + getClass();
   private Server fakeServer;
@@ -268,7 +271,7 @@
   public void testVirtualActionInputSupport() throws Exception {
     RemoteOptions options = Options.getDefaults(RemoteOptions.class);
     RemoteExecutionCache client =
-        new RemoteExecutionCache(newClient(options), options, DIGEST_UTIL);
+        new RemoteExecutionCache(reporter, newClient(options), options, DIGEST_UTIL);
     PathFragment execPath = PathFragment.create("my/exec/path");
     VirtualActionInput virtualActionInput =
         ActionsTestUtil.createVirtualActionInput(execPath, "hello");
@@ -378,7 +381,7 @@
     // arrange
     RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
     GrpcCacheClient client = newClient(remoteOptions);
-    RemoteCache remoteCache = new RemoteCache(client, remoteOptions, DIGEST_UTIL);
+    RemoteCache remoteCache = new RemoteCache(reporter, client, remoteOptions, DIGEST_UTIL);
 
     Digest fooDigest = DIGEST_UTIL.computeAsUtf8("foo-contents");
     Digest barDigest = DIGEST_UTIL.computeAsUtf8("bar-contents");
@@ -401,7 +404,7 @@
   public void testUploadDirectory() throws Exception {
     RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
     GrpcCacheClient client = newClient(remoteOptions);
-    RemoteCache remoteCache = new RemoteCache(client, remoteOptions, DIGEST_UTIL);
+    RemoteCache remoteCache = new RemoteCache(reporter, client, remoteOptions, DIGEST_UTIL);
 
     final Digest fooDigest =
         fakeFileCache.createScratchInput(ActionInputHelper.fromPath("a/foo"), "xyz");
@@ -459,7 +462,7 @@
   public void testUploadDirectoryEmpty() throws Exception {
     RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
     GrpcCacheClient client = newClient(remoteOptions);
-    RemoteCache remoteCache = new RemoteCache(client, remoteOptions, DIGEST_UTIL);
+    RemoteCache remoteCache = new RemoteCache(reporter, client, remoteOptions, DIGEST_UTIL);
 
     final Digest barDigest =
         fakeFileCache.createScratchInputDirectory(
@@ -498,7 +501,7 @@
   public void testUploadDirectoryNested() throws Exception {
     RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
     GrpcCacheClient client = newClient(remoteOptions);
-    RemoteCache remoteCache = new RemoteCache(client, remoteOptions, DIGEST_UTIL);
+    RemoteCache remoteCache = new RemoteCache(reporter, client, remoteOptions, DIGEST_UTIL);
 
     final Digest wobbleDigest =
         fakeFileCache.createScratchInput(ActionInputHelper.fromPath("bar/test/wobble"), "xyz");
@@ -649,7 +652,7 @@
     serviceRegistry.addService(ServerInterceptors.intercept(actionCache, interceptor));
 
     GrpcCacheClient client = newClient(remoteOptions);
-    RemoteCache remoteCache = new RemoteCache(client, remoteOptions, DIGEST_UTIL);
+    RemoteCache remoteCache = new RemoteCache(reporter, client, remoteOptions, DIGEST_UTIL);
     remoteCache.downloadActionResult(
         context,
         DIGEST_UTIL.asActionKey(DIGEST_UTIL.computeAsUtf8("key")),
@@ -660,7 +663,7 @@
   public void testUpload() throws Exception {
     RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
     GrpcCacheClient client = newClient(remoteOptions);
-    RemoteCache remoteCache = new RemoteCache(client, remoteOptions, DIGEST_UTIL);
+    RemoteCache remoteCache = new RemoteCache(reporter, client, remoteOptions, DIGEST_UTIL);
 
     final Digest fooDigest =
         fakeFileCache.createScratchInput(ActionInputHelper.fromPath("a/foo"), "xyz");
@@ -730,7 +733,7 @@
     RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
     remoteOptions.maxOutboundMessageSize = 80; // Enough for one digest, but not two.
     GrpcCacheClient client = newClient(remoteOptions);
-    RemoteCache remoteCache = new RemoteCache(client, remoteOptions, DIGEST_UTIL);
+    RemoteCache remoteCache = new RemoteCache(reporter, client, remoteOptions, DIGEST_UTIL);
 
     final Digest fooDigest =
         fakeFileCache.createScratchInput(ActionInputHelper.fromPath("a/foo"), "xyz");
@@ -789,7 +792,7 @@
   public void testUploadCacheMissesWithRetries() throws Exception {
     RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
     GrpcCacheClient client = newClient(remoteOptions);
-    RemoteCache remoteCache = new RemoteCache(client, remoteOptions, DIGEST_UTIL);
+    RemoteCache remoteCache = new RemoteCache(reporter, client, remoteOptions, DIGEST_UTIL);
 
     final Digest fooDigest =
         fakeFileCache.createScratchInput(ActionInputHelper.fromPath("a/foo"), "xyz");
diff --git a/src/test/java/com/google/devtools/build/lib/remote/InMemoryRemoteCache.java b/src/test/java/com/google/devtools/build/lib/remote/InMemoryRemoteCache.java
index 5799219..9dcaf36 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/InMemoryRemoteCache.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/InMemoryRemoteCache.java
@@ -16,6 +16,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import build.bazel.remote.execution.v2.Digest;
+import com.google.devtools.build.lib.events.Reporter;
 import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext;
 import com.google.devtools.build.lib.remote.options.RemoteOptions;
 import com.google.devtools.build.lib.remote.util.DigestUtil;
@@ -29,12 +30,15 @@
 class InMemoryRemoteCache extends RemoteExecutionCache {
 
   InMemoryRemoteCache(
-      Map<Digest, byte[]> casEntries, RemoteOptions options, DigestUtil digestUtil) {
-    super(new InMemoryCacheClient(casEntries), options, digestUtil);
+      Reporter reporter,
+      Map<Digest, byte[]> casEntries,
+      RemoteOptions options,
+      DigestUtil digestUtil) {
+    super(reporter, new InMemoryCacheClient(casEntries), options, digestUtil);
   }
 
-  InMemoryRemoteCache(RemoteOptions options, DigestUtil digestUtil) {
-    super(new InMemoryCacheClient(), options, digestUtil);
+  InMemoryRemoteCache(Reporter reporter, RemoteOptions options, DigestUtil digestUtil) {
+    super(reporter, new InMemoryCacheClient(), options, digestUtil);
   }
 
   Digest addContents(RemoteActionExecutionContext context, String txt)
diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcherTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcherTest.java
index 9d9dcfe..60e273a 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcherTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcherTest.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
+import com.google.common.eventbus.EventBus;
 import com.google.common.hash.HashCode;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.SettableFuture;
@@ -36,6 +37,7 @@
 import com.google.devtools.build.lib.actions.cache.VirtualActionInput;
 import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
 import com.google.devtools.build.lib.clock.JavaClock;
+import com.google.devtools.build.lib.events.Reporter;
 import com.google.devtools.build.lib.remote.common.BulkTransferException;
 import com.google.devtools.build.lib.remote.options.RemoteOptions;
 import com.google.devtools.build.lib.remote.util.DigestUtil;
@@ -65,6 +67,7 @@
 
   private static final DigestHashFunction HASH_FUNCTION = DigestHashFunction.SHA256;
 
+  private final Reporter reporter = new Reporter(new EventBus());
   private Path execRoot;
   private ArtifactRoot artifactRoot;
   private RemoteOptions options;
@@ -385,13 +388,14 @@
     return a;
   }
 
-  private static RemoteCache newCache(
+  private RemoteCache newCache(
       RemoteOptions options, DigestUtil digestUtil, Map<Digest, ByteString> cacheEntries) {
     Map<Digest, byte[]> cacheEntriesByteArray =
         Maps.newHashMapWithExpectedSize(cacheEntries.size());
     for (Map.Entry<Digest, ByteString> entry : cacheEntries.entrySet()) {
       cacheEntriesByteArray.put(entry.getKey(), entry.getValue().toByteArray());
     }
-    return new RemoteCache(new InMemoryCacheClient(cacheEntriesByteArray), options, digestUtil);
+    return new RemoteCache(
+        reporter, new InMemoryCacheClient(cacheEntriesByteArray), options, digestUtil);
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTests.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTest.java
similarity index 66%
rename from src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTests.java
rename to src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTest.java
index 054a730..cec16dc 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTests.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTest.java
@@ -16,17 +16,32 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.devtools.build.lib.remote.util.Utils.getFromFuture;
 
+import build.bazel.remote.execution.v2.Action;
 import build.bazel.remote.execution.v2.ActionResult;
 import build.bazel.remote.execution.v2.Digest;
 import build.bazel.remote.execution.v2.RequestMetadata;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.eventbus.EventBus;
 import com.google.common.util.concurrent.ListeningScheduledExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.devtools.build.lib.actions.ActionInputHelper;
+import com.google.devtools.build.lib.actions.ActionUploadFinishedEvent;
+import com.google.devtools.build.lib.actions.ActionUploadStartedEvent;
 import com.google.devtools.build.lib.actions.ArtifactRoot;
 import com.google.devtools.build.lib.actions.ArtifactRoot.RootType;
+import com.google.devtools.build.lib.actions.ResourceSet;
+import com.google.devtools.build.lib.actions.SimpleSpawn;
+import com.google.devtools.build.lib.actions.Spawn;
 import com.google.devtools.build.lib.clock.JavaClock;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+import com.google.devtools.build.lib.collect.nestedset.Order;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.events.StoredEventHandler;
+import com.google.devtools.build.lib.exec.util.FakeOwner;
 import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext;
+import com.google.devtools.build.lib.remote.common.RemoteCacheClient.ActionKey;
 import com.google.devtools.build.lib.remote.options.RemoteOptions;
 import com.google.devtools.build.lib.remote.util.DigestUtil;
 import com.google.devtools.build.lib.remote.util.TracingMetadataUtils;
@@ -54,8 +69,10 @@
 
 /** Tests for {@link RemoteCache}. */
 @RunWith(JUnit4.class)
-public class RemoteCacheTests {
+public class RemoteCacheTest {
 
+  private final Reporter reporter = new Reporter(new EventBus());
+  private final StoredEventHandler eventHandler = new StoredEventHandler();
   private RemoteActionExecutionContext context;
   private FileSystem fs;
   private Path execRoot;
@@ -67,10 +84,21 @@
 
   @Before
   public void setUp() throws Exception {
+    reporter.addHandler(eventHandler);
+
     MockitoAnnotations.initMocks(this);
     RequestMetadata metadata =
         TracingMetadataUtils.buildMetadata("none", "none", "action-id", null);
-    context = RemoteActionExecutionContext.create(metadata);
+    Spawn spawn =
+        new SimpleSpawn(
+            new FakeOwner("foo", "bar", "//dummy:label"),
+            /*arguments=*/ ImmutableList.of(),
+            /*environment=*/ ImmutableMap.of(),
+            /*executionInfo=*/ ImmutableMap.of(),
+            /*inputs=*/ NestedSetBuilder.emptySet(Order.STABLE_ORDER),
+            /*outputs=*/ ImmutableSet.of(),
+            ResourceSet.ZERO);
+    context = RemoteActionExecutionContext.create(spawn, metadata);
     fs = new InMemoryFileSystem(new JavaClock(), DigestHashFunction.SHA256);
     execRoot = fs.getPath("/execroot/main");
     execRoot.createDirectoryAndParents();
@@ -142,7 +170,7 @@
     Path file = fs.getPath("/execroot/symlink-to-file");
     RemoteOptions options = Options.getDefaults(RemoteOptions.class);
     options.remoteDownloadSymlinkTemplate = "/home/alice/cas/{hash}-{size_bytes}";
-    RemoteCache remoteCache = new InMemoryRemoteCache(cas, options, digestUtil);
+    RemoteCache remoteCache = new InMemoryRemoteCache(reporter, cas, options, digestUtil);
 
     // act
     getFromFuture(remoteCache.downloadFile(context, file, helloDigest));
@@ -171,8 +199,53 @@
         .containsExactly(emptyDigest);
   }
 
+  @Test
+  public void uploadActionResult_firesUploadEvents() throws Exception {
+    InMemoryRemoteCache remoteCache = newRemoteCache();
+    ActionKey actionKey = new ActionKey(digestUtil.compute(Action.getDefaultInstance()));
+    ActionResult actionResult = ActionResult.getDefaultInstance();
+
+    getFromFuture(remoteCache.uploadActionResult(context, actionKey, actionResult));
+
+    String resourceId = "ac/" + actionKey.getDigest().getHash();
+    assertThat(eventHandler.getPosts())
+        .containsExactly(
+            ActionUploadStartedEvent.create(context.getSpawn().getResourceOwner(), resourceId),
+            ActionUploadFinishedEvent.create(context.getSpawn().getResourceOwner(), resourceId));
+  }
+
+  @Test
+  public void uploadBlob_firesUploadEvents() throws Exception {
+    InMemoryRemoteCache remoteCache = newRemoteCache();
+    ByteString content = ByteString.copyFromUtf8("content");
+    Digest digest = digestUtil.compute(content.toByteArray());
+
+    getFromFuture(remoteCache.uploadBlob(context, digest, content));
+
+    String resourceId = "cas/" + digest.getHash();
+    assertThat(eventHandler.getPosts())
+        .containsExactly(
+            ActionUploadStartedEvent.create(context.getSpawn().getResourceOwner(), resourceId),
+            ActionUploadFinishedEvent.create(context.getSpawn().getResourceOwner(), resourceId));
+  }
+
+  @Test
+  public void uploadFile_firesUploadEvents() throws Exception {
+    InMemoryRemoteCache remoteCache = newRemoteCache();
+    Digest digest = fakeFileCache.createScratchInput(ActionInputHelper.fromPath("file"), "content");
+    Path file = execRoot.getRelative("file");
+
+    getFromFuture(remoteCache.uploadFile(context, digest, file));
+
+    String resourceId = "cas/" + digest.getHash();
+    assertThat(eventHandler.getPosts())
+        .containsExactly(
+            ActionUploadStartedEvent.create(context.getSpawn().getResourceOwner(), resourceId),
+            ActionUploadFinishedEvent.create(context.getSpawn().getResourceOwner(), resourceId));
+  }
+
   private InMemoryRemoteCache newRemoteCache() {
     RemoteOptions options = Options.getDefaults(RemoteOptions.class);
-    return new InMemoryRemoteCache(options, digestUtil);
+    return new InMemoryRemoteCache(reporter, options, digestUtil);
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteExecutionServiceTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteExecutionServiceTest.java
index b375467..d83c648 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/RemoteExecutionServiceTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteExecutionServiceTest.java
@@ -153,7 +153,7 @@
     checkNotNull(stderr.getParentDirectory()).createDirectoryAndParents();
     outErr = new FileOutErr(stdout, stderr);
 
-    cache = spy(new InMemoryRemoteCache(remoteOptions, digestUtil));
+    cache = spy(new InMemoryRemoteCache(reporter, remoteOptions, digestUtil));
     executor = mock(RemoteExecutionClient.class);
 
     RequestMetadata metadata =
diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerWithGrpcRemoteExecutorTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerWithGrpcRemoteExecutorTest.java
index f68274a..61b20a8 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerWithGrpcRemoteExecutorTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerWithGrpcRemoteExecutorTest.java
@@ -298,7 +298,7 @@
             DIGEST_UTIL,
             uploader);
     RemoteExecutionCache remoteCache =
-        new RemoteExecutionCache(cacheProtocol, remoteOptions, DIGEST_UTIL);
+        new RemoteExecutionCache(reporter, cacheProtocol, remoteOptions, DIGEST_UTIL);
     RemoteExecutionService remoteExecutionService =
         new RemoteExecutionService(
             reporter,
diff --git a/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java b/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java
index 197ac41..37c1473 100644
--- a/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java
@@ -31,6 +31,8 @@
 import com.google.devtools.build.lib.actions.ActionOwner;
 import com.google.devtools.build.lib.actions.ActionProgressEvent;
 import com.google.devtools.build.lib.actions.ActionStartedEvent;
+import com.google.devtools.build.lib.actions.ActionUploadFinishedEvent;
+import com.google.devtools.build.lib.actions.ActionUploadStartedEvent;
 import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.ArtifactRoot;
 import com.google.devtools.build.lib.actions.BuildConfigurationEvent;
@@ -1408,4 +1410,59 @@
       return false;
     }
   }
+
+  @Test
+  public void waitingRemoteCacheMessage_beforeBuildComplete_invisible() throws IOException {
+    ManualClock clock = new ManualClock();
+    Action action = mockAction("Some action", "foo");
+    UiStateTracker stateTracker = new UiStateTracker(clock);
+    stateTracker.actionUploadStarted(ActionUploadStartedEvent.create(action, "foo"));
+    LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true);
+
+    stateTracker.writeProgressBar(terminalWriter);
+
+    String output = terminalWriter.getTranscript();
+    assertThat(output).doesNotContain("1 upload");
+  }
+
+  @Test
+  public void waitingRemoteCacheMessage_afterBuildComplete_visible() throws IOException {
+    ManualClock clock = new ManualClock();
+    Action action = mockAction("Some action", "foo");
+    UiStateTracker stateTracker = new UiStateTracker(clock);
+    stateTracker.actionUploadStarted(ActionUploadStartedEvent.create(action, "foo"));
+    BuildResult buildResult = new BuildResult(clock.currentTimeMillis());
+    buildResult.setDetailedExitCode(DetailedExitCode.success());
+    buildResult.setStopTime(clock.currentTimeMillis());
+    stateTracker.buildComplete(new BuildCompleteEvent(buildResult));
+    clock.advanceMillis(Duration.ofSeconds(2).toMillis());
+    LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true);
+
+    stateTracker.writeProgressBar(terminalWriter);
+
+    String output = terminalWriter.getTranscript();
+    assertThat(output).contains("1 upload");
+  }
+
+  @Test
+  public void waitingRemoteCacheMessage_multipleUploadEvents_countCorrectly() throws IOException {
+    ManualClock clock = new ManualClock();
+    Action action = mockAction("Some action", "foo");
+    UiStateTracker stateTracker = new UiStateTracker(clock);
+    stateTracker.actionUploadStarted(ActionUploadStartedEvent.create(action, "a"));
+    BuildResult buildResult = new BuildResult(clock.currentTimeMillis());
+    buildResult.setDetailedExitCode(DetailedExitCode.success());
+    buildResult.setStopTime(clock.currentTimeMillis());
+    stateTracker.buildComplete(new BuildCompleteEvent(buildResult));
+    stateTracker.actionUploadStarted(ActionUploadStartedEvent.create(action, "b"));
+    stateTracker.actionUploadStarted(ActionUploadStartedEvent.create(action, "c"));
+    stateTracker.actionUploadFinished(ActionUploadFinishedEvent.create(action, "a"));
+    clock.advanceMillis(Duration.ofSeconds(2).toMillis());
+    LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true);
+
+    stateTracker.writeProgressBar(terminalWriter);
+
+    String output = terminalWriter.getTranscript();
+    assertThat(output).contains("2 uploads");
+  }
 }
diff --git a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/BUILD b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/BUILD
index a354c12..ac718a9 100644
--- a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/BUILD
+++ b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/BUILD
@@ -20,6 +20,7 @@
     deps = [
         "//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/events",
         "//src/main/java/com/google/devtools/build/lib/remote",
         "//src/main/java/com/google/devtools/build/lib/remote/common",
         "//src/main/java/com/google/devtools/build/lib/remote/disk",
diff --git a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/OnDiskBlobStoreCache.java b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/OnDiskBlobStoreCache.java
index cbf1586..fc81d43 100644
--- a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/OnDiskBlobStoreCache.java
+++ b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/OnDiskBlobStoreCache.java
@@ -19,6 +19,8 @@
 import build.bazel.remote.execution.v2.Directory;
 import build.bazel.remote.execution.v2.DirectoryNode;
 import build.bazel.remote.execution.v2.FileNode;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.ExtendedEventHandler;
 import com.google.devtools.build.lib.remote.RemoteCache;
 import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext;
 import com.google.devtools.build.lib.remote.disk.DiskCacheClient;
@@ -32,6 +34,17 @@
 
   public OnDiskBlobStoreCache(RemoteOptions options, Path cacheDir, DigestUtil digestUtil) {
     super(
+        new ExtendedEventHandler() {
+          @Override
+          public void post(Postable obj) {
+            // do nothing
+          }
+
+          @Override
+          public void handle(Event event) {
+            // do nothing
+          }
+        },
         new DiskCacheClient(cacheDir, /* verifyDownloads= */ true, digestUtil),
         options,
         digestUtil);