Merge "trace_processor: fix sorting of events in sched tracker"
diff --git a/Android.bp b/Android.bp
index 541e66f..480e618 100644
--- a/Android.bp
+++ b/Android.bp
@@ -32,6 +32,7 @@
     ":perfetto_protos_perfetto_trace_trusted_lite_gen",
     ":perfetto_protos_perfetto_trace_zero_gen",
     ":perfetto_src_ipc_wire_protocol_gen",
+    "src/base/event.cc",
     "src/base/file_utils.cc",
     "src/base/metatrace.cc",
     "src/base/page_allocator.cc",
@@ -157,6 +158,7 @@
     ":perfetto_src_ipc_wire_protocol_gen",
     ":perfetto_src_perfetto_cmd_protos_gen",
     "src/base/android_task_runner.cc",
+    "src/base/event.cc",
     "src/base/file_utils.cc",
     "src/base/metatrace.cc",
     "src/base/page_allocator.cc",
@@ -297,6 +299,7 @@
     ":perfetto_protos_perfetto_trace_zero_gen",
     ":perfetto_src_ipc_wire_protocol_gen",
     "src/base/android_task_runner.cc",
+    "src/base/event.cc",
     "src/base/file_utils.cc",
     "src/base/metatrace.cc",
     "src/base/page_allocator.cc",
@@ -3612,6 +3615,7 @@
     ":perfetto_protos_perfetto_trace_trusted_lite_gen",
     ":perfetto_protos_perfetto_trace_zero_gen",
     ":perfetto_src_ipc_wire_protocol_gen",
+    "src/base/event.cc",
     "src/base/file_utils.cc",
     "src/base/metatrace.cc",
     "src/base/page_allocator.cc",
@@ -3800,6 +3804,7 @@
     ":perfetto_src_traced_probes_ftrace_test_messages_lite_gen",
     ":perfetto_src_traced_probes_ftrace_test_messages_zero_gen",
     "src/base/android_task_runner.cc",
+    "src/base/event.cc",
     "src/base/file_utils.cc",
     "src/base/metatrace.cc",
     "src/base/page_allocator.cc",
@@ -4033,6 +4038,7 @@
     ":perfetto_protos_perfetto_trace_minimal_lite_gen",
     ":perfetto_protos_perfetto_trace_ps_lite_gen",
     ":perfetto_protos_perfetto_trace_sys_stats_lite_gen",
+    "src/base/event.cc",
     "src/base/file_utils.cc",
     "src/base/metatrace.cc",
     "src/base/page_allocator.cc",
diff --git a/README.chromium b/README.chromium
index a8a7cb6..33541c5 100644
--- a/README.chromium
+++ b/README.chromium
@@ -2,7 +2,7 @@
 URL: https://android.googlesource.com/platform/external/perfetto/
 Version: unknown
 License: Apache2
-License File: MODULE_LICENSE_APACHE2
+License File: NOTICE
 Security Critical: yes
 License Android Compatible: yes
 Description: Performance instrumentation and logging for Google client platforms
diff --git a/include/perfetto/base/BUILD.gn b/include/perfetto/base/BUILD.gn
index 10da3d0..5ee76ba 100644
--- a/include/perfetto/base/BUILD.gn
+++ b/include/perfetto/base/BUILD.gn
@@ -17,6 +17,7 @@
 source_set("base") {
   sources = [
     "build_config.h",
+    "event.h",
     "export.h",
     "file_utils.h",
     "logging.h",
diff --git a/include/perfetto/base/android_task_runner.h b/include/perfetto/base/android_task_runner.h
index 5087330..5aebbac 100644
--- a/include/perfetto/base/android_task_runner.h
+++ b/include/perfetto/base/android_task_runner.h
@@ -17,6 +17,7 @@
 #ifndef INCLUDE_PERFETTO_BASE_ANDROID_TASK_RUNNER_H_
 #define INCLUDE_PERFETTO_BASE_ANDROID_TASK_RUNNER_H_
 
+#include "perfetto/base/event.h"
 #include "perfetto/base/scoped_file.h"
 #include "perfetto/base/task_runner.h"
 #include "perfetto/base/thread_checker.h"
@@ -69,7 +70,7 @@
   void ScheduleDelayedWakeUp(TimeMillis time);
 
   ALooper* const looper_;
-  ScopedFile immediate_event_;
+  Event immediate_event_;
   ScopedFile delayed_timer_;
 
   ThreadChecker thread_checker_;
diff --git a/include/perfetto/base/event.h b/include/perfetto/base/event.h
new file mode 100644
index 0000000..bca6c00
--- /dev/null
+++ b/include/perfetto/base/event.h
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * 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.
+ */
+
+#ifndef INCLUDE_PERFETTO_BASE_EVENT_H_
+#define INCLUDE_PERFETTO_BASE_EVENT_H_
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/scoped_file.h"
+
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX) || \
+    PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID)
+#define PERFETTO_USE_EVENTFD() 1
+#else
+#define PERFETTO_USE_EVENTFD() 0
+#endif
+
+namespace perfetto {
+namespace base {
+
+// A waitable event that can be used with poll/select.
+// This is really a wrapper around eventfd_create with a pipe-based fallback
+// for other platforms where eventfd is not supported.
+class Event {
+ public:
+  Event();
+  ~Event();
+  Event(Event&&) noexcept = default;
+  Event& operator=(Event&&) = default;
+
+  // The non-blocking file descriptor that can be polled to wait for the event.
+  int fd() const { return fd_.get(); }
+
+  // Can be called from any thread.
+  void Notify();
+
+  // Can be called from any thread. If more Notify() are queued a Clear() call
+  // can clear all of them (up to 16 per call).
+  void Clear();
+
+ private:
+  // The eventfd, when eventfd is supported, otherwise this is the read end of
+  // the pipe for fallback mode.
+  ScopedFile fd_;
+
+#if !PERFETTO_USE_EVENTFD()
+  // The write end of the wakeup pipe.
+  ScopedFile write_fd_;
+#endif
+};
+
+}  // namespace base
+}  // namespace perfetto
+
+#endif  // INCLUDE_PERFETTO_BASE_EVENT_H_
diff --git a/include/perfetto/base/file_utils.h b/include/perfetto/base/file_utils.h
index e23add1..71f716f 100644
--- a/include/perfetto/base/file_utils.h
+++ b/include/perfetto/base/file_utils.h
@@ -17,14 +17,27 @@
 #ifndef INCLUDE_PERFETTO_BASE_FILE_UTILS_H_
 #define INCLUDE_PERFETTO_BASE_FILE_UTILS_H_
 
+#include <stddef.h>
+
 #include <string>
 
+#include "perfetto/base/utils.h"  // For ssize_t on Windows.
+
 namespace perfetto {
 namespace base {
 
 bool ReadFileDescriptor(int fd, std::string* out);
 bool ReadFile(const std::string& path, std::string* out);
 
+// Call write until all data is written or an error is detected.
+//
+// man 2 write:
+//   If a write() is interrupted by a signal handler before any bytes are
+//   written, then the call fails with the error EINTR; if it is
+//   interrupted after at least one byte has been written, the call
+//   succeeds, and returns the number of bytes written.
+ssize_t WriteAll(int fd, const void* buf, size_t count);
+
 }  // namespace base
 }  // namespace perfetto
 
diff --git a/include/perfetto/base/scoped_file.h b/include/perfetto/base/scoped_file.h
index 7e380f0..59ca75f 100644
--- a/include/perfetto/base/scoped_file.h
+++ b/include/perfetto/base/scoped_file.h
@@ -24,6 +24,7 @@
 
 #if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
 #include <corecrt_io.h>
+typedef int mode_t;
 #else
 #include <dirent.h>
 #include <unistd.h>
@@ -36,6 +37,8 @@
 namespace perfetto {
 namespace base {
 
+constexpr mode_t kInvalidMode = static_cast<mode_t>(-1);
+
 // RAII classes for auto-releasing fds and dirs.
 template <typename T,
           int (*CloseFunction)(T),
@@ -81,8 +84,8 @@
 using ScopedFile = ScopedResource<int, close, -1>;
 inline static ScopedFile OpenFile(const std::string& path,
                                   int flags,
-                                  mode_t mode = 0) {
-  PERFETTO_DCHECK((flags & O_CREAT) == 0 || mode != 0);
+                                  mode_t mode = kInvalidMode) {
+  PERFETTO_DCHECK((flags & O_CREAT) == 0 || mode != kInvalidMode);
 #if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
   ScopedFile fd(open(path.c_str(), flags, mode));
 #else
diff --git a/include/perfetto/base/unix_task_runner.h b/include/perfetto/base/unix_task_runner.h
index 65c8361..3d1a896 100644
--- a/include/perfetto/base/unix_task_runner.h
+++ b/include/perfetto/base/unix_task_runner.h
@@ -18,6 +18,7 @@
 #define INCLUDE_PERFETTO_BASE_UNIX_TASK_RUNNER_H_
 
 #include "perfetto/base/build_config.h"
+#include "perfetto/base/event.h"
 #include "perfetto/base/scoped_file.h"
 #include "perfetto/base/task_runner.h"
 #include "perfetto/base/thread_checker.h"
@@ -30,13 +31,6 @@
 #include <mutex>
 #include <vector>
 
-#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX) || \
-    PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID)
-#define PERFETTO_USE_EVENTFD() 1
-#else
-#define PERFETTO_USE_EVENTFD() 0
-#endif
-
 namespace perfetto {
 namespace base {
 
@@ -75,11 +69,7 @@
 
   // On Linux, an eventfd(2) used to waking up the task runner when a new task
   // is posted. Otherwise the read end of a pipe used for the same purpose.
-  ScopedFile event_;
-#if !PERFETTO_USE_EVENTFD()
-  // The write end of the wakeup pipe.
-  ScopedFile event_write_;
-#endif
+  Event event_;
 
   std::vector<struct pollfd> poll_fds_;
 
diff --git a/protos/perfetto/trace/chrome/chrome_trace_event.proto b/protos/perfetto/trace/chrome/chrome_trace_event.proto
index a004d92..7911f42 100644
--- a/protos/perfetto/trace/chrome/chrome_trace_event.proto
+++ b/protos/perfetto/trace/chrome/chrome_trace_event.proto
@@ -41,7 +41,7 @@
 
     // Takes precedence over |name| if set,
     // and is an index into |string_table|.
-    optional int32 name_index = 9;
+    optional uint32 name_index = 9;
   }
 
   optional string name = 1;
@@ -63,8 +63,8 @@
   // Takes precedence over respectively |name| and
   // |category_group_name_index| if set,
   // and are indices into |string_table|.
-  optional int32 name_index = 15;
-  optional int32 category_group_name_index = 16;
+  optional uint32 name_index = 15;
+  optional uint32 category_group_name_index = 16;
 }
 
 message ChromeMetadata {
diff --git a/protos/perfetto/trace_processor/raw_query.proto b/protos/perfetto/trace_processor/raw_query.proto
index 673994f..2192e41 100644
--- a/protos/perfetto/trace_processor/raw_query.proto
+++ b/protos/perfetto/trace_processor/raw_query.proto
@@ -45,4 +45,5 @@
   optional uint64 num_records = 2;
   repeated ColumnValues columns = 3;
   optional string error = 4;
+  optional uint64 execution_time_ns = 5;
 }
diff --git a/src/base/BUILD.gn b/src/base/BUILD.gn
index 019cd7b..ad3f170 100644
--- a/src/base/BUILD.gn
+++ b/src/base/BUILD.gn
@@ -37,6 +37,7 @@
   # TODO(brucedawson): Enable these for Windows when possible.
   if (!is_win) {
     sources += [
+      "event.cc",
       "temp_file.cc",
       "unix_task_runner.cc",
     ]
diff --git a/src/base/android_task_runner.cc b/src/base/android_task_runner.cc
index d8e7cc7..23697c8 100644
--- a/src/base/android_task_runner.cc
+++ b/src/base/android_task_runner.cc
@@ -17,7 +17,6 @@
 #include "perfetto/base/android_task_runner.h"
 
 #include <errno.h>
-#include <sys/eventfd.h>
 #include <sys/timerfd.h>
 
 namespace perfetto {
@@ -25,13 +24,11 @@
 
 AndroidTaskRunner::AndroidTaskRunner()
     : looper_(ALooper_prepare(0 /* require callbacks */)),
-      immediate_event_(eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC)),
       delayed_timer_(
           timerfd_create(kWallTimeClockSource, TFD_NONBLOCK | TFD_CLOEXEC)) {
   ALooper_acquire(looper_);
-  PERFETTO_CHECK(immediate_event_);
   PERFETTO_CHECK(delayed_timer_);
-  AddFileDescriptorWatch(immediate_event_.get(),
+  AddFileDescriptorWatch(immediate_event_.fd(),
                          std::bind(&AndroidTaskRunner::RunImmediateTask, this));
   AddFileDescriptorWatch(delayed_timer_.get(),
                          std::bind(&AndroidTaskRunner::RunDelayedTask, this));
@@ -81,11 +78,7 @@
 }
 
 void AndroidTaskRunner::RunImmediateTask() {
-  uint64_t unused = 0;
-  if (read(immediate_event_.get(), &unused, sizeof(unused)) != sizeof(unused) &&
-      errno != EAGAIN) {
-    PERFETTO_DPLOG("read");
-  }
+  immediate_event_.Clear();
 
   // If locking overhead becomes an issue, add a separate work queue.
   bool has_next;
@@ -133,11 +126,7 @@
 }
 
 void AndroidTaskRunner::ScheduleImmediateWakeUp() {
-  uint64_t value = 1;
-  if (write(immediate_event_.get(), &value, sizeof(value)) == -1 &&
-      errno != EAGAIN) {
-    PERFETTO_DPLOG("write");
-  }
+  immediate_event_.Notify();
 }
 
 void AndroidTaskRunner::ScheduleDelayedWakeUp(TimeMillis time) {
diff --git a/src/base/debug_crash_stack_trace.cc b/src/base/debug_crash_stack_trace.cc
index c9cc762..3220d9d 100644
--- a/src/base/debug_crash_stack_trace.cc
+++ b/src/base/debug_crash_stack_trace.cc
@@ -28,6 +28,7 @@
 #include <unwind.h>
 
 #include "perfetto/base/build_config.h"
+#include "perfetto/base/file_utils.h"
 
 // Some glibc headers hit this when using signals.
 #pragma GCC diagnostic push
@@ -66,7 +67,7 @@
 
 template <typename T>
 void Print(const T& str) {
-  write(STDERR_FILENO, str, sizeof(str));
+  perfetto::base::WriteAll(STDERR_FILENO, str, sizeof(str));
 }
 
 template <typename T>
@@ -74,7 +75,7 @@
   for (unsigned i = 0; i < sizeof(n) * 8; i += 4) {
     char nibble = static_cast<char>(n >> (sizeof(n) * 8 - i - 4)) & 0x0F;
     char c = (nibble < 10) ? '0' + nibble : 'A' + nibble - 10;
-    write(STDERR_FILENO, &c, 1);
+    perfetto::base::WriteAll(STDERR_FILENO, &c, 1);
   }
 }
 
@@ -198,14 +199,16 @@
         // might be moved.
         g_demangled_name = demangled;
       }
-      write(STDERR_FILENO, sym.sym_name, strlen(sym.sym_name));
+      perfetto::base::WriteAll(STDERR_FILENO, sym.sym_name,
+                               strlen(sym.sym_name));
     } else {
       Print("0x");
       PrintHex(frames[i]);
     }
     if (sym.file_name[0]) {
       Print("\n     ");
-      write(STDERR_FILENO, sym.file_name, strlen(sym.file_name));
+      perfetto::base::WriteAll(STDERR_FILENO, sym.file_name,
+                               strlen(sym.file_name));
     }
     Print("\n");
   }
diff --git a/src/base/event.cc b/src/base/event.cc
new file mode 100644
index 0000000..4085618
--- /dev/null
+++ b/src/base/event.cc
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * 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.
+ */
+
+#include <stdint.h>
+#include <unistd.h>
+
+#include "perfetto/base/event.h"
+#include "perfetto/base/logging.h"
+
+#if PERFETTO_USE_EVENTFD()
+#include <sys/eventfd.h>
+#endif
+
+namespace perfetto {
+namespace base {
+
+Event::Event() {
+#if PERFETTO_USE_EVENTFD()
+  fd_.reset(eventfd(/* start value */ 0, EFD_CLOEXEC | EFD_NONBLOCK));
+  PERFETTO_CHECK(fd_);
+#else
+  int pipe_fds[2];
+  PERFETTO_CHECK(pipe(pipe_fds) == 0);
+
+  // Make the pipe non-blocking so that we never block the waking thread (either
+  // the main thread or another one) when scheduling a wake-up.
+  for (auto fd : pipe_fds) {
+    int flags = fcntl(fd, F_GETFL, 0);
+    PERFETTO_CHECK(flags != -1);
+    PERFETTO_CHECK(fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0);
+    PERFETTO_CHECK(fcntl(fd, F_SETFD, FD_CLOEXEC) == 0);
+  }
+  fd_.reset(pipe_fds[0]);
+  write_fd_.reset(pipe_fds[1]);
+#endif  // !PERFETTO_USE_EVENTFD()
+}
+
+Event::~Event() = default;
+
+void Event::Notify() {
+  const uint64_t value = 1;
+
+#if PERFETTO_USE_EVENTFD()
+  ssize_t ret = write(fd_.get(), &value, sizeof(value));
+#else
+  ssize_t ret = write(write_fd_.get(), &value, sizeof(uint8_t));
+#endif
+
+  if (ret <= 0 && errno != EAGAIN) {
+    PERFETTO_DPLOG("write()");
+    PERFETTO_DCHECK(false);
+  }
+}
+
+void Event::Clear() {
+#if PERFETTO_USE_EVENTFD()
+  uint64_t value;
+  ssize_t ret = read(fd_.get(), &value, sizeof(value));
+#else
+  // Drain the byte(s) written to the wake-up pipe. We can potentially read
+  // more than one byte if several wake-ups have been scheduled.
+  char buffer[16];
+  ssize_t ret = read(fd_.get(), &buffer[0], sizeof(buffer));
+#endif
+  if (ret <= 0 && errno != EAGAIN)
+    PERFETTO_DPLOG("read()");
+}
+
+}  // namespace base
+}  // namespace perfetto
diff --git a/src/base/file_utils.cc b/src/base/file_utils.cc
index a51b9b2..44b27f7 100644
--- a/src/base/file_utils.cc
+++ b/src/base/file_utils.cc
@@ -16,11 +16,17 @@
 
 #include <sys/stat.h>
 
+#include "perfetto/base/build_config.h"
 #include "perfetto/base/file_utils.h"
-
 #include "perfetto/base/logging.h"
 #include "perfetto/base/scoped_file.h"
 
+#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+#include <unistd.h>
+#else
+#include <corecrt_io.h>
+#endif
+
 namespace perfetto {
 namespace base {
 namespace {
@@ -53,12 +59,26 @@
 }
 
 bool ReadFile(const std::string& path, std::string* out) {
-  base::ScopedFile fd = base::OpenFile(path.c_str(), O_RDONLY);
+  base::ScopedFile fd = base::OpenFile(path, O_RDONLY);
   if (!fd)
     return false;
 
   return ReadFileDescriptor(*fd, out);
 }
 
+ssize_t WriteAll(int fd, const void* buf, size_t count) {
+  size_t written = 0;
+  while (written < count) {
+    ssize_t wr = PERFETTO_EINTR(
+        write(fd, static_cast<const char*>(buf) + written, count - written));
+    if (wr == 0)
+      break;
+    if (wr < 0)
+      return wr;
+    written += static_cast<size_t>(wr);
+  }
+  return static_cast<ssize_t>(written);
+}
+
 }  // namespace base
 }  // namespace perfetto
diff --git a/src/base/metatrace.cc b/src/base/metatrace.cc
index 045d6fb..1585e43 100644
--- a/src/base/metatrace.cc
+++ b/src/base/metatrace.cc
@@ -20,6 +20,7 @@
 #include <stdlib.h>
 
 #include "perfetto/base/build_config.h"
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/time.h"
 
 #if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
@@ -49,7 +50,7 @@
                     "{\"ts\": %f, \"cat\": \"PERF\", \"ph\": \"%c\", \"name\": "
                     "\"%s\", \"pid\": %zu},\n",
                     GetWallTimeNs().count() / 1000.0, type, evt_name, cpu);
