Merge "scouting: Unify the spelling of "timestamp""
diff --git a/aos/events/event_loop_param_test.cc b/aos/events/event_loop_param_test.cc
index d3d21fd..2076467 100644
--- a/aos/events/event_loop_param_test.cc
+++ b/aos/events/event_loop_param_test.cc
@@ -3254,12 +3254,12 @@
 
   auto sender = event_loop->MakeSender<TestMessage>("/test");
 
-  // We are sending messages at 1 kHz, so we will be sending too fast after
-  // queue_size (1600) ms. After this, keep sending messages, and exactly a
-  // channel storage duration (2s) after we send the first message we should
-  // be able to successfully send a message.
+  // We are sending bunches of messages at 100 Hz, so we will be sending too
+  // fast after queue_size (800) ms. After this, keep sending messages, and
+  // exactly a channel storage duration (2s) after we send the first message we
+  // should be able to successfully send a message.
 
-  const monotonic_clock::duration kInterval = std::chrono::milliseconds(1);
+  const std::chrono::milliseconds kInterval = std::chrono::milliseconds(10);
   const monotonic_clock::duration channel_storage_duration =
       std::chrono::nanoseconds(
           event_loop->configuration()->channel_storage_duration());
@@ -3270,33 +3270,38 @@
   auto start = monotonic_clock::min_time;
 
   event_loop->AddPhasedLoop(
-      [&](int) {
-        const auto actual_err = SendTestMessage(sender);
-        const bool done_waiting = (start != monotonic_clock::min_time &&
-                                   sender.monotonic_sent_time() >=
-                                       (start + channel_storage_duration));
-        const auto expected_err =
-            (msgs_sent < queue_size || done_waiting
-                 ? RawSender::Error::kOk
-                 : RawSender::Error::kMessagesSentTooFast);
+      [&](int elapsed_cycles) {
+        // The queue is setup for 800 messages/sec.  We want to fill that up at
+        // a rate of 2000 messages/sec so we make sure we fill it up.
+        for (int i = 0; i < 2 * kInterval.count() * elapsed_cycles; ++i) {
+          const auto actual_err = SendTestMessage(sender);
+          const bool done_waiting = (start != monotonic_clock::min_time &&
+                                     sender.monotonic_sent_time() >=
+                                         (start + channel_storage_duration));
+          const auto expected_err =
+              (msgs_sent < queue_size || done_waiting
+                   ? RawSender::Error::kOk
+                   : RawSender::Error::kMessagesSentTooFast);
 
-        if (start == monotonic_clock::min_time) {
-          start = sender.monotonic_sent_time();
-        }
+          if (start == monotonic_clock::min_time) {
+            start = sender.monotonic_sent_time();
+          }
 
-        ASSERT_EQ(actual_err, expected_err);
-        counter.Count(actual_err);
-        msgs_sent++;
+          ASSERT_EQ(actual_err, expected_err);
+          counter.Count(actual_err);
+          msgs_sent++;
 
-        EXPECT_EQ(counter.failures(),
-                  msgs_sent <= queue_size
-                      ? 0
-                      : (msgs_sent - queue_size) -
-                            (actual_err == RawSender::Error::kOk ? 1 : 0));
-        EXPECT_EQ(counter.just_failed(), actual_err != RawSender::Error::kOk);
+          EXPECT_EQ(counter.failures(),
+                    msgs_sent <= queue_size
+                        ? 0
+                        : (msgs_sent - queue_size) -
+                              (actual_err == RawSender::Error::kOk ? 1 : 0));
+          EXPECT_EQ(counter.just_failed(), actual_err != RawSender::Error::kOk);
 
-        if (done_waiting) {
-          Exit();
+          if (done_waiting) {
+            Exit();
+            return;
+          }
         }
       },
       kInterval);
diff --git a/aos/network/message_bridge_test_common.json b/aos/network/message_bridge_test_common.json
index dad1675..9bb0863 100644
--- a/aos/network/message_bridge_test_common.json
+++ b/aos/network/message_bridge_test_common.json
@@ -75,28 +75,33 @@
     {
       "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/aos-message_bridge-Timestamp",
       "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
       "source_node": "pi1",
       "frequency": 15
     },
     {
       "name": "/pi2/aos/remote_timestamps/pi1/pi2/aos/aos-message_bridge-Timestamp",
       "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
       "source_node": "pi2",
       "frequency": 15
     },
     {
       "name": "/pi1/aos/remote_timestamps/pi2/test/aos-examples-Ping",
       "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
       "source_node": "pi1"
     },
     {
       "name": "/pi2/aos/remote_timestamps/pi1/test/aos-examples-Pong",
       "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
       "source_node": "pi2"
     },
     {
       "name": "/pi1/aos/remote_timestamps/pi2/unreliable/aos-examples-Ping",
       "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
       "source_node": "pi1"
     },
     {
diff --git a/aos/realtime.cc b/aos/realtime.cc
index 2f299e6..d08dd91 100644
--- a/aos/realtime.cc
+++ b/aos/realtime.cc
@@ -20,7 +20,7 @@
 #include "glog/raw_logging.h"
 
 DEFINE_bool(
-    die_on_malloc, false,
+    die_on_malloc, true,
     "If true, die when the application allocates memory in a RT section.");
 DEFINE_bool(skip_realtime_scheduler, false,
             "If true, skip changing the scheduler.  Pretend that we changed "
diff --git a/aos/util/BUILD b/aos/util/BUILD
index b46cd14..8ce96f0 100644
--- a/aos/util/BUILD
+++ b/aos/util/BUILD
@@ -1,10 +1,10 @@
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("//aos:flatbuffers.bzl", "cc_static_flatbuffer")
-load("config_validator_macro.bzl", "config_validator_rule")
+load("config_validator_macro.bzl", "config_validator_test")
 
 package(default_visibility = ["//visibility:public"])
 
-config_validator_rule(
+config_validator_test(
     name = "config_validator_test",
     config = "//aos/events:pingpong_config",
 )
@@ -499,17 +499,14 @@
     ],
 )
 
-cc_binary(
+cc_library(
     name = "config_validator",
     testonly = True,
     srcs = ["config_validator.cc"],
     target_compatible_with = ["@platforms//os:linux"],
     deps = [
-        "//aos:init",
+        ":config_validator_lib",
         "//aos:json_to_flatbuffer",
-        "//aos/events:simulated_event_loop",
-        "//aos/events/logging:log_reader",
-        "//aos/events/logging:log_writer",
         "//aos/testing:googletest",
         "@com_github_gflags_gflags//:gflags",
         "@com_github_google_glog//:glog",
@@ -538,3 +535,49 @@
         "//aos/events/logging:log_writer",
     ],
 )
+
+flatbuffer_cc_library(
+    name = "config_validator_config_fbs",
+    srcs = ["config_validator_config.fbs"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+)
+
+cc_library(
+    name = "config_validator_lib",
+    testonly = True,
+    srcs = ["config_validator_lib.cc"],
+    hdrs = ["config_validator_lib.h"],
+    deps = [
+        ":config_validator_config_fbs",
+        ":simulation_logger",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_reader",
+        "//aos/events/logging:log_writer",
+        "//aos/network:timestamp_channel",
+        "//aos/testing:tmpdir",
+        "@com_github_google_glog//:glog",
+        "@com_google_googletest//:gtest",
+    ],
+)
+
+cc_test(
+    name = "config_validator_lib_test",
+    srcs = ["config_validator_lib_test.cc"],
+    data = [
+        "//aos/util/test_data:multinode_common_logger",
+        "//aos/util/test_data:multinode_extraneous_timestamp",
+        "//aos/util/test_data:multinode_invalid_timestamp_logger_list",
+        "//aos/util/test_data:multinode_no_logged_timestamps",
+        "//aos/util/test_data:multinode_no_statistics",
+        "//aos/util/test_data:multinode_timestamp_typo",
+        "//aos/util/test_data:valid_multinode_config",
+        "//aos/util/test_data:valid_singlenode_config",
+    ],
+    deps = [
+        ":config_validator_lib",
+        "//aos:json_to_flatbuffer",
+        "//aos/testing:googletest",
+        "//aos/testing:path",
+    ],
+)
diff --git a/aos/util/config_validator.cc b/aos/util/config_validator.cc
index d5bd6ba..df21ba0 100644
--- a/aos/util/config_validator.cc
+++ b/aos/util/config_validator.cc
@@ -1,16 +1,9 @@
-#include <chrono>
-
-#include "aos/configuration.h"
-#include "aos/events/logging/log_reader.h"
-#include "aos/events/logging/log_writer.h"
-#include "aos/events/simulated_event_loop.h"
-#include "aos/init.h"
 #include "aos/json_to_flatbuffer.h"
-#include "aos/network/team_number.h"
-#include "gflags/gflags.h"
-#include "gtest/gtest.h"
+#include "aos/util/config_validator_lib.h"
 
 DEFINE_string(config, "", "Name of the config file to replay using.");
+DEFINE_string(validation_config, "{}",
+              "JSON config to use to validate the config.");
 /* This binary is used to validate that all of the
    needed remote timestamps channels are in the config
    to log the timestamps.
@@ -26,9 +19,11 @@
   const aos::FlatbufferDetachedBuffer<aos::Configuration> config =
       aos::configuration::ReadConfig(FLAGS_config);
 
-  aos::SimulatedEventLoopFactory factory(&config.message());
-
-  factory.RunFor(std::chrono::seconds(1));
+  const aos::FlatbufferDetachedBuffer<aos::util::ConfigValidatorConfig>
+      validator_config =
+          aos::JsonToFlatbuffer<aos::util::ConfigValidatorConfig>(
+              FLAGS_validation_config);
+  aos::util::ConfigIsValid(&config.message(), &validator_config.message());
 }
 
 // TODO(milind): add more tests, the above one doesn't
diff --git a/aos/util/config_validator_config.fbs b/aos/util/config_validator_config.fbs
new file mode 100644
index 0000000..cda84b2
--- /dev/null
+++ b/aos/util/config_validator_config.fbs
@@ -0,0 +1,55 @@
+namespace aos.util;
+
+// This file defines a schema for what to validate when we run the
+// config_validator against an AOS config.
+// The primary purpose of this config is to allow the user to specify what
+// sets of nodes they expect to be able to log on so that we can validate the
+// logging configurations. In the future this may also include flags to indicate
+// how aggressively to do certain checks.
+//
+// This flatbuffer should not exist in serialized form anywhere, and so is
+// safe to modify in non-backwards-compatible ways.
+
+// Species a set of nodes that you should be able to combine the logs from and
+// subsequently replay. E.g., this allows you to write a check that says
+// "If you combine logs from pi2 & pi4, you should be able to replay data from
+// nodes pi2, pi4, and pi6"; or
+// "When logs from all nodes are combined, you should be able to replay data
+// for all nodes;" or
+// "Each node should log all the data needed to replay its own data"
+// (this would require muliple LoggerNodeSetValidation's).
+//
+// Each LoggerNodeSetValidation table represents a single set of logging nodes
+// that should be able to replay data on some number of other nodes. An empty
+// list of loggers or replay_nodes indicates "all nodes." The above examples
+// could then be represented by, e.g.:
+// "pi2 & pi4 -> pi2, pi4, & pi6":
+//   {"loggers": ["pi2", "pi4"], "replay_nodes": ["pi2", "pi4", "pi6"]}
+// "all -> all": {"logger": [], "replay_nodes": []}
+// "each node -> itself": [
+//   {"logger": ["pi1"], "replay_nodes": ["pi1"]},
+//   {"logger": ["pi2"], "replay_nodes": ["pi2"]},
+//   {"logger": ["pi3"], "replay_nodes": ["pi3"]},
+//   {"logger": ["pi4"], "replay_nodes": ["pi4"]}]
+table LoggerNodeSetValidation {
+  loggers:[string] (id: 0);
+  replay_nodes:[string] (id: 1);
+}
+
+// This table specifies which
+table LoggingConfigValidation {
+  // If true, all channels should be logged by some valid set of loggers.
+  // Essentially, this is checking that no channels are configured to be
+  // NOT_LOGGED except for remote timestamp channels.
+  all_channels_logged:bool = true (id: 0);
+  // A list of all the sets of logger nodes that we care about. Typically this
+  // should at least include an entry that says that "logs from all nodes should
+  // combine to allow you to replay all nodes."
+  logger_sets:[LoggerNodeSetValidation] (id: 1);
+}
+
+table ConfigValidatorConfig {
+  logging:LoggingConfigValidation (id: 0);
+}
+
+root_type ConfigValidatorConfig;
diff --git a/aos/util/config_validator_lib.cc b/aos/util/config_validator_lib.cc
new file mode 100644
index 0000000..0f90ed6
--- /dev/null
+++ b/aos/util/config_validator_lib.cc
@@ -0,0 +1,292 @@
+#include "aos/util/config_validator_lib.h"
+
+#include <chrono>
+
+#include "aos/events/logging/log_reader.h"
+#include "aos/events/logging/log_writer.h"
+#include "aos/events/simulated_event_loop.h"
+#include "aos/network/remote_message_generated.h"
+#include "aos/network/timestamp_channel.h"
+#include "aos/testing/tmpdir.h"
+#include "aos/util/simulation_logger.h"
+
+DECLARE_bool(validate_timestamp_logger_nodes);
+
+namespace aos::util {
+
+namespace {
+void RunSimulationAndExit(const aos::Configuration *config) {
+  aos::SimulatedEventLoopFactory factory(config);
+
+  factory.RunFor(std::chrono::seconds(1));
+
+  std::exit(EXIT_SUCCESS);
+}
+
+// Checks if either the node is in the specified list of node names or if the
+// list is empty (in which case it is treated as matching all nodes).
+bool NodeInList(
+    const flatbuffers::Vector<flatbuffers::Offset<flatbuffers::String>> *list,
+    const aos::Node *node) {
+  if (list == nullptr || list->size() == 0) {
+    return true;
+  }
+  for (const flatbuffers::String *name : *list) {
+    if (name->string_view() == node->name()->string_view()) {
+      return true;
+    }
+  }
+  return false;
+}
+
+}  // namespace
+
+void ConfigIsValid(const aos::Configuration *config,
+                   const ConfigValidatorConfig *validation_config) {
+  ASSERT_TRUE(config->has_channels())
+      << "An AOS config must have channels. If you have a valid use-case for "
+         "channels with no channels, please write a design proposal.";
+
+  // First, we do some sanity checks--these are likely to indicate a malformed
+  // config, and so catching them early with a clear error message is likely to
+  // help.
+
+  // The set of all channels that are required by the channels that are
+  // configured--these are the remote timestamp channels that *must* be present,
+  // and ideally there are no other channels present.
+  std::set<const Channel *> required_timestamp_channels;
+  // The set of all channels that *look* like remote timestamp channels. This
+  // may include channels that are improperly configured and thus have typos &
+  // aren't actually going to do anything at runtime.
+  std::set<const Channel *> configured_timestamp_channels;
+  bool validation_failed = false;
+  for (size_t channel_index = 0; channel_index < config->channels()->size();
+       ++channel_index) {
+    const aos::Channel *channel = config->channels()->Get(channel_index);
+    ASSERT_TRUE(channel->has_name()) << "All AOS channels must have a name.";
+    ASSERT_TRUE(channel->has_type()) << "All AOS channels must have a type.";
+
+    const bool channel_looks_like_remote_message_channel =
+        channel->type()->string_view() ==
+        message_bridge::RemoteMessage::GetFullyQualifiedName();
+
+    const bool check_for_not_logged_channels =
+        !validation_config->has_logging() ||
+        validation_config->logging()->all_channels_logged();
+    const bool channel_is_not_logged =
+        channel->logger() == aos::LoggerConfig::NOT_LOGGED;
+    if (check_for_not_logged_channels) {
+      if (channel_looks_like_remote_message_channel != channel_is_not_logged) {
+        LOG(WARNING)
+            << "Channel " << configuration::StrippedChannelToString(channel)
+            << " is " << EnumNameLoggerConfig(channel->logger()) << " but "
+            << (channel_looks_like_remote_message_channel ? "is" : "is not")
+            << " a remote timestamp channel. This is almost certainly wrong.";
+        validation_failed = true;
+      }
+    }
+
+    if (channel_looks_like_remote_message_channel) {
+      configured_timestamp_channels.insert(channel);
+    } else {
+      if (channel->has_destination_nodes()) {
+        // TODO(james): Technically the timestamp finder should receive a
+        // non-empty application name. However, there are no known users that
+        // care at this moment.
+        message_bridge::ChannelTimestampFinder timestamp_finder(
+            config, "",
+            configuration::GetNode(config,
+                                   channel->source_node()->string_view()));
+        for (const Connection *connection : *channel->destination_nodes()) {
+          switch (connection->timestamp_logger()) {
+            case LoggerConfig::NOT_LOGGED:
+            case LoggerConfig::LOCAL_LOGGER:
+              if (connection->has_timestamp_logger_nodes()) {
+                LOG(WARNING)
+                    << "Connections that are "
+                    << EnumNameLoggerConfig(connection->timestamp_logger())
+                    << " should not have remote timestamp logger nodes "
+                       "populated. This is for the connection to "
+                    << connection->name()->string_view() << " on "
+                    << configuration::StrippedChannelToString(channel);
+                validation_failed = true;
+              }
+              break;
+            case LoggerConfig::REMOTE_LOGGER:
+            case LoggerConfig::LOCAL_AND_REMOTE_LOGGER:
+              if (!connection->has_timestamp_logger_nodes() ||
+                  connection->timestamp_logger_nodes()->size() != 1 ||
+                  connection->timestamp_logger_nodes()->Get(0)->string_view() !=
+                      channel->source_node()->string_view()) {
+                LOG(WARNING)
+                    << "Connections that are "
+                    << EnumNameLoggerConfig(connection->timestamp_logger())
+                    << " should have exactly 1 remote timestamp logger node "
+                       "populated, and that node should be the source_node ("
+                    << channel->source_node()->string_view()
+                    << "). This is for the connection to "
+                    << connection->name()->string_view() << " on "
+                    << configuration::StrippedChannelToString(channel);
+                validation_failed = true;
+              }
+              // TODO(james): This will be overly noisy, as it ends up
+              // CHECK-failing.
+              required_timestamp_channels.insert(CHECK_NOTNULL(
+                  timestamp_finder.ForChannel(channel, connection)));
+              break;
+          }
+        }
+      }
+    }
+  }
+
+  // Check that all of the things that look like timestamp channels are indeed
+  // required.
+  // Note: Because ForChannel() will die if a required channel is not present,
+  // we do not do a separate check that all the required channels exist.
+  for (const auto &channel : configured_timestamp_channels) {
+    if (required_timestamp_channels.count(channel) == 0) {
+      LOG(WARNING) << "Timestamp channel "
+                   << configuration::StrippedChannelToString(channel)
+                   << " was specified in the config but is not used.";
+      validation_failed = true;
+    }
+  }
+
+  if (validation_failed) {
+    FAIL() << "Remote timestamp linting failed.";
+    return;
+  }
+
+  // Because the most common way for simulation to fail involves it dying, force
+  // it to fail in a slightly more controlled manner.
+  ASSERT_EXIT(RunSimulationAndExit(config),
+              ::testing::ExitedWithCode(EXIT_SUCCESS), "");
+
+  if (!validation_config->has_logging() || !configuration::MultiNode(config)) {
+    return;
+  }
+
+  // We will run all the logger configs in two modes:
+  // 1) We don't send any data on any non-infrastructure channels; this confirms
+  //    that the logs are readable in the absence of any user applications being
+  //    present.
+  // 2) We confirm that we can generate a good logfile that actually has data
+  //    on every channel (some checks in the LogReader may not get hit if there
+  //    is no data on a given channel).
+  const std::string log_path = aos::testing::TestTmpDir() + "/logs/";
+  for (const bool send_data_on_channels : {false, true}) {
+    SCOPED_TRACE(send_data_on_channels);
+    for (const LoggerNodeSetValidation *logger_set :
+         *validation_config->logging()->logger_sets()) {
+      SCOPED_TRACE(aos::FlatbufferToJson(logger_set));
+      aos::SimulatedEventLoopFactory factory(config);
+      std::vector<std::unique_ptr<LoggerState>> loggers;
+      if (logger_set->has_loggers() && logger_set->loggers()->size() > 0) {
+        std::vector<std::string> logger_nodes;
+        for (const auto &node : *logger_set->loggers()) {
+          logger_nodes.push_back(node->str());
+        }
+        loggers = MakeLoggersForNodes(&factory, logger_nodes, log_path);
+      } else {
+        loggers = MakeLoggersForAllNodes(&factory, log_path);
+      }
+
+      std::vector<std::unique_ptr<EventLoop>> test_loops;
+      std::map<std::string, std::vector<std::unique_ptr<RawSender>>>
+          test_senders;
+
+      if (send_data_on_channels) {
+        // Make a sender on every non-infrastructure channel on every node
+        // (including channels that may not be observable by the current logger
+        // set).
+        for (const aos::Node *node : configuration::GetNodes(config)) {
+          test_loops.emplace_back(factory.MakeEventLoop("", node));
+          for (const aos::Channel *channel : *config->channels()) {
+            // TODO(james): Make a more sophisticated check for "infrastructure"
+            // channels than just looking for a "/aos" in the channel--we don't
+            // accidentally want to spam nonsense data onto any timestamp
+            // channels, though.
+            if (configuration::ChannelIsSendableOnNode(channel, node) &&
+                channel->name()->str().find("/aos") == std::string::npos &&
+                channel->logger() != LoggerConfig::NOT_LOGGED) {
+              test_senders[node->name()->str()].emplace_back(
+                  test_loops.back()->MakeRawSender(channel));
+              RawSender *sender =
+                  test_senders[node->name()->str()].back().get();
+              test_loops.back()->OnRun([sender, channel]() {
+                flatbuffers::DetachedBuffer buffer =
+                    JsonToFlatbuffer("{}", channel->schema());
+                sender->CheckOk(sender->Send(buffer.data(), buffer.size()));
+              });
+            }
+          }
+        }
+      }
+
+      factory.RunFor(std::chrono::seconds(2));
+
+      // Get all of the loggers to close before trying to read the logfiles.
+      loggers.clear();
+
+      // Confirm that we can read the log, and that if we put data in it that we
+      // can find data on all the nodes that the user cares about.
+      logger::LogReader reader(logger::SortParts(logger::FindLogs(log_path)));
+      SimulatedEventLoopFactory replay_factory(reader.configuration());
+      reader.RegisterWithoutStarting(&replay_factory);
+
+      // Find every channel we deliberately sent data on, and if it is for a
+      // node that we care about, confirm that we get it during replay.
+      std::vector<std::unique_ptr<EventLoop>> replay_loops;
+      std::vector<std::unique_ptr<RawFetcher>> fetchers;
+      for (const aos::Node *node :
+           configuration::GetNodes(replay_factory.configuration())) {
+        // If the user doesn't care about this node, don't check it.
+        if (!NodeInList(logger_set->replay_nodes(), node)) {
+          continue;
+        }
+        replay_loops.emplace_back(replay_factory.MakeEventLoop("", node));
+        for (const auto &sender : test_senders[node->name()->str()]) {
+          const aos::Channel *channel = configuration::GetChannel(
+              replay_factory.configuration(), sender->channel(), "", node);
+          fetchers.emplace_back(replay_loops.back()->MakeRawFetcher(channel));
+        }
+      }
+
+      std::vector<std::pair<const aos::Node *, std::unique_ptr<RawFetcher>>>
+          remote_fetchers;
+      for (const auto &fetcher : fetchers) {
+        for (auto &loop : replay_loops) {
+          const Connection *connection =
+              configuration::ConnectionToNode(fetcher->channel(), loop->node());
+          if (connection != nullptr) {
+            remote_fetchers.push_back(std::make_pair(
+                loop->node(), loop->MakeRawFetcher(fetcher->channel())));
+          }
+        }
+      }
+
+      replay_factory.Run();
+
+      for (auto &fetcher : fetchers) {
+        EXPECT_TRUE(fetcher->Fetch())
+            << "Failed to log or replay any data on "
+            << configuration::StrippedChannelToString(fetcher->channel());
+      }
+
+      for (auto &pair : remote_fetchers) {
+        EXPECT_TRUE(pair.second->Fetch())
+            << "Failed to log or replay any data on "
+            << configuration::StrippedChannelToString(pair.second->channel())
+            << " from remote node " << logger::MaybeNodeName(pair.first) << ".";
+      }
+
+      reader.Deregister();
+
+      // Clean up the logs.
+      UnlinkRecursive(log_path);
+    }
+  }
+}
+
+}  // namespace aos::util
diff --git a/aos/util/config_validator_lib.h b/aos/util/config_validator_lib.h
new file mode 100644
index 0000000..61658e5
--- /dev/null
+++ b/aos/util/config_validator_lib.h
@@ -0,0 +1,12 @@
+#ifndef AOS_UTIL_CONFIG_VALIDATOR_H_
+#define AOS_UTIL_CONFIG_VALIDATOR_H_
+
+#include "aos/configuration.h"
+#include "aos/util/config_validator_config_generated.h"
+#include "gtest/gtest.h"
+namespace aos::util {
+
+void ConfigIsValid(const aos::Configuration *config,
+                   const ConfigValidatorConfig *validation_config);
+}  // namespace aos::util
+#endif  // AOS_UTIL_CONFIG_VALIDATOR_H_
diff --git a/aos/util/config_validator_lib_test.cc b/aos/util/config_validator_lib_test.cc
new file mode 100644
index 0000000..c68695c
--- /dev/null
+++ b/aos/util/config_validator_lib_test.cc
@@ -0,0 +1,189 @@
+#include "aos/util/config_validator_lib.h"
+
+#include "aos/json_to_flatbuffer.h"
+#include "aos/testing/path.h"
+#include "gtest/gtest-spi.h"
+
+using aos::testing::ArtifactPath;
+namespace aos::util::testing {
+
+// Check that a reasonably normal config passes the config validator with a
+// reasonable set of checks turned on.
+TEST(ConfigValidatorTest, NoErrorOnValidConfigs) {
+  const FlatbufferDetachedBuffer<Configuration> config =
+      configuration::ReadConfig(
+          ArtifactPath("aos/util/test_data/valid_multinode_config.json"));
+  const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+      JsonToFlatbuffer<ConfigValidatorConfig>(R"json({"logging": {
+      "all_channels_logged": true,
+      "logger_sets": [
+        {
+          "loggers": [],
+          "replay_nodes": []
+        },
+        {
+          "loggers": ["pi1"],
+          "replay_nodes": ["pi1"]
+        },
+        {
+          "loggers": ["pi2"],
+          "replay_nodes": ["pi2"]
+        }
+      ]}})json");
+  ConfigIsValid(&config.message(), &validator_config.message());
+}
+
+// Check that a reasonably normal single-node config passes the config validator
+// with a reasonable set of checks turned on.
+TEST(ConfigValidatorTest, NoErrorOnValidSingleNodeConfig) {
+  const FlatbufferDetachedBuffer<Configuration> config =
+      configuration::ReadConfig(
+          ArtifactPath("aos/util/test_data/valid_singlenode_config.json"));
+  const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+      JsonToFlatbuffer<ConfigValidatorConfig>(R"json({"logging": {
+      "all_channels_logged": true,
+      "logger_sets": [
+        {
+          "loggers": [],
+          "replay_nodes": []
+        }
+      ]}})json");
+  ConfigIsValid(&config.message(), &validator_config.message());
+}
+
+// Checks that the validator fails if the message bridge statistics channels are
+// missing.
+TEST(ConfigValidatorTest, FailOnMissingStatisticsChannels) {
+  EXPECT_FATAL_FAILURE(
+      {
+        const FlatbufferDetachedBuffer<Configuration> config =
+            configuration::ReadConfig(ArtifactPath(
+                "aos/util/test_data/multinode_no_statistics.json"));
+        const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+            JsonToFlatbuffer<ConfigValidatorConfig>("{}");
+        ConfigIsValid(&config.message(), &validator_config.message());
+      },
+      "Statistics");
+}
+
+// Checks that the validator fails if a timestamp channel has a typo and so
+// doesn't exist.
+TEST(ConfigValidatorTest, FailOnTimestampTypo) {
+  EXPECT_DEATH(
+      {
+        const FlatbufferDetachedBuffer<Configuration> config =
+            configuration::ReadConfig(ArtifactPath(
+                "aos/util/test_data/multinode_timestamp_typo.json"));
+        const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+            JsonToFlatbuffer<ConfigValidatorConfig>("{}");
+        ConfigIsValid(&config.message(), &validator_config.message());
+      },
+      "not found in config");
+}
+
+// Checks that the validator fails if there is a RemoteMessage channel that is
+// *not* a timestamp channel (Since this is almost always a typo).
+TEST(ConfigValidatorTest, FailOnExtraneousTimestampChannel) {
+  EXPECT_FATAL_FAILURE(
+      {
+        const FlatbufferDetachedBuffer<Configuration> config =
+            configuration::ReadConfig(ArtifactPath(
+                "aos/util/test_data/multinode_extraneous_timestamp.json"));
+        const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+            JsonToFlatbuffer<ConfigValidatorConfig>("{}");
+        ConfigIsValid(&config.message(), &validator_config.message());
+      },
+      "linting failed");
+}
+
+// Checks that the validator fails on timestamp logger nodes that won't really
+// log the timestamps.
+TEST(ConfigValidatorTest, FailOnInvalidRemoteTimestampLogger) {
+  EXPECT_FATAL_FAILURE(
+      {
+        const FlatbufferDetachedBuffer<Configuration> config =
+            configuration::ReadConfig(
+                ArtifactPath("aos/util/test_data/"
+                             "multinode_invalid_timestamp_logger_list.json"));
+        const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+            JsonToFlatbuffer<ConfigValidatorConfig>(R"json({"logging": {
+      "all_channels_logged": true,
+      "logger_sets": [
+        {
+          "loggers": [],
+          "replay_nodes": []
+        }
+      ]}})json");
+        ConfigIsValid(&config.message(), &validator_config.message());
+      },
+      "linting failed");
+}
+
+// Checks that if you attempt to log on pi2 but expect it to have data for pi1
+// then the test fails (at least, for a config which does not forward all the
+// channels between the nodes).
+TEST(ConfigValidatorTest, FailOnNormalInsufficientLogging) {
+  EXPECT_NONFATAL_FAILURE(
+      {
+        const FlatbufferDetachedBuffer<Configuration> config =
+            configuration::ReadConfig(
+                ArtifactPath("aos/util/test_data/valid_multinode_config.json"));
+        const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+            JsonToFlatbuffer<ConfigValidatorConfig>(R"json({"logging": {
+      "all_channels_logged": true,
+      "logger_sets": [
+        {
+          "loggers": ["pi2"],
+          "replay_nodes": ["pi1"]
+        }
+      ]}})json");
+        ConfigIsValid(&config.message(), &validator_config.message());
+      },
+      "Failed to log");
+}
+
+// Checks that if we have a node that is configured to log all the data from all
+// the nodes that the test passes.
+TEST(ConfigValidatorTest, PassCommonLoggerNode) {
+  const FlatbufferDetachedBuffer<Configuration> config =
+      configuration::ReadConfig(
+          ArtifactPath("aos/util/test_data/multinode_common_logger.json"));
+  const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+      JsonToFlatbuffer<ConfigValidatorConfig>(R"json({"logging": {
+      "all_channels_logged": true,
+      "logger_sets": [
+        {
+          "loggers": ["pi2"],
+          "replay_nodes": ["pi1"]
+        },
+        {
+          "loggers": [],
+          "replay_nodes": []
+        }
+      ]}})json");
+  ConfigIsValid(&config.message(), &validator_config.message());
+}
+
+// Sets up a config that will not actually log sufficient timestamp data to
+// support full replay, and ensures that we identify that.
+TEST(ConfigValidatorTest, FailOnInsufficientConfiguredTimestampData) {
+  EXPECT_NONFATAL_FAILURE(
+      {
+        const FlatbufferDetachedBuffer<Configuration> config =
+            configuration::ReadConfig(ArtifactPath(
+                "aos/util/test_data/multinode_no_logged_timestamps.json"));
+        const FlatbufferDetachedBuffer<ConfigValidatorConfig> validator_config =
+            JsonToFlatbuffer<ConfigValidatorConfig>(R"json({"logging": {
+      "all_channels_logged": true,
+      "logger_sets": [
+        {
+          "loggers": [],
+          "replay_nodes": []
+        }
+      ]}})json");
+        ConfigIsValid(&config.message(), &validator_config.message());
+      },
+      R"json(Failed to log or replay any data on { "name": "/test", "type": "aos.examples.Ping" } from remote node pi2)json");
+}
+
+}  // namespace aos::util::testing
diff --git a/aos/util/config_validator_macro.bzl b/aos/util/config_validator_macro.bzl
index 453c5c2..cd05bab 100644
--- a/aos/util/config_validator_macro.bzl
+++ b/aos/util/config_validator_macro.bzl
@@ -1,20 +1,17 @@
-def config_validator_rule(name, config, extension = ".bfbs", visibility = None):
+def config_validator_test(name, config, logger_sets = [{}], check_for_not_logged_channels = False, extension = ".bfbs", visibility = None):
     '''
     Macro to take a config and pass it to the config validator to validate that it will work on a real system.
 
-    Currently just checks that the system can startup, but will check that timestamp channels are properly logged in the future.
-
     Args:
         name: name that the config validator uses, e.g. "test_config",
         config: config rule that needs to be validated, e.g. "//aos/events:pingpong_config",
     '''
     config_file = config + extension
-    native.genrule(
+    config_json = json.encode({"logging": {"all_channels_logged": check_for_not_logged_channels, "logger_sets": logger_sets}})
+    native.cc_test(
         name = name,
-        outs = [name + ".txt"],
-        cmd = "$(location //aos/util:config_validator) --config $(location %s) > $@" % config_file,
-        srcs = [config_file],
-        tools = ["//aos/util:config_validator"],
-        testonly = True,
+        deps = ["//aos/util:config_validator"],
+        args = ["--config=$(location %s)" % config_file, "--validation_config='%s'" % config_json],
+        data = [config_file],
         visibility = visibility,
     )
diff --git a/aos/util/file_test.cc b/aos/util/file_test.cc
index d4382c4..ba03ea5 100644
--- a/aos/util/file_test.cc
+++ b/aos/util/file_test.cc
@@ -7,8 +7,6 @@
 #include "aos/testing/tmpdir.h"
 #include "gtest/gtest.h"
 
-DECLARE_bool(die_on_malloc);
-
 namespace aos {
 namespace util {
 namespace testing {
@@ -52,9 +50,6 @@
 
   FileReader reader(test_file);
 
-  gflags::FlagSaver flag_saver;
-  FLAGS_die_on_malloc = true;
-  RegisterMallocHook();
   aos::ScopedRealtime realtime;
   {
     std::array<char, 20> contents;
@@ -79,9 +74,6 @@
 
   FileWriter writer(test_file);
 
-  gflags::FlagSaver flag_saver;
-  FLAGS_die_on_malloc = true;
-  RegisterMallocHook();
   FileWriter::WriteResult result;
   {
     aos::ScopedRealtime realtime;
@@ -104,9 +96,6 @@
   // Mess up the file management by closing the file descriptor.
   PCHECK(0 == close(writer.fd()));
 
-  gflags::FlagSaver flag_saver;
-  FLAGS_die_on_malloc = true;
-  RegisterMallocHook();
   FileWriter::WriteResult result;
   {
     aos::ScopedRealtime realtime;
diff --git a/aos/util/simulation_logger.cc b/aos/util/simulation_logger.cc
index e459978..1f55ece 100644
--- a/aos/util/simulation_logger.cc
+++ b/aos/util/simulation_logger.cc
@@ -1,11 +1,12 @@
 #include "aos/util/simulation_logger.h"
+#include "aos/events/logging/logfile_utils.h"
 
 namespace aos::util {
 LoggerState::LoggerState(aos::SimulatedEventLoopFactory *factory,
                          const aos::Node *node, std::string_view output_folder)
     : event_loop_(factory->MakeEventLoop("logger", node)),
       namer_(std::make_unique<aos::logger::MultiNodeFilesLogNamer>(
-          absl::StrCat(output_folder, "/", node->name()->string_view(), "/"),
+          absl::StrCat(output_folder, "/", logger::MaybeNodeName(node), "/"),
           event_loop_.get())),
       logger_(std::make_unique<aos::logger::Logger>(event_loop_.get())) {
   event_loop_->SkipTimingReport();
diff --git a/aos/util/test_data/BUILD b/aos/util/test_data/BUILD
new file mode 100644
index 0000000..016dd07
--- /dev/null
+++ b/aos/util/test_data/BUILD
@@ -0,0 +1,28 @@
+load("//aos:config.bzl", "aos_config")
+
+[
+    aos_config(
+        name = name,
+        src = name + "_source.json",
+        flatbuffers = [
+            "//aos/network:remote_message_fbs",
+            "//aos/events:ping_fbs",
+            "//aos/network:message_bridge_client_fbs",
+            "//aos/network:message_bridge_server_fbs",
+            "//aos/network:timestamp_fbs",
+        ],
+        target_compatible_with = ["@platforms//os:linux"],
+        visibility = ["//visibility:public"],
+        deps = ["//aos/events:aos_config"],
+    )
+    for name in [
+        "valid_multinode_config",
+        "valid_singlenode_config",
+        "multinode_no_statistics",
+        "multinode_timestamp_typo",
+        "multinode_extraneous_timestamp",
+        "multinode_invalid_timestamp_logger_list",
+        "multinode_common_logger",
+        "multinode_no_logged_timestamps",
+    ]
+]
diff --git a/aos/util/test_data/multinode_common_logger_source.json b/aos/util/test_data/multinode_common_logger_source.json
new file mode 100644
index 0000000..81aa065
--- /dev/null
+++ b/aos/util/test_data/multinode_common_logger_source.json
@@ -0,0 +1,156 @@
+{
+  "channels": [
+    {
+      "name": "/pi1/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi1",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi2",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi1",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi2",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi1",
+      "frequency": 2
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi2",
+      "frequency": 2
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos/remote_timestamps/pi1/pi2/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/test/aos-examples-Ping",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1"
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi1",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi2",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "source_node": "pi1",
+      "logger": "LOCAL_AND_REMOTE_LOGGER",
+      "logger_nodes": ["pi2"],
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"]
+        }
+      ],
+      "max_size": 20480
+    }
+  ],
+  "maps": [
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi1"
+      },
+      "rename": {
+        "name": "/pi1/aos"
+      }
+    },
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi2"
+      },
+      "rename": {
+        "name": "/pi2/aos"
+      }
+    }
+  ],
+  "nodes": [
+    {
+      "name": "pi1"
+    },
+    {
+      "name": "pi2"
+    }
+  ]
+}
diff --git a/aos/util/test_data/multinode_extraneous_timestamp_source.json b/aos/util/test_data/multinode_extraneous_timestamp_source.json
new file mode 100644
index 0000000..2e889d0
--- /dev/null
+++ b/aos/util/test_data/multinode_extraneous_timestamp_source.json
@@ -0,0 +1,161 @@
+{
+  "channels": [
+    {
+      "name": "/pi1/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi1",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi2",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi1",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi2",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi1",
+      "frequency": 2
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi2",
+      "frequency": 2
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/os-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos/remote_timestamps/pi1/pi2/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/test/aos-examples-Ping",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1"
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi1",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi2",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "source_node": "pi1",
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"]
+        }
+      ],
+      "max_size": 20480
+    }
+  ],
+  "maps": [
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi1"
+      },
+      "rename": {
+        "name": "/pi1/aos"
+      }
+    },
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi2"
+      },
+      "rename": {
+        "name": "/pi2/aos"
+      }
+    }
+  ],
+  "nodes": [
+    {
+      "name": "pi1"
+    },
+    {
+      "name": "pi2"
+    }
+  ]
+}
diff --git a/aos/util/test_data/multinode_invalid_timestamp_logger_list_source.json b/aos/util/test_data/multinode_invalid_timestamp_logger_list_source.json
new file mode 100644
index 0000000..e501437
--- /dev/null
+++ b/aos/util/test_data/multinode_invalid_timestamp_logger_list_source.json
@@ -0,0 +1,154 @@
+{
+  "channels": [
+    {
+      "name": "/pi1/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi1",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi2",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi1",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi2",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi1",
+      "frequency": 2
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi2",
+      "frequency": 2
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos/remote_timestamps/pi1/pi2/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/test/aos-examples-Ping",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1"
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi1",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi2",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "source_node": "pi1",
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"]
+        }
+      ],
+      "max_size": 20480
+    }
+  ],
+  "maps": [
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi1"
+      },
+      "rename": {
+        "name": "/pi1/aos"
+      }
+    },
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi2"
+      },
+      "rename": {
+        "name": "/pi2/aos"
+      }
+    }
+  ],
+  "nodes": [
+    {
+      "name": "pi1"
+    },
+    {
+      "name": "pi2"
+    }
+  ]
+}
diff --git a/aos/util/test_data/multinode_no_logged_timestamps_source.json b/aos/util/test_data/multinode_no_logged_timestamps_source.json
new file mode 100644
index 0000000..a956f73
--- /dev/null
+++ b/aos/util/test_data/multinode_no_logged_timestamps_source.json
@@ -0,0 +1,147 @@
+{
+  "channels": [
+    {
+      "name": "/pi1/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi1",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi2",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi1",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi2",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi1",
+      "frequency": 2
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi2",
+      "frequency": 2
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos/remote_timestamps/pi1/pi2/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi1",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi2",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "source_node": "pi1",
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "NOT_LOGGED"
+        }
+      ],
+      "max_size": 20480
+    }
+  ],
+  "maps": [
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi1"
+      },
+      "rename": {
+        "name": "/pi1/aos"
+      }
+    },
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi2"
+      },
+      "rename": {
+        "name": "/pi2/aos"
+      }
+    }
+  ],
+  "nodes": [
+    {
+      "name": "pi1"
+    },
+    {
+      "name": "pi2"
+    }
+  ]
+}
diff --git a/aos/util/test_data/multinode_no_statistics_source.json b/aos/util/test_data/multinode_no_statistics_source.json
new file mode 100644
index 0000000..44fa5d8
--- /dev/null
+++ b/aos/util/test_data/multinode_no_statistics_source.json
@@ -0,0 +1,130 @@
+{
+  "channels": [
+    {
+      "name": "/pi1/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi1",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi2",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi1",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi2",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos/remote_timestamps/pi1/pi2/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/test/aos-examples-Ping",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1"
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi1",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi2",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "source_node": "pi1",
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"]
+        }
+      ],
+      "max_size": 20480
+    }
+  ],
+  "maps": [
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi1"
+      },
+      "rename": {
+        "name": "/pi1/aos"
+      }
+    },
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi2"
+      },
+      "rename": {
+        "name": "/pi2/aos"
+      }
+    }
+  ],
+  "nodes": [
+    {
+      "name": "pi1"
+    },
+    {
+      "name": "pi2"
+    }
+  ]
+}
diff --git a/aos/util/test_data/multinode_timestamp_typo_source.json b/aos/util/test_data/multinode_timestamp_typo_source.json
new file mode 100644
index 0000000..afb4275
--- /dev/null
+++ b/aos/util/test_data/multinode_timestamp_typo_source.json
@@ -0,0 +1,154 @@
+{
+  "channels": [
+    {
+      "name": "/pi1/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi1",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi2",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi1",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi2",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi1",
+      "frequency": 2
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi2",
+      "frequency": 2
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/aos-message_bridge-Timestam",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos/remote_timestamps/pi1/pi2/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/test/aos-examples-Ping",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1"
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi1",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi2",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "source_node": "pi1",
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"]
+        }
+      ],
+      "max_size": 20480
+    }
+  ],
+  "maps": [
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi1"
+      },
+      "rename": {
+        "name": "/pi1/aos"
+      }
+    },
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi2"
+      },
+      "rename": {
+        "name": "/pi2/aos"
+      }
+    }
+  ],
+  "nodes": [
+    {
+      "name": "pi1"
+    },
+    {
+      "name": "pi2"
+    }
+  ]
+}
diff --git a/aos/util/test_data/valid_multinode_config_source.json b/aos/util/test_data/valid_multinode_config_source.json
new file mode 100644
index 0000000..0c35251
--- /dev/null
+++ b/aos/util/test_data/valid_multinode_config_source.json
@@ -0,0 +1,154 @@
+{
+  "channels": [
+    {
+      "name": "/pi1/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi1",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi2",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi1",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi2",
+      "frequency": 15,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "priority": 1,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi1",
+      "frequency": 2
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi2",
+      "frequency": 2
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/pi1/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1",
+      "frequency": 15
+    },
+    {
+      "name": "/pi2/aos/remote_timestamps/pi1/pi2/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi2",
+      "frequency": 15
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2/test/aos-examples-Ping",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1"
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi1",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi2",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "source_node": "pi1",
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "timestamp_logger": "REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"]
+        }
+      ],
+      "max_size": 20480
+    }
+  ],
+  "maps": [
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi1"
+      },
+      "rename": {
+        "name": "/pi1/aos"
+      }
+    },
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi2"
+      },
+      "rename": {
+        "name": "/pi2/aos"
+      }
+    }
+  ],
+  "nodes": [
+    {
+      "name": "pi1"
+    },
+    {
+      "name": "pi2"
+    }
+  ]
+}
diff --git a/aos/util/test_data/valid_singlenode_config_source.json b/aos/util/test_data/valid_singlenode_config_source.json
new file mode 100644
index 0000000..736eace
--- /dev/null
+++ b/aos/util/test_data/valid_singlenode_config_source.json
@@ -0,0 +1,23 @@
+{
+  "channels": [
+    {
+      "name": "/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/aos",
+      "type": "aos.timing.Report",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "max_size": 20480
+    }
+  ]
+}
diff --git a/frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc b/frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
index d64c419..e45f880 100644
--- a/frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
@@ -16,7 +16,6 @@
 
 DEFINE_string(output_folder, "",
               "If set, logs all channels to the provided logfile.");
-DECLARE_bool(die_on_malloc);
 
 namespace frc971 {
 namespace control_loops {
@@ -78,7 +77,6 @@
         drivetrain_plant_(drivetrain_plant_event_loop_.get(),
                           drivetrain_plant_imu_event_loop_.get(), dt_config_,
                           std::chrono::microseconds(500)) {
-    FLAGS_die_on_malloc = true;
     set_team_id(frc971::control_loops::testing::kTeamNumber);
     set_battery_voltage(12.0);
 
diff --git a/frc971/solvers/BUILD b/frc971/solvers/BUILD
new file mode 100644
index 0000000..fc89616
--- /dev/null
+++ b/frc971/solvers/BUILD
@@ -0,0 +1,22 @@
+cc_library(
+    name = "convex",
+    hdrs = ["convex.h"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "@com_github_google_glog//:glog",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_test(
+    name = "convex_test",
+    srcs = [
+        "convex_test.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":convex",
+        "//aos/testing:googletest",
+    ],
+)
diff --git a/frc971/solvers/convex.h b/frc971/solvers/convex.h
new file mode 100644
index 0000000..aa14c1a
--- /dev/null
+++ b/frc971/solvers/convex.h
@@ -0,0 +1,381 @@
+#include <sys/types.h>
+#include <unistd.h>
+
+#include <Eigen/Dense>
+#include <iomanip>
+
+#include "absl/strings/str_join.h"
+#include "glog/logging.h"
+
+namespace frc971 {
+namespace solvers {
+
+// TODO(austin): Steal JET from Ceres to generate the derivatives easily and
+// quickly?
+//
+// States is the number of inputs to the optimization problem.
+// M is the number of inequality constraints.
+// N is the number of equality constraints.
+template <size_t States, size_t M, size_t N>
+class ConvexProblem {
+ public:
+  // Returns the function to minimize and it's derivatives.
+  virtual double f0(
+      Eigen::Ref<const Eigen::Matrix<double, States, 1>> X) const = 0;
+  virtual Eigen::Matrix<double, States, 1> df0(
+      Eigen::Ref<const Eigen::Matrix<double, States, 1>> X) const = 0;
+  virtual Eigen::Matrix<double, States, States> ddf0(
+      Eigen::Ref<const Eigen::Matrix<double, States, 1>> X) const = 0;
+
+  // Returns the constraints f(X) < 0, and their derivative.
+  virtual Eigen::Matrix<double, M, 1> f(
+      Eigen::Ref<const Eigen::Matrix<double, States, 1>> X) const = 0;
+  virtual Eigen::Matrix<double, M, States> df(
+      Eigen::Ref<const Eigen::Matrix<double, States, 1>> X) const = 0;
+
+  // Returns the equality constraints of the form A x = b
+  virtual Eigen::Matrix<double, N, States> A() const = 0;
+  virtual Eigen::Matrix<double, N, 1> b() const = 0;
+};
+
+// Implements a Primal-Dual Interior point method convex solver.
+// See 11.7 of https://web.stanford.edu/~boyd/cvxbook/bv_cvxbook.pdf
+//
+// States is the number of inputs to the optimization problem.
+// M is the number of inequality constraints.
+// N is the number of equality constraints.
+template <size_t States, size_t M, size_t N>
+class Solver {
+ public:
+  // Ratio to require the cost to decrease when line searching.
+  static constexpr double kAlpha = 0.05;
+  // Line search step parameter.
+  static constexpr double kBeta = 0.5;
+  static constexpr double kMu = 2.0;
+  // Terminal condition for the primal problem (equality constraints) and dual
+  // (gradient + inequality constraints).
+  static constexpr double kEpsilonF = 1e-6;
+  // Terminal condition for nu, the surrogate duality gap.
+  static constexpr double kEpsilon = 1e-6;
+
+  // Solves the problem given a feasible initial solution.
+  Eigen::Matrix<double, States, 1> Solve(
+      const ConvexProblem<States, M, N> &problem,
+      Eigen::Ref<const Eigen::Matrix<double, States, 1>> X_initial);
+
+ private:
+  // Class to hold all the derivataves and function evaluations.
+  struct Derivatives {
+    Eigen::Matrix<double, States, 1> gradient;
+    Eigen::Matrix<double, States, States> hessian;
+
+    // Inequality function f
+    Eigen::Matrix<double, M, 1> f;
+    // df
+    Eigen::Matrix<double, M, States> df;
+
+    // ddf is assumed to be 0 because for the linear constraint distance
+    // function we are using, it is actually 0, and by assuming it is zero
+    // rather than passing it through as 0 to the solver, we can save enough CPU
+    // to make it worth it.
+
+    // A
+    Eigen::Matrix<double, N, States> A;
+    // Ax - b
+    Eigen::Matrix<double, N, 1> Axmb;
+  };
+
+  // Computes all the values for the given problem at the given state.
+  Derivatives ComputeDerivative(
+      const ConvexProblem<States, M, N> &problem,
+      const Eigen::Ref<const Eigen::Matrix<double, States + M + N, 1>> y);
+
+  // Computes Rt at the given state and with the given t_inverse.  See 11.53 of
+  // cvxbook.pdf.
+  Eigen::Matrix<double, States + M + N, 1> Rt(
+      const Derivatives &derivatives,
+      Eigen::Matrix<double, States + M + N, 1> y, double t_inverse);
+
+  // Prints out all the derivatives with VLOG at the provided verbosity.
+  void PrintDerivatives(
+      const Derivatives &derivatives,
+      const Eigen::Ref<const Eigen::Matrix<double, States + M + N, 1>> y,
+      std::string_view prefix, int verbosity);
+};
+
+template <size_t States, size_t M, size_t N>
+Eigen::Matrix<double, States + M + N, 1> Solver<States, M, N>::Rt(
+    const Derivatives &derivatives, Eigen::Matrix<double, States + M + N, 1> y,
+    double t_inverse) {
+  Eigen::Matrix<double, States + M + N, 1> result;
+
+  Eigen::Ref<Eigen::Matrix<double, States, 1>> r_dual =
+      result.template block<States, 1>(0, 0);
+  Eigen::Ref<Eigen::Matrix<double, M, 1>> r_cent =
+      result.template block<M, 1>(States, 0);
+  Eigen::Ref<Eigen::Matrix<double, N, 1>> r_pri =
+      result.template block<N, 1>(States + M, 0);
+
+  Eigen::Ref<const Eigen::Matrix<double, M, 1>> lambda =
+      y.template block<M, 1>(States, 0);
+  Eigen::Ref<const Eigen::Matrix<double, N, 1>> v =
+      y.template block<N, 1>(States + M, 0);
+
+  r_dual = derivatives.gradient + derivatives.df.transpose() * lambda +
+           derivatives.A.transpose() * v;
+  r_cent = -(Eigen::DiagonalMatrix<double, M>(lambda) * derivatives.f +
+             t_inverse * Eigen::Matrix<double, M, 1>::Ones());
+  r_pri = derivatives.Axmb;
+
+  return result;
+}
+
+template <size_t States, size_t M, size_t N>
+Eigen::Matrix<double, States, 1> Solver<States, M, N>::Solve(
+    const ConvexProblem<States, M, N> &problem,
+    Eigen::Ref<const Eigen::Matrix<double, States, 1>> X_initial) {
+  const Eigen::IOFormat kHeavyFormat(Eigen::StreamPrecision, 0, ", ",
+                                     ",\n                        "
+                                     "                                     ",
+                                     "[", "]", "[", "]");
+
+  Eigen::Matrix<double, States + M + N, 1> y =
+      Eigen::Matrix<double, States + M + N, 1>::Constant(1.0);
+  y.template block<States, 1>(0, 0) = X_initial;
+
+  Derivatives derivatives = ComputeDerivative(problem, y);
+
+  for (size_t i = 0; i < M; ++i) {
+    CHECK_LE(derivatives.f(i, 0), 0.0)
+        << ": Initial state " << X_initial.transpose().format(kHeavyFormat)
+        << " not feasible";
+  }
+
+  PrintDerivatives(derivatives, y, "", 1);
+
+  size_t iteration = 0;
+  while (true) {
+    // Solve for the primal-dual search direction by solving the newton step.
+    Eigen::Ref<const Eigen::Matrix<double, M, 1>> lambda =
+        y.template block<M, 1>(States, 0);
+
+    const double nu = -(derivatives.f.transpose() * lambda)(0, 0);
+    const double t_inverse = nu / (kMu * lambda.rows());
+    Eigen::Matrix<double, States + M + N, 1> rt_orig =
+        Rt(derivatives, y, t_inverse);
+
+    Eigen::Matrix<double, States + M + N, States + M + N> m1;
+    m1.setZero();
+    m1.template block<States, States>(0, 0) = derivatives.hessian;
+    m1.template block<States, M>(0, States) = derivatives.df.transpose();
+    m1.template block<States, N>(0, States + M) = derivatives.A.transpose();
+    m1.template block<M, States>(States, 0) =
+        -(Eigen::DiagonalMatrix<double, M>(lambda) * derivatives.df);
+    m1.template block<M, M>(States, States) -=
+        Eigen::DiagonalMatrix<double, M>(derivatives.f);
+    m1.template block<N, States>(States + M, 0) = derivatives.A;
+
+    Eigen::Matrix<double, States + M + N, 1> dy =
+        m1.colPivHouseholderQr().solve(-rt_orig);
+
+    Eigen::Ref<Eigen::Matrix<double, M, 1>> dlambda =
+        dy.template block<M, 1>(States, 0);
+
+    double s = 1.0;
+
+    // Now, time to do line search.
+    //
+    // Start by keeping lambda positive.  Make sure our step doesn't let
+    // lambda cross 0.
+    for (int i = 0; i < dlambda.rows(); ++i) {
+      if (lambda(i) + s * dlambda(i) < 0.0) {
+        // Ignore tiny steps in lambda.  They cause issues when we get really
+        // close to having our constraints met but haven't converged the rest
+        // of the problem and start to run into rounding issues in the matrix
+        // solve portion.
+        if (dlambda(i) < 0.0 && dlambda(i) > -1e-12) {
+          VLOG(1) << "  lambda(" << i << ") " << lambda(i) << " + " << s
+                  << " * " << dlambda(i) << " -> s would be now "
+                  << -lambda(i) / dlambda(i);
+          dlambda(i) = 0.0;
+          VLOG(1) << "  dy -> " << std::setprecision(12) << std::fixed
+                  << std::setfill(' ') << dy.transpose().format(kHeavyFormat);
+          continue;
+        }
+        VLOG(1) << "  lambda(" << i << ") " << lambda(i) << " + " << s << " * "
+                << dlambda(i) << " -> s now " << -lambda(i) / dlambda(i);
+        s = -lambda(i) / dlambda(i);
+      }
+    }
+
+    VLOG(1) << "  After lambda line search, s is " << s;
+
+    VLOG(3) << "  Initial step " << iteration << " -> " << std::setprecision(12)
+            << std::fixed << std::setfill(' ')
+            << dy.transpose().format(kHeavyFormat);
+    VLOG(3) << "   rt ->                                        "
+            << std::setprecision(12) << std::fixed << std::setfill(' ')
+            << rt_orig.transpose().format(kHeavyFormat);
+
+    const double rt_orig_squared_norm = rt_orig.squaredNorm();
+
+    Eigen::Matrix<double, States + M + N, 1> next_y;
+    Eigen::Matrix<double, States + M + N, 1> rt;
+    Derivatives next_derivatives;
+    while (true) {
+      next_y = y + s * dy;
+      next_derivatives = ComputeDerivative(problem, next_y);
+      rt = Rt(next_derivatives, next_y, t_inverse);
+
+      const Eigen::Ref<const Eigen::VectorXd> next_x =
+          next_y.block(0, 0, next_derivatives.hessian.rows(), 1);
+      const Eigen::Ref<const Eigen::VectorXd> next_lambda =
+          next_y.block(next_x.rows(), 0, next_derivatives.f.rows(), 1);
+
+      const Eigen::Ref<const Eigen::VectorXd> next_v = next_y.block(
+          next_x.rows() + next_lambda.rows(), 0, next_derivatives.A.rows(), 1);
+
+      VLOG(1) << "    next_rt(" << iteration << ") is " << rt.norm() << " -> "
+              << std::setprecision(12) << std::fixed << std::setfill(' ')
+              << rt.transpose().format(kHeavyFormat);
+
+      PrintDerivatives(next_derivatives, next_y, "next_", 3);
+
+      if (next_derivatives.f.maxCoeff() > 0.0) {
+        VLOG(1) << "   f_next > 0.0  -> " << next_derivatives.f.maxCoeff()
+                << ", continuing line search.";
+        s *= kBeta;
+      } else if (next_derivatives.Axmb.squaredNorm() < 0.1 &&
+                 rt.squaredNorm() >
+                     std::pow(1.0 - kAlpha * s, 2.0) * rt_orig_squared_norm) {
+        VLOG(1) << "   |Rt| > |Rt+1| " << rt.norm() << " >  " << rt_orig.norm()
+                << ", drt -> " << std::setprecision(12) << std::fixed
+                << std::setfill(' ')
+                << (rt_orig - rt).transpose().format(kHeavyFormat);
+        s *= kBeta;
+      } else {
+        break;
+      }
+    }
+
+    VLOG(1) << "  Terminated line search with s " << s << ", " << rt.norm()
+            << "(|Rt+1|) < " << rt_orig.norm() << "(|Rt|)";
+    y = next_y;
+
+    const Eigen::Ref<const Eigen::VectorXd> next_lambda =
+        y.template block<M, 1>(States, 0);
+
+    // See if we hit our convergence criteria.
+    const double r_primal_squared_norm =
+        rt.template block<N, 1>(States + M, 0).squaredNorm();
+    VLOG(1) << "  rt_next(" << iteration << ") is " << rt.norm() << " -> "
+            << std::setprecision(12) << std::fixed << std::setfill(' ')
+            << rt.transpose().format(kHeavyFormat);
+    if (r_primal_squared_norm < kEpsilonF * kEpsilonF) {
+      const double r_dual_squared_norm =
+          rt.template block<States, 1>(0, 0).squaredNorm();
+      if (r_dual_squared_norm < kEpsilonF * kEpsilonF) {
+        const double next_nu =
+            -(next_derivatives.f.transpose() * next_lambda)(0, 0);
+        if (next_nu < kEpsilon) {
+          VLOG(1) << "  r_primal(" << iteration << ") -> "
+                  << std::sqrt(r_primal_squared_norm) << " < " << kEpsilonF
+                  << ", r_dual(" << iteration << ") -> "
+                  << std::sqrt(r_dual_squared_norm) << " < " << kEpsilonF
+                  << ", nu(" << iteration << ") -> " << next_nu << " < "
+                  << kEpsilon;
+          break;
+        } else {
+          VLOG(1) << "  nu(" << iteration << ") -> " << next_nu << " < "
+                  << kEpsilon << ", not done yet";
+        }
+
+      } else {
+        VLOG(1) << "  r_dual(" << iteration << ") -> "
+                << std::sqrt(r_dual_squared_norm) << " < " << kEpsilonF
+                << ", not done yet";
+      }
+    } else {
+      VLOG(1) << "  r_primal(" << iteration << ") -> "
+              << std::sqrt(r_primal_squared_norm) << " < " << kEpsilonF
+              << ", not done yet";
+    }
+    VLOG(1) << "  step(" << iteration << ") " << std::setprecision(12)
+            << (s * dy).transpose().format(kHeavyFormat);
+    VLOG(1) << " y(" << iteration << ") is now " << std::setprecision(12)
+            << y.transpose().format(kHeavyFormat);
+
+    // Very import, use the last set of derivatives we picked for our new y
+    // for the next iteration.  This avoids re-computing it.
+    derivatives = std::move(next_derivatives);
+
+    ++iteration;
+    if (iteration > 100) {
+      LOG(FATAL) << "Too many iterations";
+    }
+  }
+
+  return y.template block<States, 1>(0, 0);
+}
+
+template <size_t States, size_t M, size_t N>
+typename Solver<States, M, N>::Derivatives
+Solver<States, M, N>::ComputeDerivative(
+    const ConvexProblem<States, M, N> &problem,
+    const Eigen::Ref<const Eigen::Matrix<double, States + M + N, 1>> y) {
+  const Eigen::Ref<const Eigen::Matrix<double, States, 1>> x =
+      y.template block<States, 1>(0, 0);
+
+  Derivatives derivatives;
+  derivatives.gradient = problem.df0(x);
+  derivatives.hessian = problem.ddf0(x);
+  derivatives.f = problem.f(x);
+  derivatives.df = problem.df(x);
+  derivatives.A = problem.A();
+  derivatives.Axmb =
+      derivatives.A * y.template block<States, 1>(0, 0) - problem.b();
+  return derivatives;
+}
+
+template <size_t States, size_t M, size_t N>
+void Solver<States, M, N>::PrintDerivatives(
+    const Derivatives &derivatives,
+    const Eigen::Ref<const Eigen::Matrix<double, States + M + N, 1>> y,
+    std::string_view prefix, int verbosity) {
+  const Eigen::Ref<const Eigen::VectorXd> x =
+      y.block(0, 0, derivatives.hessian.rows(), 1);
+  const Eigen::Ref<const Eigen::VectorXd> lambda =
+      y.block(x.rows(), 0, derivatives.f.rows(), 1);
+
+  if (VLOG_IS_ON(verbosity)) {
+    Eigen::IOFormat heavy(Eigen::StreamPrecision, 0, ", ",
+                          ",\n                        "
+                          "                                     ",
+                          "[", "]", "[", "]");
+    heavy.rowSeparator =
+        heavy.rowSeparator +
+        std::string(absl::StrCat(getpid()).size() + prefix.size(), ' ');
+
+    const Eigen::Ref<const Eigen::VectorXd> v =
+        y.block(x.rows() + lambda.rows(), 0, derivatives.A.rows(), 1);
+    VLOG(verbosity) << "   " << prefix << "x: " << x.transpose().format(heavy);
+    VLOG(verbosity) << "   " << prefix
+                    << "lambda: " << lambda.transpose().format(heavy);
+    VLOG(verbosity) << "   " << prefix << "v: " << v.transpose().format(heavy);
+    VLOG(verbosity) << "  " << prefix
+                    << "hessian:     " << derivatives.hessian.format(heavy);
+    VLOG(verbosity) << "  " << prefix
+                    << "gradient:    " << derivatives.gradient.format(heavy);
+    VLOG(verbosity) << "  " << prefix
+                    << "A:           " << derivatives.A.format(heavy);
+    VLOG(verbosity) << "  " << prefix
+                    << "Ax-b:        " << derivatives.Axmb.format(heavy);
+    VLOG(verbosity) << "  " << prefix
+                    << "f:           " << derivatives.f.format(heavy);
+    VLOG(verbosity) << "  " << prefix
+                    << "df:          " << derivatives.df.format(heavy);
+  }
+}
+
+};  // namespace solvers
+};  // namespace frc971
diff --git a/frc971/solvers/convex_test.cc b/frc971/solvers/convex_test.cc
new file mode 100644
index 0000000..213e70b
--- /dev/null
+++ b/frc971/solvers/convex_test.cc
@@ -0,0 +1,106 @@
+#include "frc971/solvers/convex.h"
+
+#include "gtest/gtest.h"
+
+namespace frc971 {
+namespace solvers {
+namespace testing {
+
+const Eigen::IOFormat kHeavyFormat(Eigen::StreamPrecision, 0, ", ",
+                                   ",\n                        "
+                                   "                                     ",
+                                   "[", "]", "[", "]");
+
+class SimpleQP : public ConvexProblem<2, 4, 1> {
+ public:
+  // QP of the for 0.5 * X^t Q_ X + p.T * X
+  SimpleQP(Eigen::Matrix<double, 2, 2> Q, Eigen::Matrix<double, 2, 1> p,
+           double x0_max, double x0_min, double x1_max, double x1_min)
+      : Q_(Q), p_(p) {
+    C_ << 1, 0, -1, 0, 0, 1, 0, -1;
+    c_ << x0_max, -x0_min, x1_max, -x1_min;
+  }
+
+  double f0(Eigen::Ref<const Eigen::Matrix<double, 2, 1>> X) const override {
+    return 0.5 * (X.transpose() * Q_ * X)(0, 0);
+  }
+
+  Eigen::Matrix<double, 2, 1> df0(
+      Eigen::Ref<const Eigen::Matrix<double, 2, 1>> X) const override {
+    return Q_ * X + p_;
+  }
+
+  Eigen::Matrix<double, 2, 2> ddf0(
+      Eigen::Ref<const Eigen::Matrix<double, 2, 1>> /*X*/) const override {
+    return Q_;
+  }
+
+  // Returns the constraints f(X) < 0, and their derivitive.
+  Eigen::Matrix<double, 4, 1> f(
+      Eigen::Ref<const Eigen::Matrix<double, 2, 1>> X) const override {
+    return C_ * X - c_;
+  }
+  Eigen::Matrix<double, 4, 2> df(
+      Eigen::Ref<const Eigen::Matrix<double, 2, 1>> /*X*/) const override {
+    return C_;
+  }
+
+  // Returns the equality constraints of the form A x = b
+  Eigen::Matrix<double, 1, 2> A() const override {
+    return Eigen::Matrix<double, 1, 2>(1, -1);
+  }
+  Eigen::Matrix<double, 1, 1> b() const override {
+    return Eigen::Matrix<double, 1, 1>(0);
+  }
+
+ private:
+  Eigen::Matrix<double, 2, 2> Q_;
+  Eigen::Matrix<double, 2, 1> p_;
+
+  Eigen::Matrix<double, 4, 2> C_;
+  Eigen::Matrix<double, 4, 1> c_;
+};
+
+// Test a constrained quadratic problem where the constraints aren't active.
+TEST(SolverTest, SimpleQP) {
+  Eigen::Matrix<double, 2, 2> Q = Eigen::DiagonalMatrix<double, 2>(1.0, 1.0);
+  Eigen::Matrix<double, 2, 1> p(-4, -6);
+
+  SimpleQP qp(Q, p, 6, -1, 6, -1);
+  Solver<2, 4, 1> s;
+  Eigen::Vector2d result = s.Solve(qp, Eigen::Matrix<double, 2, 1>(0, 0));
+  LOG(INFO) << "Result is " << std::setprecision(12)
+            << result.transpose().format(kHeavyFormat);
+  EXPECT_NEAR((result - Eigen::Vector2d(5.0, 5.0)).norm(), 0.0, 1e-6);
+}
+
+// Test a constrained quadratic problem where the constraints are active.
+TEST(SolverTest, Constrained) {
+  Eigen::Matrix<double, 2, 2> Q = Eigen::DiagonalMatrix<double, 2>(1.0, 2.0);
+  Eigen::Matrix<double, 2, 1> p(-5, -10);
+
+  SimpleQP qp(Q, p, 4, -1, 5, -1);
+  Solver<2, 4, 1> s;
+  Eigen::Vector2d result = s.Solve(qp, Eigen::Matrix<double, 2, 1>(3, 4));
+  LOG(INFO) << "Result is " << std::setprecision(12)
+            << result.transpose().format(kHeavyFormat);
+  EXPECT_NEAR((result - Eigen::Vector2d(4.0, 4.0)).norm(), 0.0, 1e-6);
+}
+
+// Test a constrained quadratic problem where the constraints are active and the
+// initial value is the solution.
+TEST(SolverTest, ConstrainedFromSolution) {
+  Eigen::Matrix<double, 2, 2> Q = Eigen::DiagonalMatrix<double, 2>(1.0, 2.0);
+  Eigen::Matrix<double, 2, 1> p(-5, -10);
+
+  SimpleQP qp(Q, p, 4, -1, 5, -1);
+  Solver<2, 4, 1> s;
+  Eigen::Vector2d result = s.Solve(qp, Eigen::Matrix<double, 2, 1>(4, 4));
+  LOG(INFO) << "Result is " << std::setprecision(12)
+            << result.transpose().format(kHeavyFormat);
+  EXPECT_NEAR((result - Eigen::Vector2d(4.0, 4.0)).norm(), 0.0, 1e-6);
+}
+
+}  // namespace testing
+}  // namespace solvers
+}  // namespace frc971
diff --git a/y2020/BUILD b/y2020/BUILD
index 2f6f963..32d392a 100644
--- a/y2020/BUILD
+++ b/y2020/BUILD
@@ -2,6 +2,12 @@
 load("//aos:config.bzl", "aos_config")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("//tools/build_rules:template.bzl", "jinja2_template")