-  ignore_result(write(fd, json, static_cast<size_t>(len)));
+  ignore_result(WriteAll(fd, json, static_cast<size_t>(len)));
 }
 
 }  // namespace base
diff --git a/src/base/task_runner_unittest.cc b/src/base/task_runner_unittest.cc
index 19a3dd5..0bde467 100644
--- a/src/base/task_runner_unittest.cc
+++ b/src/base/task_runner_unittest.cc
@@ -27,6 +27,8 @@
 
 #include <thread>
 
+#include "perfetto/base/file_utils.h"
+
 namespace perfetto {
 namespace base {
 namespace {
@@ -62,7 +64,7 @@
 
   void Write() {
     const char b = '?';
-    PERFETTO_DCHECK(write(write_fd.get(), &b, 1) == 1);
+    PERFETTO_DCHECK(WriteAll(write_fd.get(), &b, 1) == 1);
   }
 
   ScopedFile read_fd;
diff --git a/src/base/unix_socket.cc b/src/base/unix_socket.cc
index fb4f5ff..bb56248 100644
--- a/src/base/unix_socket.cc
+++ b/src/base/unix_socket.cc
@@ -65,7 +65,8 @@
 #endif
 
 void ShiftMsgHdr(size_t n, struct msghdr* msg) {
-  for (size_t i = 0; i < msg->msg_iovlen; ++i) {
+  using LenType = decltype(msg->msg_iovlen);  // Mac and Linux don't agree.
+  for (LenType i = 0; i < msg->msg_iovlen; ++i) {
     struct iovec* vec = &msg->msg_iov[i];
     if (n < vec->iov_len) {
       // We sent a part of this iovec.
diff --git a/src/base/unix_socket_unittest.cc b/src/base/unix_socket_unittest.cc
index 382f9bc..531f1e4 100644
--- a/src/base/unix_socket_unittest.cc
+++ b/src/base/unix_socket_unittest.cc
@@ -16,6 +16,7 @@
 
 #include "perfetto/base/unix_socket.h"
 
+#include <signal.h>
 #include <sys/mman.h>
 
 #include <list>
@@ -24,6 +25,7 @@
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
 #include "perfetto/base/build_config.h"
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/logging.h"
 #include "perfetto/base/temp_file.h"
 #include "perfetto/base/utils.h"
@@ -200,8 +202,8 @@
   ASSERT_TRUE(srv_conn);
   ASSERT_TRUE(cli->is_connected());
 
-  ScopedFile null_fd(open("/dev/null", O_RDONLY));
-  ScopedFile zero_fd(open("/dev/zero", O_RDONLY));
+  ScopedFile null_fd(base::OpenFile("/dev/null", O_RDONLY));
+  ScopedFile zero_fd(base::OpenFile("/dev/zero", O_RDONLY));
 
   auto cli_did_recv = task_runner_.CreateCheckpoint("cli_did_recv");
   EXPECT_CALL(event_listener_, OnDataAvailable(cli.get()))
@@ -351,7 +353,7 @@
     auto srv = UnixSocket::Listen(kSocketName, &event_listener_, &task_runner_);
     ASSERT_TRUE(srv->is_listening());
     // Signal the other process that it can connect.
-    ASSERT_EQ(1, PERFETTO_EINTR(write(pipes[1], ".", 1)));
+    ASSERT_EQ(1, base::WriteAll(pipes[1], ".", 1));
     auto checkpoint = task_runner_.CreateCheckpoint("change_seen_by_server");
     EXPECT_CALL(event_listener_, OnNewIncomingConnection(srv.get(), _))
         .WillOnce(Invoke(
diff --git a/src/base/unix_task_runner.cc b/src/base/unix_task_runner.cc
index 006b7af..44501df 100644
--- a/src/base/unix_task_runner.cc
+++ b/src/base/unix_task_runner.cc
@@ -24,32 +24,11 @@
 
 #include <limits>
 
-#if PERFETTO_USE_EVENTFD()
-#include <sys/eventfd.h>
-#endif
-
 namespace perfetto {
 namespace base {
 
 UnixTaskRunner::UnixTaskRunner() {
-#if PERFETTO_USE_EVENTFD()
-  event_.reset(eventfd(/* start value */ 0, EFD_CLOEXEC | EFD_NONBLOCK));
-#else
-  int pipe_fds[2];
-  PERFETTO_CHECK(pipe(pipe_fds) == 0);
-
-  // Make the pipe non-blocking so that we never block the waking thread (either
-  // the main thread or another one) when scheduling a wake-up.
-  for (auto fd : pipe_fds) {
-    int flags = fcntl(fd, F_GETFL, 0);
-    PERFETTO_CHECK(flags != -1);
-    PERFETTO_CHECK(fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0);
-    PERFETTO_CHECK(fcntl(fd, F_SETFD, FD_CLOEXEC) == 0);
-  }
-  event_.reset(pipe_fds[0]);
-  event_write_.reset(pipe_fds[1]);
-#endif  // !PERFETTO_USE_EVENTFD()
-  AddFileDescriptorWatch(event_.get(), [] {
+  AddFileDescriptorWatch(event_.fd(), [] {
     // Not reached -- see PostFileDescriptorWatches().
     PERFETTO_DCHECK(false);
   });
@@ -58,14 +37,7 @@
 UnixTaskRunner::~UnixTaskRunner() = default;
 
 void UnixTaskRunner::WakeUp() {
-  const uint64_t value = 1;
-#if PERFETTO_USE_EVENTFD()
-  ssize_t ret = write(event_.get(), &value, sizeof(value));
-#else
-  ssize_t ret = write(event_write_.get(), &value, sizeof(uint8_t));
-#endif
-  if (ret <= 0 && errno != EAGAIN)
-    PERFETTO_DPLOG("write()");
+  event_.Notify();
 }
 
 void UnixTaskRunner::Run() {
@@ -153,18 +125,8 @@
 
     // The wake-up event is handled inline to avoid an infinite recursion of
     // posted tasks.
-    if (poll_fds_[i].fd == event_.get()) {
-#if PERFETTO_USE_EVENTFD()
-      uint64_t value;
-      ssize_t ret = read(event_.get(), &value, sizeof(value));
-#else
-      // Drain the byte(s) written to the wake-up pipe. We can potentially read
-      // more than one byte if several wake-ups have been scheduled.
-      char buffer[16];
-      ssize_t ret = read(event_.get(), &buffer[0], sizeof(buffer));
-#endif
-      if (ret <= 0 && errno != EAGAIN)
-        PERFETTO_DPLOG("read()");
+    if (poll_fds_[i].fd == event_.fd()) {
+      event_.Clear();
       continue;
     }
 
diff --git a/src/base/utils_unittest.cc b/src/base/utils_unittest.cc
index 459b420..1f556d6 100644
--- a/src/base/utils_unittest.cc
+++ b/src/base/utils_unittest.cc
@@ -23,6 +23,8 @@
 
 #include "gtest/gtest.h"
 
+#include "perfetto/base/file_utils.h"
+
 namespace perfetto {
 namespace base {
 namespace {
@@ -79,7 +81,7 @@
   if (pid == 0 /* child */) {
     usleep(5000);
     kill(parent_pid, SIGUSR2);
-    ignore_result(write(pipe_fd[1], "foo\0", 4));
+    ignore_result(WriteAll(pipe_fd[1], "foo\0", 4));
     _exit(0);
   }
 
diff --git a/src/base/watchdog_posix.cc b/src/base/watchdog_posix.cc
index d8c064c..85cbb96 100644
--- a/src/base/watchdog_posix.cc
+++ b/src/base/watchdog_posix.cc
@@ -127,7 +127,7 @@
 }
 
 void Watchdog::ThreadMain() {
-  base::ScopedFile stat_fd(open("/proc/self/stat", O_RDONLY));
+  base::ScopedFile stat_fd(base::OpenFile("/proc/self/stat", O_RDONLY));
   if (!stat_fd) {
     PERFETTO_ELOG("Failed to open stat file to enforce resource limits.");
     return;
diff --git a/src/ipc/client_impl_unittest.cc b/src/ipc/client_impl_unittest.cc
index 050fcca..61c7768 100644
--- a/src/ipc/client_impl_unittest.cc
+++ b/src/ipc/client_impl_unittest.cc
@@ -23,6 +23,7 @@
 
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/temp_file.h"
 #include "perfetto/base/unix_socket.h"
 #include "perfetto/base/utils.h"
@@ -349,7 +350,8 @@
 
   base::TempFile tx_file = base::TempFile::CreateUnlinked();
   static constexpr char kFileContent[] = "shared file";
-  base::ignore_result(write(tx_file.fd(), kFileContent, sizeof(kFileContent)));
+  ASSERT_EQ(base::WriteAll(tx_file.fd(), kFileContent, sizeof(kFileContent)),
+            sizeof(kFileContent));
   host_->next_reply_fd = tx_file.fd();
 
   EXPECT_CALL(*host_method, OnInvoke(_, _))
@@ -393,7 +395,8 @@
 
   base::TempFile tx_file = base::TempFile::CreateUnlinked();
   static constexpr char kFileContent[] = "shared file";
-  base::ignore_result(write(tx_file.fd(), kFileContent, sizeof(kFileContent)));
+  ASSERT_EQ(base::WriteAll(tx_file.fd(), kFileContent, sizeof(kFileContent)),
+            sizeof(kFileContent));
   EXPECT_CALL(*host_method, OnInvoke(_, _))
       .WillOnce(Invoke(
           [](const Frame::InvokeMethod&, Frame::InvokeMethodReply* reply) {
diff --git a/src/ipc/host_impl_unittest.cc b/src/ipc/host_impl_unittest.cc
index 26b56c8..8247935 100644
--- a/src/ipc/host_impl_unittest.cc
+++ b/src/ipc/host_impl_unittest.cc
@@ -20,6 +20,7 @@
 
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/scoped_file.h"
 #include "perfetto/base/temp_file.h"
 #include "perfetto/base/unix_socket.h"
@@ -330,7 +331,8 @@
   cli_->InvokeMethod(cli_->last_bound_service_id_, 1, req_args);
   auto on_reply_sent = task_runner_->CreateCheckpoint("on_reply_sent");
   base::TempFile tx_file = base::TempFile::CreateUnlinked();
-  base::ignore_result(write(tx_file.fd(), kFileContent, sizeof(kFileContent)));
+  ASSERT_EQ(base::WriteAll(tx_file.fd(), kFileContent, sizeof(kFileContent)),
+            sizeof(kFileContent));
   EXPECT_CALL(*fake_service, OnFakeMethod1(_, _))
       .WillOnce(Invoke([on_reply_sent, &tx_file](const RequestProto&,
                                                  DeferredBase* reply) {
@@ -370,7 +372,8 @@
   static constexpr char kFileContent[] = "shared file";
   RequestProto req_args;
   base::TempFile tx_file = base::TempFile::CreateUnlinked();
-  base::ignore_result(write(tx_file.fd(), kFileContent, sizeof(kFileContent)));
+  ASSERT_EQ(base::WriteAll(tx_file.fd(), kFileContent, sizeof(kFileContent)),
+            sizeof(kFileContent));
   cli_->InvokeMethod(cli_->last_bound_service_id_, 1, req_args, false,
                      tx_file.fd());
   EXPECT_CALL(*cli_, OnInvokeMethodReply(_));
diff --git a/src/perfetto_cmd/perfetto_cmd.cc b/src/perfetto_cmd/perfetto_cmd.cc
index bd166ba..8c8f20e 100644
--- a/src/perfetto_cmd/perfetto_cmd.cc
+++ b/src/perfetto_cmd/perfetto_cmd.cc
@@ -333,7 +333,7 @@
     // violation (about system_server ending up with a writable FD to our dir).
     char fdpath[64];
     sprintf(fdpath, "/proc/self/fd/%d", fileno(*trace_out_stream_));
-    base::ScopedFile read_only_fd(open(fdpath, O_RDONLY));
+    base::ScopedFile read_only_fd(base::OpenFile(fdpath, O_RDONLY));
     PERFETTO_CHECK(read_only_fd);
     trace_out_stream_.reset();
     android::binder::Status status =
@@ -359,7 +359,7 @@
     // If we are tracing to DropBox, there's no need to make a
     // filesystem-visible temporary file.
     // TODO(skyostil): Fall back to base::TempFile for older devices.
-    fd.reset(open(kTempDropBoxTraceDir, O_TMPFILE | O_RDWR, 0600));
+    fd = base::OpenFile(kTempDropBoxTraceDir, O_TMPFILE | O_RDWR, 0600);
     if (!fd) {
       PERFETTO_ELOG("Could not create a temporary trace file in %s",
                     kTempDropBoxTraceDir);
@@ -371,7 +371,7 @@
   } else if (trace_out_path_ == "-") {
     fd.reset(dup(STDOUT_FILENO));
   } else {
-    fd.reset(open(trace_out_path_.c_str(), O_RDWR | O_CREAT | O_TRUNC, 0600));
+    fd = base::OpenFile(trace_out_path_, O_RDWR | O_CREAT | O_TRUNC, 0600);
   }
   trace_out_stream_.reset(fdopen(fd.release(), "wb"));
   PERFETTO_CHECK(trace_out_stream_);
@@ -396,8 +396,8 @@
   sa.sa_handler = [](int) {
     PERFETTO_LOG("SIGINT received: disabling tracing");
     char one = '1';
-    PERFETTO_CHECK(PERFETTO_EINTR(write(g_consumer_cmd->ctrl_c_pipe_wr(), &one,
-                                        sizeof(one))) == 1);
+    PERFETTO_CHECK(base::WriteAll(g_consumer_cmd->ctrl_c_pipe_wr(), &one,
+                                  sizeof(one)) == 1);
   };
   sa.sa_flags = static_cast<decltype(sa.sa_flags)>(SA_RESETHAND | SA_RESTART);
 #pragma GCC diagnostic pop
diff --git a/src/perfetto_cmd/rate_limiter.cc b/src/perfetto_cmd/rate_limiter.cc
index 081b6ed..2b789af 100644
--- a/src/perfetto_cmd/rate_limiter.cc
+++ b/src/perfetto_cmd/rate_limiter.cc
@@ -20,6 +20,7 @@
 #include <sys/types.h>
 #include <unistd.h>
 
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/logging.h"
 #include "perfetto/base/scoped_file.h"
 #include "perfetto/base/utils.h"
@@ -157,8 +158,7 @@
 }
 
 bool RateLimiter::LoadState(PerfettoCmdState* state) {
-  base::ScopedFile in_fd;
-  in_fd.reset(open(GetStateFilePath().c_str(), O_RDONLY));
+  base::ScopedFile in_fd(base::OpenFile(GetStateFilePath(), O_RDONLY));
 
   if (!in_fd)
     return false;
@@ -170,15 +170,14 @@
 }
 
 bool RateLimiter::SaveState(const PerfettoCmdState& state) {
-  base::ScopedFile out_fd;
   // Rationale for 0666: the cmdline client can be executed under two
   // different Unix UIDs: shell and statsd. If we run one after the
   // other and the file has 0600 permissions, then the 2nd run won't
   // be able to read the file and will clear it, aborting the trace.
   // SELinux still prevents that anything other than the perfetto
   // executable can change the guardrail file.
-  out_fd.reset(
-      open(GetStateFilePath().c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0666));
+  base::ScopedFile out_fd(
+      base::OpenFile(GetStateFilePath(), O_WRONLY | O_CREAT | O_TRUNC, 0666));
   if (!out_fd)
     return false;
   char buf[1024];
@@ -186,7 +185,7 @@
   PERFETTO_CHECK(size < sizeof(buf));
   if (!state.SerializeToArray(&buf, static_cast<int>(size)))
     return false;
-  ssize_t written = PERFETTO_EINTR(write(out_fd.get(), &buf, size));
+  ssize_t written = base::WriteAll(out_fd.get(), &buf, size);
   return written >= 0 && static_cast<size_t>(written) == size;
 }
 
diff --git a/src/perfetto_cmd/rate_limiter_unittest.cc b/src/perfetto_cmd/rate_limiter_unittest.cc
index 753e0e6..988c7b9 100644
--- a/src/perfetto_cmd/rate_limiter_unittest.cc
+++ b/src/perfetto_cmd/rate_limiter_unittest.cc
@@ -18,6 +18,7 @@
 
 #include <stdio.h>
 
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/scoped_file.h"
 #include "perfetto/base/temp_file.h"
 #include "perfetto/base/utils.h"
@@ -69,10 +70,9 @@
 };
 
 void WriteGarbageToFile(const std::string& path) {
-  base::ScopedFile fd;
-  fd.reset(open(path.c_str(), O_WRONLY | O_CREAT, 0600));
+  base::ScopedFile fd(base::OpenFile(path, O_WRONLY | O_CREAT, 0600));
   constexpr char data[] = "Some random bytes.";
-  if (write(fd.get(), data, sizeof(data)) != sizeof(data))
+  if (base::WriteAll(fd.get(), data, sizeof(data)) != sizeof(data))
     ADD_FAILURE() << "Could not write garbage";
 }
 
diff --git a/src/profiling/memory/client.cc b/src/profiling/memory/client.cc
index 083dea4..1d63c83 100644
--- a/src/profiling/memory/client.cc
+++ b/src/profiling/memory/client.cc
@@ -35,8 +35,10 @@
 #include <unwindstack/RegsGetLocal.h>
 
 #include "perfetto/base/logging.h"
+#include "perfetto/base/scoped_file.h"
 #include "perfetto/base/unix_socket.h"
 #include "perfetto/base/utils.h"
+#include "src/profiling/memory/sampler.h"
 #include "src/profiling/memory/wire_protocol.h"
 
 namespace perfetto {
@@ -73,6 +75,27 @@
   return getpid() == gettid();
 }
 
+// TODO(b/117203899): Remove this after making bionic implementation safe to
+// use.
+char* FindMainThreadStack() {
+  base::ScopedFstream maps(fopen("/proc/self/maps", "r"));
+  if (!maps) {
+    return nullptr;
+  }
+  while (!feof(*maps)) {
+    char line[1024];
+    char* data = fgets(line, sizeof(line), *maps);
+    if (data != nullptr && strstr(data, "[stack]")) {
+      char* sep = strstr(data, "-");
+      if (sep == nullptr)
+        continue;
+      sep++;
+      return reinterpret_cast<char*>(strtoll(sep, nullptr, 16));
+    }
+  }
+  return nullptr;
+}
+
 }  // namespace
 
 void FreePage::Add(const uint64_t addr,
@@ -92,6 +115,7 @@
 void FreePage::FlushLocked(SocketPool* pool) {
   WireMessage msg = {};
   msg.record_type = RecordType::Free;
+  free_page_.num_entries = offset_;
   msg.free_header = &free_page_;
   BorrowedSocket fd(pool->Borrow());
   SendWireMessage(*fd, msg);
@@ -141,11 +165,17 @@
 }
 
 Client::Client(std::vector<base::ScopedFile> socks)
-    : socket_pool_(std::move(socks)) {
+    : pthread_key_(ThreadLocalSamplingData::KeyDestructor),
+      socket_pool_(std::move(socks)),
+      main_thread_stack_base_(FindMainThreadStack()) {
+  PERFETTO_DCHECK(pthread_key_.valid());
+
   uint64_t size = 0;
+  base::ScopedFile maps(base::OpenFile("/proc/self/maps", O_RDONLY));
+  base::ScopedFile mem(base::OpenFile("/proc/self/mem", O_RDONLY));
   int fds[2];
-  fds[0] = open("/proc/self/maps", O_RDONLY | O_CLOEXEC);
-  fds[1] = open("/proc/self/mem", O_RDONLY | O_CLOEXEC);
+  fds[0] = *maps;
+  fds[1] = *mem;
   auto fd = socket_pool_.Borrow();
   // Send an empty record to transfer fds for /proc/self/maps and
   // /proc/self/mem.
@@ -201,6 +231,7 @@
   metadata.sequence_number = ++sequence_number_;
 
   WireMessage msg{};
+  msg.record_type = RecordType::Malloc;
   msg.alloc_header = &metadata;
   msg.payload = const_cast<char*>(stacktop);
   msg.payload_size = static_cast<size_t>(stack_size);
@@ -214,4 +245,11 @@
   free_page_.Add(alloc_address, ++sequence_number_, &socket_pool_);
 }
 
+bool Client::ShouldSampleAlloc(uint64_t alloc_size,
+                               void* (*unhooked_malloc)(size_t),
+                               void (*unhooked_free)(void*)) {
+  return ShouldSample(pthread_key_.get(), alloc_size, client_config_.rate,
+                      unhooked_malloc, unhooked_free);
+}
+
 }  // namespace perfetto
diff --git a/src/profiling/memory/client.h b/src/profiling/memory/client.h
index 93a262f..980196f 100644
--- a/src/profiling/memory/client.h
+++ b/src/profiling/memory/client.h
@@ -17,6 +17,7 @@
 #ifndef SRC_PROFILING_MEMORY_CLIENT_H_
 #define SRC_PROFILING_MEMORY_CLIENT_H_
 
+#include <pthread.h>
 #include <stddef.h>
 
 #include <mutex>
@@ -95,6 +96,30 @@
 
 const char* GetThreadStackBase();
 
+// RAII wrapper around pthread_key_t. This is different from a ScopedResource
+// because it needs a separate boolean indicating validity.
+class PThreadKey {
+ public:
+  PThreadKey(const PThreadKey&) = delete;
+  PThreadKey& operator=(const PThreadKey&) = delete;
+
+  PThreadKey(void (*destructor)(void*)) noexcept
+      : valid_(pthread_key_create(&key_, destructor) == 0) {}
+  ~PThreadKey() noexcept {
+    if (valid_)
+      pthread_key_delete(key_);
+  }
+  bool valid() const { return valid_; }
+  pthread_key_t get() const {
+    PERFETTO_DCHECK(valid_);
+    return key_;
+  }
+
+ private:
+  pthread_key_t key_;
+  bool valid_;
+};
+
 // This is created and owned by the malloc hooks.
 class Client {
  public:
@@ -102,6 +127,9 @@
   Client(const std::string& sock_name, size_t conns);
   void RecordMalloc(uint64_t alloc_size, uint64_t alloc_address);
   void RecordFree(uint64_t alloc_address);
+  bool ShouldSampleAlloc(uint64_t alloc_size,
+                         void* (*unhooked_malloc)(size_t),
+                         void (*unhooked_free)(void*));
 
   ClientConfiguration client_config_for_testing() { return client_config_; }
 
@@ -109,6 +137,7 @@
   const char* GetStackBase();
 
   ClientConfiguration client_config_;
+  PThreadKey pthread_key_;
   SocketPool socket_pool_;
   FreePage free_page_;
   const char* main_thread_stack_base_ = nullptr;
diff --git a/src/profiling/memory/client_unittest.cc b/src/profiling/memory/client_unittest.cc
index df848da..8b5d79b 100644
--- a/src/profiling/memory/client_unittest.cc
+++ b/src/profiling/memory/client_unittest.cc
@@ -25,15 +25,15 @@
 
 TEST(SocketPoolTest, Basic) {
   std::vector<base::ScopedFile> files;
-  files.emplace_back(open("/dev/null", O_RDONLY));
+  files.emplace_back(base::OpenFile("/dev/null", O_RDONLY));
   SocketPool pool(std::move(files));
   BorrowedSocket sock = pool.Borrow();
 }
 
 TEST(SocketPoolTest, Multiple) {
   std::vector<base::ScopedFile> files;
-  files.emplace_back(open("/dev/null", O_RDONLY));
-  files.emplace_back(open("/dev/null", O_RDONLY));
+  files.emplace_back(base::OpenFile("/dev/null", O_RDONLY));
+  files.emplace_back(base::OpenFile("/dev/null", O_RDONLY));
   SocketPool pool(std::move(files));
   BorrowedSocket sock = pool.Borrow();
   BorrowedSocket sock_2 = pool.Borrow();
@@ -41,7 +41,7 @@
 
 TEST(SocketPoolTest, Blocked) {
   std::vector<base::ScopedFile> files;
-  files.emplace_back(open("/dev/null", O_RDONLY));
+  files.emplace_back(base::OpenFile("/dev/null", O_RDONLY));
   SocketPool pool(std::move(files));
   BorrowedSocket sock = pool.Borrow();
   std::thread t([&pool] { pool.Borrow(); });
@@ -54,7 +54,7 @@
 
 TEST(SocketPoolTest, MultipleBlocked) {
   std::vector<base::ScopedFile> files;
-  files.emplace_back(open("/dev/null", O_RDONLY));
+  files.emplace_back(base::OpenFile("/dev/null", O_RDONLY));
   SocketPool pool(std::move(files));
   BorrowedSocket sock = pool.Borrow();
   std::thread t([&pool] { pool.Borrow(); });
diff --git a/src/profiling/memory/sampler_unittest.cc b/src/profiling/memory/sampler_unittest.cc
index d598e71..fc91b74 100644
--- a/src/profiling/memory/sampler_unittest.cc
+++ b/src/profiling/memory/sampler_unittest.cc
@@ -20,43 +20,39 @@
 
 #include <thread>
 
+#include "src/profiling/memory/client.h"  // For PThreadKey.
+
 namespace perfetto {
 namespace {
 
 TEST(SamplerTest, TestLarge) {
-  pthread_key_t key;
-  ASSERT_EQ(pthread_key_create(&key, ThreadLocalSamplingData::KeyDestructor),
-            0);
-  EXPECT_EQ(ShouldSample(key, 1024, 512, malloc, free), 1);
-  pthread_key_delete(key);
+  PThreadKey key(ThreadLocalSamplingData::KeyDestructor);
+  ASSERT_TRUE(key.valid());
+  EXPECT_EQ(ShouldSample(key.get(), 1024, 512, malloc, free), 1);
 }
 
 TEST(SamplerTest, TestSmall) {
-  pthread_key_t key;
-  ASSERT_EQ(pthread_key_create(&key, ThreadLocalSamplingData::KeyDestructor),
-            0);
+  PThreadKey key(ThreadLocalSamplingData::KeyDestructor);
+  ASSERT_TRUE(key.valid());
   // As we initialize interval_to_next_sample_ with 0, the first sample
   // should always get sampled.
-  EXPECT_EQ(ShouldSample(key, 1, 512, malloc, free), 1);
-  pthread_key_delete(key);
+  EXPECT_EQ(ShouldSample(key.get(), 1, 512, malloc, free), 1);
 }
 
 TEST(SamplerTest, TestSmallFromThread) {
-  pthread_key_t key;
-  ASSERT_EQ(pthread_key_create(&key, ThreadLocalSamplingData::KeyDestructor),
-            0);
-  std::thread th([key] {
+  PThreadKey key(ThreadLocalSamplingData::KeyDestructor);
+  ASSERT_TRUE(key.valid());
+  std::thread th([&key] {
     // As we initialize interval_to_next_sample_ with 0, the first sample
     // should always get sampled.
-    EXPECT_EQ(ShouldSample(key, 1, 512, malloc, free), 1);
+    EXPECT_EQ(ShouldSample(key.get(), 1, 512, malloc, free), 1);
   });
-  std::thread th2([key] {
+  std::thread th2([&key] {
     // The threads should have separate state.
-    EXPECT_EQ(ShouldSample(key, 1, 512, malloc, free), 1);
+    EXPECT_EQ(ShouldSample(key.get(), 1, 512, malloc, free), 1);
   });
   th.join();
   th2.join();
-  pthread_key_delete(key);
 }
 
 }  // namespace
diff --git a/src/profiling/memory/socket_listener_unittest.cc b/src/profiling/memory/socket_listener_unittest.cc
index 65d7455..f40cb9f 100644
--- a/src/profiling/memory/socket_listener_unittest.cc
+++ b/src/profiling/memory/socket_listener_unittest.cc
@@ -68,8 +68,9 @@
 
   task_runner.RunUntilCheckpoint("connected");
   uint64_t size = 1;
-  base::ScopedFile fds[2] = {base::ScopedFile(open("/dev/null", O_RDONLY)),
-                             base::ScopedFile(open("/dev/null", O_RDONLY))};
+  base::ScopedFile fds[2] = {
+      base::ScopedFile(base::OpenFile("/dev/null", O_RDONLY)),
+      base::ScopedFile(base::OpenFile("/dev/null", O_RDONLY))};
   int raw_fds[2] = {*fds[0], *fds[1]};
   ASSERT_TRUE(client_socket->Send(&size, sizeof(size), raw_fds,
                                   base::ArraySize(raw_fds),
diff --git a/src/profiling/memory/unwinding.cc b/src/profiling/memory/unwinding.cc
index 8ed2780..f43743b 100644
--- a/src/profiling/memory/unwinding.cc
+++ b/src/profiling/memory/unwinding.cc
@@ -145,7 +145,7 @@
           flags |= unwindstack::MAPS_FLAGS_DEVICE_MAP;
         }
         maps_.push_back(
-            new unwindstack::MapInfo(start, end, pgoff, flags, name));
+            new unwindstack::MapInfo(nullptr, start, end, pgoff, flags, name));
       });
 }
 
diff --git a/src/profiling/memory/unwinding_unittest.cc b/src/profiling/memory/unwinding_unittest.cc
index f80a0ee..3993692 100644
--- a/src/profiling/memory/unwinding_unittest.cc
+++ b/src/profiling/memory/unwinding_unittest.cc
@@ -33,7 +33,7 @@
 namespace {
 
 TEST(UnwindingTest, StackMemoryOverlay) {
-  base::ScopedFile proc_mem(open("/proc/self/mem", O_RDONLY));
+  base::ScopedFile proc_mem(base::OpenFile("/proc/self/mem", O_RDONLY));
   ASSERT_TRUE(proc_mem);
   uint8_t fake_stack[1] = {120};
   StackMemory memory(*proc_mem, 0u, fake_stack, 1);
@@ -45,7 +45,7 @@
 TEST(UnwindingTest, StackMemoryNonOverlay) {
   uint8_t value = 52;
 
-  base::ScopedFile proc_mem(open("/proc/self/mem", O_RDONLY));
+  base::ScopedFile proc_mem(base::OpenFile("/proc/self/mem", O_RDONLY));
   ASSERT_TRUE(proc_mem);
   uint8_t fake_stack[1] = {120};
   StackMemory memory(*proc_mem, 0u, fake_stack, 1);
@@ -55,7 +55,7 @@
 }
 
 TEST(UnwindingTest, FileDescriptorMapsParse) {
-  base::ScopedFile proc_maps(open("/proc/self/maps", O_RDONLY));
+  base::ScopedFile proc_maps(base::OpenFile("/proc/self/maps", O_RDONLY));
   ASSERT_TRUE(proc_maps);
   FileDescriptorMaps maps(std::move(proc_maps));
   ASSERT_TRUE(maps.Parse());
@@ -119,8 +119,8 @@
 
 // TODO(fmayer): Investigate why this fails out of tree.
 TEST(UnwindingTest, MAYBE_DoUnwind) {
-  base::ScopedFile proc_maps(open("/proc/self/maps", O_RDONLY));
-  base::ScopedFile proc_mem(open("/proc/self/mem", O_RDONLY));
+  base::ScopedFile proc_maps(base::OpenFile("/proc/self/maps", O_RDONLY));
+  base::ScopedFile proc_mem(base::OpenFile("/proc/self/mem", O_RDONLY));
   GlobalCallstackTrie callsites;
   ProcessMetadata metadata(getpid(), std::move(proc_maps), std::move(proc_mem),
                            &callsites);
diff --git a/src/trace_processor/trace_processor.cc b/src/trace_processor/trace_processor.cc
index 61c1b80..5e07842 100644
--- a/src/trace_processor/trace_processor.cc
+++ b/src/trace_processor/trace_processor.cc
@@ -19,6 +19,7 @@
 #include <sqlite3.h>
 #include <functional>
 
+#include "perfetto/base/time.h"
 #include "src/trace_processor/counters_table.h"
 #include "src/trace_processor/json_trace_parser.h"
 #include "src/trace_processor/process_table.h"
@@ -102,6 +103,8 @@
   protos::RawQueryResult proto;
   query_interrupted_.store(false, std::memory_order_relaxed);
 
+  base::TimeNanos t_start = base::GetWallTimeNs();
+
   const auto& sql = args.sql_query();
   sqlite3_stmt* raw_stmt;
   int err = sqlite3_prepare_v2(*db_, sql.c_str(), static_cast<int>(sql.size()),
@@ -175,6 +178,8 @@
     query_interrupted_ = false;
   }
 
+  base::TimeNanos t_end = base::GetWallTimeNs();
+  proto.set_execution_time_ns(static_cast<uint64_t>((t_end - t_start).count()));
   callback(proto);
 }
 
diff --git a/src/trace_processor/trace_processor_shell.cc b/src/trace_processor/trace_processor_shell.cc
index 487ac8f..05540e1 100644
--- a/src/trace_processor/trace_processor_shell.cc
+++ b/src/trace_processor/trace_processor_shell.cc
@@ -202,8 +202,7 @@
   TraceProcessor::Config config;
   config.optimization_mode = OptimizationMode::kMaxBandwidth;
   TraceProcessor tp(config);
-  base::ScopedFile fd;
-  fd.reset(open(trace_file_path, O_RDONLY));
+  base::ScopedFile fd(base::OpenFile(trace_file_path, O_RDONLY));
   PERFETTO_CHECK(fd);
 
   // Load the trace in chunks using async IO. We create a simple pipeline where,
diff --git a/src/traced/probes/filesystem/fs_mount_unittest.cc b/src/traced/probes/filesystem/fs_mount_unittest.cc
index da544e8..6c6b489 100644
--- a/src/traced/probes/filesystem/fs_mount_unittest.cc
+++ b/src/traced/probes/filesystem/fs_mount_unittest.cc
@@ -24,6 +24,7 @@
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
 #include "perfetto/base/build_config.h"
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/scoped_file.h"
 #include "perfetto/base/temp_file.h"
 #include "perfetto/base/utils.h"
@@ -52,7 +53,8 @@
 )";
 
   base::TempFile tmp_file = base::TempFile::Create();
-  base::ignore_result(write(tmp_file.fd(), kMounts, sizeof(kMounts)));
+  ASSERT_EQ(base::WriteAll(tmp_file.fd(), kMounts, sizeof(kMounts)),
+            sizeof(kMounts));
   std::multimap<BlockDeviceID, std::string> mounts =
       ParseMounts(tmp_file.path().c_str());
   struct stat dev_stat = {}, root_stat = {};
diff --git a/src/traced/probes/ftrace/ftrace_controller.cc b/src/traced/probes/ftrace/ftrace_controller.cc
index 5cff028..abc5d09 100644
--- a/src/traced/probes/ftrace/ftrace_controller.cc
+++ b/src/traced/probes/ftrace/ftrace_controller.cc
@@ -29,6 +29,7 @@
 #include <utility>
 
 #include "perfetto/base/build_config.h"
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/logging.h"
 #include "perfetto/base/time.h"
 #include "perfetto/tracing/core/trace_writer.h"
@@ -73,18 +74,14 @@
 }
 
 void WriteToFile(const char* path, const char* str) {
-  int fd = open(path, O_WRONLY);
-  if (fd == -1)
+  auto fd = base::OpenFile(path, O_WRONLY);
+  if (!fd)
     return;
-  perfetto::base::ignore_result(write(fd, str, strlen(str)));
-  perfetto::base::ignore_result(close(fd));
+  base::ignore_result(base::WriteAll(*fd, str, strlen(str)));
 }
 
 void ClearFile(const char* path) {
-  int fd = open(path, O_WRONLY | O_TRUNC);
-  if (fd == -1)
-    return;
-  perfetto::base::ignore_result(close(fd));
+  auto fd = base::OpenFile(path, O_WRONLY | O_TRUNC);
 }
 
 }  // namespace
diff --git a/src/traced/probes/ftrace/ftrace_controller_unittest.cc b/src/traced/probes/ftrace/ftrace_controller_unittest.cc
index 9e74a0a..aa059f8 100644
--- a/src/traced/probes/ftrace/ftrace_controller_unittest.cc
+++ b/src/traced/probes/ftrace/ftrace_controller_unittest.cc
@@ -163,7 +163,7 @@
   }
 
   base::ScopedFile OpenPipeForCpu(size_t /*cpu*/) override {
-    return base::ScopedFile(open("/dev/null", O_RDONLY));
+    return base::ScopedFile(base::OpenFile("/dev/null", O_RDONLY));
   }
 
   MOCK_METHOD2(WriteToFile,
diff --git a/src/traced/probes/ftrace/ftrace_procfs.cc b/src/traced/probes/ftrace/ftrace_procfs.cc
index 87095cf..cb84dd7 100644
--- a/src/traced/probes/ftrace/ftrace_procfs.cc
+++ b/src/traced/probes/ftrace/ftrace_procfs.cc
@@ -46,7 +46,7 @@
 void KernelLogWrite(const char* s) {
   PERFETTO_DCHECK(*s && s[strlen(s) - 1] == '\n');
   if (FtraceProcfs::g_kmesg_fd != -1)
-    base::ignore_result(write(FtraceProcfs::g_kmesg_fd, s, strlen(s)));
+    base::ignore_result(base::WriteAll(FtraceProcfs::g_kmesg_fd, s, strlen(s)));
 }
 
 }  // namespace
@@ -224,7 +224,7 @@
   base::ScopedFile fd = base::OpenFile(path, O_WRONLY);
   if (!fd)
     return false;
-  ssize_t written = PERFETTO_EINTR(write(fd.get(), str.c_str(), str.length()));
+  ssize_t written = base::WriteAll(fd.get(), str.c_str(), str.length());
   ssize_t length = static_cast<ssize_t>(str.length());
   // This should either fail or write fully.
   PERFETTO_CHECK(written == length || written == -1);
diff --git a/src/traced/probes/sys_stats/sys_stats_data_source.cc b/src/traced/probes/sys_stats/sys_stats_data_source.cc
index cd6efd1..00ab945 100644
--- a/src/traced/probes/sys_stats/sys_stats_data_source.cc
+++ b/src/traced/probes/sys_stats/sys_stats_data_source.cc
@@ -45,8 +45,7 @@
 constexpr size_t kReadBufSize = 1024 * 16;
 
 base::ScopedFile OpenReadOnly(const char* path) {
-  base::ScopedFile fd;
-  fd.reset(open(path, O_RDONLY | O_CLOEXEC));
+  base::ScopedFile fd(base::OpenFile(path, O_RDONLY));
   if (!fd)
     PERFETTO_PLOG("Failed opening %s", path);
   return fd;
diff --git a/src/tracing/core/tracing_service_impl.cc b/src/tracing/core/tracing_service_impl.cc
index 9cab744..80b924c 100644
--- a/src/tracing/core/tracing_service_impl.cc
+++ b/src/tracing/core/tracing_service_impl.cc
@@ -31,6 +31,7 @@
 #include <algorithm>
 
 #include "perfetto/base/build_config.h"
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/task_runner.h"
 #include "perfetto/base/utils.h"
 #include "perfetto/tracing/core/consumer.h"
@@ -80,7 +81,7 @@
 ssize_t writev(int fd, const struct iovec* iov, int iovcnt) {
   ssize_t total_size = 0;
   for (int i = 0; i < iovcnt; ++i) {
-    ssize_t current_size = write(fd, iov[i].iov_base, iov[i].iov_len);
+    ssize_t current_size = base::WriteAll(fd, iov[i].iov_base, iov[i].iov_len);
     if (current_size != static_cast<ssize_t>(iov[i].iov_len))
       return -1;
     total_size += current_size;
diff --git a/src/tracing/ipc/posix_shared_memory_unittest.cc b/src/tracing/ipc/posix_shared_memory_unittest.cc
index b07cb00..e6739bf 100644
--- a/src/tracing/ipc/posix_shared_memory_unittest.cc
+++ b/src/tracing/ipc/posix_shared_memory_unittest.cc
@@ -25,6 +25,7 @@
 
 #include "gtest/gtest.h"
 #include "perfetto/base/build_config.h"
+#include "perfetto/base/file_utils.h"
 #include "perfetto/base/scoped_file.h"
 #include "perfetto/base/temp_file.h"
 #include "perfetto/base/utils.h"
@@ -69,7 +70,7 @@
   base::TempFile tmp_file = base::TempFile::CreateUnlinked();
   const int fd_num = tmp_file.fd();
   ASSERT_EQ(0, ftruncate(fd_num, base::kPageSize));
-  ASSERT_EQ(7, PERFETTO_EINTR(write(fd_num, "foobar", 7)));
+  ASSERT_EQ(7, base::WriteAll(fd_num, "foobar", 7));
 
   std::unique_ptr<PosixSharedMemory> shm =
       PosixSharedMemory::AttachToFd(tmp_file.ReleaseFD());
diff --git a/tools/install-build-deps b/tools/install-build-deps
index 7770631..1efaf33 100755
--- a/tools/install-build-deps
+++ b/tools/install-build-deps
@@ -163,7 +163,7 @@
   # These dependencies are for libunwindstack, which is used by src/profiling.
   ('buildtools/android-core',
    'https://android.googlesource.com/platform/system/core.git',
-   'ec004eb1b376d2d9008829a25f47ac3fcfd728ab',
+   'd3a7ddcf8dd97e162d7d4a9b3f87b4f1ef797d5f',
    'all'
   ),
 
@@ -175,7 +175,7 @@
 
   ('buildtools/bionic',
    'https://android.googlesource.com/platform/bionic.git',
-   '3fd45bba4857fdbf320b6e89d2ae0569d9463bf5',
+   '4b7c5cca7fbd0330cdfef41c97f1401824e78fba',
    'all'
   ),
 
diff --git a/tools/pipestats.cc b/tools/pipestats.cc
index d82ecbb..15fbe27 100644
--- a/tools/pipestats.cc
+++ b/tools/pipestats.cc
@@ -47,7 +47,7 @@
 
 int PipestatsMain(int argc, char** argv) {
   PERFETTO_CHECK(argc == 2);
-  base::ScopedFile trace_fd(open(argv[1], O_RDONLY));
+  base::ScopedFile trace_fd(base::OpenFile(argv[1], O_RDONLY));
   PERFETTO_CHECK(trace_fd);
   std::thread reader(ReadLoop, trace_fd.get());
 
diff --git a/ui/package-lock.json b/ui/package-lock.json
index d5a0e39..67ee3c9 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -3256,6 +3256,11 @@
       "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==",
       "dev": true
     },
+    "immer": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-1.7.2.tgz",
+      "integrity": "sha512-4Urocwu9+XLDJw4Tc6ZCg7APVjjLInCFvO4TwGsAYV5zT6YYSor14dsZR0+0tHlDIN92cFUOq+i7fC00G5vTxA=="
+    },
     "immutable": {
       "version": "3.8.2",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
@@ -5974,20 +5979,12 @@
       "requires": {
         "@types/estree": "0.0.39",
         "@types/node": "*"
-      },
-      "dependencies": {
-        "@types/node": {
-          "version": "8.10.15",
-          "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.15.tgz",
-          "integrity": "sha512-qNb+m5Cuj6YUMK7YFcvuSgcHCKfVg1uXAUOP91SWvAakZlZTzbGmJaBi99CgDWEAyfZo51NlUhXkuP5WtXsgjg==",
-          "dev": true
-        }
       }
     },
     "rollup-plugin-commonjs": {
-      "version": "9.1.3",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.3.tgz",
-      "integrity": "sha512-g91ZZKZwTW7F7vL6jMee38I8coj/Q9GBdTmXXeFL7ldgC1Ky5WJvHgbKlAiXXTh762qvohhExwUgeQGFh9suGg==",
+      "version": "9.1.8",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.8.tgz",
+      "integrity": "sha512-c3nAfVVyEwbq9OohIeQudfQQdGV9Cl1RE8MUc90fH9UdtCiWAYpI+au3HxGwNf1DdV51HfBjCDbT4fwjsZEUUg==",
       "dev": true,
       "requires": {
         "estree-walker": "^0.5.1",
@@ -5997,9 +5994,9 @@
       }
     },
     "rollup-plugin-node-resolve": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.3.0.tgz",
-      "integrity": "sha512-9zHGr3oUJq6G+X0oRMYlzid9fXicBdiydhwGChdyeNRGPcN/majtegApRKHLR5drboUvEWU+QeUmGTyEZQs3WA==",
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.4.0.tgz",
+      "integrity": "sha512-PJcd85dxfSBWih84ozRtBkB731OjXk0KnzN0oGp7WOWcarAFkVa71cV5hTJg2qpVsV2U8EUwrzHP3tvy9vS3qg==",
       "dev": true,
       "requires": {
         "builtin-modules": "^2.0.0",
@@ -6015,6 +6012,17 @@
         }
       }
     },
+    "rollup-plugin-replace": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.0.0.tgz",
+      "integrity": "sha512-pK9mTd/FNrhtBxcTBXoh0YOwRIShV0gGhv9qvUtNcXHxIMRZMXqfiZKVBmCRGp8/2DJRy62z2JUE7/5tP6WxOQ==",
+      "dev": true,
+      "requires": {
+        "magic-string": "^0.22.4",
+        "minimatch": "^3.0.2",
+        "rollup-pluginutils": "^2.0.1"
+      }
+    },
     "rollup-pluginutils": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.3.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index b28879f..148426d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -13,6 +13,7 @@
   "dependencies": {
     "@types/mithril": "^1.1.12",
     "@types/uuid": "^3.4.3",
+    "immer": "^1.7.2",
     "mithril": "^1.1.6",
     "protobufjs": "^6.8.6",
     "uuid": "^3.3.2"
@@ -26,8 +27,9 @@
     "node-sass": "^4.9.2",
     "puppeteer": "^1.5.0",
     "rollup": "^0.59.4",
-    "rollup-plugin-commonjs": "^9.1.3",
-    "rollup-plugin-node-resolve": "^3.3.0",
+    "rollup-plugin-commonjs": "^9.1.8",
+    "rollup-plugin-node-resolve": "^3.4.0",
+    "rollup-plugin-replace": "^2.0.0",
     "sorcery": "^0.10.0",
     "tslib": "^1.9.3",
     "tslint": "^5.10.0",
diff --git a/ui/rollup.config.js b/ui/rollup.config.js
index 168f320..8c884f1 100644
--- a/ui/rollup.config.js
+++ b/ui/rollup.config.js
@@ -1,5 +1,6 @@
 import commonjs from 'rollup-plugin-commonjs';
 import nodeResolve from 'rollup-plugin-node-resolve';
+import replace from 'rollup-plugin-replace';
 
 export default {
   output: {name: 'perfetto'},
@@ -15,6 +16,11 @@
         'fs',
         'path',
       ]
+    }),
+
+    replace({
+      'immer_1.produce': 'immer_1',
     })
+
   ]
 }
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index cac3d75..8ef1161 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -312,6 +312,19 @@
   height: 25px;
 }
 
+header.overview {
+  display: flex;
+  justify-content: space-between;
+}
+
+.query-error {
+  user-select: text;
+}
+
+span.code {
+  user-select: text;
+}
+
 .text-column {
   font-size: 115%;
   // 2-3 alphabets per line is comfortable for reading.
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index bd7cada..5ae4598 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -12,125 +12,215 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {State} from './state';
-import {TimeSpan} from './time';
+import {DraftObject} from 'immer';
 
-export interface Action { type: string; }
+import {defaultTraceTime, State, Status, TraceTime} from './state';
 
-export function openTraceFromUrl(url: string) {
-  return {
-    type: 'OPEN_TRACE_FROM_URL',
-    url,
-  };
+type StateDraft = DraftObject<State>;
+
+
+function clearTraceState(state: StateDraft) {
+  state.traceTime = defaultTraceTime;
+  state.visibleTraceTime = defaultTraceTime;
+  state.pinnedTracks = [];
+  state.scrollingTracks = [];
 }
 
-export function openTraceFromFile(file: File) {
-  return {
-    type: 'OPEN_TRACE_FROM_FILE',
-    file,
-  };
+export const StateActions = {
+
+  navigate(state: StateDraft, args: {route: string}): void {
+    state.route = args.route;
+  },
+
+  openTraceFromFile(state: StateDraft, args: {file: File}): void {
+    clearTraceState(state);
+    const id = `${state.nextId++}`;
+    state.engines[id] = {
+      id,
+      ready: false,
+      source: args.file,
+    };
+    state.route = `/viewer`;
+  },
+
+  openTraceFromUrl(state: StateDraft, args: {url: string}): void {
+    clearTraceState(state);
+    const id = `${state.nextId++}`;
+    state.engines[id] = {
+      id,
+      ready: false,
+      source: args.url,
+    };
+    state.route = `/viewer`;
+  },
+
+  addTrack(
+      state: StateDraft,
+      args: {engineId: string; kind: string; name: string; config: {};}): void {
+    const id = `${state.nextId++}`;
+    state.tracks[id] = {
+      id,
+      engineId: args.engineId,
+      kind: args.kind,
+      name: args.name,
+      config: args.config,
+    };
+    state.scrollingTracks.push(id);
+  },
+
+  reqTrackData(state: StateDraft, args: {
+    trackId: string; start: number; end: number; resolution: number;
+  }): void {
+    const id = args.trackId;
+    state.tracks[id].dataReq = {
+      start: args.start,
+      end: args.end,
+      resolution: args.resolution
+    };
+  },
+
+  clearTrackDataReq(state: StateDraft, args: {trackId: string}): void {
+    const id = args.trackId;
+    state.tracks[id].dataReq = undefined;
+  },
+
+  executeQuery(
+      state: StateDraft,
+      args: {queryId: string; engineId: string; query: string}): void {
+    state.queries[args.queryId] = {
+      id: args.queryId,
+      engineId: args.engineId,
+      query: args.query,
+    };
+  },
+
+  deleteQuery(state: StateDraft, args: {queryId: string}): void {
+    delete state.queries[args.queryId];
+  },
+
+  moveTrack(
+      state: StateDraft, args: {trackId: string; direction: 'up' | 'down';}):
+      void {
+        const id = args.trackId;
+        const isPinned = state.pinnedTracks.includes(id);
+        const isScrolling = state.scrollingTracks.includes(id);
+        if (!isScrolling && !isPinned) {
+          throw new Error(`No track with id ${id}`);
+        }
+        const tracks = isPinned ? state.pinnedTracks : state.scrollingTracks;
+
+        const oldIndex: number = tracks.indexOf(id);
+        const newIndex = args.direction === 'up' ? oldIndex - 1 : oldIndex + 1;
+        const swappedTrackId = tracks[newIndex];
+        if (isPinned && newIndex === state.pinnedTracks.length) {
+          // Move from last element of pinned to first element of scrolling.
+          state.scrollingTracks.unshift(state.pinnedTracks.pop()!);
+        } else if (isScrolling && newIndex === -1) {
+          // Move first element of scrolling to last element of pinned.
+          state.pinnedTracks.push(state.scrollingTracks.shift()!);
+        } else if (swappedTrackId) {
+          tracks[newIndex] = id;
+          tracks[oldIndex] = swappedTrackId;
+        }
+      },
+
+  toggleTrackPinned(state: StateDraft, args: {trackId: string}): void {
+    const id = args.trackId;
+    const isPinned = state.pinnedTracks.includes(id);
+
+    if (isPinned) {
+      state.pinnedTracks.splice(state.pinnedTracks.indexOf(id), 1);
+      state.scrollingTracks.unshift(id);
+    } else {
+      state.scrollingTracks.splice(state.scrollingTracks.indexOf(id), 1);
+      state.pinnedTracks.push(id);
+    }
+  },
+
+  setEngineReady(state: StateDraft, args: {engineId: string; ready: boolean}):
+      void {
+        state.engines[args.engineId].ready = args.ready;
+      },
+
+  createPermalink(state: StateDraft, args: {requestId: string}): void {
+    state.permalink = {requestId: args.requestId, hash: undefined};
+  },
+
+  setPermalink(state: StateDraft, args: {requestId: string; hash: string}):
+      void {
+        // Drop any links for old requests.
+        if (state.permalink.requestId !== args.requestId) return;
+        state.permalink = args;
+      },
+
+  loadPermalink(state: StateDraft, args: {requestId: string; hash: string}):
+      void {
+        state.permalink = args;
+      },
+
+  setTraceTime(state: StateDraft, args: TraceTime): void {
+    state.traceTime = args;
+  },
+
+  setVisibleTraceTime(state: StateDraft, args: TraceTime): void {
+    state.visibleTraceTime = args;
+  },
+
+  updateStatus(state: StateDraft, args: Status): void {
+    state.status = args;
+  },
+
+  // TODO(hjd): Remove setState - it causes problems due to reuse of ids.
+  setState(_state: StateDraft, _args: {newState: State}): void {
+    // This has to be handled at a higher level since we can't
+    // replace the whole tree here however we still need a method here
+    // so it appears on the proxy Actions class.
+    throw new Error('Called setState on StateActions.');
+  },
+};
+
+
+// When we are on the frontend side, we don't really want to execute the
+// actions above, we just want to serialize them and marshal their
+// arguments, send them over to the controller side and have them being
+// executed there. The magic below takes care of turning each action into a
+// function that returns the marshaled args.
+
+// A DeferredAction is a bundle of Args and a method name. This is the marshaled
+// version of a StateActions method call.
+export interface DeferredAction<Args = {}> {
+  type: string;
+  args: Args;
 }
 
-// TODO(hjd): Remove CPU and add a generic way to handle track specific state.
-export function addTrack(
-    engineId: string, trackKind: string, name: string, config: {}) {
-  return {
-    type: 'ADD_TRACK',
-    engineId,
-    trackKind,
-    name,
-    config,
-  };
-}
+// This type magic creates a type function DeferredActions<T> which takes a type
+// T and 'maps' its attributes. For each attribute on T matching the signature:
+// (state: StateDraft, args: Args) => void
+// DeferredActions<T> has an attribute:
+// (args: Args) => DeferredAction<Args>
+type ActionFunction<Args> = (state: StateDraft, args: Args) => void;
+type DeferredActionFunc<T> = T extends ActionFunction<infer Args>?
+    (args: Args) => DeferredAction<Args>:
+    never;
+type DeferredActions<C> = {
+  [P in keyof C]: DeferredActionFunc<C[P]>;
+};
 
-export function requestTrackData(
-    trackId: string, start: number, end: number, resolution: number) {
-  return {type: 'REQ_TRACK_DATA', trackId, start, end, resolution};
-}
-
-export function clearTrackDataRequest(trackId: string) {
-  return {type: 'CLEAR_TRACK_DATA_REQ', trackId};
-}
-
-export function executeQuery(engineId: string, queryId: string, query: string) {
-  return {
-    type: 'EXECUTE_QUERY',
-    engineId,
-    queryId,
-    query,
-  };
-}
-
-export function deleteQuery(queryId: string) {
-  return {
-    type: 'DELETE_QUERY',
-    queryId,
-  };
-}
-
-export function navigate(route: string) {
-  return {
-    type: 'NAVIGATE',
-    route,
-  };
-}
-
-export function moveTrack(trackId: string, direction: 'up'|'down') {
-  return {
-    type: 'MOVE_TRACK',
-    trackId,
-    direction,
-  };
-}
-
-export function toggleTrackPinned(trackId: string) {
-  return {
-    type: 'TOGGLE_TRACK_PINNED',
-    trackId,
-  };
-}
-
-export function setEngineReady(engineId: string, ready = true) {
-  return {type: 'SET_ENGINE_READY', engineId, ready};
-}
-
-export function createPermalink() {
-  return {type: 'CREATE_PERMALINK', requestId: new Date().toISOString()};
-}
-
-export function setPermalink(requestId: string, hash: string) {
-  return {type: 'SET_PERMALINK', requestId, hash};
-}
-
-export function loadPermalink(hash: string) {
-  return {type: 'LOAD_PERMALINK', requestId: new Date().toISOString(), hash};
-}
-
-export function setState(newState: State) {
-  return {
-    type: 'SET_STATE',
-    newState,
-  };
-}
-
-export function setTraceTime(ts: TimeSpan) {
-  return {
-    type: 'SET_TRACE_TIME',
-    startSec: ts.start,
-    endSec: ts.end,
-    lastUpdate: Date.now() / 1000,
-  };
-}
-
-export function setVisibleTraceTime(ts: TimeSpan) {
-  return {
-    type: 'SET_VISIBLE_TRACE_TIME',
-    startSec: ts.start,
-    endSec: ts.end,
-    lastUpdate: Date.now() / 1000,
-  };
-}
-
-export function updateStatus(msg: string) {
-  return {type: 'UPDATE_STATUS', msg, timestamp: Date.now() / 1000};
-}
+// Actions is an implementation of DeferredActions<typeof StateActions>.
+// (since StateActions is a variable not a type we have to do
+// 'typeof StateActions' to access the (unnamed) type of StateActions).
+// It's a Proxy such that any attribute access returns a function:
+// (args) => {return {type: ATTRIBUTE_NAME, args};}
+export const Actions =
+    // tslint:disable-next-line no-any
+    new Proxy<DeferredActions<typeof StateActions>>({} as any, {
+      // tslint:disable-next-line no-any
+      get(_: any, prop: string, _2: any) {
+        return (args: {}): DeferredAction<{}> => {
+          return {
+            type: prop,
+            args,
+          };
+        };
+      },
+    });
diff --git a/ui/src/common/actions_unittest.ts b/ui/src/common/actions_unittest.ts
new file mode 100644
index 0000000..7a3dc94
--- /dev/null
+++ b/ui/src/common/actions_unittest.ts
@@ -0,0 +1,238 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// 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.
+
+import {produce} from 'immer';
+import {StateActions} from './actions';
+import {createEmptyState, State, TrackState} from './state';
+
+function fakeTrack(state: State, id: string): TrackState {
+  const track: TrackState = {
+    id,
+    engineId: '1',
+    kind: 'SOME_TRACK_KIND',
+    name: 'A track',
+    config: {},
+  };
+  state.tracks[id] = track;
+  return track;
+}
+
+test('navigate', () => {
+  const after = produce(createEmptyState(), draft => {
+    StateActions.navigate(draft, {route: '/foo'});
+  });
+  expect(after.route).toBe('/foo');
+});
+
+test('add tracks', () => {
+  const once = produce(createEmptyState(), draft => {
+    StateActions.addTrack(draft, {
+      engineId: '1',
+      kind: 'cpu',
+      name: 'Cpu 1',
+      config: {},
+    });
+  });
+  const twice = produce(once, draft => {
+    StateActions.addTrack(draft, {
+      engineId: '2',
+      kind: 'cpu',
+      name: 'Cpu 2',
+      config: {},
+    });
+  });
+
+  expect(Object.values(twice.tracks).length).toBe(2);
+  expect(twice.scrollingTracks.length).toBe(2);
+});
+
+test('reorder tracks', () => {
+  const once = produce(createEmptyState(), draft => {
+    StateActions.addTrack(draft, {
+      engineId: '1',
+      kind: 'cpu',
+      name: 'Cpu 1',
+      config: {},
+    });
+    StateActions.addTrack(draft, {
+      engineId: '2',
+      kind: 'cpu',
+      name: 'Cpu 2',
+      config: {},
+    });
+  });
+
+  const firstTrackId = once.scrollingTracks[0];
+  const secondTrackId = once.scrollingTracks[1];
+
+  const twice = produce(once, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: `${firstTrackId}`,
+      direction: 'down',
+    });
+  });
+
+  expect(twice.scrollingTracks[0]).toBe(secondTrackId);
+  expect(twice.scrollingTracks[1]).toBe(firstTrackId);
+});
+
+test('reorder pinned to scrolling', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a', 'b'];
+  state.scrollingTracks = ['c'];
+
+  const after = produce(state, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: 'b',
+      direction: 'down',
+    });
+  });
+
+  expect(after.pinnedTracks).toEqual(['a']);
+  expect(after.scrollingTracks).toEqual(['b', 'c']);
+});
+
+test('reorder scrolling to pinned', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a'];
+  state.scrollingTracks = ['b', 'c'];
+
+  const after = produce(state, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: 'b',
+      direction: 'up',
+    });
+  });
+
+  expect(after.pinnedTracks).toEqual(['a', 'b']);
+  expect(after.scrollingTracks).toEqual(['c']);
+});
+
+test('reorder clamp bottom', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a', 'b'];
+  state.scrollingTracks = ['c'];
+
+  const after = produce(state, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: 'a',
+      direction: 'up',
+    });
+  });
+  expect(after).toEqual(state);
+});
+
+test('reorder clamp top', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a'];
+  state.scrollingTracks = ['b', 'c'];
+
+  const after = produce(state, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: 'c',
+      direction: 'down',
+    });
+  });
+  expect(after).toEqual(state);
+});
+
+test('pin', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a'];
+  state.scrollingTracks = ['b', 'c'];
+
+  const after = produce(state, draft => {
+    StateActions.toggleTrackPinned(draft, {
+      trackId: 'c',
+    });
+  });
+  expect(after.pinnedTracks).toEqual(['a', 'c']);
+  expect(after.scrollingTracks).toEqual(['b']);
+});
+
+test('unpin', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a', 'b'];
+  state.scrollingTracks = ['c'];
+
+  const after = produce(state, draft => {
+    StateActions.toggleTrackPinned(draft, {
+      trackId: 'a',
+    });
+  });
+  expect(after.pinnedTracks).toEqual(['b']);
+  expect(after.scrollingTracks).toEqual(['a', 'c']);
+});
+
+test('open trace', () => {
+  const after = produce(createEmptyState(), draft => {
+    StateActions.openTraceFromUrl(draft, {
+      url: 'https://example.com/bar',
+    });
+  });
+
+  const engineKeys = Object.keys(after.engines);
+  expect(engineKeys.length).toBe(1);
+  expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
+  expect(after.route).toBe('/viewer');
+});
+
+test('open second trace from file', () => {
+  const once = produce(createEmptyState(), draft => {
+    StateActions.openTraceFromUrl(draft, {
+      url: 'https://example.com/bar',
+    });
+  });
+
+  const twice = produce(once, draft => {
+    StateActions.addTrack(draft, {
+      engineId: '1',
+      kind: 'cpu',
+      name: 'Cpu 1',
+      config: {},
+    });
+  });
+
+  const thrice = produce(twice, draft => {
+    StateActions.openTraceFromUrl(draft, {
+      url: 'https://example.com/foo',
+    });
+  });
+
+  const engineKeys = Object.keys(thrice.engines);
+  expect(engineKeys.length).toBe(2);
+  expect(thrice.engines[engineKeys[0]].source).toBe('https://example.com/bar');
+  expect(thrice.engines[engineKeys[1]].source).toBe('https://example.com/foo');
+  expect(thrice.pinnedTracks.length).toBe(0);
+  expect(thrice.scrollingTracks.length).toBe(0);
+  expect(thrice.route).toBe('/viewer');
+});
diff --git a/ui/src/controller/globals.ts b/ui/src/controller/globals.ts
index 8a336fa..c46ffbf 100644
--- a/ui/src/controller/globals.ts
+++ b/ui/src/controller/globals.ts
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {produce} from 'immer';
+
 import {assertExists} from '../base/logging';
 import {Remote} from '../base/remote';
-import {Action} from '../common/actions';
+import {DeferredAction, StateActions} from '../common/actions';
 import {createEmptyState, State} from '../common/state';
+
 import {ControllerAny} from './controller';
 import {Engine} from './engine';
-import {rootReducer} from './reducer';
 import {WasmEngineProxy} from './wasm_engine_proxy';
 import {
   createWasmEngine,
@@ -33,19 +35,19 @@
   private _rootController?: ControllerAny;
   private _frontend?: Remote;
   private _runningControllers = false;
-  private _queuedActions = new Array<Action>();
+  private _queuedActions = new Array<DeferredAction>();
 
   initialize(rootController: ControllerAny, frontendProxy: Remote) {
-    this._state = createEmptyState();
     this._rootController = rootController;
     this._frontend = frontendProxy;
+    this._state = createEmptyState();
   }
 
-  dispatch(action: Action): void {
+  dispatch(action: DeferredAction): void {
     this.dispatchMultiple([action]);
   }
 
-  dispatchMultiple(actions: Action[]): void {
+  dispatchMultiple(actions: DeferredAction[]): void {
     this._queuedActions = this._queuedActions.concat(actions);
 
     // If we are in the middle of running the controllers, queue the actions
@@ -67,10 +69,10 @@
     for (let iter = 0; runAgain || this._queuedActions.length > 0; iter++) {
       if (iter > 100) throw new Error('Controllers are stuck in a livelock');
       const actions = this._queuedActions;
-      this._queuedActions = new Array<Action>();
+      this._queuedActions = new Array<DeferredAction>();
       for (const action of actions) {
         console.debug('Applying action', action);
-        this._state = rootReducer(this.state, action);
+        this.applyAction(action);
       }
       this._runningControllers = true;
       try {
@@ -102,8 +104,21 @@
     return assertExists(this._state);
   }
 
-  set state(state: State) {
-    this._state = state;
+  applyAction(action: DeferredAction): void {
+    assertExists(this._state);
+    // We need a special case for when we want to replace the whole tree.
+    if (action.type === 'setState') {
+      const args = (action as DeferredAction<{newState: State}>).args;
+      this._state = args.newState;
+      return;
+    }
+    // 'produce' creates a immer proxy which wraps the current state turning
+    // all imperative mutations of the state done in the callback into
+    // immutable changes to the returned state.
+    this._state = produce(this.state, draft => {
+      // tslint:disable-next-line no-any
+      (StateActions as any)[action.type](draft, action.args);
+    });
   }
 
   resetForTesting() {
diff --git a/ui/src/controller/permalink_controller.ts b/ui/src/controller/permalink_controller.ts
index 0146c56..0388934 100644
--- a/ui/src/controller/permalink_controller.ts
+++ b/ui/src/controller/permalink_controller.ts
@@ -15,7 +15,7 @@
 import * as uuidv4 from 'uuid/v4';
 
 import {assertExists} from '../base/logging';
-import {setPermalink, setState} from '../common/actions';
+import {Actions} from '../common/actions';
 import {EngineConfig, State} from '../common/state';
 
 import {Controller} from './controller';
@@ -40,14 +40,14 @@
     // if the |link| is not set, this is a request to create a permalink.
     if (globals.state.permalink.hash === undefined) {
       PermalinkController.createPermalink().then(hash => {
-        globals.dispatch(setPermalink(requestId, hash));
+        globals.dispatch(Actions.setPermalink({requestId, hash}));
       });
       return;
     }
 
     // Otherwise, this is a request to load the permalink.
     PermalinkController.loadState(globals.state.permalink.hash).then(state => {
-      globals.dispatch(setState(state));
+      globals.dispatch(Actions.setState({newState: state}));
       this.lastRequestId = state.permalink.requestId;
     });
   }
diff --git a/ui/src/controller/query_controller.ts b/ui/src/controller/query_controller.ts
index a700f71..14bbf6e 100644
--- a/ui/src/controller/query_controller.ts
+++ b/ui/src/controller/query_controller.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import {assertExists} from '../base/logging';
-import {deleteQuery} from '../common/actions';
+import {Actions} from '../common/actions';
 import {rawQueryResultColumns, rawQueryResultIter, Row} from '../common/protos';
 import {QueryResponse} from '../common/queries';
 import {Controller} from './controller';
@@ -37,7 +37,7 @@
         this.runQuery(config.query).then(result => {
           console.log(`Query ${config.query} took ${result.durationMs} ms`);
           globals.publish('QueryResult', {id: this.args.queryId, data: result});
-          globals.dispatch(deleteQuery(this.args.queryId));
+          globals.dispatch(Actions.deleteQuery({queryId: this.args.queryId}));
         });
         this.setState('querying');
         break;
diff --git a/ui/src/controller/reducer.ts b/ui/src/controller/reducer.ts
deleted file mode 100644
index 8e6cc9b..0000000
--- a/ui/src/controller/reducer.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// 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.
-
-import {defaultTraceTime, State} from '../common/state';
-
-// TODO(hjd): Type check this better.
-// tslint:disable-next-line no-any
-export function rootReducer(state: State, action: any): State {
-  switch (action.type) {
-    case 'NAVIGATE': {
-      const nextState = {...state};
-      nextState.route = action.route;
-      return nextState;
-    }
-
-    case 'OPEN_TRACE_FROM_FILE': {
-      const nextState = {...state};
-      nextState.traceTime = {...defaultTraceTime};
-      nextState.visibleTraceTime = {...defaultTraceTime};
-      const id = `${nextState.nextId++}`;
-      // Reset displayed tracks.
-      nextState.pinnedTracks = [];
-      nextState.scrollingTracks = [];
-      nextState.engines[id] = {
-        id,
-        ready: false,
-        source: action.file,
-      };
-      nextState.route = `/viewer`;
-
-      return nextState;
-    }
-
-    case 'OPEN_TRACE_FROM_URL': {
-      const nextState = {...state};
-      nextState.traceTime = {...defaultTraceTime};
-      nextState.visibleTraceTime = {...defaultTraceTime};
-      const id = `${nextState.nextId++}`;
-      // Reset displayed tracks.
-      nextState.pinnedTracks = [];
-      nextState.scrollingTracks = [];
-      nextState.engines[id] = {
-        id,
-        ready: false,
-        source: action.url,
-      };
-      nextState.route = `/viewer`;
-      return nextState;
-    }
-
-    case 'ADD_TRACK': {
-      const nextState = {...state};
-      nextState.tracks = {...state.tracks};
-      nextState.scrollingTracks = [...state.scrollingTracks];
-      const id = `${nextState.nextId++}`;
-      nextState.tracks[id] = {
-        id,
-        engineId: action.engineId,
-        kind: action.trackKind,
-        name: action.name,
-        config: action.config,
-      };
-      nextState.scrollingTracks.push(id);
-      return nextState;
-    }
-
-    case 'REQ_TRACK_DATA': {
-      const id = action.trackId;
-      const nextState = {...state};
-      const nextTracks = nextState.tracks = {...state.tracks};
-      const nextTrack = nextTracks[id] = {...nextTracks[id]};
-      nextTrack.dataReq = {
-        start: action.start,
-        end: action.end,
-        resolution: action.resolution
-      };
-      return nextState;
-    }
-
-    case 'CLEAR_TRACK_DATA_REQ': {
-      const id = action.trackId;
-      const nextState = {...state};
-      const nextTracks = nextState.tracks = {...state.tracks};
-      const nextTrack = nextTracks[id] = {...nextTracks[id]};
-      nextTrack.dataReq = undefined;
-      return nextState;
-    }
-
-    case 'EXECUTE_QUERY': {
-      const nextState = {...state};
-      nextState.queries = {...state.queries};
-      nextState.queries[action.queryId] = {
-        id: action.queryId,
-        engineId: action.engineId,
-        query: action.query,
-      };
-      return nextState;
-    }
-
-    case 'DELETE_QUERY': {
-      const nextState = {...state};
-      nextState.queries = {...state.queries};
-      delete nextState.queries[action.queryId];
-      return nextState;
-    }
-
-    case 'MOVE_TRACK': {
-      if (!action.direction) {
-        throw new Error('No direction given');
-      }
-      const id = action.trackId;
-      const isPinned = state.pinnedTracks.includes(id);
-      const isScrolling = state.scrollingTracks.includes(id);
-      if (!isScrolling && !isPinned) {
-        throw new Error(`No track with id ${id}`);
-      }
-      const nextState = {...state};
-      const scrollingTracks = nextState.scrollingTracks =
-          state.scrollingTracks.slice();
-      const pinnedTracks = nextState.pinnedTracks = state.pinnedTracks.slice();
-
-      const tracks = isPinned ? pinnedTracks : scrollingTracks;
-
-      const oldIndex = tracks.indexOf(id);
-      const newIndex = action.direction === 'up' ? oldIndex - 1 : oldIndex + 1;
-      const swappedTrackId = tracks[newIndex];
-      if (isPinned && newIndex === pinnedTracks.length) {
-        // Move from last element of pinned to first element of scrolling.
-        scrollingTracks.unshift(pinnedTracks.pop()!);
-      } else if (isScrolling && newIndex === -1) {
-        // Move first element of scrolling to last element of pinned.
-        pinnedTracks.push(scrollingTracks.shift()!);
-      } else if (swappedTrackId) {
-        tracks[newIndex] = id;
-        tracks[oldIndex] = swappedTrackId;
-      } else {
-        return state;
-      }
-      return nextState;
-    }
-
-    case 'TOGGLE_TRACK_PINNED': {
-      const id = action.trackId;
-      const isPinned = state.pinnedTracks.includes(id);
-
-      const nextState = {...state};
-      const pinnedTracks = nextState.pinnedTracks = [...state.pinnedTracks];
-      const scrollingTracks = nextState.scrollingTracks =
-          [...state.scrollingTracks];
-      if (isPinned) {
-        pinnedTracks.splice(pinnedTracks.indexOf(id), 1);
-        scrollingTracks.unshift(id);
-      } else {
-        scrollingTracks.splice(scrollingTracks.indexOf(id), 1);
-        pinnedTracks.push(id);
-      }
-      return nextState;
-    }
-
-    case 'SET_ENGINE_READY': {
-      const nextState = {...state};  // Creates a shallow copy.
-      nextState.engines = {...state.engines};
-      nextState.engines[action.engineId].ready = action.ready;
-      return nextState;
-    }
-
-    case 'CREATE_PERMALINK': {
-      const nextState = {...state};
-      nextState.permalink = {requestId: action.requestId, hash: undefined};
-      return nextState;
-    }
-
-    case 'SET_PERMALINK': {
-      // Drop any links for old requests.
-      if (state.permalink.requestId !== action.requestId) return state;
-
-      const nextState = {...state};
-      nextState.permalink = {requestId: action.requestId, hash: action.hash};
-      return nextState;
-    }
-
-    case 'LOAD_PERMALINK': {
-      const nextState = {...state};
-      nextState.permalink = {requestId: action.requestId, hash: action.hash};
-      return nextState;
-    }
-
-    case 'SET_STATE': {
-      return action.newState;
-    }
-
-    case 'SET_TRACE_TIME': {
-      const nextState = {...state};
-      nextState.traceTime.startSec = action.startSec;
-      nextState.traceTime.endSec = action.endSec;
-      nextState.traceTime.lastUpdate = action.lastUpdate;
-      return nextState;
-    }
-
-    case 'SET_VISIBLE_TRACE_TIME': {
-      const nextState = {...state};
-      nextState.visibleTraceTime.startSec = action.startSec;
-      nextState.visibleTraceTime.endSec = action.endSec;
-      nextState.visibleTraceTime.lastUpdate = action.lastUpdate;
-      return nextState;
-    }
-
-    case 'UPDATE_STATUS': {
-      const nextState = {...state};
-      nextState.status = {msg: action.msg, timestamp: action.timestamp};
-      return nextState;
-    }
-
-    default:
-      break;
-  }
-  return state;
-}
diff --git a/ui/src/controller/reducer_unittest.ts b/ui/src/controller/reducer_unittest.ts
deleted file mode 100644
index fc0f246..0000000
--- a/ui/src/controller/reducer_unittest.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// 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.
-
-import {moveTrack, toggleTrackPinned} from '../common/actions';
-import {createEmptyState, State, TrackState} from '../common/state';
-
-import {rootReducer} from './reducer';
-
-function fakeTrack(state: State, id: string): TrackState {
-  const track: TrackState = {
-    id,
-    engineId: '1',
-    kind: 'SOME_TRACK_KIND',
-    name: 'A track',
-    config: {},
-  };
-  state.tracks[id] = track;
-  return track;
-}
-
-test('navigate', async () => {
-  const before = createEmptyState();
-  const after = rootReducer(before, {type: 'NAVIGATE', route: '/foo'});
-  expect(after.route).toBe('/foo');
-});
-
-test('add tracks', () => {
-  const empty = createEmptyState();
-  const step1 = rootReducer(empty, {
-    type: 'ADD_TRACK',
-    engineId: '1',
-    trackKind: 'cpu',
-    cpu: '1',
-  });
-  const state = rootReducer(step1, {
-    type: 'ADD_TRACK',
-    engineId: '2',
-    trackKind: 'cpu',
-    cpu: '2',
-  });
-  expect(Object.values(state.tracks).length).toBe(2);
-  expect(state.scrollingTracks.length).toBe(2);
-});
-
-test('reorder tracks', () => {
-  const empty = createEmptyState();
-  const step1 = rootReducer(empty, {
-    type: 'ADD_TRACK',
-    engineId: '1',
-    trackKind: 'cpu',
-    config: {},
-  });
-  const before = rootReducer(step1, {
-    type: 'ADD_TRACK',
-    engineId: '2',
-    trackKind: 'cpu',
-    config: {},
-  });
-
-  const firstTrackId = before.scrollingTracks[0];
-  const secondTrackId = before.scrollingTracks[1];
-
-  const after = rootReducer(before, {
-    type: 'MOVE_TRACK',
-    trackId: `${firstTrackId}`,
-    direction: 'down',
-  });
-
-  // Ensure the order is swapped. This test would fail to detect side effects
-  // if the before state was modified, so other tests are needed as well.
-  expect(after.scrollingTracks[0]).toBe(secondTrackId);
-  expect(after.scrollingTracks[1]).toBe(firstTrackId);
-
-  // Ensure the track state contents have actually swapped places in the new
-  // state, but not in the old one.
-  expect(before.tracks[before.scrollingTracks[0]].engineId).toBe('1');
-  expect(before.tracks[before.scrollingTracks[1]].engineId).toBe('2');
-  expect(after.tracks[after.scrollingTracks[0]].engineId).toBe('2');
-  expect(after.tracks[after.scrollingTracks[1]].engineId).toBe('1');
-});
-
-test('reorder pinned to scrolling', () => {
-  const before = createEmptyState();
-
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a', 'b'];
-  before.scrollingTracks = ['c'];
-
-  const after = rootReducer(before, moveTrack('b', 'down'));
-  expect(after.pinnedTracks).toEqual(['a']);
-  expect(after.scrollingTracks).toEqual(['b', 'c']);
-});
-
-test('reorder scrolling to pinned', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a'];
-  before.scrollingTracks = ['b', 'c'];
-
-  const after = rootReducer(before, moveTrack('b', 'up'));
-  expect(after.pinnedTracks).toEqual(['a', 'b']);
-  expect(after.scrollingTracks).toEqual(['c']);
-});
-
-test('reorder clamp bottom', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a', 'b'];
-  before.scrollingTracks = ['c'];
-
-  const after = rootReducer(before, moveTrack('a', 'up'));
-  expect(after).toEqual(before);
-});
-
-test('reorder clamp top', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a'];
-  before.scrollingTracks = ['b', 'c'];
-
-  const after = rootReducer(before, moveTrack('c', 'down'));
-  expect(after).toEqual(before);
-});
-
-test('pin', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a'];
-  before.scrollingTracks = ['b', 'c'];
-
-  const after = rootReducer(before, toggleTrackPinned('c'));
-  expect(after.pinnedTracks).toEqual(['a', 'c']);
-  expect(after.scrollingTracks).toEqual(['b']);
-});
-
-test('unpin', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a', 'b'];
-  before.scrollingTracks = ['c'];
-
-  const after = rootReducer(before, toggleTrackPinned('a'));
-  expect(after.pinnedTracks).toEqual(['b']);
-  expect(after.scrollingTracks).toEqual(['a', 'c']);
-});
-
-test('open trace', async () => {
-  const before = createEmptyState();
-  const after = rootReducer(before, {
-    type: 'OPEN_TRACE_FROM_URL',
-    url: 'https://example.com/bar',
-  });
-  const engineKeys = Object.keys(after.engines);
-  expect(engineKeys.length).toBe(1);
-  expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
-  expect(after.route).toBe('/viewer');
-});
-
-test('set state', async () => {
-  const newState = createEmptyState();
-  const before = createEmptyState();
-  const after = rootReducer(before, {
-    type: 'SET_STATE',
-    newState,
-  });
-  expect(after).toBe(newState);
-});
-
-test('open second trace from file', () => {
-  const before = createEmptyState();
-  const afterFirst = rootReducer(before, {
-    type: 'OPEN_TRACE_FROM_URL',
-    url: 'https://example.com/bar',
-  });
-  afterFirst.scrollingTracks = ['track1', 'track2'];
-  afterFirst.pinnedTracks = ['track3', 'track4'];
-  const after = rootReducer(afterFirst, {
-    type: 'OPEN_TRACE_FROM_URL',
-    url: 'https://example.com/foo',
-  });
-  const engineKeys = Object.keys(after.engines);
-  expect(engineKeys.length).toBe(2);
-  expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
-  expect(after.engines[engineKeys[1]].source).toBe('https://example.com/foo');
-  expect(after.pinnedTracks.length).toBe(0);
-  expect(after.scrollingTracks.length).toBe(0);
-  expect(after.route).toBe('/viewer');
-});
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index 06f570f..e68d26c 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -16,13 +16,8 @@
 
 import {assertExists, assertTrue} from '../base/logging';
 import {
-  Action,
-  addTrack,
-  navigate,
-  setEngineReady,
-  setTraceTime,
-  setVisibleTraceTime,
-  updateStatus
+  Actions,
+  DeferredAction,
 } from '../common/actions';
 import {TimeSpan} from '../common/time';
 import {QuantizedLoad, ThreadDesc} from '../frontend/globals';
@@ -64,11 +59,17 @@
     const engineCfg = assertExists(globals.state.engines[this.engineId]);
     switch (this.state) {
       case 'init':
-        globals.dispatch(setEngineReady(this.engineId, false));
+        globals.dispatch(Actions.setEngineReady({
+          engineId: this.engineId,
+          ready: false,
+        }));
         this.loadTrace().then(() => {
-          globals.dispatch(setEngineReady(this.engineId, true));
+          globals.dispatch(Actions.setEngineReady({
+            engineId: this.engineId,
+            ready: true,
+          }));
         });
-        globals.dispatch(updateStatus('Opening trace'));
+        this.updateStatus('Opening trace');
         this.setState('loading_trace');
         break;
 
@@ -110,7 +111,7 @@
   }
 
   private async loadTrace() {
-    globals.dispatch(updateStatus('Creating trace processor'));
+    this.updateStatus('Creating trace processor');
     const engineCfg = assertExists(globals.state.engines[this.engineId]);
     this.engine = globals.createEngine();
 
@@ -124,12 +125,12 @@
         const arrBuf = reader.readAsArrayBuffer(slice);
         await this.engine.parse(new Uint8Array(arrBuf));
         const progress = Math.round((off + slice.size) / blob.size * 100);
-        globals.dispatch(updateStatus(`${statusHeader} ${progress} %`));
+        this.updateStatus(`${statusHeader} ${progress} %`);
       }
     } else {
       const resp = await fetch(engineCfg.source);
       if (resp.status !== 200) {
-        globals.dispatch(updateStatus(`HTTP error ${resp.status}`));
+        this.updateStatus(`HTTP error ${resp.status}`);
         throw new Error(`fetch() failed with HTTP error ${resp.status}`);
       }
       // tslint:disable-next-line no-any
@@ -152,7 +153,7 @@
           const tElapsed = (nowMs - tStartMs) / 1e3;
           let status = `${statusHeader} ${mb.toFixed(1)} MB `;
           status += `(${(mb / tElapsed).toFixed(1)} MB/s)`;
-          globals.dispatch(updateStatus(status));
+          this.updateStatus(status);
         }
         if (readRes.done) break;
       }
@@ -161,13 +162,18 @@
     await this.engine.notifyEof();
 
     const traceTime = await this.engine.getTraceTimeBounds();
+    const traceTimeState = {
+      startSec: traceTime.start,
+      endSec: traceTime.end,
+      lastUpdate: Date.now() / 1000,
+    };
     const actions = [
-      setTraceTime(traceTime),
-      navigate('/viewer'),
+      Actions.setTraceTime(traceTimeState),
+      Actions.navigate({route: '/viewer'}),
     ];
 
     if (globals.state.visibleTraceTime.lastUpdate === 0) {
-      actions.push(setVisibleTraceTime(traceTime));
+      actions.push(Actions.setVisibleTraceTime(traceTimeState));
     }
 
     globals.dispatchMultiple(actions);
@@ -178,20 +184,28 @@
   }
 
   private async listTracks() {
-    globals.dispatch(updateStatus('Loading tracks'));
+    this.updateStatus('Loading tracks');
     const engine = assertExists<Engine>(this.engine);
-    const addToTrackActions: Action[] = [];
+    const addToTrackActions: DeferredAction[] = [];
     const numCpus = await engine.getNumberOfCpus();
     for (let cpu = 0; cpu < numCpus; cpu++) {
-      addToTrackActions.push(
-          addTrack(this.engineId, CPU_SLICE_TRACK_KIND, `Cpu ${cpu}`, {
-            cpu,
-          }));
+      addToTrackActions.push(Actions.addTrack({
+        engineId: this.engineId,
+        kind: CPU_SLICE_TRACK_KIND,
+        name: `Cpu ${cpu}`,
+        config: {
+          cpu,
+        }
+      }));
     }
 
-    const threadQuery = await engine.query(
-        'select upid, utid, tid, thread.name, max(slices.depth) ' +
-        'from thread inner join slices using(utid) group by utid');
+    const threadQuery = await engine.query(`
+      select upid, utid, tid, thread.name, depth
+      from thread inner join (
+        select utid, max(slices.depth) as depth
+        from slices
+        group by utid
+      ) using(utid)`);
     for (let i = 0; i < threadQuery.numRecords; i++) {
       const upid = threadQuery.columns[0].longValues![i];
       const utid = threadQuery.columns[1].longValues![i];
@@ -199,18 +213,22 @@
       let threadName = threadQuery.columns[3].stringValues![i];
       threadName += `[${threadId}]`;
       const maxDepth = threadQuery.columns[4].longValues![i];
-      addToTrackActions.push(
-          addTrack(this.engineId, SLICE_TRACK_KIND, threadName, {
-            upid: upid as number,
-            utid: utid as number,
-            maxDepth: maxDepth as number,
-          }));
+      addToTrackActions.push(Actions.addTrack({
+        engineId: this.engineId,
+        kind: SLICE_TRACK_KIND,
+        name: threadName,
+        config: {
+          upid: upid as number,
+          utid: utid as number,
+          maxDepth: maxDepth as number,
+        }
+      }));
     }
     globals.dispatchMultiple(addToTrackActions);
   }
 
   private async listThreads() {
-    globals.dispatch(updateStatus('Reading thread list'));
+    this.updateStatus('Reading thread list');
     const sqlQuery = 'select utid, tid, pid, thread.name, process.name ' +
         'from thread inner join process using(upid)';
     const threadRows = await assertExists(this.engine).query(sqlQuery);
@@ -231,9 +249,9 @@
     const numSteps = 100;
     const stepSec = traceTime.duration / numSteps;
     for (let step = 0; step < numSteps; step++) {
-      globals.dispatch(updateStatus(
+      this.updateStatus(
           'Loading overview ' +
-          `${Math.round((step + 1) / numSteps * 1000) / 10}%`));
+          `${Math.round((step + 1) / numSteps * 1000) / 10}%`);
       const startSec = traceTime.start + step * stepSec;
       const startNs = Math.floor(startSec * 1e9);
       const endSec = startSec + stepSec;
@@ -270,4 +288,11 @@
       globals.publish('OverviewData', slicesData);
     }  // for (step ...)
   }
+
+  private updateStatus(msg: string): void {
+    globals.dispatch(Actions.updateStatus({
+      msg,
+      timestamp: Date.now() / 1000,
+    }));
+  }
 }
diff --git a/ui/src/controller/track_controller.ts b/ui/src/controller/track_controller.ts
index cbf444c..4340847 100644
--- a/ui/src/controller/track_controller.ts
+++ b/ui/src/controller/track_controller.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import {assertExists} from '../base/logging';
-import {clearTrackDataRequest} from '../common/actions';
+import {Actions} from '../common/actions';
 import {Registry} from '../common/registry';
 import {TrackState} from '../common/state';
 
@@ -55,7 +55,7 @@
   run() {
     const dataReq = this.trackState.dataReq;
     if (dataReq === undefined) return;
-    globals.dispatch(clearTrackDataRequest(this.trackId));
+    globals.dispatch(Actions.clearTrackDataReq({trackId: this.trackId}));
     this.onBoundsChange(dataReq.start, dataReq.end, dataReq.resolution);
   }
 }
diff --git a/ui/src/frontend/clipboard.ts b/ui/src/frontend/clipboard.ts
new file mode 100644
index 0000000..28d70be
--- /dev/null
+++ b/ui/src/frontend/clipboard.ts
@@ -0,0 +1,23 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// 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.
+
+export async function copyToClipboard(text: string): Promise<void> {
+  try {
+    // TODO(hjd): Fix typescript type for navigator.
+    // tslint:disable-next-line no-any
+    await(navigator as any).clipboard.writeText(text);
+  } catch (err) {
+    console.error(`Failed to copy "${text}" to clipboard: ${err}`);
+  }
+}
diff --git a/ui/src/frontend/frontend_local_state.ts b/ui/src/frontend/frontend_local_state.ts
index b9aa740..47911ea 100644
--- a/ui/src/frontend/frontend_local_state.ts
+++ b/ui/src/frontend/frontend_local_state.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {setVisibleTraceTime} from '../common/actions';
+import {Actions} from '../common/actions';
 import {TimeSpan} from '../common/time';
 
 import {globals} from './globals';
@@ -44,8 +44,12 @@
     this.pendingGlobalTimeUpdate = this.visibleWindowTime;
     if (alreadyPosted) return;
     setTimeout(() => {
-      globals.dispatch(setVisibleTraceTime(this.pendingGlobalTimeUpdate!));
       this._visibleTimeLastUpdate = Date.now() / 1000;
+      globals.dispatch(Actions.setVisibleTraceTime({
+        startSec: this.pendingGlobalTimeUpdate!.start,
+        endSec: this.pendingGlobalTimeUpdate!.end,
+        lastUpdate: this._visibleTimeLastUpdate,
+      }));
       this.pendingGlobalTimeUpdate = undefined;
     }, 100);
   }
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index e370127..ea0c248 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -13,13 +13,13 @@
 // limitations under the License.
 
 import {assertExists} from '../base/logging';
-import {Action} from '../common/actions';
+import {DeferredAction} from '../common/actions';
 import {createEmptyState, State} from '../common/state';
 
 import {FrontendLocalState} from './frontend_local_state';
 import {RafScheduler} from './raf_scheduler';
 
-type Dispatch = (action: Action) => void;
+type Dispatch = (action: DeferredAction) => void;
 type TrackDataStore = Map<string, {}>;
 type QueryResultsStore = Map<string, {}>;
 
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 0b92765..0905de4 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -17,7 +17,7 @@
 import * as m from 'mithril';
 
 import {forwardRemoteCalls} from '../base/remote';
-import {loadPermalink} from '../common/actions';
+import {Actions} from '../common/actions';
 import {State} from '../common/state';
 import {TimeSpan} from '../common/time';
 import {globals, QuantizedLoad, ThreadDesc} from './globals';
@@ -120,7 +120,11 @@
   // /?s=xxxx for permalinks.
   const stateHash = router.param('s');
   if (stateHash) {
-    globals.dispatch(loadPermalink(stateHash));
+    // TODO(hjd): Should requestId not be set to nextId++ in the controller?
+    globals.dispatch(Actions.loadPermalink({
+      requestId: new Date().toISOString(),
+      hash: stateHash,
+    }));
   }
 
   // Prevent pinch zoom.
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 4fb18f5..9329053 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -32,10 +32,12 @@
  */
 const SCROLLING_CANVAS_OVERDRAW_FACTOR = 2;
 
+// We need any here so we can accept vnodes with arbitrary attrs.
+// tslint:disable-next-line:no-any
+export type AnyAttrsVnode = m.Vnode<any, {}>;
+
 interface Attrs {
-  // Panels with non-empty attrs does not work without any.
-  // tslint:disable-next-line:no-any
-  panels: Array<m.Vnode<any, {}>>;
+  panels: AnyAttrsVnode[];
   doesScroll: boolean;
 }
 
@@ -55,26 +57,29 @@
     renderStats: new RunningStatistics(10),
   };
 