+load("//aos/util:config_validator_macro.bzl", "config_validator_test")
+
+config_validator_test(
+    name = "config_validator_test",
+    config = "//y2020:aos_config",
+)
 
 robot_downloader(
     binaries = [
diff --git a/y2020/control_loops/drivetrain/localizer_test.cc b/y2020/control_loops/drivetrain/localizer_test.cc
index d280523..9e9e7dd 100644
--- a/y2020/control_loops/drivetrain/localizer_test.cc
+++ b/y2020/control_loops/drivetrain/localizer_test.cc
@@ -15,7 +15,6 @@
 
 DEFINE_string(output_file, "",
               "If set, logs all channels to the provided logfile.");
-DECLARE_bool(die_on_malloc);
 
 // This file tests that the full 2020 localizer behaves sanely.
 
@@ -147,7 +146,6 @@
     CHECK_EQ(aos::configuration::GetNodeIndex(configuration(), pi1_), 1);
     set_team_id(frc971::control_loops::testing::kTeamNumber);
     set_battery_voltage(12.0);
-    FLAGS_die_on_malloc = true;
 
     if (!FLAGS_output_file.empty()) {
       logger_event_loop_ = MakeEventLoop("logger", roborio_);
diff --git a/y2022/BUILD b/y2022/BUILD
index 1a04c2e..f8bf59f 100644
--- a/y2022/BUILD
+++ b/y2022/BUILD
@@ -2,6 +2,12 @@
 load("//aos:config.bzl", "aos_config")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("//tools/build_rules:template.bzl", "jinja2_template")
+load("//aos/util:config_validator_macro.bzl", "config_validator_test")
+
+config_validator_test(
+    name = "config_validator_test",
+    config = "//y2022:aos_config",
+)
 
 robot_downloader(
     binaries = [
diff --git a/y2023/BUILD b/y2023/BUILD
index 6be5cac..667b59d 100644
--- a/y2023/BUILD
+++ b/y2023/BUILD
@@ -2,9 +2,9 @@
 load("//aos:config.bzl", "aos_config")
 load("//tools/build_rules:template.bzl", "jinja2_template")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
-load("//aos/util:config_validator_macro.bzl", "config_validator_rule")
+load("//aos/util:config_validator_macro.bzl", "config_validator_test")
 
-config_validator_rule(
+config_validator_test(
     name = "config_validator_test",
     config = "//y2023:aos_config",
 )
diff --git a/y2023/localizer/localizer_test.cc b/y2023/localizer/localizer_test.cc
index e8b7d56..2cb5756 100644
--- a/y2023/localizer/localizer_test.cc
+++ b/y2023/localizer/localizer_test.cc
@@ -14,7 +14,6 @@
 
 DEFINE_string(output_folder, "",
               "If set, logs all channels to the provided logfile.");
-DECLARE_bool(die_on_malloc);
 DECLARE_double(max_distance_to_target);
 
 namespace y2023::localizer::testing {
@@ -75,7 +74,6 @@
         status_fetcher_(
             imu_test_event_loop_->MakeFetcher<Status>("/localizer")) {
     FLAGS_max_distance_to_target = 100.0;
-    FLAGS_die_on_malloc = true;
     {
       aos::TimerHandler *timer = roborio_test_event_loop_->AddTimer([this]() {
         {
diff --git a/y2023/y2023_roborio.json b/y2023/y2023_roborio.json
index d018cd9..172d11c 100644
--- a/y2023/y2023_roborio.json
+++ b/y2023/y2023_roborio.json
@@ -321,9 +321,6 @@
     {
       "name": "drivetrain",
       "executable_name": "drivetrain",
-      "args": [
-        "--die_on_malloc"
-      ],
       "nodes": [
         "roborio"
       ]
@@ -331,9 +328,6 @@
     {
       "name": "trajectory_generator",
       "executable_name": "trajectory_generator",
-      "args": [
-        "--die_on_malloc"
-      ],
       "nodes": [
         "roborio"
       ]
@@ -341,9 +335,6 @@
     {
       "name": "superstructure",
       "executable_name": "superstructure",
-      "args": [
-        "--die_on_malloc"
-      ],
       "nodes": [
         "roborio"
       ]
@@ -371,6 +362,9 @@
     {
       "name": "wpilib_interface",
       "executable_name": "wpilib_interface",
+      "args": [
+        "--nodie_on_malloc"
+      ],
       "nodes": [
         "roborio"
       ]