-  // attrs received in the most recent mithril redraw.
-  private attrs?: Attrs;
+  // Attrs received in the most recent mithril redraw. We receive a new vnode
+  // with new attrs on every redraw, and we cache it here so that resize
+  // listeners and canvas redraw callbacks can access it.
+  private attrs: Attrs;
 
-  private canvasOverdrawFactor: number;
   private ctx?: CanvasRenderingContext2D;
 
   private onResize: () => void = () => {};
   private parentOnScroll: () => void = () => {};
   private canvasRedrawer: () => void;
 
+  get canvasOverdrawFactor() {
+    return this.attrs.doesScroll ? SCROLLING_CANVAS_OVERDRAW_FACTOR : 1;
+  }
+
   constructor(vnode: m.CVnode<Attrs>) {
-    this.canvasOverdrawFactor =
-        vnode.attrs.doesScroll ? SCROLLING_CANVAS_OVERDRAW_FACTOR : 1;
+    this.attrs = vnode.attrs;
     this.canvasRedrawer = () => this.redrawCanvas();
     globals.rafScheduler.addRedrawCallback(this.canvasRedrawer);
     perfDisplay.addContainer(this);
   }
 
   oncreate(vnodeDom: m.CVnodeDOM<Attrs>) {
-    const attrs = vnodeDom.attrs;
     // Save the canvas context in the state.
     const canvas =
         vnodeDom.dom.querySelector('.main-canvas') as HTMLCanvasElement;
@@ -89,21 +94,18 @@
     this.parentWidth = clientRect.width;
     this.parentHeight = clientRect.height;
 
-    this.updatePanelHeightsFromDom(vnodeDom);
+    this.readPanelHeightsFromDom(vnodeDom.dom);
     (vnodeDom.dom as HTMLElement).style.height = `${this.totalPanelHeight}px`;
 
-    this.canvasHeight = this.getCanvasHeight(attrs.doesScroll);
-    this.updateCanvasDimensions(vnodeDom);
+    this.updateCanvasDimensions();
+    this.repositionCanvas();
 
     // Save the resize handler in the state so we can remove it later.
     // TODO: Encapsulate resize handling better.
     this.onResize = () => {
-      const clientRect =
-          assertExists(vnodeDom.dom.parentElement).getBoundingClientRect();
-      this.parentWidth = clientRect.width;
-      this.parentHeight = clientRect.height;
-      this.canvasHeight = this.getCanvasHeight(attrs.doesScroll);
-      this.updateCanvasDimensions(vnodeDom);
+      this.readParentSizeFromDom(vnodeDom.dom);
+      this.updateCanvasDimensions();
+      this.repositionCanvas();
       globals.rafScheduler.scheduleFullRedraw();
     };
 
@@ -111,10 +113,10 @@
     window.addEventListener('resize', this.onResize);
 
     // TODO(dproy): Handle change in doesScroll attribute.
-    if (vnodeDom.attrs.doesScroll) {
+    if (this.attrs.doesScroll) {
       this.parentOnScroll = () => {
-        this.scrollTop = vnodeDom.dom.parentElement!.scrollTop;
-        this.repositionCanvas(vnodeDom);
+        this.scrollTop = assertExists(vnodeDom.dom.parentElement).scrollTop;
+        this.repositionCanvas();
         globals.rafScheduler.scheduleRedraw();
       };
       vnodeDom.dom.parentElement!.addEventListener(
@@ -132,8 +134,6 @@
   }
 
   view({attrs}: m.CVnode<Attrs>) {
-    // We receive a new vnode object with new attrs on every mithril redraw. We
-    // store the latest attrs so redrawCanvas can use it.
     this.attrs = attrs;
     const renderPanel = (panel: m.Vnode) => perfDebug() ?
         m('.panel', panel, m('.debug-panel-border')) :
@@ -146,25 +146,27 @@
   }
 
   onupdate(vnodeDom: m.CVnodeDOM<Attrs>) {
-    this.repositionCanvas(vnodeDom);
+    const totalPanelHeightChanged = this.readPanelHeightsFromDom(vnodeDom.dom);
+    const parentSizeChanged = this.readParentSizeFromDom(vnodeDom.dom);
 
-    if (this.updatePanelHeightsFromDom(vnodeDom)) {
+    if (totalPanelHeightChanged) {
       (vnodeDom.dom as HTMLElement).style.height = `${this.totalPanelHeight}px`;
     }
 
-    // In non-scrolling case, canvas height can change if panel heights changed.
-    const canvasHeight = this.getCanvasHeight(vnodeDom.attrs.doesScroll);
-    if (this.canvasHeight !== canvasHeight) {
-      this.canvasHeight = canvasHeight;
-      this.updateCanvasDimensions(vnodeDom);
+    const canvasSizeShouldChange =
+        this.attrs.doesScroll ? parentSizeChanged : totalPanelHeightChanged;
+    if (canvasSizeShouldChange) {
+      this.updateCanvasDimensions();
+      this.repositionCanvas();
     }
   }
 
-  private updateCanvasDimensions(vnodeDom: m.CVnodeDOM<Attrs>) {
-    const canvas =
-        assertExists(vnodeDom.dom.querySelector('canvas.main-canvas')) as
-        HTMLCanvasElement;
+  private updateCanvasDimensions() {
+    this.canvasHeight = this.attrs.doesScroll ?
+        this.parentHeight * this.canvasOverdrawFactor :
+        this.totalPanelHeight;
     const ctx = assertExists(this.ctx);
+    const canvas = assertExists(ctx.canvas);
     canvas.style.height = `${this.canvasHeight}px`;
     const dpr = window.devicePixelRatio;
     ctx.canvas.width = this.parentWidth * dpr;
@@ -172,13 +174,36 @@
     ctx.scale(dpr, dpr);
   }
 
-  private updatePanelHeightsFromDom(vnodeDom: m.CVnodeDOM<Attrs>): boolean {
+  private repositionCanvas() {
+    const canvas = assertExists(assertExists(this.ctx).canvas);
+    const canvasYStart = this.scrollTop - this.getCanvasOverdrawHeightPerSide();
+    canvas.style.transform = `translateY(${canvasYStart}px)`;
+  }
+
+  /**
+   * Reads dimensions of parent node. Returns true if read dimensions are
+   * different from what was cached in the state.
+   */
+  private readParentSizeFromDom(dom: Element): boolean {
+    const oldWidth = this.parentWidth;
+    const oldHeight = this.parentHeight;
+    const clientRect = assertExists(dom.parentElement).getBoundingClientRect();
+    this.parentWidth = clientRect.width;
+    this.parentHeight = clientRect.height;
+    return this.parentHeight !== oldHeight || this.parentWidth !== oldWidth;
+  }
+
+  /**
+   * Reads dimensions of panels. Returns true if total panel height is different
+   * from what was cached in state.
+   */
+  private readPanelHeightsFromDom(dom: Element): boolean {
     const prevHeight = this.totalPanelHeight;
     this.panelHeights = [];
     this.totalPanelHeight = 0;
 
-    const panels = vnodeDom.dom.querySelectorAll('.panel');
-    assertTrue(panels.length === vnodeDom.attrs.panels.length);
+    const panels = dom.querySelectorAll('.panel');
+    assertTrue(panels.length === this.attrs.panels.length);
     for (let i = 0; i < panels.length; i++) {
       const height = panels[i].getBoundingClientRect().height;
       this.panelHeights[i] = height;
@@ -188,19 +213,6 @@
     return this.totalPanelHeight !== prevHeight;
   }
 
-  private getCanvasHeight(doesScroll: boolean) {
-    return doesScroll ? this.parentHeight * this.canvasOverdrawFactor :
-                        this.totalPanelHeight;
-  }
-
-  private repositionCanvas(vnodeDom: m.CVnodeDOM<Attrs>) {
-    const canvas =
-        assertExists(vnodeDom.dom.querySelector('canvas.main-canvas')) as
-        HTMLCanvasElement;
-    const canvasYStart = this.scrollTop - this.getCanvasOverdrawHeightPerSide();
-    canvas.style.transform = `translateY(${canvasYStart}px)`;
-  }
-
   private overlapsCanvas(yStart: number, yEnd: number) {
     return yEnd > 0 && yStart < this.canvasHeight;
   }
diff --git a/ui/src/frontend/record_page.ts b/ui/src/frontend/record_page.ts
index 79d8417..21e295e 100644
--- a/ui/src/frontend/record_page.ts
+++ b/ui/src/frontend/record_page.ts
@@ -14,21 +14,12 @@
 
 import * as m from 'mithril';
 
+import {copyToClipboard} from './clipboard';
 import {createPage} from './pages';
 
 const RECORD_COMMAND_LINE =
     'echo CgYIgKAGIAESIwohCgxsaW51eC5mdHJhY2UQAKIGDhIFc2NoZWQSBWlucHV0GJBOMh0KFnBlcmZldHRvLnRyYWNlZF9wcm9iZXMQgCAYBEAASAA= | base64 --decode | adb shell "perfetto -c - -o /data/misc/perfetto-traces/trace" && adb pull /data/misc/perfetto-traces/trace /tmp/trace';
 
-async function copyToClipboard(text: string): Promise<void> {
-  try {
-    // TODO(hjd): Fix typescript type for navigator.
-    // tslint:disable-next-line no-any
-    await(navigator as any).clipboard.writeText(text);
-  } catch (err) {
-    console.error(`Failed to copy "${text}" to clipboard: ${err}`);
-  }
-}
-
 interface CodeSampleAttrs {
   text: string;
 }
diff --git a/ui/src/frontend/router.ts b/ui/src/frontend/router.ts
index 98bf553..0830712 100644
--- a/ui/src/frontend/router.ts
+++ b/ui/src/frontend/router.ts
@@ -14,7 +14,7 @@
 
 import * as m from 'mithril';
 
-import {Action, navigate} from '../common/actions';
+import {Actions, DeferredAction} from '../common/actions';
 
 interface RouteMap {
   [route: string]: m.Component;
@@ -25,7 +25,7 @@
 export class Router {
   constructor(
       private defaultRoute: string, private routes: RouteMap,
-      private dispatch: (a: Action) => void) {
+      private dispatch: (a: DeferredAction) => void) {
     if (!(defaultRoute in routes)) {
       throw Error('routes must define a component for defaultRoute.');
     }
@@ -57,7 +57,7 @@
     if (!(route in this.routes)) {
       console.info(
           `Route ${route} not known redirecting to ${this.defaultRoute}.`);
-      this.dispatch(navigate(this.defaultRoute));
+      this.dispatch(Actions.navigate({route: this.defaultRoute}));
     }
   }
 
@@ -68,7 +68,7 @@
   navigateToCurrentHash() {
     const hashRoute = this.getRouteFromHash();
     const newRoute = hashRoute in this.routes ? hashRoute : this.defaultRoute;
-    this.dispatch(navigate(newRoute));
+    this.dispatch(Actions.navigate({route: newRoute}));
     // TODO(dproy): Handle case when new route has a permalink.
   }
 
diff --git a/ui/src/frontend/router_jsdomtest.ts b/ui/src/frontend/router_jsdomtest.ts
index 2d1c4ce..4b8aadb 100644
--- a/ui/src/frontend/router_jsdomtest.ts
+++ b/ui/src/frontend/router_jsdomtest.ts
@@ -14,7 +14,7 @@
 
 import {dingus} from 'dingusjs';
 
-import {Action, navigate} from '../common/actions';
+import {Actions, DeferredAction} from '../common/actions';
 
 import {Router} from './router';
 
@@ -49,7 +49,7 @@
 });
 
 test('Set valid route on hash', () => {
-  const dispatch = dingus<(a: Action) => void>();
+  const dispatch = dingus<(a: DeferredAction) => void>();
   const router = new Router(
       '/',
       {
@@ -67,19 +67,19 @@
 });
 
 test('Redirects to default for invalid route in setRouteOnHash ', () => {
-  const dispatch = dingus<(a: Action) => void>();
+  const dispatch = dingus<(a: DeferredAction) => void>();
   // const dispatch = () => {console.log("action received")};
 
   const router = new Router('/', {'/': mockComponent}, dispatch);
   router.setRouteOnHash('foo');
   expect(dispatch.calls.length).toBe(1);
   expect(dispatch.calls[0][1].length).toBeGreaterThanOrEqual(1);
-  expect(dispatch.calls[0][1][0]).toEqual(navigate('/'));
+  expect(dispatch.calls[0][1][0]).toEqual(Actions.navigate({route: '/'}));
 });
 
 test('Navigate on hash change', done => {
-  const mockDispatch = (a: Action) => {
-    expect(a).toEqual(navigate('/viewer'));
+  const mockDispatch = (a: DeferredAction) => {
+    expect(a).toEqual(Actions.navigate({route: '/viewer'}));
     done();
   };
   new Router(
@@ -93,8 +93,8 @@
 });
 
 test('Redirects to default when invalid route set in window location', done => {
-  const mockDispatch = (a: Action) => {
-    expect(a).toEqual(navigate('/'));
+  const mockDispatch = (a: DeferredAction) => {
+    expect(a).toEqual(Actions.navigate({route: '/'}));
     done();
   };
 
@@ -110,20 +110,20 @@
 });
 
 test('navigateToCurrentHash with valid current route', () => {
-  const dispatch = dingus<(a: Action) => void>();
+  const dispatch = dingus<(a: DeferredAction) => void>();
   window.location.hash = '#!/b';
   const router =
       new Router('/', {'/': mockComponent, '/b': mockComponent}, dispatch);
   router.navigateToCurrentHash();
-  expect(dispatch.calls[0][1][0]).toEqual(navigate('/b'));
+  expect(dispatch.calls[0][1][0]).toEqual(Actions.navigate({route: '/b'}));
 });
 
 test('navigateToCurrentHash with invalid current route', () => {
-  const dispatch = dingus<(a: Action) => void>();
+  const dispatch = dingus<(a: DeferredAction) => void>();
   window.location.hash = '#!/invalid';
   const router = new Router('/', {'/': mockComponent}, dispatch);
   router.navigateToCurrentHash();
-  expect(dispatch.calls[0][1][0]).toEqual(navigate('/'));
+  expect(dispatch.calls[0][1][0]).toEqual(Actions.navigate({route: '/'}));
 });
 
 test('Params parsing', () => {
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index 6f8e5d2..543c78c 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -14,13 +14,7 @@
 
 import * as m from 'mithril';
 
-import {
-  createPermalink,
-  executeQuery,
-  navigate,
-  openTraceFromFile,
-  openTraceFromUrl
-} from '../common/actions';
+import {Actions} from '../common/actions';
 
 import {globals} from './globals';
 
@@ -60,7 +54,11 @@
 function createCannedQuery(query: string): (_: Event) => void {
   return (e: Event) => {
     e.preventDefault();
-    globals.dispatch(executeQuery('0', 'command', query));
+    globals.dispatch(Actions.executeQuery({
+      engineId: '0',
+      queryId: 'command',
+      query,
+    }));
   };
 }
 
@@ -148,7 +146,7 @@
 function openTraceUrl(url: string): (e: Event) => void {
   return e => {
     e.preventDefault();
-    globals.dispatch(openTraceFromUrl(url));
+    globals.dispatch(Actions.openTraceFromUrl({url}));
   };
 }
 
@@ -157,22 +155,25 @@
     throw new Error('Not an input element');
   }
   if (!e.target.files) return;
-  globals.dispatch(openTraceFromFile(e.target.files[0]));
+  globals.dispatch(Actions.openTraceFromFile({file: e.target.files[0]}));
 }
 
 function navigateHome(e: Event) {
   e.preventDefault();
-  globals.dispatch(navigate('/'));
+  globals.dispatch(Actions.navigate({route: '/'}));
 }
 
 function navigateRecord(e: Event) {
   e.preventDefault();
-  globals.dispatch(navigate('/record'));
+  globals.dispatch(Actions.navigate({route: '/record'}));
 }
 
 function dispatchCreatePermalink(e: Event) {
   e.preventDefault();
-  globals.dispatch(createPermalink());
+  // TODO(hjd): Should requestId not be set to nextId++ in the controller?
+  globals.dispatch(Actions.createPermalink({
+    requestId: new Date().toISOString(),
+  }));
 }
 
 export class Sidebar implements m.ClassComponent {
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index 03b142a..acb4d80 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -14,7 +14,7 @@
 
 import * as m from 'mithril';
 
-import {deleteQuery, executeQuery} from '../common/actions';
+import {Actions} from '../common/actions';
 import {QueryResponse} from '../common/queries';
 import {EngineConfig} from '../common/state';
 
@@ -28,7 +28,7 @@
 
 function clearOmniboxResults() {
   globals.queryResults.delete(QUERY_ID);
-  globals.dispatch(deleteQuery(QUERY_ID));
+  globals.dispatch(Actions.deleteQuery({queryId: QUERY_ID}));
 }
 
 function onKeyDown(e: Event) {
@@ -81,10 +81,12 @@
   if (mode === 'search') {
     const name = txt.value.replace(/'/g, '\\\'').replace(/[*]/g, '%');
     const query = `select str from strings where str like '%${name}%' limit 10`;
-    globals.dispatch(executeQuery('0', QUERY_ID, query));
+    globals.dispatch(
+        Actions.executeQuery({engineId: '0', queryId: QUERY_ID, query}));
   }
   if (mode === 'command' && key === 'Enter') {
-    globals.dispatch(executeQuery('0', 'command', txt.value));
+    globals.dispatch(Actions.executeQuery(
+        {engineId: '0', queryId: 'command', query: txt.value}));
   }
 }
 
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index a12024d..9386985 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -14,8 +14,7 @@
 
 import * as m from 'mithril';
 
-import {moveTrack, toggleTrackPinned} from '../common/actions';
-import {Action} from '../common/actions';
+import {Actions, DeferredAction} from '../common/actions';
 import {TrackState} from '../common/state';
 
 import {globals} from './globals';
@@ -41,15 +40,17 @@
         '.track-shell',
         m('h1', attrs.trackState.name),
         m(TrackButton, {
-          action: moveTrack(attrs.trackState.id, 'up'),
+          action: Actions.moveTrack(
+              {trackId: attrs.trackState.id, direction: 'up'}),
           i: 'arrow_upward_alt',
         }),
         m(TrackButton, {
-          action: moveTrack(attrs.trackState.id, 'down'),
+          action: Actions.moveTrack(
+              {trackId: attrs.trackState.id, direction: 'down'}),
           i: 'arrow_downward_alt',
         }),
         m(TrackButton, {
-          action: toggleTrackPinned(attrs.trackState.id),
+          action: Actions.toggleTrackPinned({trackId: attrs.trackState.id}),
           i: isPinned(attrs.trackState.id) ? 'star' : 'star_border',
         }));
   }
@@ -87,7 +88,7 @@
 }
 
 interface TrackButtonAttrs {
-  action: Action;
+  action: DeferredAction;
   i: string;
 }
 class TrackButton implements m.ClassComponent<TrackButtonAttrs> {
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index e801992..d89bd5d 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -17,20 +17,22 @@
 import {QueryResponse} from '../common/queries';
 import {TimeSpan} from '../common/time';
 
+import {copyToClipboard} from './clipboard';
 import {FlameGraphPanel} from './flame_graph_panel';
 import {globals} from './globals';
 import {HeaderPanel} from './header_panel';
 import {OverviewTimelinePanel} from './overview_timeline_panel';
 import {createPage} from './pages';
 import {PanAndZoomHandler} from './pan_and_zoom_handler';
-import {PanelContainer} from './panel_container';
+import {Panel} from './panel';
+import {AnyAttrsVnode, PanelContainer} from './panel_container';
 import {TimeAxisPanel} from './time_axis_panel';
 import {TRACK_SHELL_WIDTH} from './track_panel';
 import {TrackPanel} from './track_panel';
 
 const MAX_ZOOM_SPAN_SEC = 1e-4;  // 0.1 ms.
 
-class QueryTable implements m.ClassComponent {
+class QueryTable extends Panel {
   view() {
     const resp = globals.queryResults.get('command') as QueryResponse;
     if (resp === undefined) {
@@ -53,12 +55,33 @@
     return m(
         'div',
         m('header.overview',
-          `Query result - ${Math.round(resp.durationMs)} ms`,
-          m('span.code', resp.query)),
+          m('span',
+            `Query result - ${Math.round(resp.durationMs)} ms`,
+            m('span.code', resp.query)),
+          resp.error ? null :
+                       m('button',
+                         {
+                           onclick: () => {
+                             const lines: string[][] = [];
+                             lines.push(resp.columns);
+                             for (const row of resp.rows) {
+                               const line = [];
+                               for (const col of resp.columns) {
+                                 line.push(row[col].toString());
+                               }
+                               lines.push(line);
+                             }
+                             copyToClipboard(
+                                 lines.map(line => line.join('\t')).join('\n'));
+                           },
+                         },
+                         'Copy as .tsv')),
         resp.error ?
             m('.query-error', `SQL error: ${resp.error}`) :
             m('table.query-table', m('thead', header), m('tbody', rows)));
   }
+
+  renderCanvas() {}
 }
 
 /**
@@ -130,7 +153,8 @@
   }
 
   view() {
-    const scrollingPanels = globals.state.scrollingTracks.length > 0 ?
+    const scrollingPanels: AnyAttrsVnode[] =
+        globals.state.scrollingTracks.length > 0 ?
         [
           m(HeaderPanel, {title: 'Tracks', key: 'tracksheader'}),
           ...globals.state.scrollingTracks.map(
@@ -138,10 +162,10 @@
           m(FlameGraphPanel, {key: 'flamegraph'}),
         ] :
         [];
+    scrollingPanels.unshift(m(QueryTable));
+
     return m(
         '.page',
-        m(QueryTable),
-        // TODO: Pan and zoom logic should be in its own mithril component.
         m('.pan-and-zoom-content',
           m('.pinned-panel-container', m(PanelContainer, {
               doesScroll: false,
diff --git a/ui/src/tracks/chrome_slices/frontend.ts b/ui/src/tracks/chrome_slices/frontend.ts
index 6f64494..0072a75 100644
--- a/ui/src/tracks/chrome_slices/frontend.ts
+++ b/ui/src/tracks/chrome_slices/frontend.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {requestTrackData} from '../../common/actions';
+import {Actions} from '../../common/actions';
 import {TrackState} from '../../common/state';
 import {checkerboardExcept} from '../../frontend/checkerboard';
 import {globals} from '../../frontend/globals';
@@ -58,8 +58,12 @@
     const reqEnd = visibleWindowTime.end + visibleWindowTime.duration;
     const reqRes = getCurResolution();
     this.reqPending = false;
-    globals.dispatch(
-        requestTrackData(this.trackState.id, reqStart, reqEnd, reqRes));
+    globals.dispatch(Actions.reqTrackData({
+      trackId: this.trackState.id,
+      start: reqStart,
+      end: reqEnd,
+      resolution: reqRes
+    }));
   }
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {
diff --git a/ui/src/tracks/cpu_slices/frontend.ts b/ui/src/tracks/cpu_slices/frontend.ts
index ec01b08..e6d0e6c 100644
--- a/ui/src/tracks/cpu_slices/frontend.ts
+++ b/ui/src/tracks/cpu_slices/frontend.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import {assertTrue} from '../../base/logging';
-import {requestTrackData} from '../../common/actions';
+import {Actions} from '../../common/actions';
 import {TrackState} from '../../common/state';
 import {checkerboardExcept} from '../../frontend/checkerboard';
 import {globals} from '../../frontend/globals';
@@ -78,8 +78,12 @@
     const reqEnd = visibleWindowTime.end + visibleWindowTime.duration;
     const reqRes = getCurResolution();
     this.reqPending = false;
-    globals.dispatch(
-        requestTrackData(this.trackState.id, reqStart, reqEnd, reqRes));
+    globals.dispatch(Actions.reqTrackData({
+      trackId: this.trackState.id,
+      start: reqStart,
+      end: reqEnd,
+      resolution: reqRes
+    }));
   }
 
   renderCanvas(ctx: CanvasRenderingContext2D): void {